HEX
Server: LiteSpeed
System: Linux s1049.use1.mysecurecloudhost.com 4.18.0-477.27.2.lve.el8.x86_64 #1 SMP Wed Oct 11 12:32:56 UTC 2023 x86_64
User: xedaptot (3356)
PHP: 8.3.31
Disabled: NONE
Upload Files
File: /home/xedaptot/be.naniguide.com/tests/Unit/Plans/CreditsServiceTest.php
<?php

/**
 * CreditsServiceTest — covers the 4-concept Credit service.
 *
 * Hot path (canUse/consume/grant) goes through CreditTracker (file-based in test env).
 * Cold path (remaining/initializeForPlan/resetForCycle/zeroOut) writes DB + tracker.
 *
 * See docs/payment-order-plan-subscription-saas/SUBSCRIPTION-COMPREHENSIVE-DESIGN.md §6.1.
 */

use App\Model\Customer;
use App\Model\PlanCredit;
use App\Model\Plan;
use App\Model\Subscription;
use App\Model\SubscriptionCreditState;
use App\Services\Plans\Credits\CreditKey;
use App\Services\Plans\Credits\CreditsService;
use App\Services\Plans\Credits\InsufficientCreditsException;
use Illuminate\Support\Facades\DB;
use Tests\TestCase;

uses(TestCase::class);

beforeEach(function () {
    $this->customer = Customer::forceCreate([
        'uid'      => uniqid('test_c_'),
        'timezone' => 'UTC',
        'status'   => 'active',
    ]);
    $this->plan     = Plan::forceCreate([
        'uid'              => uniqid('test_plan_'),
        'name'             => 'Test Plan',
        'currency_id'      => 1,
        'frequency_amount' => 1,
        'frequency_unit'   => 'month',
        'price'            => 0,
        'status'           => 'active',
    ]);

    $this->sub = Subscription::create([
        'uid'                    => (string) \Illuminate\Support\Str::uuid(),
        'customer_id'            => $this->customer->id,
        'plan_id'                => $this->plan->id,
        'status'                 => 'active',
        'current_period_ends_at' => now()->addMonth(),
    ]);

    // Seed plan credits
    PlanCredit::factory()->for($this->plan, 'plan')->forKey(CreditKey::SEND_EMAIL)
        ->state(['amount_per_cycle' => 100])->create();
    PlanCredit::factory()->for($this->plan, 'plan')->forKey(CreditKey::VERIFY_CONTACT)
        ->state(['amount_per_cycle' => null])->create(); // unlimited
    // AI_GENERATION intentionally NOT seeded → not granted

    $this->svc = app(CreditsService::class);
});

afterEach(function () {
    // Clean up tracker files for the test subscription
    $pattern = storage_path("app/quota/subscription-credit-{$this->sub->uid}-*");
    foreach (glob($pattern) ?: [] as $f) {
        @unlink($f);
    }
    foreach (glob($pattern . '-lock') ?: [] as $f) {
        @unlink($f);
    }

    DB::table('subscription_credit_state')->where('subscription_id', $this->sub->id)->delete();
    DB::table('plan_credits')->where('plan_id', $this->plan->id)->delete();
    $this->sub->delete();
    $this->plan->delete();
    $this->customer->delete();
});

test('initializeForPlan seeds state rows and primes tracker', function () {
    $this->svc->initializeForPlan($this->sub);

    $rows = SubscriptionCreditState::where('subscription_id', $this->sub->id)->get();
    expect($rows)->toHaveCount(2); // SEND_EMAIL + VERIFY_CONTACT (no AI)

    $sendRow = $rows->firstWhere('credit_key', CreditKey::SEND_EMAIL->value);
    expect($sendRow->remaining)->toBe(100);
    expect($sendRow->granted_per_cycle)->toBe(100);

    $verifyRow = $rows->firstWhere('credit_key', CreditKey::VERIFY_CONTACT->value);
    expect($verifyRow->remaining)->toBeNull();           // unlimited
    expect($verifyRow->granted_per_cycle)->toBeNull();

    // Tracker primed
    expect($this->svc->remainingLive($this->sub, CreditKey::SEND_EMAIL))->toBe(100);
    expect($this->svc->remainingLive($this->sub, CreditKey::VERIFY_CONTACT))->toBeNull(); // unlimited
});

