'CluPilot Cloud e.U.', 'address' => 'Dreherstraße 66/1/8', 'postcode' => '1110', 'city' => 'Wien', 'vat_id' => 'ATU00000000', ]); }); afterEach(function () { Carbon::setTestNow(); }); /** A Stripe that answers, with the catalogue — packages and modules — mirrored into it. */ function addonStripe(): FakeStripeClient { $fake = new FakeStripeClient; app()->instance(StripeClient::class, $fake); app(Kernel::class)->call('stripe:sync-catalogue'); return $fake; } /** A paying contract with somebody on it to send a document to. */ function addonContract(string $plan = 'team'): Subscription { $customer = Customer::factory()->create([ 'name' => 'Berger GmbH', 'email' => 'kundin@example.com', ]); return Subscription::factory()->plan($plan)->create([ 'customer_id' => $customer->id, 'stripe_subscription_id' => 'sub_mod', 'stripe_item_id' => 'si_plan', 'current_period_start' => now(), 'current_period_end' => now()->addMonth(), ]); } /** The Stripe Price a package is sold at on this term. */ function addonPlanPrice(Subscription $contract): string { return (string) PlanPrice::query() ->where('plan_version_id', $contract->plan_version_id) ->where('term', $contract->term) ->value('stripe_price_id'); } /** * The Stripe Price a module is sold at, monthly, at today's catalogue figure — * to a customer who is charged VAT. * * There are two Prices per module and interval now: this one and the bare net a * verified business in another member state is charged. The contracts in this * file are domestic, so the domestic treatment is the one their items bill on. */ function addonModulePrice(string $key, int $monthlyCents): string { return (string) app(AddonPrices::class)->liveFor( $key, $monthlyCents, 'EUR', Subscription::TERM_MONTHLY, TaxTreatment::domestic(), ); } /** * What Stripe actually takes for a net catalogue figure from an ordinary, * domestic customer. * * Their Price carries the GROSS, so every amount on a Stripe invoice line for them * already contains the VAT. The fixtures below state the net figure a reader * recognises from the price sheet and put it through the same call the sync does, * rather than writing 21480 and leaving the next reader to work out where it came * from. A reverse-charge business is charged the bare net instead, and that is * tested where it belongs, in ReverseChargePriceTest. */ function atTheTill(int $netCents): int { return TaxTreatment::advertisedCents($netCents); } /** * One line as Stripe puts it on an invoice. * * @return array */ function stripeLine(string $priceId, int $amount, Carbon $from, Carbon $to, int $quantity = 1, bool $proration = false): array { return [ 'id' => 'il_'.substr(sha1($priceId.$amount.$from->timestamp), 0, 8), 'amount' => $amount, 'quantity' => $quantity, 'proration' => $proration, 'period' => ['start' => $from->timestamp, 'end' => $to->timestamp], 'price' => ['id' => $priceId, 'metadata' => []], 'description' => 'Stripe’s own wording, which no customer should have to read', ]; } /** * The `invoice.paid` event, carrying the lines Stripe charged for. * * @param array> $lines * @return array */ function paidInvoice(string $id, array $lines, string $reason, Carbon $from, Carbon $to): array { // What Stripe took IS the sum of its lines. The catalogue is pushed gross, // so there is nothing to add on top — and the document has to total to this // figure exactly, which is what these tests are checking. $charged = array_sum(array_column($lines, 'amount')); return [ 'id' => 'evt_'.$id, 'type' => 'invoice.paid', 'data' => ['object' => [ 'id' => $id, 'subscription' => 'sub_mod', 'billing_reason' => $reason, 'currency' => 'eur', 'amount_paid' => $charged, 'total' => $charged, 'period_start' => $from->timestamp, 'period_end' => $to->timestamp, 'lines' => ['data' => $lines, 'has_more' => false], ]], ]; } it('gives the owner’s example four invoices, and puts package and module on the fourth', function () { Mail::fake(); Queue::fake(); $stripe = addonStripe(); $contract = addonContract(); $planPrice = addonPlanPrice($contract); $month1 = now()->copy(); $month2 = $month1->copy()->addMonth(); $month3 = $month2->copy()->addMonth(); $month4 = $month3->copy()->addMonth(); // Month one and month two: the package on its own, one document each. $this->postJson(route('webhooks.stripe'), paidInvoice( 'in_m1', [stripeLine($planPrice, atTheTill(17900), $month1, $month2)], 'subscription_cycle', $month1, $month2, ))->assertOk(); Carbon::setTestNow($month2); $this->postJson(route('webhooks.stripe'), paidInvoice( 'in_m2', [stripeLine($planPrice, atTheTill(17900), $month2, $month3)], 'subscription_cycle', $month2, $month3, ))->assertOk(); expect(Invoice::query()->count())->toBe(2); // The middle of month three: the customer books a storage pack. The item // goes onto the subscription and Stripe settles it there and then — // always_invoice, which prorates by days AND charges immediately, so the // booking produces its own invoice rather than a credit line a month later. Carbon::setTestNow($month3->copy()->addDays(14)); Subscription::query()->whereKey($contract->id)->update([ 'current_period_start' => $month3, 'current_period_end' => $month4, ]); app(BookAddon::class)($contract->fresh(), 'storage', 1); expect($stripe->itemCalls)->toHaveCount(1) ->and($stripe->itemCalls[0]['call'])->toBe('add') ->and($stripe->itemCalls[0]['proration'])->toBe(StripeClient::PRORATE_IMMEDIATELY) ->and($stripe->itemCalls[0]['price'])->toBe(addonModulePrice('storage', 1000)); // Which is the invoice Stripe then raises: half a month of the module, on // its own, in the middle of a term. The owner's fourth document. $storagePrice = addonModulePrice('storage', 1000); $this->postJson(route('webhooks.stripe'), paidInvoice( 'in_m3_storage', [stripeLine($storagePrice, atTheTill(484), now(), $month4, proration: true)], 'subscription_update', now(), $month4, ))->assertOk(); // From month four the package and the module are charged together, so they // are ONE document — which is exactly what the owner asked for, and what // separate invoices per line would not have been. Carbon::setTestNow($month4); $this->postJson(route('webhooks.stripe'), paidInvoice( 'in_m4', [ stripeLine($planPrice, atTheTill(17900), $month4, $month4->copy()->addMonth()), stripeLine($storagePrice, atTheTill(1000), $month4, $month4->copy()->addMonth()), ], 'subscription_cycle', $month4, $month4->copy()->addMonth(), ))->assertOk(); $invoices = Invoice::query()->orderBy('id')->get(); // Four documents, four numbers, in one unbroken series. expect($invoices)->toHaveCount(4) ->and($invoices->pluck('number')->all())->toBe([ 'RE-2026-0001', 'RE-2026-0002', 'RE-2026-0003', 'RE-2026-0004', ]); // The first three carry one line each: package, package, module. expect($invoices[0]->snapshot['lines'])->toHaveCount(1) ->and($invoices[1]->snapshot['lines'])->toHaveCount(1) ->and($invoices[2]->snapshot['lines'])->toHaveCount(1) ->and($invoices[2]->snapshot['lines'][0]['description'])->toBe(__('billing.storage_title')) // Prorated by days, and it says so — a figure that is not the monthly // price the customer knows has to explain itself. ->and($invoices[2]->net_cents)->toBe(484) ->and($invoices[2]->snapshot['lines'][0]['details'])->toContain(__('invoice.line_prorated')); // And the fourth carries both, on one document with one number. $fourth = $invoices[3]; expect($fourth->snapshot['lines'])->toHaveCount(2) ->and(array_column($fourth->snapshot['lines'], 'description')) ->toBe([__('billing.cart.plan', ['plan' => 'Team']), __('billing.storage_title')]) // Stripe took 226,80 € and the document says 226,80 €, split into the // net and the VAT that were inside it. The catalogue is pushed gross, so // the total is not something the document adds up to — it is the figure // it starts from and divides. ->and($fourth->gross_cents)->toBe(atTheTill(17900) + atTheTill(1000)) ->and($fourth->gross_cents)->toBe(22680) ->and($fourth->net_cents)->toBe(18900) ->and($fourth->tax_cents)->toBe(3780); // One mail per document, never two for one. Mail::assertQueuedCount(4); }); it('issues one document and consumes one number however often Stripe redelivers a module invoice', function () { Mail::fake(); Queue::fake(); addonStripe(); $contract = addonContract(); $event = paidInvoice( 'in_twice', [stripeLine(addonPlanPrice($contract), atTheTill(17900), now(), now()->addMonth())], 'subscription_cycle', now(), now()->addMonth(), ); $this->postJson(route('webhooks.stripe'), $event)->assertOk(); $this->postJson(route('webhooks.stripe'), $event)->assertOk(); // An invoice number comes out of a gapless series and can never be handed // out twice — so the second delivery must not take one, not even one it // then throws away. expect(Invoice::query()->count())->toBe(1) ->and(InvoiceSeries::query()->where('kind', 'invoice')->value('next_number'))->toBe(2); Mail::assertQueuedCount(1); }); it('keeps a cancelled module until the period end and leaves it off the next cycle invoice', function () { Mail::fake(); Queue::fake(); $stripe = addonStripe(); $contract = addonContract(); $addon = app(BookAddon::class)($contract, 'storage', 1); $item = $addon->fresh()->stripe_item_id; expect($item)->not->toBeNull() ->and($stripe->itemsOn('sub_mod'))->toHaveCount(1); // Cancelled in the middle of the term the customer has already paid for. Carbon::setTestNow(now()->addDays(10)); app(BookAddon::class)->cancelAtPeriodEnd($addon->fresh()); $addon->refresh(); // They keep it: the booking is still running, with an appointment for the // end of the term. Nothing was credited — the item simply came off, so the // NEXT cycle is smaller and this one is untouched. expect($addon->cancelled_at)->toBeNull() ->and($addon->cancels_at->timestamp)->toBe($contract->current_period_end->timestamp) ->and($stripe->itemsOn('sub_mod'))->toHaveCount(0) ->and(collect($stripe->itemCalls)->last()) ->toBe(['call' => 'remove', 'item' => $item, 'proration' => StripeClient::PRORATE_NONE]); // The cycle turns. Stripe bills the package alone, and the appointment is // kept: the module stops being held as well as being billed. Carbon::setTestNow($contract->current_period_end->copy()->addMinute()); $this->artisan('clupilot:end-cancelled-addons')->assertSuccessful(); expect($addon->fresh()->cancelled_at)->not->toBeNull(); $this->postJson(route('webhooks.stripe'), paidInvoice( 'in_after_cancel', [stripeLine(addonPlanPrice($contract), atTheTill(17900), now(), now()->addMonth())], 'subscription_cycle', now(), now()->addMonth(), ))->assertOk(); $invoice = Invoice::query()->latest('id')->sole(); expect($invoice->snapshot['lines'])->toHaveCount(1) ->and($invoice->net_cents)->toBe(17900); }); it('books a second storage pack as a quantity rather than a second item', function () { Mail::fake(); Queue::fake(); $stripe = addonStripe(); $contract = addonContract(); app(BookAddon::class)($contract, 'storage', 1); app(BookAddon::class)($contract->fresh(), 'storage', 1); // Two packs are twice as much storage on one item, not two items at the // same price — Stripe allows the latter and nothing could ever tell the two // apart afterwards. $items = $stripe->itemsOn('sub_mod'); expect($items)->toHaveCount(1) ->and(reset($items)['quantity'])->toBe(2) ->and(collect($stripe->itemCalls)->last()['call'])->toBe('quantity') // More of a pack is had now and paid for the days that are left. ->and(collect($stripe->itemCalls)->last()['proration'])->toBe(StripeClient::PRORATE_IMMEDIATELY); // Both bookings point at the one item, so a third would join it too. expect(SubscriptionAddon::query()->pluck('stripe_item_id')->unique())->toHaveCount(1); // And the document says two, at the unit price, so the reader can multiply. $this->postJson(route('webhooks.stripe'), paidInvoice( 'in_packs', [ stripeLine(addonPlanPrice($contract), atTheTill(17900), now(), now()->addMonth()), stripeLine(addonModulePrice('storage', 1000), atTheTill(2000), now(), now()->addMonth(), quantity: 2), ], 'subscription_cycle', now(), now()->addMonth(), ))->assertOk(); $storage = collect(Invoice::query()->sole()->snapshot['lines']) ->firstWhere('description', __('billing.storage_title')); expect($storage['quantity_milli'])->toBe(2000) ->and($storage['unit_net_cents'])->toBe(1000); }); it('books a module onto a granted contract without asking Stripe anything', function () { fakeServices(); Queue::fake(); $stripe = addonStripe(); // Any call at all would throw and be parked 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); $addon = app(BookAddon::class)($granted->fresh(), 'storage', 1); expect($addon->exists)->toBeTrue() ->and($addon->fresh()->stripe_item_id)->toBeNull() ->and($stripe->itemCalls)->toBeEmpty() ->and($granted->fresh()->stripe_addon_sync)->toBeNull(); // And the sweep leaves it alone too: a contract Stripe never sold is not // out of step with Stripe. $this->artisan('clupilot:sync-stripe-subscriptions')->assertSuccessful(); expect($stripe->itemCalls)->toBeEmpty(); }); it('does not undo a booking Stripe could not be told about, and finishes it on the sweep', function () { Mail::fake(); Queue::fake(); $stripe = addonStripe(); $contract = addonContract(); $stripe->failWith = 'Connection timed out'; $addon = app(BookAddon::class)($contract, 'storage', 1); // The pack is booked and, on a running machine, already delivered. Rolling // that back because an API was away would take storage off a customer who // is holding it. expect($addon->fresh()->isActive())->toBeTrue() ->and($addon->fresh()->stripe_item_id)->toBeNull(); // And what did not happen is on the contract, not only in a log line. expect($contract->fresh()->stripe_addon_sync['error'])->toContain('Connection timed out') ->and($contract->fresh()->stripe_addon_sync['attempts'])->toBe(1); // The hourly sweep finishes it once Stripe answers again — the same one // that retries a plan change that never landed. $stripe->failWith = null; $this->artisan('clupilot:sync-stripe-subscriptions')->assertSuccessful(); expect($stripe->itemsOn('sub_mod'))->toHaveCount(1) ->and($addon->fresh()->stripe_item_id)->not->toBeNull() ->and($contract->fresh()->stripe_addon_sync)->toBeNull(); }); it('gives an upgrade’s proration a document for the amount Stripe actually charged', function () { Mail::fake(); Queue::fake(); addonStripe(); $contract = addonContract(); $business = (string) PlanPrice::query() ->where('plan_version_id', Subscription::snapshotFrom('business')['plan_version_id']) ->where('term', 'monthly') ->value('stripe_price_id'); // Stripe worked this out against ITS boundaries: the days left on the small // package credited, the days left on the big one charged. A document written // from our contract snapshot would state a sum nobody was charged, which is // why this used to get no document at all — and why that was wrong. $this->postJson(route('webhooks.stripe'), paidInvoice( 'in_upgrade', [ stripeLine(addonPlanPrice($contract), atTheTill(-8950), now(), now()->addDays(15), proration: true), stripeLine($business, atTheTill(17450), now(), now()->addDays(15), proration: true), ], 'subscription_update', now(), now()->addDays(15), ))->assertOk(); $invoice = Invoice::query()->sole(); expect($invoice->snapshot['lines'])->toHaveCount(2) ->and($invoice->net_cents)->toBe(8500) ->and($invoice->tax_cents)->toBe(1700) ->and($invoice->gross_cents)->toBe(10200) ->and(array_column($invoice->snapshot['lines'], 'description'))->toBe([ __('billing.cart.plan', ['plan' => 'Team']), __('billing.cart.plan', ['plan' => 'Business']), ]); Mail::assertQueuedCount(1); }); it('keeps a line it cannot name rather than dropping it from the document', function () { Mail::fake(); Queue::fake(); addonStripe(); $contract = addonContract(); // A Price created by hand in Stripe's dashboard: money was taken for // something this catalogue has never heard of. Leaving it off would give the // customer a document whose total they cannot account for. $this->postJson(route('webhooks.stripe'), paidInvoice( 'in_unknown', [ stripeLine(addonPlanPrice($contract), atTheTill(17900), now(), now()->addMonth()), stripeLine('price_typed_by_hand', atTheTill(5000), now(), now()->addMonth()), ], 'subscription_cycle', now(), now()->addMonth(), ))->assertOk(); $invoice = Invoice::query()->sole(); expect($invoice->snapshot['lines'])->toHaveCount(2) ->and($invoice->net_cents)->toBe(22900) // Under Stripe's own description, which is at least true, rather than // under a guess or not at all. ->and($invoice->snapshot['lines'][1]['description']) ->toBe('Stripe’s own wording, which no customer should have to read'); });