388 lines
18 KiB
PHP
388 lines
18 KiB
PHP
<?php
|
|
|
|
use App\Actions\ApplyStripeBillingEvent;
|
|
use App\Actions\BookAddon;
|
|
use App\Actions\RecordCommercialEvent;
|
|
use App\Livewire\Billing;
|
|
use App\Models\Customer;
|
|
use App\Models\Host;
|
|
use App\Models\Instance;
|
|
use App\Models\Order;
|
|
use App\Models\Subscription;
|
|
use App\Models\SubscriptionAddon;
|
|
use App\Models\SubscriptionRecord;
|
|
use App\Models\User;
|
|
use App\Services\Billing\AddonCatalogue;
|
|
use Illuminate\Support\Facades\Queue;
|
|
|
|
/**
|
|
* The register answers, years later, what a customer was sold and for how much
|
|
* — without reconstructing it from tables that have moved on since.
|
|
*/
|
|
|
|
/** A business in another member state, its VAT ID verified — reverse charge. */
|
|
function reverseChargeCustomer(): Customer
|
|
{
|
|
return Customer::factory()->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 the charged figure is the domestic gross, because a
|
|
// Stripe Price carries one amount and everybody pays the one they were shown.
|
|
$record = app(RecordCommercialEvent::class)('purchase', $subscription, 17900, chargedGrossCents: 21480);
|
|
|
|
// 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(21480)
|
|
->and($record->tax_cents)->toBe(0)
|
|
->and($record->gross_cents)->toBe(21480)
|
|
->and($record->reverse_charge)->toBeTrue();
|
|
});
|
|
|
|
it('does not call a reverse-charge sale charged at the advertised amount a mismatch', function () {
|
|
$subscription = Subscription::factory()->plan('team')->create(['customer_id' => reverseChargeCustomer()->id]);
|
|
|
|
// 179,00 net at the till is 214,80 for everybody — the figure on the website,
|
|
// the figure on the Stripe Price, the figure taken from the card. The flag was
|
|
// holding it against the customer's OWN rate, which is zero, and so reported
|
|
// every single EU business sale as charged at the wrong amount. A flag that
|
|
// means "somebody must look at this" cannot be false for the healthy majority.
|
|
$record = app(RecordCommercialEvent::class)('purchase', $subscription, 17900, chargedGrossCents: 21480);
|
|
|
|
expect($record->snapshot['amounts']['expected_gross_cents'])->toBe(21480)
|
|
->and($record->snapshot['amounts']['charged_gross_cents'])->toBe(21480)
|
|
->and($record->snapshot['amounts']['matches_catalogue'])->toBeTrue();
|
|
|
|
// And it still catches the thing it is for: a reverse-charge customer who was
|
|
// charged something other than the advertised amount.
|
|
$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();
|
|
});
|