test('canUse returns false when plan does not grant the credit', function () {
    $this->svc->initializeForPlan($this->sub);

    expect($this->svc->canUse($this->sub, CreditKey::AI_GENERATION, 1))->toBeFalse();
});

test('canUse returns true when enough credits remain', function () {
    $this->svc->initializeForPlan($this->sub);

    expect($this->svc->canUse($this->sub, CreditKey::SEND_EMAIL, 50))->toBeTrue();
    expect($this->svc->canUse($this->sub, CreditKey::SEND_EMAIL, 100))->toBeTrue();
    expect($this->svc->canUse($this->sub, CreditKey::SEND_EMAIL, 101))->toBeFalse();
});

test('canUse returns true for unlimited credits', function () {
    $this->svc->initializeForPlan($this->sub);

    expect($this->svc->canUse($this->sub, CreditKey::VERIFY_CONTACT, 999_999))->toBeTrue();
});

test('consume decrements tracker', function () {
    $this->svc->initializeForPlan($this->sub);

    $this->svc->consume($this->sub, CreditKey::SEND_EMAIL, 30);
    expect($this->svc->remainingLive($this->sub, CreditKey::SEND_EMAIL))->toBe(70);

    $this->svc->consume($this->sub, CreditKey::SEND_EMAIL, 20);
    expect($this->svc->remainingLive($this->sub, CreditKey::SEND_EMAIL))->toBe(50);
});

test('consume is no-op on unlimited', function () {
    $this->svc->initializeForPlan($this->sub);

    $this->svc->consume($this->sub, CreditKey::VERIFY_CONTACT, 5_000);
    expect($this->svc->remainingLive($this->sub, CreditKey::VERIFY_CONTACT))->toBeNull();
});

test('consume throws when insufficient', function () {
    $this->svc->initializeForPlan($this->sub);

    expect(fn () => $this->svc->consume($this->sub, CreditKey::SEND_EMAIL, 101))
        ->toThrow(InsufficientCreditsException::class);
});

test('consume throws when plan does not grant credit', function () {
    $this->svc->initializeForPlan($this->sub);

    expect(fn () => $this->svc->consume($this->sub, CreditKey::AI_GENERATION, 1))
        ->toThrow(InsufficientCreditsException::class);
});

test('grant adds credits for non-unlimited keys', function () {
    $this->svc->initializeForPlan($this->sub);
    $this->svc->consume($this->sub, CreditKey::SEND_EMAIL, 40);
    expect($this->svc->remainingLive($this->sub, CreditKey::SEND_EMAIL))->toBe(60);

    $this->svc->grant($this->sub, CreditKey::SEND_EMAIL, 25);
    expect($this->svc->remainingLive($this->sub, CreditKey::SEND_EMAIL))->toBe(85);
});

test('resetForCycle restores remaining to granted_per_cycle', function () {
    $this->svc->initializeForPlan($this->sub);
    $this->svc->consume($this->sub, CreditKey::SEND_EMAIL, 60);

    $this->svc->resetForCycle($this->sub);

    expect($this->svc->remainingLive($this->sub, CreditKey::SEND_EMAIL))->toBe(100);
    $state = SubscriptionCreditState::where('subscription_id', $this->sub->id)
        ->where('credit_key', CreditKey::SEND_EMAIL->value)->first();
    expect($state->remaining)->toBe(100);
});

test('zeroOut sets all credits to zero', function () {
    $this->svc->initializeForPlan($this->sub);

    $this->svc->zeroOut($this->sub);

    expect($this->svc->remainingLive($this->sub, CreditKey::SEND_EMAIL))->toBe(0);
    expect($this->svc->remainingLive($this->sub, CreditKey::VERIFY_CONTACT))->toBe(0);
});

// ─── Snapshot read methods (Plan Versioning §10) ────────────────────────────

test('remainingSnapshot returns state.remaining; false when sub not granted', function () {
    $this->svc->initializeForPlan($this->sub);

    expect($this->svc->remainingSnapshot($this->sub, CreditKey::SEND_EMAIL))->toBe(100);
    expect($this->svc->remainingSnapshot($this->sub, CreditKey::VERIFY_CONTACT))->toBeNull(); // unlimited
    expect($this->svc->remainingSnapshot($this->sub, CreditKey::AI_GENERATION))->toBeFalse(); // not granted
});

