create([ 'vat_id' => 'DE123456789', 'vat_id_verified_at' => now(), 'vat_id_verified_value' => 'DE123456789', ]); } it('records the sale the moment a contract is opened', function () { Queue::fake(); $this->postJson(route('webhooks.stripe'), [ 'id' => 'evt_reg', 'type' => 'checkout.session.completed', 'data' => ['object' => [ 'id' => 'cs_reg', 'payment_status' => 'paid', 'customer_details' => ['email' => 'register@example.com', 'name' => 'Kanzlei Berger'], // Stripe's amount_total is what was charged: net plus 20 % VAT. 'amount_total' => 21480, 'currency' => 'eur', 'metadata' => ['plan' => 'team', 'datacenter' => 'fsn'], ]], ])->assertOk(); $record = SubscriptionRecord::query()->sole(); // Flat columns, because these are what gets searched and relied on. expect($record->event)->toBe('purchase') ->and($record->plan_key)->toBe('team') ->and($record->plan_version)->toBe(1) ->and($record->term)->toBe('monthly') ->and($record->net_cents)->toBe(17900) // what was agreed ->and($record->gross_cents)->toBe(21480) // what was charged ->and($record->tax_cents)->toBe(3580) ->and($record->snapshot['amounts']['matches_catalogue'])->toBeTrue() ->and($record->currency)->toBe('EUR') ->and($record->reverse_charge)->toBeFalse() ->and($record->customer_name)->toBe('Kanzlei Berger') ->and($record->stripe_event_id)->toBe('cs_reg'); // Plus the whole snapshot, for the questions nobody has asked yet. expect($record->snapshot['plan']['capabilities']['ram_mb'])->toBe(8192) ->and($record->snapshot['customer']['email'])->toBe('register@example.com') ->and($record->snapshot_version)->toBe(1); }); it('shows the customer their whole monthly bill, modules included', function () { $subscription = Subscription::factory()->plan('team')->create(); $instance = Instance::factory()->create([ 'customer_id' => $subscription->customer_id, 'host_id' => Host::factory()->active()->create()->id, 'plan' => 'team', 'status' => 'active', ]); $subscription->update(['instance_id' => $instance->id]); app(BookAddon::class)($subscription, 'priority_support'); $user = User::factory()->create(['email' => $subscription->customer->email]); $page = Livewire\Livewire::actingAs($user)->test(Billing::class); // The plan alone is not what they pay once a module is booked. expect($page->viewData('totalMonthlyCents'))->toBe(17900 + 2900); // And it reaches the page, not only the component. expect($page->html())->toContain('Gesamt inkl. Module')->toContain('208,00'); }); it('refuses to be edited or deleted, in bulk as well as one at a time', function () { $subscription = Subscription::factory()->plan('team')->create(); $record = app(RecordCommercialEvent::class)('purchase', $subscription, 17900); // A careless data fix takes exactly this shape, and model events do not // fire for it. expect(fn () => SubscriptionRecord::query()->where('id', $record->id)->update(['net_cents' => 1])) ->toThrow(RuntimeException::class, 'append-only'); expect(fn () => SubscriptionRecord::query()->where('id', $record->id)->delete()) ->toThrow(RuntimeException::class, 'append-only'); // A register that can be rewritten proves nothing; corrections are recorded, // not applied in place. expect(fn () => $record->update(['net_cents' => 1])) ->toThrow(RuntimeException::class, 'append-only'); expect(fn () => $record->delete())->toThrow(RuntimeException::class, 'append-only') ->and(SubscriptionRecord::query()->count())->toBe(1) ->and($record->fresh()->net_cents)->toBe(17900); }); it('books a module at today\'s price and freezes it there', function () { $subscription = Subscription::factory()->plan('team')->create(); app(BookAddon::class)($subscription, 'priority_support'); $addon = SubscriptionAddon::query()->sole(); expect($addon->price_cents)->toBe(2900) ->and($addon->addon_key)->toBe('priority_support') ->and($addon->isActive())->toBeTrue(); // The catalogue moves; this customer does not. config()->set('provisioning.addons.priority_support.price_cents', 4900); $offered = app(AddonCatalogue::class)->forSubscription($subscription->fresh()); expect($offered['priority_support']['price_cents'])->toBe(2900) ->and($offered['priority_support']['booked'])->toBeTrue() // One they have NOT booked is a sale still to be made, at today's price. ->and($offered['collabora_pro']['price_cents'])->toBe(1900) ->and($offered['collabora_pro']['booked'])->toBeFalse(); }); it('adds booked modules to the monthly total, all of it frozen', function () { $subscription = Subscription::factory()->plan('team')->create(); app(BookAddon::class)($subscription, 'priority_support'); // 29,00 app(BookAddon::class)($subscription, 'extra_backups'); // 5,00 // Everything moves in the catalogue afterwards. config()->set('provisioning.addons.priority_support.price_cents', 9900); config()->set('provisioning.addons.extra_backups.price_cents', 9900); expect($subscription->fresh()->totalMonthlyCents())->toBe(17900 + 2900 + 500); }); it('counts a yearly contract and its modules per month', function () { $subscription = Subscription::factory()->plan('team', 'yearly')->create(); app(BookAddon::class)($subscription, 'priority_support'); // The plan is stored for the whole year; the module is per month. expect($subscription->fresh()->totalMonthlyCents())->toBe(17900 + 2900); }); it('refuses to reprice a module that is already booked', function () { $subscription = Subscription::factory()->plan('team')->create(); $addon = app(BookAddon::class)($subscription, 'priority_support'); expect(fn () => $addon->update(['price_cents' => 9900])) ->toThrow(RuntimeException::class, 'frozen'); }); it('keeps a cancelled module on record, and off the bill', function () { $subscription = Subscription::factory()->plan('team')->create(); $addon = app(BookAddon::class)($subscription, 'priority_support'); app(BookAddon::class)->cancel($addon); // A second request on a stale instance must not enter the event twice. app(BookAddon::class)->cancel($subscription->addons()->sole()); expect(SubscriptionRecord::query()->where('event', 'addon_cancelled')->count())->toBe(1) ->and($subscription->fresh()->totalMonthlyCents())->toBe(17900) // What they were paying, and until when, is evidence. ->and(SubscriptionAddon::query()->sole()->cancelled_at)->not->toBeNull() ->and(app(AddonCatalogue::class)->forSubscription($subscription->fresh())['priority_support']['booked'])->toBeFalse(); $events = SubscriptionRecord::query()->pluck('event')->all(); expect($events)->toContain('addon_booked')->toContain('addon_cancelled'); }); it('books a module once when the same order arrives twice', function () { $subscription = Subscription::factory()->plan('team')->create(); $order = Order::factory()->create([ 'customer_id' => $subscription->customer_id, 'type' => 'addon', 'addon_key' => 'collabora_pro', ]); app(BookAddon::class)($subscription, 'collabora_pro', 1, $order); app(BookAddon::class)($subscription, 'collabora_pro', 1, $order); $record = SubscriptionRecord::query()->where('event', 'addon_booked')->sole(); expect(SubscriptionAddon::query()->count())->toBe(1) // Filed under the add-on's own order, not the plan purchase that // opened the contract — otherwise nothing reconciles. ->and($record->order_id)->toBe($order->id) ->and($record->order_id)->not->toBe($subscription->order_id); }); it('sums several bookings of one module rather than showing an arbitrary one', function () { $subscription = Subscription::factory()->plan('team')->create(); // Two storage packs, bought at different times and different prices. app(BookAddon::class)($subscription, AddonCatalogue::STORAGE); config()->set('provisioning.storage_addon.price_cents', 1500); app(BookAddon::class)($subscription, AddonCatalogue::STORAGE); $storage = app(AddonCatalogue::class)->forSubscription($subscription->fresh())[AddonCatalogue::STORAGE]; expect($storage['quantity'])->toBe(2) ->and($storage['monthly_cents'])->toBe(2500) // 10,00 + 15,00 // No single honest figure when the bookings disagree, so none is shown. ->and($storage['price_cents'])->toBeNull() ->and($storage['bookings'])->toHaveCount(2) // And the page cannot disagree with the bill. ->and($subscription->fresh()->totalMonthlyCents())->toBe(17900 + 2500); }); it('records what was actually charged, not only what the catalogue says', function () { Queue::fake(); $this->postJson(route('webhooks.stripe'), [ 'id' => 'evt_coupon', 'type' => 'checkout.session.completed', 'data' => ['object' => [ 'id' => 'cs_coupon', 'payment_status' => 'paid', 'customer_details' => ['email' => 'gutschein@example.com'], // A discount was applied: less than the catalogue price plus VAT. 'amount_total' => 14900, 'currency' => 'eur', 'metadata' => ['plan' => 'team', 'datacenter' => 'fsn'], ]], ])->assertOk(); $record = SubscriptionRecord::query()->sole(); // The transaction, split by the rate that applied: a discount lowers the // taxable amount, it does not produce negative VAT. expect($record->gross_cents)->toBe(14900) // what the bank saw ->and($record->net_cents)->toBe(12417) ->and($record->tax_cents)->toBe(2483) ->and($record->net_cents + $record->tax_cents)->toBe($record->gross_cents) // And what was agreed is kept beside it, rather than reconciled away — // it is exactly the thing someone asks about later. ->and($record->snapshot['amounts']['agreed_net_cents'])->toBe(17900) ->and($record->snapshot['amounts']['expected_gross_cents'])->toBe(21480) ->and($record->snapshot['amounts']['matches_catalogue'])->toBeFalse(); }); it('records a free checkout as free, not as paid in full', function () { Queue::fake(); $this->postJson(route('webhooks.stripe'), [ 'id' => 'evt_free', 'type' => 'checkout.session.completed', 'data' => ['object' => [ 'id' => 'cs_free', 'payment_status' => 'paid', 'customer_details' => ['email' => 'gratis@example.com'], 'amount_total' => 0, // fully discounted 'currency' => 'eur', 'metadata' => ['plan' => 'team', 'datacenter' => 'fsn'], ]], ])->assertOk(); $record = SubscriptionRecord::query()->sole(); expect($record->gross_cents)->toBe(0) ->and($record->net_cents)->toBe(0) ->and($record->tax_cents)->toBe(0) ->and($record->snapshot['amounts']['agreed_net_cents'])->toBe(17900) ->and($record->snapshot['amounts']['matches_catalogue'])->toBeFalse(); }); it('carries the booked modules into the snapshot of a later event', function () { $subscription = Subscription::factory()->plan('team')->create(); app(BookAddon::class)($subscription, 'priority_support'); $record = app(RecordCommercialEvent::class)('upgrade', $subscription->fresh(), 5000); expect($record->snapshot['addons'])->toHaveCount(1) ->and($record->snapshot['addons'][0]['key'])->toBe('priority_support') ->and($record->snapshot['addons'][0]['price_cents'])->toBe(2900); }); it('records a customer name that later disappears', function () { $subscription = Subscription::factory()->plan('team')->create(); $customer = $subscription->customer; $record = app(RecordCommercialEvent::class)('purchase', $subscription, 17900); $customer->delete(); // The link goes; what happened does not. expect($record->fresh()->customer_id)->toBeNull() ->and($record->fresh()->customer_name)->toBe($customer->name) ->and($record->fresh()->net_cents)->toBe(17900) ->and($record->fresh()->plan_key)->toBe('team'); }); it('states net, tax and gross rather than leaving them to be recomputed', function () { $subscription = Subscription::factory()->plan('team')->create(['customer_id' => reverseChargeCustomer()->id]); // What a real reverse-charge purchase records: OpenSubscription always passes // the charged figure, and for this customer that figure is the BARE NET — // their checkout used the net Stripe Price, because reverse charge means no // VAT is owed to us at all. $record = app(RecordCommercialEvent::class)('purchase', $subscription, 17900, chargedGrossCents: 17900); // The whole of it is net at 0 %. Recorded as it stood on the day — a rate // that changes later must not change the answer for this sale. expect($record->net_cents)->toBe(17900) ->and($record->tax_cents)->toBe(0) ->and($record->gross_cents)->toBe(17900) ->and($record->reverse_charge)->toBeTrue(); }); it('calls a correctly charged reverse-charge sale a match, and an overcharged one a mismatch', function () { $subscription = Subscription::factory()->plan('team')->create(['customer_id' => reverseChargeCustomer()->id]); // 179,00 net is the WHOLE of what this customer owes: no VAT is charged to // them, so there is none in the price either, and their checkout used the net // Stripe Price. The register expects that figure, and finding it is a match. $record = app(RecordCommercialEvent::class)('purchase', $subscription, 17900, chargedGrossCents: 17900); expect($record->snapshot['amounts']['expected_gross_cents'])->toBe(17900) ->and($record->snapshot['amounts']['charged_gross_cents'])->toBe(17900) ->and($record->snapshot['amounts']['matches_catalogue'])->toBeTrue(); // And now it catches the thing it is for, in the direction that actually cost // somebody money: the domestic gross taken from a reverse-charge business is a // flat 20 % surcharge with no VAT line anywhere for them to reclaim. That is // the defect this whole change ends, and the flag whose job is "somebody must // look at this" has to be the thing that would have found it. $overcharged = app(RecordCommercialEvent::class)('purchase', $subscription, 17900, chargedGrossCents: 21480); expect($overcharged->snapshot['amounts']['matches_catalogue'])->toBeFalse(); // A short charge is still a mismatch, in the other direction. $short = app(RecordCommercialEvent::class)('purchase', $subscription, 17900, chargedGrossCents: 14900); expect($short->snapshot['amounts']['matches_catalogue'])->toBeFalse(); }); it('says it does not know whether the right amount was taken when nothing was taken', function () { $subscription = Subscription::factory()->plan('team')->create(); // A plan change moves terms; Stripe raises the proration invoice afterwards // and that arrives as its own event with the real money on it. This row used // to be stamped with the expected figure as though it had been charged, and // then to agree with itself — "yes, the right amount was taken" about an // event where nothing was. $record = app(RecordCommercialEvent::class)('upgrade', $subscription, 5000); expect($record->snapshot['amounts']['charged_gross_cents'])->toBeNull() ->and($record->snapshot['amounts']['matches_catalogue'])->toBeNull() // What was agreed is still stated, which is the whole point of keeping it. ->and($record->snapshot['amounts']['agreed_net_cents'])->toBe(5000) ->and($record->snapshot['amounts']['expected_gross_cents'])->toBe(6000); }); it('holds a renewal against the whole term, modules included', function () { $subscription = Subscription::factory()->plan('team')->create(); $subscription->update(['stripe_subscription_id' => 'sub_renewal_modules']); app(BookAddon::class)($subscription, 'priority_support'); // 29,00 net / month $start = now()->startOfDay(); // What Stripe bills for the cycle: the package item AND the module item, each // at the gross of its net figure. Held against `price_cents` alone, every // renewal on a contract with a single module was filed as charged at the // wrong amount — the one thing this flag exists to point at, false for a // customer whose bill was correct to the cent. app(ApplyStripeBillingEvent::class)->invoicePaid([ 'id' => 'in_renewal_modules', 'subscription' => 'sub_renewal_modules', 'billing_reason' => 'subscription_cycle', 'period_start' => $start->getTimestamp(), 'period_end' => $start->copy()->addMonth()->getTimestamp(), 'amount_paid' => 24960, // (179,00 + 29,00) + 20 % ]); $record = SubscriptionRecord::query()->where('event', SubscriptionRecord::EVENT_RENEWAL)->sole(); expect($record->snapshot['amounts']['agreed_net_cents'])->toBe(17900 + 2900) ->and($record->snapshot['amounts']['expected_gross_cents'])->toBe(24960) ->and($record->snapshot['amounts']['matches_catalogue'])->toBeTrue(); });