CluPilotCloud/tests/Feature/Billing/StripePlanChangeTest.php

274 lines
11 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

<?php
use App\Actions\ApplyPlanChange;
use App\Actions\GrantSubscription;
use App\Actions\MoveStripeSubscriptionPrice;
use App\Models\Customer;
use App\Models\Order;
use App\Models\PlanPrice;
use App\Models\Subscription;
use App\Models\SubscriptionRecord;
use App\Services\Stripe\FakeStripeClient;
use App\Services\Stripe\StripeClient;
use Illuminate\Contracts\Console\Kernel;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Queue;
/**
* A plan change that reaches Stripe.
*
* The contract moved, the machine was resized, the register was written — and
* Stripe went on billing the old price, because nothing ever told it. A customer
* who upgraded in March paid for the smaller package every month afterwards.
*
* Two things were missing besides the call itself: the SUBSCRIPTION ITEM id,
* which is what a price hangs off and which nothing stored, and a decision about
* proration per direction.
*/
function stripeCatalogue(): FakeStripeClient
{
$fake = new FakeStripeClient;
app()->instance(StripeClient::class, $fake);
// The plan prices need their Stripe ids before anything can be moved onto
// one — that is what stripe:sync-catalogue is for.
app(Kernel::class)->call('stripe:sync-catalogue');
return $fake;
}
/** A paying contract Stripe knows about, with the item it bills through. */
function stripeContractOn(string $plan, ?string $itemId = 'si_pc'): Subscription
{
$order = Order::factory()->withSubscription()->create(['datacenter' => 'fsn', 'plan' => $plan]);
$order->subscription->update([
'stripe_subscription_id' => 'sub_pc',
'stripe_item_id' => $itemId,
]);
return $order->subscription->fresh();
}
/** The Stripe Price a package is sold at on this term. */
function stripePriceFor(string $plan, string $term = 'monthly'): string
{
return (string) PlanPrice::query()
->where('plan_version_id', Subscription::snapshotFrom($plan, $term)['plan_version_id'])
->where('term', $term)
->value('stripe_price_id');
}
it('moves an upgrade onto the new price and has Stripe settle it at once', function () {
fakeServices();
Queue::fake();
$stripe = stripeCatalogue();
$contract = stripeContractOn('team');
app(ApplyPlanChange::class)($contract, 'business');
expect($stripe->priceChanges)->toHaveCount(1);
// always_invoice, not a proration riding on the next cycle invoice: the
// cycle invoice is the one WE issue a document for, from the contract's
// frozen price, and it has to be exactly what Stripe took.
expect($stripe->priceChanges[0])->toBe([
'subscription' => 'sub_pc',
'item' => 'si_pc',
'price' => stripePriceFor('business'),
'proration' => StripeClient::PRORATE_IMMEDIATELY,
]);
$fresh = $contract->fresh();
expect($fresh->stripe_price_id)->toBe(stripePriceFor('business'))
->and($fresh->stripe_price_synced_at)->not->toBeNull()
->and($fresh->stripe_price_sync)->toBeNull();
});
it('moves a downgrade onto the smaller price and has Stripe settle nothing', function () {
fakeServices();
Queue::fake();
$stripe = stripeCatalogue();
$contract = stripeContractOn('business');
// A downgrade lands exactly at the period end, which is the only moment
// PlanChange allows one.
Carbon::setTestNow($contract->current_period_end->copy()->addHour());
app(ApplyPlanChange::class)($contract, 'team');
// Nothing to prorate at a boundary, and anything but `none` risks a stray
// credit line for a fraction of a day. The next cycle simply bills less.
expect($stripe->priceChanges)->toHaveCount(1)
->and($stripe->priceChanges[0]['price'])->toBe(stripePriceFor('team'))
->and($stripe->priceChanges[0]['proration'])->toBe(StripeClient::PRORATE_NONE);
Carbon::setTestNow();
});
it('leaves a granted contract alone and does not call Stripe at all', function () {
fakeServices();
Queue::fake();
$stripe = stripeCatalogue();
// Any call at all would throw and be recorded as a failure, so a clean
// contract afterwards is proof that none was made.
$stripe->failWith = 'Stripe must not be called for a granted contract.';
$customer = Customer::factory()->create();
$granted = app(GrantSubscription::class)($customer, admin(), 'team', 'monthly', 'fsn', 0);
app(ApplyPlanChange::class)($granted->fresh(), 'business');
$fresh = $granted->fresh();
// The change lands exactly as it does for anyone else…
expect($fresh->plan)->toBe('business')
->and(SubscriptionRecord::query()->where('event', SubscriptionRecord::EVENT_UPGRADE)->count())->toBe(1)
// …and nothing was asked of Stripe, nor recorded as owing it.
->and($stripe->priceChanges)->toBeEmpty()
->and($fresh->stripe_price_sync)->toBeNull()
->and($fresh->stripe_price_id)->toBeNull();
});
it('does not undo a change Stripe could not be told about, and says where it stopped', function () {
fakeServices();
Queue::fake();
$stripe = stripeCatalogue();
$contract = stripeContractOn('team');
$stripe->failWith = 'Connection timed out';
app(ApplyPlanChange::class)($contract, 'business');
$fresh = $contract->fresh();
// The contract and the machine have already moved. Rolling that back
// because an API was away would leave the customer on a package their
// machine no longer matches.
expect($fresh->plan)->toBe('business')
->and(SubscriptionRecord::query()->where('event', SubscriptionRecord::EVENT_UPGRADE)->count())->toBe(1)
->and($fresh->stripe_price_id)->toBeNull();
// And what did not happen is on the contract, not only in a log line that
// scrolls away: this customer is still being charged the old price.
expect($fresh->stripe_price_sync['error'])->toContain('Connection timed out')
->and($fresh->stripe_price_sync['price'])->toBe(stripePriceFor('business'))
->and($fresh->stripe_price_sync['behaviour'])->toBe(StripeClient::PRORATE_IMMEDIATELY)
->and($fresh->stripe_price_sync['attempts'])->toBe(1);
// The hourly sweep finishes it once Stripe answers again, with the
// behaviour the direction chose at the time.
$stripe->failWith = null;
$this->artisan('clupilot:sync-stripe-subscriptions')->assertSuccessful();
expect($stripe->priceChanges)->toHaveCount(1)
->and($stripe->priceChanges[0]['proration'])->toBe(StripeClient::PRORATE_IMMEDIATELY)
->and($contract->fresh()->stripe_price_sync)->toBeNull()
->and($contract->fresh()->stripe_price_id)->toBe(stripePriceFor('business'));
});
it('parks the change when the catalogue has never been mirrored into Stripe', function () {
fakeServices();
Queue::fake();
// No stripe:sync-catalogue: the prices carry no Stripe id, so there is
// nothing to move the subscription onto.
$stripe = new FakeStripeClient;
app()->instance(StripeClient::class, $stripe);
$contract = stripeContractOn('team');
app(ApplyPlanChange::class)($contract, 'business');
expect($stripe->priceChanges)->toBeEmpty()
->and($contract->fresh()->plan)->toBe('business')
->and($contract->fresh()->stripe_price_sync['error'])->toContain('stripe:sync-catalogue');
});
it('refuses to move a Stripe-billed contract onto an internal package instead of parking it forever', function () {
fakeServices();
Queue::fake();
// A full, successful sync — unlike the test above. Enterprise is
// `internal` (see plan_families.internal) and stripe:sync-catalogue
// skips every internal family on purpose (its own docblock: a Price
// cannot be deleted once minted, so a package nobody is ever charged
// for stays out of the mirror entirely). So even after a clean run its
// price carries no Stripe id — and no later run will ever add one while
// it stays internal, which is what tells this apart from the ordinary
// "not synced YET" case above that one sync run fixes.
$stripe = stripeCatalogue();
$contract = stripeContractOn('team');
app(ApplyPlanChange::class)($contract, 'enterprise');
// Refused before anything moved: the contract, the register and Stripe
// all stay exactly where they were, and nothing sits in
// stripe_price_sync for clupilot:sync-stripe-subscriptions to retry
// hourly, forever, because the price it is retrying for will never
// exist.
expect($stripe->priceChanges)->toBeEmpty()
->and($contract->fresh()->plan)->toBe('team')
->and($contract->fresh()->stripe_price_sync)->toBeNull()
->and(SubscriptionRecord::query()->where('event', SubscriptionRecord::EVENT_UPGRADE)->count())->toBe(0);
});
it('learns the subscription item from Stripes own events', function () {
$contract = stripeContractOn('team', itemId: null);
$this->postJson(route('webhooks.stripe'), [
'id' => 'evt_items', 'type' => 'customer.subscription.updated',
'data' => ['object' => [
'id' => 'sub_pc', 'status' => 'active',
'items' => ['data' => [['id' => 'si_learned', 'price' => ['id' => 'price_x']]]],
'current_period_start' => now()->timestamp,
'current_period_end' => now()->addMonth()->timestamp,
]],
])->assertOk();
// Nothing stored an item id before, and a price hangs off the item — so
// without this a plan change had no handle to swap anything with.
expect($contract->fresh()->stripe_item_id)->toBe('si_learned');
});
it('asks Stripe for the item a contract older than the column never recorded', function () {
fakeServices();
Queue::fake();
$stripe = stripeCatalogue();
$stripe->items['sub_pc'] = 'si_fetched';
$contract = stripeContractOn('team', itemId: null);
app(ApplyPlanChange::class)($contract, 'business');
// Asked once and kept: the item id survives a price swap, so no later
// change has to ask again.
expect($stripe->priceChanges[0]['item'])->toBe('si_fetched')
->and($contract->fresh()->stripe_item_id)->toBe('si_fetched');
});
it('does not ask Stripe to move a subscription that is already on the price', function () {
fakeServices();
Queue::fake();
$stripe = stripeCatalogue();
$contract = stripeContractOn('team');
app(ApplyPlanChange::class)($contract, 'business');
expect($stripe->priceChanges)->toHaveCount(1);
// Straight back through, as a retried trigger or the sweep would come. A
// second swap onto the same price would raise a proration for a move that
// is not one.
app(MoveStripeSubscriptionPrice::class)($contract->fresh(), StripeClient::PRORATE_IMMEDIATELY);
expect($stripe->priceChanges)->toHaveCount(1);
});