246 lines
9.2 KiB
PHP
246 lines
9.2 KiB
PHP
<?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 Stripe’s 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);
|
||
});
|