Certified/tests/Feature/Billing/SubscriptionPlanChangeTest.php
2026-08-09 16:33:53 +02:00

137 lines
6.1 KiB
PHP

<?php
use App\Enums\SubscriptionPlan;
use App\Enums\SubscriptionStatus;
use App\Models\Subscription;
use App\Models\SubscriptionInvoice;
use App\Models\User;
use App\Services\Billing\SubscriptionManager;
use Illuminate\Support\Facades\Http;
beforeEach(function () {
config([
'services.moneybird.api_token' => 'test-token',
'services.moneybird.administration_id' => '123456',
]);
});
test('switching from free to starter creates a contact, invoice, and recurring template', function () {
Http::fake([
'*/contacts/customer_id/*' => Http::response('', 404),
'*/contacts.json' => Http::response(['id' => 'contact-1'], 201),
'*/sales_invoices.json' => Http::response([
'id' => 'invoice-1',
'state' => 'open',
'invoice_date' => today()->toDateString(),
'due_date' => today()->addDays(14)->toDateString(),
'payment_url' => 'https://moneybird.dev/pay/invoice-1',
], 201),
'*/recurring_sales_invoices.json' => Http::response(['id' => 'recurring-1'], 201),
]);
$user = User::factory()->create();
$subscription = app(SubscriptionManager::class)->changePlan($user, SubscriptionPlan::Starter);
expect($subscription->plan)->toBe(SubscriptionPlan::Starter)
->and($subscription->status)->toBe(SubscriptionStatus::AwaitingPayment)
->and($subscription->moneybird_contact_id)->toBe('contact-1')
->and($subscription->moneybird_recurring_invoice_id)->toBe('recurring-1')
->and($subscription->current_period_starts_at->toDateString())->toBe(today()->toDateString())
->and($subscription->current_period_ends_at->toDateString())->toBe(today()->addYear()->toDateString());
$invoice = SubscriptionInvoice::sole();
expect($invoice->moneybird_invoice_id)->toBe('invoice-1')
->and($invoice->amount_cents)->toBe(SubscriptionPlan::Starter->priceInCents())
->and($invoice->payment_url)->toBe('https://moneybird.dev/pay/invoice-1');
Http::assertSent(fn ($request) => str_contains((string) $request->url(), '/contacts.json')
&& $request['contact']['customer_id'] === (string) $user->id);
});
test('an existing moneybird contact is reused instead of creating a duplicate', function () {
Http::fake([
'*/contacts/customer_id/*' => Http::response(['id' => 'existing-contact'], 200),
'*/sales_invoices.json' => Http::response(['id' => 'invoice-1', 'state' => 'open'], 201),
'*/recurring_sales_invoices.json' => Http::response(['id' => 'recurring-1'], 201),
]);
$user = User::factory()->create();
$subscription = app(SubscriptionManager::class)->changePlan($user, SubscriptionPlan::Starter);
expect($subscription->moneybird_contact_id)->toBe('existing-contact');
Http::assertNotSent(fn ($request) => str_contains((string) $request->url(), '/contacts.json') && $request->method() === 'POST');
});
test('upgrading between paid plans bills a prorated top-up and repoints the recurring template', function () {
Http::fake([
'*/sales_invoices.json' => Http::response([
'id' => 'invoice-2',
'state' => 'open',
'invoice_date' => today()->toDateString(),
], 201),
'*/recurring_sales_invoices/recurring-1.json' => Http::response(['id' => 'recurring-1'], 200),
]);
$user = User::factory()->create();
$subscription = Subscription::factory()->for($user)->starter()->create([
'moneybird_contact_id' => 'contact-1',
'moneybird_recurring_invoice_id' => 'recurring-1',
'current_period_starts_at' => today(),
'current_period_ends_at' => today()->addYear(),
]);
$updated = app(SubscriptionManager::class)->changePlan($user, SubscriptionPlan::Unlimited);
expect($updated->plan)->toBe(SubscriptionPlan::Unlimited)
->and($updated->status)->toBe(SubscriptionStatus::AwaitingPayment)
// Full year remaining, so the prorated top-up is the full price diff.
->and($updated->current_period_ends_at->toDateString())->toBe($subscription->current_period_ends_at->toDateString());
$invoice = SubscriptionInvoice::sole();
expect($invoice->amount_cents)->toBe(SubscriptionPlan::Unlimited->priceInCents() - SubscriptionPlan::Starter->priceInCents());
Http::assertSent(fn ($request) => str_contains((string) $request->url(), '/recurring_sales_invoices/recurring-1.json')
&& $request->method() === 'PATCH');
});
test('downgrading to a cheaper paid plan does not create an invoice', function () {
Http::fake([
'*/recurring_sales_invoices/recurring-1.json' => Http::response(['id' => 'recurring-1'], 200),
]);
$user = User::factory()->create();
Subscription::factory()->for($user)->unlimited()->create([
'moneybird_recurring_invoice_id' => 'recurring-1',
'current_period_starts_at' => today(),
'current_period_ends_at' => today()->addYear(),
]);
$updated = app(SubscriptionManager::class)->changePlan($user, SubscriptionPlan::Starter);
expect($updated->plan)->toBe(SubscriptionPlan::Starter);
expect(SubscriptionInvoice::count())->toBe(0);
Http::assertNotSent(fn ($request) => str_contains((string) $request->url(), '/sales_invoices.json'));
});
test('switching to free deletes the recurring template and resets the subscription', function () {
Http::fake([
'*/recurring_sales_invoices/recurring-1.json' => Http::response('', 204),
]);
$user = User::factory()->create();
Subscription::factory()->for($user)->starter()->create([
'moneybird_recurring_invoice_id' => 'recurring-1',
]);
$subscription = app(SubscriptionManager::class)->changePlan($user, SubscriptionPlan::Free);
expect($subscription->plan)->toBe(SubscriptionPlan::Free)
->and($subscription->status)->toBe(SubscriptionStatus::Active)
->and($subscription->moneybird_recurring_invoice_id)->toBeNull()
->and($subscription->current_period_ends_at)->toBeNull();
Http::assertSent(fn ($request) => str_contains((string) $request->url(), '/recurring_sales_invoices/recurring-1.json')
&& $request->method() === 'DELETE');
});