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