CluPilotCloud/tests/Feature/Billing/StripeBillingTest.php

541 lines
23 KiB
PHP

<?php
use App\Models\PlanFamily;
use App\Models\PlanPrice;
use App\Models\PlanVersion;
use App\Models\StripeAddonPrice;
use App\Models\StripePendingEvent;
use App\Models\Subscription;
use App\Models\SubscriptionRecord;
use App\Services\Billing\PlanCatalogue;
use App\Services\Billing\TaxTreatment;
use App\Services\Stripe\FakeStripeClient;
use App\Services\Stripe\StripeClient;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Queue;
/**
* Stripe owns the billing cycle from here: retries, dunning, off-session SCA
* and invoice numbering are theirs, and we learn about all of it by webhook.
* Our side owns capability, because Stripe does not know how big the VM is.
*/
function fakeStripe(): FakeStripeClient
{
$fake = new FakeStripeClient;
app()->instance(StripeClient::class, $fake);
return $fake;
}
/** A contract Stripe will send events about. */
function stripeContract(string $stripeId = 'sub_1'): Subscription
{
return Subscription::factory()->plan('team')->create([
'stripe_subscription_id' => $stripeId,
'current_period_start' => now()->subMonth(),
'current_period_end' => now(),
]);
}
/** The plan half of the mirror — modules live in the same lists. */
function planPrices(FakeStripeClient $stripe): Collection
{
return collect($stripe->prices)->filter(fn ($price) => isset($price['metadata']['plan_family']));
}
function planProducts(FakeStripeClient $stripe): Collection
{
return collect($stripe->products)->filter(fn ($product) => isset($product['metadata']['plan_family']));
}
/** The module half: a Price per module and term, so a booking has one to bill on. */
function modulePrices(FakeStripeClient $stripe): Collection
{
return collect($stripe->prices)->filter(fn ($price) => isset($price['metadata']['addon']));
}
it('mirrors the catalogue into Stripe, once', function () {
$stripe = fakeStripe();
$this->artisan('stripe:sync-catalogue')->assertSuccessful();
// A product per family, a price per priced row of a published version.
expect(planProducts($stripe))->toHaveCount(4)
->and(planPrices($stripe))->toHaveCount(8)
->and(PlanFamily::query()->whereNull('stripe_product_id')->count())->toBe(0)
->and(PlanPrice::query()->whereNull('stripe_price_id')->count())->toBe(0);
// And every module we sell, on both terms: a Stripe Price carries its own
// interval, so a module on a yearly contract needs a yearly one. Without
// these a booked module could never become an item on the subscription,
// which is why it was charged in the month it was booked and never again.
$modules = array_merge(array_keys((array) config('provisioning.addons')), ['storage']);
// A yearly module Price is twelve months of the NET figure, grossed once —
// which is what a yearly customer is actually charged for it.
expect(modulePrices($stripe))->toHaveCount(count($modules) * 2)
->and(StripeAddonPrice::query()->where('addon_key', 'storage')->where('interval', 'year')->value('amount_cents'))
->toBe(TaxTreatment::chargedCents(12 * (int) config('provisioning.storage_addon.price_cents')))
->and(StripeAddonPrice::query()->where('addon_key', 'storage')->where('interval', 'year')->value('net_cents'))
->toBe(12 * (int) config('provisioning.storage_addon.price_cents'));
// A Stripe Price cannot be edited, so a second run that minted duplicates
// would leave two live prices for one plan and no way to tell them apart.
$this->artisan('stripe:sync-catalogue')->assertSuccessful();
expect(planPrices($stripe))->toHaveCount(8)
->and(modulePrices($stripe))->toHaveCount(count($modules) * 2)
->and($stripe->products)->toHaveCount(4 + count($modules));
});
it('gives each price its own recurring interval', function () {
$stripe = fakeStripe();
$this->artisan('stripe:sync-catalogue');
$intervals = planPrices($stripe)->groupBy('interval')->map->count();
// Monthly and yearly cannot share a Price, because the interval belongs to
// the Price itself.
expect($intervals['month'])->toBe(4)
->and($intervals['year'])->toBe(4);
// The GROSS figure, which is the one on the website and the one taken from
// the card. The catalogue row stays net.
$team = planPrices($stripe)->first(fn ($p) => $p['metadata']['plan_family'] === 'team' && $p['interval'] === 'month');
expect($team['amount'])->toBe(21480)
->and($team['amount'])->toBe(TaxTreatment::chargedCents(17900))
->and($team['metadata']['plan_version'])->toBe('1');
});
it('does not put an unpublished draft in the price list', function () {
$stripe = fakeStripe();
$family = PlanFamily::query()->where('key', 'team')->sole();
app(PlanCatalogue::class)->draft($family, [
'quota_gb' => 600, 'traffic_gb' => 3000, 'seats' => 30, 'ram_mb' => 8192,
'cores' => 4, 'disk_gb' => 640, 'performance' => 'enhanced', 'template_vmid' => 9000,
'features' => [],
], ['monthly' => 19900, 'yearly' => 238800]);
$this->artisan('stripe:sync-catalogue');
// A draft has promised nothing; a Price for it would be a price list entry
// for something that may never exist.
expect(planPrices($stripe))->toHaveCount(8)
->and(planPrices($stripe)->pluck('amount'))->not->toContain(19900);
});
it('does not mint a second object when a run is interrupted before the id is stored', function () {
$stripe = fakeStripe();
$this->artisan('stripe:sync-catalogue');
// A crash between Stripe creating the objects and us recording their ids.
PlanFamily::query()->update(['stripe_product_id' => null]);
PlanPrice::query()->update(['stripe_price_id' => null]);
$this->artisan('stripe:sync-catalogue')->assertSuccessful();
// Stripe replays the original answer for a repeated idempotency key, so the
// catalogue reconnects to what is already there instead of duplicating it —
// and a Price cannot be deleted afterwards to tidy up.
expect(planProducts($stripe))->toHaveCount(4)
->and(planPrices($stripe))->toHaveCount(8)
->and(PlanPrice::query()->whereNull('stripe_price_id')->count())->toBe(0);
});
it('does not revive a contract Stripe already ended', function () {
$subscription = stripeContract();
$this->postJson(route('webhooks.stripe'), [
'id' => 'evt_end', 'type' => 'customer.subscription.deleted',
'data' => ['object' => ['id' => 'sub_1', 'status' => 'canceled', 'ended_at' => now()->timestamp]],
])->assertOk();
// Stripe does not guarantee delivery order: the final invoice can land
// after the deletion.
$this->postJson(route('webhooks.stripe'), [
'id' => 'evt_late', 'type' => 'invoice.paid',
'data' => ['object' => [
'id' => 'in_late', 'subscription' => 'sub_1', 'amount_paid' => 21480,
'billing_reason' => 'subscription_cycle',
'period_start' => now()->timestamp, 'period_end' => now()->addMonth()->timestamp,
]],
])->assertOk();
$subscription->refresh();
// The payment is on record; the departed customer does not get their
// service back, and the cancellation is not recorded a second time.
expect($subscription->status)->toBe('cancelled')
->and($subscription->cancelled_at)->not->toBeNull()
->and(SubscriptionRecord::query()->where('event', 'renewal')->count())->toBe(1)
->and(SubscriptionRecord::query()->where('event', 'cancellation')->count())->toBe(1);
});
it('does not put a plan that has never been published in Stripe at all', function () {
$stripe = fakeStripe();
$family = PlanFamily::query()->create(['key' => 'agency', 'name' => 'Agentur', 'tier' => 5]);
app(PlanCatalogue::class)->draft($family, [
'quota_gb' => 600, 'traffic_gb' => 3000, 'seats' => 30, 'ram_mb' => 8192,
'cores' => 4, 'disk_gb' => 640, 'performance' => 'enhanced', 'template_vmid' => 9000,
'features' => [],
], ['monthly' => 24900, 'yearly' => 298800]);
$this->artisan('stripe:sync-catalogue');
// Not even the Product: it would be a price-list entry for something that
// has promised nothing to anyone.
expect(planProducts($stripe))->toHaveCount(4)
->and($family->fresh()->stripe_product_id)->toBeNull();
});
it('does not record the checkout\'s own invoice as a renewal', function () {
stripeContract();
$this->postJson(route('webhooks.stripe'), [
'id' => 'evt_first', 'type' => 'invoice.paid',
'data' => ['object' => [
'id' => 'in_first', 'subscription' => 'sub_1', 'amount_paid' => 21480,
// Stripe sends this for the invoice created during checkout.
'billing_reason' => 'subscription_create',
'period_start' => now()->timestamp, 'period_end' => now()->addMonth()->timestamp,
]],
])->assertOk()->assertJson(['applied' => false]);
// The purchase is already in the register; counting it again here would
// double every customer's first payment.
expect(SubscriptionRecord::query()->where('event', 'renewal')->count())->toBe(0);
});
it('does not move the term on for a proration or a manual charge', function () {
$subscription = stripeContract();
$originalEnd = $subscription->current_period_end;
$this->postJson(route('webhooks.stripe'), [
'id' => 'evt_prorata', 'type' => 'invoice.paid',
'data' => ['object' => [
'id' => 'in_prorata', 'subscription' => 'sub_1', 'amount_paid' => 4200,
'billing_reason' => 'subscription_update',
'period_start' => now()->timestamp, 'period_end' => now()->addMonth()->timestamp,
]],
])->assertOk();
// Money received, so it is on record — but a top-up does not start a new
// month, and pushing the period forward on one would give it away.
$record = SubscriptionRecord::query()->where('event', 'invoice_paid')->sole();
expect($record->gross_cents)->toBe(4200)
->and($record->snapshot['billing_reason'])->toBe('subscription_update')
->and(SubscriptionRecord::query()->where('event', 'renewal')->count())->toBe(0)
->and($subscription->fresh()->current_period_end->timestamp)->toBe($originalEnd->timestamp);
});
it('refuses an older event delivered behind a newer one', function () {
$subscription = stripeContract();
$secondTermEnd = now()->addMonths(2);
// Renewal N+1 arrives first.
$this->postJson(route('webhooks.stripe'), [
'id' => 'evt_n2', 'type' => 'invoice.paid', 'created' => now()->timestamp,
'data' => ['object' => [
'id' => 'in_n2', 'subscription' => 'sub_1', 'amount_paid' => 21480,
'billing_reason' => 'subscription_cycle',
'period_start' => now()->addMonth()->timestamp, 'period_end' => $secondTermEnd->timestamp,
]],
])->assertOk();
// Renewal N turns up late, and a failed attempt from before that.
$this->postJson(route('webhooks.stripe'), [
'id' => 'evt_n1', 'type' => 'invoice.paid', 'created' => now()->subHour()->timestamp,
'data' => ['object' => [
'id' => 'in_n1', 'subscription' => 'sub_1', 'amount_paid' => 21480,
'billing_reason' => 'subscription_cycle',
'period_start' => now()->timestamp, 'period_end' => now()->addMonth()->timestamp,
]],
])->assertOk();
$this->postJson(route('webhooks.stripe'), [
'id' => 'evt_old_fail', 'type' => 'invoice.payment_failed', 'created' => now()->subHours(2)->timestamp,
'data' => ['object' => ['id' => 'in_old', 'subscription' => 'sub_1', 'attempt_count' => 1]],
])->assertOk();
$subscription->refresh();
// The term is not shortened, and a customer who has since paid is not
// flipped back to overdue.
expect($subscription->current_period_end->timestamp)->toBe($secondTermEnd->timestamp)
->and($subscription->stripe_status)->toBe('active')
// Both payments are still on record: a late arrival is recorded, it
// just does not get to rewrite the running picture.
->and(SubscriptionRecord::query()->where('event', 'renewal')->count())->toBe(2)
->and(SubscriptionRecord::query()->where('event', 'payment_failed')->count())->toBe(1);
});
it('lets a payment that went through outrank a failure from the same second', function () {
$subscription = stripeContract();
$second = now();
// Stripe timestamps have one-second resolution, so a failed attempt and
// the retry that succeeded routinely share one.
$this->postJson(route('webhooks.stripe'), [
'id' => 'evt_ok', 'type' => 'invoice.paid', 'created' => $second->timestamp,
'data' => ['object' => [
'id' => 'in_ok', 'subscription' => 'sub_1', 'amount_paid' => 21480,
'billing_reason' => 'subscription_cycle',
'period_start' => $second->timestamp, 'period_end' => $second->copy()->addMonth()->timestamp,
]],
])->assertOk();
$this->postJson(route('webhooks.stripe'), [
'id' => 'evt_nok', 'type' => 'invoice.payment_failed', 'created' => $second->timestamp,
'data' => ['object' => ['id' => 'in_ok', 'subscription' => 'sub_1', 'attempt_count' => 1]],
])->assertOk();
// The customer has paid; a same-second failure must not say otherwise.
expect($subscription->fresh()->stripe_status)->toBe('active')
->and(SubscriptionRecord::query()->where('event', 'payment_failed')->count())->toBe(1);
});
it('holds a billing event that arrives before the contract exists, and applies it after', function () {
Queue::fake();
// The cancellation overtakes the checkout — Stripe does not deliver in
// order, and a checkout takes a moment to become a contract.
$this->postJson(route('webhooks.stripe'), [
'id' => 'evt_early', 'type' => 'customer.subscription.deleted', 'created' => now()->timestamp,
'data' => ['object' => ['id' => 'sub_early', 'status' => 'canceled', 'ended_at' => now()->timestamp]],
])->assertOk()->assertJson(['applied' => false]);
expect(StripePendingEvent::query()->count())->toBe(1);
$this->postJson(route('webhooks.stripe'), [
'id' => 'evt_checkout', 'type' => 'checkout.session.completed',
'data' => ['object' => [
'id' => 'cs_early', 'payment_status' => 'paid', 'subscription' => 'sub_early',
'customer_details' => ['email' => 'schnell@example.com'],
'amount_total' => 21480, 'currency' => 'eur',
'metadata' => ['plan' => 'team', 'datacenter' => 'fsn'],
]],
])->assertOk();
// Answering 2xx and forgetting it would have left us serving someone who
// had already left.
$subscription = Subscription::query()->sole();
expect($subscription->status)->toBe('cancelled')
->and(SubscriptionRecord::query()->where('event', 'cancellation')->count())->toBe(1)
// The holding area is a holding area, not a second event log.
->and(StripePendingEvent::query()->count())->toBe(0);
});
it('holds nothing for a contract that is right there', function () {
stripeContract();
// Deliberately skipped: the checkout's own invoice.
$this->postJson(route('webhooks.stripe'), [
'id' => 'evt_c', 'type' => 'invoice.paid', 'created' => now()->timestamp,
'data' => ['object' => [
'id' => 'in_c', 'subscription' => 'sub_1', 'amount_paid' => 21480,
'billing_reason' => 'subscription_create',
]],
])->assertOk();
// Already recorded: a redelivered renewal.
$renewal = [
'id' => 'evt_r', 'type' => 'invoice.paid', 'created' => now()->timestamp,
'data' => ['object' => [
'id' => 'in_r', 'subscription' => 'sub_1', 'amount_paid' => 21480,
'billing_reason' => 'subscription_cycle',
'period_start' => now()->timestamp, 'period_end' => now()->addMonth()->timestamp,
]],
];
$this->postJson(route('webhooks.stripe'), $renewal)->assertOk();
$this->postJson(route('webhooks.stripe'), $renewal)->assertOk();
// The holding area waits for contracts that do not exist yet. Ordinary
// traffic must not silt it up with rows nothing will ever come back for.
expect(StripePendingEvent::query()->count())->toBe(0)
->and(SubscriptionRecord::query()->where('event', 'renewal')->count())->toBe(1);
});
it('holds an event only once however often Stripe redelivers it', function () {
$event = [
'id' => 'evt_repeat', 'type' => 'invoice.paid', 'created' => now()->timestamp,
'data' => ['object' => [
'id' => 'in_repeat', 'subscription' => 'sub_nothere', 'amount_paid' => 21480,
'billing_reason' => 'subscription_cycle',
]],
];
$this->postJson(route('webhooks.stripe'), $event)->assertOk();
$this->postJson(route('webhooks.stripe'), $event)->assertOk();
expect(StripePendingEvent::query()->count())->toBe(1);
});
it('creates nothing when Stripe is not connected', function () {
$stripe = fakeStripe();
$stripe->configured = false;
$this->artisan('stripe:sync-catalogue')->assertFailed();
expect($stripe->products)->toBeEmpty()
->and(PlanFamily::query()->whereNotNull('stripe_product_id')->count())->toBe(0);
});
it('moves the period on when Stripe says a renewal was paid', function () {
$subscription = stripeContract();
$end = now()->addMonth();
$this->postJson(route('webhooks.stripe'), [
'id' => 'evt_paid', 'type' => 'invoice.paid',
'data' => ['object' => [
'id' => 'in_100', 'subscription' => 'sub_1', 'amount_paid' => 21480,
'billing_reason' => 'subscription_cycle',
'period_start' => now()->timestamp, 'period_end' => $end->timestamp,
]],
])->assertOk()->assertJson(['applied' => true]);
$subscription->refresh();
expect($subscription->current_period_end->timestamp)->toBe($end->timestamp)
->and($subscription->stripe_status)->toBe('active');
$record = SubscriptionRecord::query()->where('event', 'renewal')->sole();
// The invoice is the authority for the amount; the contract for the terms.
expect($record->gross_cents)->toBe(21480)
->and($record->stripe_invoice_id)->toBe('in_100')
->and($record->plan_key)->toBe('team');
});
it('records one renewal however many times Stripe delivers the invoice', function () {
stripeContract();
$event = [
'id' => 'evt_dup', 'type' => 'invoice.paid',
'data' => ['object' => [
'id' => 'in_dup', 'subscription' => 'sub_1', 'amount_paid' => 21480,
'billing_reason' => 'subscription_cycle',
'period_start' => now()->timestamp, 'period_end' => now()->addMonth()->timestamp,
]],
];
$this->postJson(route('webhooks.stripe'), $event)->assertOk();
$this->postJson(route('webhooks.stripe'), $event)->assertOk()->assertJson(['applied' => false]);
expect(SubscriptionRecord::query()->where('event', 'renewal')->count())->toBe(1);
});
it('records a failed payment without cutting the customer off', function () {
$subscription = stripeContract();
$this->postJson(route('webhooks.stripe'), [
'id' => 'evt_fail', 'type' => 'invoice.payment_failed',
'data' => ['object' => ['id' => 'in_fail', 'subscription' => 'sub_1', 'attempt_count' => 1]],
])->assertOk();
$subscription->refresh();
// Stripe runs the dunning schedule. Cutting someone off on the first
// failed attempt would punish an expired card as a refusal to pay.
expect($subscription->stripe_status)->toBe('past_due')
->and($subscription->status)->toBe('active')
->and(SubscriptionRecord::query()->where('event', 'payment_failed')->sole()->stripe_invoice_id)
->toBe('in_fail');
});
it('ends the contract, and records it, when Stripe ends the subscription', function () {
$subscription = stripeContract();
$endedAt = now()->addDay();
$this->postJson(route('webhooks.stripe'), [
'id' => 'evt_del', 'type' => 'customer.subscription.deleted',
'data' => ['object' => ['id' => 'sub_1', 'status' => 'canceled', 'ended_at' => $endedAt->timestamp]],
])->assertOk()->assertJson(['applied' => true]);
$subscription->refresh();
expect($subscription->status)->toBe('cancelled')
->and($subscription->cancelled_at->timestamp)->toBe($endedAt->timestamp);
$record = SubscriptionRecord::query()->where('event', 'cancellation')->sole();
expect($record->plan_key)->toBe('team');
// A retry must not record the ending twice.
$this->postJson(route('webhooks.stripe'), [
'id' => 'evt_del', 'type' => 'customer.subscription.deleted',
'data' => ['object' => ['id' => 'sub_1', 'status' => 'canceled', 'ended_at' => $endedAt->timestamp]],
])->assertOk()->assertJson(['applied' => false]);
expect(SubscriptionRecord::query()->where('event', 'cancellation')->count())->toBe(1);
});
it('copies Stripe\'s status without handing it the keys', function () {
$subscription = stripeContract();
$this->postJson(route('webhooks.stripe'), [
'id' => 'evt_upd', 'type' => 'customer.subscription.updated',
'data' => ['object' => [
'id' => 'sub_1', 'status' => 'past_due',
'current_period_start' => now()->timestamp,
'current_period_end' => now()->addMonth()->timestamp,
]],
])->assertOk();
$subscription->refresh();
// They are the authority on whether the money arrived; we stay the
// authority on what the customer may use.
expect($subscription->stripe_status)->toBe('past_due')
->and($subscription->status)->toBe('active');
});
it('carries the Stripe subscription from the checkout onto the contract', function () {
Queue::fake();
$this->postJson(route('webhooks.stripe'), [
'id' => 'evt_new', 'type' => 'checkout.session.completed',
'data' => ['object' => [
'id' => 'cs_new', 'payment_status' => 'paid', 'subscription' => 'sub_new',
'customer_details' => ['email' => 'neu@example.com'],
'amount_total' => 21480, 'currency' => 'eur',
'metadata' => ['plan' => 'team', 'datacenter' => 'fsn'],
]],
])->assertOk();
// Without this, no later billing event could be matched to the contract.
expect(Subscription::query()->sole()->stripe_subscription_id)->toBe('sub_new');
});
it('never rewrites the snapshot when a renewal arrives', function () {
$subscription = stripeContract();
// The catalogue moves on between terms.
$catalogue = app(PlanCatalogue::class);
$current = $catalogue->currentVersion('team');
$catalogue->schedule($current, $current->available_from, now());
$dearer = PlanVersion::query()->create([
...$current->only(['plan_family_id', 'quota_gb', 'traffic_gb', 'seats', 'ram_mb', 'cores', 'disk_gb', 'performance', 'template_vmid']),
'version' => 2, 'features' => $current->features, 'available_from' => now(),
]);
$dearer->prices()->create(['term' => 'monthly', 'amount_cents' => 29900, 'currency' => 'EUR']);
$dearer->prices()->create(['term' => 'yearly', 'amount_cents' => 358800, 'currency' => 'EUR']);
$catalogue->publish($dearer, now());
$this->postJson(route('webhooks.stripe'), [
'id' => 'evt_ren', 'type' => 'invoice.paid',
'data' => ['object' => [
'id' => 'in_ren', 'subscription' => 'sub_1', 'amount_paid' => 21480,
'billing_reason' => 'subscription_cycle',
'period_start' => now()->timestamp, 'period_end' => now()->addMonth()->timestamp,
]],
])->assertOk();
// A renewal moves the month, not the terms.
expect($subscription->fresh()->price_cents)->toBe(17900)
->and($subscription->fresh()->plan_version_id)->toBe($current->id);
});