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
});