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); });