CluPilotCloud/tests/Feature/Billing/StripePlanChangeTest.php

246 lines
9.2 KiB
PHP
Raw Permalink 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('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);
});