test('grantedFromSnapshot returns state.granted_per_cycle; false when sub not granted', function () {
    $this->svc->initializeForPlan($this->sub);
    $this->svc->consume($this->sub, CreditKey::SEND_EMAIL, 40);

    // granted is the per-cycle cap, NOT the live remaining
    expect($this->svc->grantedFromSnapshot($this->sub, CreditKey::SEND_EMAIL))->toBe(100);
    expect($this->svc->grantedFromSnapshot($this->sub, CreditKey::VERIFY_CONTACT))->toBeNull(); // unlimited
    expect($this->svc->grantedFromSnapshot($this->sub, CreditKey::AI_GENERATION))->toBeFalse(); // not granted
});

test('grantedFromSnapshot stays frozen when admin edits plan_credits mid-cycle', function () {
    $this->svc->initializeForPlan($this->sub);
    expect($this->svc->grantedFromSnapshot($this->sub, CreditKey::SEND_EMAIL))->toBe(100);

    // Admin raises the plan grant 100 → 500 mid-cycle
    PlanCredit::where('plan_id', $this->plan->id)
        ->where('credit_key', CreditKey::SEND_EMAIL->value)
        ->update(['amount_per_cycle' => 500]);

    // Snapshot must NOT see the new live cap (Plan Versioning §10).
    expect($this->svc->grantedFromSnapshot($this->sub, CreditKey::SEND_EMAIL))->toBe(100);
    // creditForPlan reflects the live edit (it reads plan_credits directly).
    expect($this->svc->creditForPlan($this->plan, CreditKey::SEND_EMAIL))->toBe(500);
});

test('creditsFromSnapshot returns granted_per_cycle map of THIS sub, not the plan', function () {
    $this->svc->initializeForPlan($this->sub);

    $map = $this->svc->creditsFromSnapshot($this->sub);

    expect($map)->toHaveKey(CreditKey::SEND_EMAIL->value);
    expect($map)->toHaveKey(CreditKey::VERIFY_CONTACT->value);
    expect($map)->not->toHaveKey(CreditKey::AI_GENERATION->value);
    expect($map[CreditKey::SEND_EMAIL->value])->toBe(100);
    expect($map[CreditKey::VERIFY_CONTACT->value])->toBeNull();
});

test('creditsFromSnapshot is empty when sub pre-dates plan_credits config (senddera bug pattern)', function () {
    // Reproduce the senddera pattern: subscription created BEFORE PlanCredit
    // rows existed → onSubscribe ran with empty plan_credits → 0 state rows.
    DB::table('plan_credits')->where('plan_id', $this->plan->id)->delete();

    $this->svc->initializeForPlan($this->sub);

    expect($this->svc->creditsFromSnapshot($this->sub))->toBe([]);
    expect($this->svc->grantedFromSnapshot($this->sub, CreditKey::SEND_EMAIL))->toBeFalse();
    expect($this->svc->remainingSnapshot($this->sub, CreditKey::SEND_EMAIL))->toBeFalse();
    expect($this->svc->canUse($this->sub, CreditKey::SEND_EMAIL, 1))->toBeFalse();
});

test('usedRatioFromSnapshot uses snapshot for both numerator and denominator', function () {
    $this->svc->initializeForPlan($this->sub);
    $this->svc->consume($this->sub, CreditKey::SEND_EMAIL, 30);
    $this->svc->syncSnapshotFromTracker($this->sub); // pull live → snapshot

    expect($this->svc->usedRatioFromSnapshot($this->sub, CreditKey::SEND_EMAIL))->toBe(0.3);

    // Admin raises plan grant 100 → 500 mid-cycle. Snapshot still sees 100/cycle,
    // ratio must stay at 30/100 = 0.3, NOT recompute against the new 500 cap.
    PlanCredit::where('plan_id', $this->plan->id)
        ->where('credit_key', CreditKey::SEND_EMAIL->value)
        ->update(['amount_per_cycle' => 500]);

    expect($this->svc->usedRatioFromSnapshot($this->sub, CreditKey::SEND_EMAIL))->toBe(0.3);
});

test('usedRatioFromSnapshot returns 1 when sub not granted; 0 when unlimited', function () {
    $this->svc->initializeForPlan($this->sub);

    expect($this->svc->usedRatioFromSnapshot($this->sub, CreditKey::AI_GENERATION))->toBe(1.0); // no state row
    expect($this->svc->usedRatioFromSnapshot($this->sub, CreditKey::VERIFY_CONTACT))->toBe(0.0); // unlimited
});