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/ai.naniguide.com/tests/Unit/Plans/PlanSystemIntegrationTest.php
<?php

/**
 * PlanSystemIntegrationTest — end-to-end demonstration of the 4-concept plan system.
 *
 * Exercises: PlanSeeder → SubscriptionLifecycle → CreditGate / QuotaGate /
 * EntitlementGate / RateLimitGate → CreditsService consume → renewal reset.
 *
 * Uses real DB with transaction rollback (no mocks). Confirms the pieces
 * cooperate as documented in docs/payment-order-plan-subscription-saas/SUBSCRIPTION-COMPREHENSIVE-DESIGN.md.
 */

use App\Model\Customer;
use App\Model\Plan;
use App\Model\PlanEntitlement;
use App\Model\Subscription;
use App\Model\SubscriptionCreditState;
use App\Services\Plans\Credits\CreditKey;
use App\Services\Plans\Credits\CreditsService;
use App\Services\Plans\Entitlements\EntitlementKey;
use App\Services\Plans\Gates\CreditGate;
use App\Services\Plans\Gates\EntitlementGate;
use App\Services\Plans\Gates\QuotaGate;
use App\Services\Plans\Lifecycle\SubscriptionLifecycle;
use App\Services\Plans\Quotas\QuotaKey;
use App\Services\Plans\RateLimits\RateLimitKey;
use Illuminate\Support\Facades\DB;
use Tests\TestCase;

uses(TestCase::class);

/**
 * Minimal inline plan setup — mirrors a small subset of the demo seeder
 * (Stage02_PlansSeeder) for test isolation. Kept here so tests don't depend
 * on the full demo data pipeline.
 */
function integrationTest_makePlan(string $name, float $price, array $credits, array $quotas, array $enabledEnts, array $rate): Plan
{
    $plan = Plan::forceCreate([
        'uid'              => uniqid('integ_p_'),
        'name'             => $name,
        'currency_id'      => 1,
        'frequency_amount' => 1,
        'frequency_unit'   => 'month',
        'price'            => $price,
        'status'           => 'active',
    ]);

    foreach ($credits as $key => $amt) {
        \App\Model\PlanCredit::create([
            'plan_id'          => $plan->id,
            'credit_key'       => $key,
            'amount_per_cycle' => $amt, // null = unlimited
        ]);
    }
    foreach ($quotas as $key => $limit) {
        \App\Model\PlanQuota::create([
            'plan_id'     => $plan->id,
            'quota_key'   => $key,
            'limit_value' => $limit, // null = unlimited
        ]);
    }
    foreach (\App\Services\Plans\Entitlements\EntitlementKey::cases() as $ek) {
        \App\Model\PlanEntitlement::create([
            'plan_id'         => $plan->id,
            'entitlement_key' => $ek->value,
            'enabled'         => in_array($ek->value, $enabledEnts, true),
        ]);
    }
    if (! empty($rate)) {
        \App\Model\PlanRateLimit::create([
            'plan_id'     => $plan->id,
            'rate_key'    => $rate['key'],
            'limit_value' => $rate['limit'],
            'time_amount' => $rate['amount'] ?? 1,
            'time_unit'   => $rate['unit'] ?? 'minute',
        ]);
    }

    return $plan;
}

beforeEach(function () {
    $this->freePlan = integrationTest_makePlan(
        'Integ Free', 0.00,
        credits: [CreditKey::SEND_EMAIL->value => 100],
        quotas: [
            QuotaKey::MAX_LISTS->value       => 1,
            QuotaKey::MAX_USERS->value       => 1,
            QuotaKey::MAX_SUBSCRIBERS->value => 500,
        ],
        enabledEnts: [EntitlementKey::HAS_UNSUBSCRIBE_URL_REQUIRED->value],
        rate: ['key' => RateLimitKey::SEND_EMAIL_RATE->value, 'limit' => 10, 'amount' => 1, 'unit' => 'minute'],
    );

    $this->standardPlan = integrationTest_makePlan(
        'Integ Standard', 20.00,
        credits: [
            CreditKey::SEND_EMAIL->value     => 10_000,
            CreditKey::VERIFY_CONTACT->value => 1_000,
            CreditKey::AI_GENERATION->value  => 50,
        ],
        quotas: [
            QuotaKey::MAX_LISTS->value          => 10,
            QuotaKey::MAX_CAMPAIGNS->value      => 200,
            QuotaKey::MAX_SUBSCRIBERS->value    => 10_000,
            QuotaKey::MAX_SUBSCRIBERS_PER_LIST->value => 10_000,
            QuotaKey::MAX_USERS->value          => 3,
        ],
        enabledEnts: [
            EntitlementKey::HAS_API_ACCESS->value,
            EntitlementKey::HAS_LIST_IMPORT->value,
        ],
        rate: ['key' => RateLimitKey::SEND_EMAIL_RATE->value, 'limit' => 100, 'amount' => 1, 'unit' => 'minute'],
    );

    $this->proPlan = integrationTest_makePlan(
        'Integ Pro', 50.00,
        credits: [
            CreditKey::SEND_EMAIL->value     => null, // unlimited
            CreditKey::VERIFY_CONTACT->value => 10_000,
            CreditKey::AI_GENERATION->value  => 500,
        ],
        quotas: [
            QuotaKey::MAX_CONTACTS->value    => null, // unlimited
            QuotaKey::MAX_LISTS->value       => 50,
            QuotaKey::MAX_CAMPAIGNS->value   => null,
            QuotaKey::MAX_SUBSCRIBERS->value => null,
            QuotaKey::MAX_USERS->value       => 10,
        ],
        enabledEnts: [
            EntitlementKey::HAS_API_ACCESS->value,
            EntitlementKey::HAS_SSO->value,
            EntitlementKey::HAS_WHITE_LABEL->value,
            EntitlementKey::HAS_BULK_SEND->value,
            EntitlementKey::HAS_LIST_IMPORT->value,
            EntitlementKey::HAS_LIST_EXPORT->value,
            EntitlementKey::ALLOW_SENDING_AMAZON->value,
            EntitlementKey::ALLOW_SENDING_SENDGRID->value,
        ],
        rate: ['key' => RateLimitKey::SEND_EMAIL_RATE->value, 'limit' => 1000, 'amount' => 1, 'unit' => 'minute'],
    );

    $this->customer = Customer::forceCreate([
        'uid'      => uniqid('integ_c_'),
        'timezone' => 'UTC',
        'status'   => 'active',
    ]);
});

afterEach(function () {
    if (isset($this->subscription)) {
        $pattern = storage_path("app/quota/subscription-credit-{$this->subscription->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->subscription->id)->delete();
        $this->subscription->delete();
    }
    $this->customer->delete();

    // Clean up plans and their 4-concept rows
    foreach ([$this->freePlan, $this->standardPlan, $this->proPlan] as $p) {
        DB::table('plan_credits')->where('plan_id', $p->id)->delete();
        DB::table('plan_quotas')->where('plan_id', $p->id)->delete();
        DB::table('plan_entitlements')->where('plan_id', $p->id)->delete();
        DB::table('plan_rate_limits')->where('plan_id', $p->id)->delete();
        $p->delete();
    }
});

test('seeded Pro plan exposes full catalog via Gates', function () {
    expect($this->proPlan)->not->toBeNull();

    // Subscribe customer to Pro
    $this->subscription = Subscription::create([
        'uid'                    => (string) \Illuminate\Support\Str::uuid(),
        'customer_id'            => $this->customer->id,
        'plan_id'                => $this->proPlan->id,
        'status'                 => 'active',
        'current_period_ends_at' => now()->addMonth(),
    ]);

    app(SubscriptionLifecycle::class)->onSubscribe($this->subscription);

    // Credit: Pro has unlimited send_email, 10k verify, 500 AI
    expect(CreditGate::allows($this->customer, CreditKey::SEND_EMAIL, 1_000_000))->toBeTrue();
    expect(CreditGate::allows($this->customer, CreditKey::VERIFY_CONTACT, 5_000))->toBeTrue();
    expect(CreditGate::allows($this->customer, CreditKey::VERIFY_CONTACT, 10_001))->toBeFalse();
    expect(CreditGate::allows($this->customer, CreditKey::AI_GENERATION, 500))->toBeTrue();

    // Quota: Pro has unlimited contacts, 50 lists, 10 users
    expect(QuotaGate::allows($this->customer, QuotaKey::MAX_LISTS))->toBeTrue();
    expect(QuotaGate::allows($this->customer, QuotaKey::MAX_USERS))->toBeTrue();
    // Unlimited quotas return true regardless
    expect(QuotaGate::allows($this->customer, QuotaKey::MAX_CONTACTS))->toBeTrue();

    // Entitlement: Pro has SSO, white label, bulk send
    expect(EntitlementGate::allows($this->customer, EntitlementKey::HAS_SSO))->toBeTrue();
    expect(EntitlementGate::allows($this->customer, EntitlementKey::HAS_WHITE_LABEL))->toBeTrue();
    expect(EntitlementGate::allows($this->customer, EntitlementKey::HAS_BULK_SEND))->toBeTrue();
    expect(EntitlementGate::allows($this->customer, EntitlementKey::HAS_LIST_IMPORT))->toBeTrue();

    // Pro has all sending providers
    expect(EntitlementGate::allows($this->customer, EntitlementKey::ALLOW_SENDING_SENDGRID))->toBeTrue();
    expect(EntitlementGate::allows($this->customer, EntitlementKey::ALLOW_SENDING_AMAZON))->toBeTrue();
});

test('Free plan denies most capabilities', function () {
    $this->subscription = Subscription::create([
        'uid'                    => (string) \Illuminate\Support\Str::uuid(),
        'customer_id'            => $this->customer->id,
        'plan_id'                => $this->freePlan->id,
        'status'                 => 'active',
        'current_period_ends_at' => now()->addMonth(),
    ]);

    app(SubscriptionLifecycle::class)->onSubscribe($this->subscription);

    // Free: 100 send_email, no verify
    expect(CreditGate::allows($this->customer, CreditKey::SEND_EMAIL, 100))->toBeTrue();
    expect(CreditGate::allows($this->customer, CreditKey::SEND_EMAIL, 101))->toBeFalse();
    expect(CreditGate::allows($this->customer, CreditKey::VERIFY_CONTACT, 1))->toBeFalse();
    expect(CreditGate::allows($this->customer, CreditKey::AI_GENERATION, 1))->toBeFalse();

    // Free: 1 list max
    expect(QuotaGate::allows($this->customer, QuotaKey::MAX_LISTS))->toBeTrue();

    // Free: limited entitlements
    expect(EntitlementGate::allows($this->customer, EntitlementKey::HAS_API_ACCESS))->toBeFalse();
    expect(EntitlementGate::allows($this->customer, EntitlementKey::HAS_SSO))->toBeFalse();
    expect(EntitlementGate::allows($this->customer, EntitlementKey::HAS_BULK_SEND))->toBeFalse();
    // Free forces unsubscribe URL
    expect(EntitlementGate::allows($this->customer, EntitlementKey::HAS_UNSUBSCRIBE_URL_REQUIRED))->toBeTrue();
});

test('Standard plan: consume credits, verify remaining, renewal resets', function () {
    $this->subscription = Subscription::create([
        'uid'                    => (string) \Illuminate\Support\Str::uuid(),
        'customer_id'            => $this->customer->id,
        'plan_id'                => $this->standardPlan->id,
        'status'                 => 'active',
        'current_period_ends_at' => now()->addMonth(),
    ]);

    $lifecycle = app(SubscriptionLifecycle::class);
    $credits   = app(CreditsService::class);

    $lifecycle->onSubscribe($this->subscription);

    // Standard: 10000 send_email, 1000 verify
    expect($credits->remainingLive($this->subscription, CreditKey::SEND_EMAIL))->toBe(10_000);
    expect($credits->remainingLive($this->subscription, CreditKey::VERIFY_CONTACT))->toBe(1_000);

    // Consume 3000 send emails
    $credits->consume($this->subscription, CreditKey::SEND_EMAIL, 3_000);
    expect($credits->remainingLive($this->subscription, CreditKey::SEND_EMAIL))->toBe(7_000);

    // Gate reflects remaining
    expect(CreditGate::allows($this->customer, CreditKey::SEND_EMAIL, 7_000))->toBeTrue();
    expect(CreditGate::allows($this->customer, CreditKey::SEND_EMAIL, 7_001))->toBeFalse();

    // Renew → reset to cycle grant
    $lifecycle->onRenew($this->subscription);
    expect($credits->remainingLive($this->subscription, CreditKey::SEND_EMAIL))->toBe(10_000);
});

test('onExpire zeros credits; Gate denies', function () {
    $this->subscription = Subscription::create([
        'uid'                    => (string) \Illuminate\Support\Str::uuid(),
        'customer_id'            => $this->customer->id,
        'plan_id'                => $this->standardPlan->id,
        'status'                 => 'active',
        'current_period_ends_at' => now()->addMonth(),
    ]);

    $lifecycle = app(SubscriptionLifecycle::class);
    $lifecycle->onSubscribe($this->subscription);
    expect(CreditGate::allows($this->customer, CreditKey::SEND_EMAIL, 1))->toBeTrue();

    // Simulate end-of-cycle + no renewal
    $lifecycle->onExpire($this->subscription);

    // Credits zeroed on state
    $state = SubscriptionCreditState::where('subscription_id', $this->subscription->id)
        ->where('credit_key', CreditKey::SEND_EMAIL->value)
        ->first();
    expect($state->remaining)->toBe(0);
});

test('entitlement toggle reflects through gate immediately', function () {
    $this->subscription = Subscription::create([
        'uid'                    => (string) \Illuminate\Support\Str::uuid(),
        'customer_id'            => $this->customer->id,
        'plan_id'                => $this->standardPlan->id,
        'status'                 => 'active',
        'current_period_ends_at' => now()->addMonth(),
    ]);

    // Standard normally lacks SSO
    expect(EntitlementGate::allows($this->customer, EntitlementKey::HAS_SSO))->toBeFalse();

    // Toggle SSO on for this plan via the explicit `enabled` column
    PlanEntitlement::where('plan_id', $this->standardPlan->id)
        ->where('entitlement_key', EntitlementKey::HAS_SSO->value)
        ->update(['enabled' => true]);

    // Gate picks up the change (reads live)
    expect(EntitlementGate::allows($this->customer, EntitlementKey::HAS_SSO))->toBeTrue();
});

test('services/Gates read the 4-concept tables for an active customer', function () {
    $this->subscription = Subscription::create([
        'uid'                    => (string) \Illuminate\Support\Str::uuid(),
        'customer_id'            => $this->customer->id,
        'plan_id'                => $this->standardPlan->id,
        'status'                 => 'active',
        'current_period_ends_at' => now()->addMonth(),
    ]);

    $quotas = app(\App\Services\Plans\Quotas\QuotasService::class);
    expect($quotas->limit($this->customer, \App\Services\Plans\Quotas\QuotaKey::MAX_LISTS))->toBe(10);
    expect($quotas->limit($this->customer, \App\Services\Plans\Quotas\QuotaKey::MAX_CAMPAIGNS))->toBe(200);
    expect($quotas->limit($this->customer, \App\Services\Plans\Quotas\QuotaKey::MAX_SUBSCRIBERS))->toBe(10_000);

    expect(\App\Services\Plans\Gates\EntitlementGate::allows(
        $this->customer,
        \App\Services\Plans\Entitlements\EntitlementKey::HAS_API_ACCESS
    ))->toBeTrue();
    expect(\App\Services\Plans\Gates\EntitlementGate::allows(
        $this->customer,
        \App\Services\Plans\Entitlements\EntitlementKey::HAS_LIST_IMPORT
    ))->toBeTrue();

    // Rate limit row exists on Standard
    $rate = \App\Model\PlanRateLimit::where('plan_id', $this->standardPlan->id)
        ->where('rate_key', \App\Services\Plans\RateLimits\RateLimitKey::SEND_EMAIL_RATE->value)
        ->first();
    expect($rate)->not->toBeNull();
    expect((int) $rate->limit_value)->toBe(100);
    expect((int) $rate->time_amount)->toBe(1);
    expect($rate->time_unit)->toBe('minute');

    // Footer — Standard has no HAS_CUSTOM_FOOTER
    expect(\App\Services\Plans\Gates\EntitlementGate::allows(
        $this->customer,
        \App\Services\Plans\Entitlements\EntitlementKey::HAS_CUSTOM_FOOTER
    ))->toBeFalse();
});