From 7582df3de67757f37054d942fb50bc8877b2a7c0 Mon Sep 17 00:00:00 2001 From: nexxo Date: Sun, 26 Jul 2026 13:07:34 +0200 Subject: [PATCH] feat(billing): a proof register, and modules frozen at their booked price MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two things the contract could not answer on its own: what happened, and what the customer's whole bill is. `subscription_records` is append-only, one row per commercial event. Flat columns for everything searched or relied on as evidence — event, customer, subscription, plan family and version, term, net/tax/gross, currency, tax rate, reverse charge, Stripe ids — PLUS a versioned JSON copy of the whole snapshot. Not JSON alone: you cannot query it, and "the amount is in there somewhere" is poor evidence. Copied, not joined, so a row still answers after the customer, the plan or the version have gone. The flat columns describe the TRANSACTION. Gross is what was actually taken; net and tax are that gross split by the rate that applied on the day. A discount lowers the taxable amount rather than creating negative VAT, and a free checkout is recorded as free instead of as paid in full. What was agreed sits beside it in the snapshot, so a charge that differs from the catalogue is preserved as a question rather than reconciled away. The register refuses to be rewritten, in bulk as well as one row at a time — model events do not fire for `query()->update()`, which is exactly the shape a careless data fix takes. `subscription_addons` carries the owner's rule: a customer's total is their subscription plus their modules, and all of it is frozen at what was agreed. Price protection covering only the plan would let a module quietly double for someone who booked it two years ago. A module they have NOT booked is a sale still to be made, at today's price — so the catalogue is consulted only for what is not in this table. Several bookings of one module are summed rather than reduced to one arbitrary row, or the page and the bill would disagree. Concurrency: one order books one module, enforced by a unique index rather than a lookup two retries can both pass; cancellation is an atomic claim; and each booking commits with its own register entry, so a failure cannot leave a sale without evidence and a retry that finds the booking cannot skip the event. Verified in the browser: with the module raised from 29,00 € to 59,00 € in the catalogue, the customer who booked it still sees 29,00 € and a total of 208,00 €, while a new customer is quoted 59,00 €. 436 tests green. Codex review clean after seven rounds. Co-Authored-By: Claude Opus 5 --- app/Actions/BookAddon.php | 132 ++++++++ app/Actions/OpenSubscription.php | 40 ++- app/Actions/RecordCommercialEvent.php | 141 ++++++++ app/Livewire/Billing.php | 9 +- app/Models/Builders/AppendOnlyBuilder.php | 41 +++ app/Models/Subscription.php | 27 ++ app/Models/SubscriptionAddon.php | 84 +++++ app/Models/SubscriptionRecord.php | 105 ++++++ app/Services/Billing/AddonCatalogue.php | 89 +++++ ...60000_create_subscription_addons_table.php | 70 ++++ ...0001_create_subscription_records_table.php | 76 +++++ lang/de/billing.php | 3 + lang/en/billing.php | 3 + resources/views/livewire/billing.blade.php | 31 +- tests/Feature/Billing/ProofRegisterTest.php | 307 ++++++++++++++++++ 15 files changed, 1152 insertions(+), 6 deletions(-) create mode 100644 app/Actions/BookAddon.php create mode 100644 app/Actions/RecordCommercialEvent.php create mode 100644 app/Models/Builders/AppendOnlyBuilder.php create mode 100644 app/Models/SubscriptionAddon.php create mode 100644 app/Models/SubscriptionRecord.php create mode 100644 app/Services/Billing/AddonCatalogue.php create mode 100644 database/migrations/2026_07_26_060000_create_subscription_addons_table.php create mode 100644 database/migrations/2026_07_26_060001_create_subscription_records_table.php create mode 100644 tests/Feature/Billing/ProofRegisterTest.php diff --git a/app/Actions/BookAddon.php b/app/Actions/BookAddon.php new file mode 100644 index 0000000..a69f0cb --- /dev/null +++ b/app/Actions/BookAddon.php @@ -0,0 +1,132 @@ +priceCents($addonKey); + + if ($price === null) { + throw new RuntimeException("Unknown add-on: {$addonKey}"); + } + + if ($quantity < 1) { + throw new RuntimeException('An add-on is booked at least once.'); + } + + try { + return $this->book($subscription, $addonKey, $quantity, $order, $price); + } catch (UniqueConstraintViolationException) { + // A concurrent retry won. The unique index on (order_id, addon_key) + // is what actually enforces "one order books one module" — the + // lookup below is only the fast path, and two transactions can both + // pass it. + return SubscriptionAddon::query() + ->where('order_id', $order?->id) + ->where('addon_key', $addonKey) + ->firstOrFail(); + } + } + + private function book(Subscription $subscription, string $addonKey, int $quantity, ?Order $order, int $price): SubscriptionAddon + { + // The booking and its entry in the register commit together: if the + // record could fail afterwards, retrying the same order would find the + // add-on already there and skip the event for good. + return DB::transaction(function () use ($subscription, $addonKey, $quantity, $order, $price) { + // Idempotent against a retried webhook: one order books one module. + if ($order !== null) { + $existing = SubscriptionAddon::query() + ->where('order_id', $order->id) + ->where('addon_key', $addonKey) + ->first(); + + if ($existing !== null) { + return $existing; + } + } + + $addon = SubscriptionAddon::create([ + 'subscription_id' => $subscription->id, + 'order_id' => $order?->id, + 'addon_key' => $addonKey, + 'price_cents' => $price, + 'currency' => Subscription::catalogueCurrency(), + 'quantity' => $quantity, + 'booked_at' => now(), + ]); + + ($this->record)( + event: SubscriptionRecord::EVENT_ADDON_BOOKED, + subscription: $subscription, + netCents: $addon->monthlyCents(), + extra: ['addon' => ['key' => $addonKey, 'quantity' => $quantity, 'price_cents' => $price]], + order: $order, + stripe: ['event' => $order?->stripe_event_id], + // What was actually charged for the module, on the same terms + // as a plan purchase: a discount or a free booking has to be + // reconcilable, not reconstructed from the catalogue. + chargedGrossCents: $order?->stripe_event_id !== null ? (int) $order->amount_cents : null, + ); + + return $addon; + }); + } + + /** + * Stop charging for a module, without losing what it cost. + * + * Cancelled, not deleted: what a customer was paying, and until when, is + * part of the same record as what they bought. + */ + public function cancel(SubscriptionAddon $addon): SubscriptionAddon + { + return DB::transaction(function () use ($addon) { + // Claim the cancellation conditionally, so two requests arriving + // together write one event between them. Checking `isActive()` on + // separate instances and updating afterwards lets both through, and + // the register would show a module cancelled twice. + $claimed = SubscriptionAddon::query() + ->whereKey($addon->getKey()) + ->whereNull('cancelled_at') + ->update(['cancelled_at' => now(), 'updated_at' => now()]); + + if ($claimed === 0) { + return $addon->refresh(); + } + + $addon->refresh(); + + ($this->record)( + event: SubscriptionRecord::EVENT_ADDON_CANCELLED, + subscription: $addon->subscription, + netCents: -$addon->monthlyCents(), + extra: ['addon' => ['key' => $addon->addon_key, 'quantity' => $addon->quantity]], + order: $addon->order, + ); + + return $addon; + }); + } +} diff --git a/app/Actions/OpenSubscription.php b/app/Actions/OpenSubscription.php index 58c90cf..ecf7806 100644 --- a/app/Actions/OpenSubscription.php +++ b/app/Actions/OpenSubscription.php @@ -4,6 +4,9 @@ namespace App\Actions; use App\Models\Order; use App\Models\Subscription; +use App\Models\SubscriptionRecord; +use Illuminate\Support\Carbon; +use Illuminate\Support\Facades\DB; /** * Opens the contract a paid order bought. @@ -24,6 +27,8 @@ use App\Models\Subscription; */ class OpenSubscription { + public function __construct(private RecordCommercialEvent $record) {} + public function __invoke(Order $order, string $term = Subscription::TERM_MONTHLY): Subscription { // A retried webhook must not open a second contract for one purchase. @@ -35,7 +40,16 @@ class OpenSubscription $start = now(); - return Subscription::create(array_merge( + // The contract and its entry in the register commit together. If the + // record could fail after the subscription is in, the next webhook + // retry would find the contract, return early, and leave a paid sale + // permanently missing from the evidence. + return DB::transaction(fn () => $this->open($order, $term, $start)); + } + + private function open(Order $order, string $term, Carbon $start): Subscription + { + $subscription = Subscription::create(array_merge( // The version the order carries, when the checkout recorded one: // what the customer saw beats what happens to be on sale by the // time their payment reaches us. @@ -51,5 +65,29 @@ class OpenSubscription 'status' => 'active', ], )); + + // The sale, entered in the register the moment it happens. Written from + // the contract that was just frozen, so the evidence and the contract + // cannot describe different purchases. + ($this->record)( + event: SubscriptionRecord::EVENT_PURCHASE, + subscription: $subscription, + netCents: $subscription->price_cents, + at: $start, + stripe: ['event' => $order->stripe_event_id], + // The order carries what Stripe actually took — gross, including + // whatever tax or discount applied on the day. The contract price + // is what was agreed; this is what was paid, and the register has + // to be able to state both. + // + // Keyed on the Stripe id, not on the amount being non-zero: a fully + // discounted checkout charges zero, and reading that as "no amount + // recorded" would file a free sale as though it had been paid for + // in full. An order without a Stripe id was never charged through + // a checkout at all, and has nothing to report. + chargedGrossCents: $order->stripe_event_id !== null ? (int) $order->amount_cents : null, + ); + + return $subscription; } } diff --git a/app/Actions/RecordCommercialEvent.php b/app/Actions/RecordCommercialEvent.php new file mode 100644 index 0000000..c18430d --- /dev/null +++ b/app/Actions/RecordCommercialEvent.php @@ -0,0 +1,141 @@ + $extra merged into the JSON snapshot + * @param array $stripe event / invoice / subscription ids + */ + public function __invoke( + string $event, + Subscription $subscription, + int $netCents, + ?Carbon $at = null, + array $extra = [], + array $stripe = [], + ?int $chargedGrossCents = null, + ?Order $order = null, + ): SubscriptionRecord { + $at ??= now(); + $customer = $subscription->customer; + $tax = TaxTreatment::for($customer); + + $agreedNet = $netCents; + $expectedGross = $tax->grossCents($agreedNet); + + // The flat columns describe the TRANSACTION, not the contract: gross is + // what was taken from the customer, and net and tax are that gross + // split by the rate that applied on the day. + // + // Split, not subtracted from the agreed price. A discount lowers the + // taxable amount; it does not create negative VAT, and recording + // 14900 charged against a 179,00 contract as "minus 30,00 tax" would + // make the register wrong in exactly the case someone audits. + if ($chargedGrossCents !== null) { + $gross = $chargedGrossCents; + $net = (int) round($gross / (1 + $tax->rate)); + } else { + $net = $agreedNet; + $gross = $expectedGross; + } + + $version = $subscription->planVersion; + + return SubscriptionRecord::create([ + 'event' => $event, + + 'customer_id' => $subscription->customer_id, + 'subscription_id' => $subscription->id, + // The order this event belongs to — a booked module has its own, + // and pointing it at the original plan purchase would file every + // add-on under the wrong transaction. + 'order_id' => $order?->id ?? $subscription->order_id, + 'plan_version_id' => $subscription->plan_version_id, + + // Copied rather than joined: these have to still answer the question + // after the customer, the plan or the version have gone. + 'customer_name' => $customer?->name, + 'plan_key' => $subscription->plan, + 'plan_version' => $version?->version, + 'term' => $subscription->term, + + 'net_cents' => $net, + 'tax_cents' => $gross - $net, + 'gross_cents' => $gross, + 'currency' => $subscription->currency, + 'tax_rate' => round($tax->rate * 100, 2), + 'reverse_charge' => $tax->reverseCharge, + + 'stripe_event_id' => $stripe['event'] ?? null, + 'stripe_invoice_id' => $stripe['invoice'] ?? null, + 'stripe_subscription_id' => $stripe['subscription'] ?? null, + + 'occurred_at' => $at, + + 'snapshot_version' => SubscriptionRecord::SNAPSHOT_VERSION, + // The long tail: everything nobody has thought to ask about yet. + // The flat columns above are what gets queried. + 'snapshot' => array_merge([ + 'subscription' => $subscription->only(Subscription::FROZEN), + 'plan' => [ + 'key' => $subscription->plan, + 'version' => $version?->version, + 'version_id' => $subscription->plan_version_id, + 'family' => $version?->family?->name, + 'capabilities' => $version?->capabilities(), + ], + 'period' => [ + 'start' => $subscription->current_period_start?->toIso8601String(), + 'end' => $subscription->current_period_end?->toIso8601String(), + ], + 'addons' => $subscription->addons()->active()->get() + ->map(fn ($addon) => [ + 'key' => $addon->addon_key, + 'price_cents' => $addon->price_cents, + 'quantity' => $addon->quantity, + 'booked_at' => $addon->booked_at?->toIso8601String(), + ])->all(), + 'customer' => [ + 'name' => $customer?->name, + 'email' => $customer?->email, + 'vat_id' => $customer?->vat_id, + ], + // Kept even when they agree. A charge that differs from the + // catalogue plus tax is exactly the thing someone will ask + // about later, and reconciling it silently would erase the + // question along with the answer. + 'amounts' => [ + // What was agreed, beside what was actually taken. The + // flat columns state the transaction; this states whether + // it came out at the contract price, which is the question + // someone asks a year later. + 'agreed_net_cents' => $agreedNet, + 'expected_gross_cents' => $expectedGross, + 'charged_gross_cents' => $gross, + 'matches_catalogue' => $gross === $expectedGross, + ], + ], $extra), + ]); + } +} diff --git a/app/Livewire/Billing.php b/app/Livewire/Billing.php index d85e0b5..6142b61 100644 --- a/app/Livewire/Billing.php +++ b/app/Livewire/Billing.php @@ -6,6 +6,7 @@ use App\Livewire\Concerns\ResolvesCustomer; use App\Models\Customer; use App\Models\Order; use App\Models\Subscription; +use App\Services\Billing\AddonCatalogue; use App\Services\Billing\PlanCatalogue; use Illuminate\Support\Facades\DB; use Livewire\Attributes\Layout; @@ -167,7 +168,13 @@ class Billing extends Component // different tax treatments on the same page. 'tax' => \App\Services\Billing\TaxTreatment::for($customer), 'trafficMeter' => $instance !== null ? \App\Services\Traffic\TrafficMeter::for($instance) : null, - 'addons' => (array) config('provisioning.addons'), + // Booked modules at the price they were booked at; everything else + // at today's. Reading them all off the catalogue would re-price + // half a customer's bill behind their back. + 'addons' => collect(app(AddonCatalogue::class)->forSubscription($instance?->subscription)) + ->except(AddonCatalogue::STORAGE) + ->all(), + 'totalMonthlyCents' => $instance?->subscription?->totalMonthlyCents(), 'pending' => $customer ? $customer->orders()->where('status', 'pending')->latest('id')->get() : collect(), diff --git a/app/Models/Builders/AppendOnlyBuilder.php b/app/Models/Builders/AppendOnlyBuilder.php new file mode 100644 index 0000000..3c1f785 --- /dev/null +++ b/app/Models/Builders/AppendOnlyBuilder.php @@ -0,0 +1,41 @@ +where(...)->update(...)` + * through — which is exactly the shape a careless data fix takes. For a table + * whose value IS that it cannot be edited, the guard has to sit where the bulk + * operations pass. + * + * Not a security boundary: anyone with database access can still do as they + * please. It is a guard against the application quietly doing it by accident, + * which is the realistic way a register loses its meaning. + */ +class AppendOnlyBuilder extends Builder +{ + public function update(array $values) + { + throw new RuntimeException( + 'The proof register is append-only. Record a correcting event instead of editing history.' + ); + } + + public function delete() + { + throw new RuntimeException( + 'The proof register is append-only. A record that can be deleted is not evidence.' + ); + } + + public function forceDelete() + { + return $this->delete(); + } +} diff --git a/app/Models/Subscription.php b/app/Models/Subscription.php index 2383af6..5e35244 100644 --- a/app/Models/Subscription.php +++ b/app/Models/Subscription.php @@ -7,6 +7,7 @@ use Illuminate\Database\Eloquent\Concerns\HasUuids; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; +use Illuminate\Database\Eloquent\Relations\HasMany; use RuntimeException; class Subscription extends Model @@ -85,6 +86,18 @@ class Subscription extends Model return $this->belongsTo(PlanVersion::class, 'plan_version_id'); } + /** Modules booked onto this contract, each frozen at its booked price. */ + public function addons(): HasMany + { + return $this->hasMany(SubscriptionAddon::class); + } + + /** Every commercial event this contract has been through. */ + public function records(): HasMany + { + return $this->hasMany(SubscriptionRecord::class); + } + public function instance(): BelongsTo { return $this->belongsTo(Instance::class); @@ -173,4 +186,18 @@ class Subscription extends Model ? (int) round($this->price_cents / 12) : (int) $this->price_cents; } + + /** + * The whole monthly bill, net: the plan plus every module still booked. + * + * All of it frozen — that is the owner's rule. A total assembled from the + * contract for the plan and from today's catalogue for the modules would + * protect half a customer's price and quietly re-price the other half. + */ + public function totalMonthlyCents(): int + { + return $this->monthlyPriceCents() + $this->addons + ->where('cancelled_at', null) + ->sum(fn (SubscriptionAddon $addon) => $addon->monthlyCents()); + } } diff --git a/app/Models/SubscriptionAddon.php b/app/Models/SubscriptionAddon.php new file mode 100644 index 0000000..29cc814 --- /dev/null +++ b/app/Models/SubscriptionAddon.php @@ -0,0 +1,84 @@ +getDirty()), self::FROZEN); + + if ($frozen !== []) { + throw new RuntimeException( + 'A booked module is frozen at its booked price; tried to change: '.implode(', ', $frozen). + '. Cancel it and book it again at today\'s price instead.' + ); + } + }); + } + + protected function casts(): array + { + return [ + 'price_cents' => 'integer', + 'quantity' => 'integer', + 'booked_at' => 'datetime', + 'cancelled_at' => 'datetime', + ]; + } + + public function uniqueIds(): array + { + return ['uuid']; + } + + public function subscription(): BelongsTo + { + return $this->belongsTo(Subscription::class); + } + + public function order(): BelongsTo + { + return $this->belongsTo(Order::class); + } + + public function scopeActive(Builder $query): Builder + { + return $query->whereNull('cancelled_at'); + } + + /** Net per month for this booking, packs included. */ + public function monthlyCents(): int + { + return $this->price_cents * $this->quantity; + } + + public function isActive(): bool + { + return $this->cancelled_at === null; + } +} diff --git a/app/Models/SubscriptionRecord.php b/app/Models/SubscriptionRecord.php new file mode 100644 index 0000000..d2ddb0e --- /dev/null +++ b/app/Models/SubscriptionRecord.php @@ -0,0 +1,105 @@ + 'integer', + 'tax_cents' => 'integer', + 'gross_cents' => 'integer', + 'tax_rate' => 'decimal:2', + 'reverse_charge' => 'boolean', + 'plan_version' => 'integer', + 'snapshot_version' => 'integer', + 'snapshot' => 'array', + 'occurred_at' => 'datetime', + ]; + } + + public function uniqueIds(): array + { + return ['uuid']; + } + + /** + * The model events above only fire for instance operations. A bulk + * `query()->update()` or `->delete()` would sail straight past them, so the + * builder refuses those too. + */ + public function newEloquentBuilder($query): AppendOnlyBuilder + { + return new AppendOnlyBuilder($query); + } + + public function customer(): BelongsTo + { + return $this->belongsTo(Customer::class); + } + + public function subscription(): BelongsTo + { + return $this->belongsTo(Subscription::class); + } + + public function order(): BelongsTo + { + return $this->belongsTo(Order::class); + } + + public function planVersion(): BelongsTo + { + return $this->belongsTo(PlanVersion::class, 'plan_version_id'); + } +} diff --git a/app/Services/Billing/AddonCatalogue.php b/app/Services/Billing/AddonCatalogue.php new file mode 100644 index 0000000..0db0198 --- /dev/null +++ b/app/Services/Billing/AddonCatalogue.php @@ -0,0 +1,89 @@ +priceCents($key) !== null; + } + + /** + * Every module we sell, each answered for this customer: booked ones at + * the price they were booked at, the rest at today's. + * + * A module can be booked more than once — storage is sold in packs, and two + * orders can each carry one — and each booking keeps its own price. So the + * bookings are summed rather than reduced to one row: showing a single + * arbitrary price while charging for all of them would let the page and the + * bill say different things. + * + * @return array}> + */ + public function forSubscription(?Subscription $subscription): array + { + $booked = $subscription?->addons()->active()->orderBy('id')->get()->groupBy('addon_key') + ?? collect(); + + $keys = array_merge(array_keys((array) config('provisioning.addons')), [self::STORAGE]); + + $rows = []; + + foreach ($keys as $key) { + $own = $booked->get($key) ?? collect(); + $prices = $own->pluck('price_cents')->unique(); + + $rows[$key] = [ + 'key' => $key, + // Their price if they have exactly one, today's if they have + // none — and null when their bookings disagree, because there + // is no single honest figure to print. + 'price_cents' => match (true) { + $own->isEmpty() => $this->priceCents($key), + $prices->count() === 1 => (int) $prices->first(), + default => null, + }, + 'monthly_cents' => (int) $own->sum(fn ($addon) => $addon->monthlyCents()), + 'booked' => $own->isNotEmpty(), + 'quantity' => (int) $own->sum('quantity'), + 'bookings' => $own->map(fn ($addon) => [ + 'price_cents' => $addon->price_cents, + 'quantity' => $addon->quantity, + ])->values()->all(), + ]; + } + + return $rows; + } +} diff --git a/database/migrations/2026_07_26_060000_create_subscription_addons_table.php b/database/migrations/2026_07_26_060000_create_subscription_addons_table.php new file mode 100644 index 0000000..a64c698 --- /dev/null +++ b/database/migrations/2026_07_26_060000_create_subscription_addons_table.php @@ -0,0 +1,70 @@ +id(); + $table->uuid()->unique(); + $table->foreignId('subscription_id')->constrained()->cascadeOnDelete(); + $table->foreignId('order_id')->nullable()->constrained()->nullOnDelete(); + + // Which module, by the key the catalogue and the lang files use. + $table->string('addon_key'); + + // Frozen at booking. Net, per month, per unit. + $table->unsignedInteger('price_cents'); + $table->char('currency', 3); + + // Storage is sold in packs, so a customer can hold several of one + // module. One row per booking keeps each pack's own price. + $table->unsignedSmallInteger('quantity')->default(1); + + $table->timestamp('booked_at'); + // Cancelling sets this rather than removing the row: what a + // customer was paying, and until when, is part of the answer. + $table->timestamp('cancelled_at')->nullable(); + + $table->timestamps(); + + $table->index(['subscription_id', 'addon_key']); + $table->index(['subscription_id', 'cancelled_at']); + + // One order books one module, once. Two retries arriving together + // both see no row and both insert otherwise — and the customer's + // recurring bill quietly doubles. A booking made without an order + // (an operator granting a module) is not constrained: NULLs do not + // collide, which is the behaviour wanted here. + $table->unique(['order_id', 'addon_key']); + }); + } + + public function down(): void + { + Schema::dropIfExists('subscription_addons'); + } +}; diff --git a/database/migrations/2026_07_26_060001_create_subscription_records_table.php b/database/migrations/2026_07_26_060001_create_subscription_records_table.php new file mode 100644 index 0000000..04650d2 --- /dev/null +++ b/database/migrations/2026_07_26_060001_create_subscription_records_table.php @@ -0,0 +1,76 @@ +id(); + $table->uuid()->unique(); + + // purchase | upgrade | downgrade | addon_booked | addon_cancelled | + // cancellation + $table->string('event'); + + // Nullable and nullOnDelete throughout: a customer exercising their + // right to erasure must not take the commercial record with them, + // and the flat columns below still say what happened. + $table->foreignId('customer_id')->nullable()->constrained()->nullOnDelete(); + $table->foreignId('subscription_id')->nullable()->constrained()->nullOnDelete(); + $table->foreignId('order_id')->nullable()->constrained()->nullOnDelete(); + $table->foreignId('plan_version_id')->nullable()->constrained()->nullOnDelete(); + + // Copied, not joined. These have to survive the rows they came from. + $table->string('customer_name')->nullable(); + $table->string('plan_key'); + $table->unsignedInteger('plan_version')->nullable(); + $table->string('term'); + + $table->integer('net_cents'); + $table->integer('tax_cents'); + $table->integer('gross_cents'); + $table->char('currency', 3); + $table->decimal('tax_rate', 5, 2); + $table->boolean('reverse_charge')->default(false); + + $table->string('stripe_event_id')->nullable(); + $table->string('stripe_invoice_id')->nullable(); + $table->string('stripe_subscription_id')->nullable(); + + $table->timestamp('occurred_at'); + + $table->unsignedSmallInteger('snapshot_version')->default(1); + $table->json('snapshot'); + + $table->timestamps(); + + $table->index(['customer_id', 'occurred_at']); + $table->index(['subscription_id', 'occurred_at']); + $table->index(['event', 'occurred_at']); + $table->index('plan_key'); + }); + } + + public function down(): void + { + Schema::dropIfExists('subscription_records'); + } +}; diff --git a/lang/de/billing.php b/lang/de/billing.php index 6581433..ade0875 100644 --- a/lang/de/billing.php +++ b/lang/de/billing.php @@ -75,6 +75,9 @@ return [ 'storage_body' => 'Jederzeit erweiterbar — +:gb GB für :price pro Monat, monatlich kündbar.', 'storage_cta' => '+:gb GB buchen', 'addon_cta' => 'Hinzufügen', + 'total_with_addons' => 'Gesamt inkl. Module: :total', + 'addon_packs' => ':count Pakete', + 'addon_booked' => 'Gebucht — Preis fest', 'purchased' => 'Kauf vorgemerkt — wir schalten ihn nach der Zahlung frei.', 'mock_note' => 'Zahlung & Bereitstellung folgen nach Anbindung des Zahlungsanbieters.', diff --git a/lang/en/billing.php b/lang/en/billing.php index b389d24..3aa73a5 100644 --- a/lang/en/billing.php +++ b/lang/en/billing.php @@ -75,6 +75,9 @@ return [ 'storage_body' => 'Expandable anytime — +:gb GB for :price per month, cancel monthly.', 'storage_cta' => 'Add :gb GB', 'addon_cta' => 'Add', + 'total_with_addons' => 'Total incl. modules: :total', + 'addon_packs' => ':count packs', + 'addon_booked' => 'Booked — price fixed', 'purchased' => 'Purchase noted — we activate it after payment.', 'mock_note' => 'Payment & fulfillment follow once the payment provider is connected.', diff --git a/resources/views/livewire/billing.blade.php b/resources/views/livewire/billing.blade.php index 36a0716..73988da 100644 --- a/resources/views/livewire/billing.blade.php +++ b/resources/views/livewire/billing.blade.php @@ -17,6 +17,13 @@

{{ $eur($current['price_cents'] ?? 0) }}

{{ __('billing.net_per_month') }}

+ @if ($totalMonthlyCents !== null && $totalMonthlyCents !== ($current['price_cents'] ?? 0)) + {{-- The plan alone is not the bill once modules are booked, + and every part of it is frozen at what was agreed. --}} +

+ {{ __('billing.total_with_addons', ['total' => $eur($totalMonthlyCents)]) }} +

+ @endif
@@ -185,15 +192,31 @@

{{ __('billing.addon.'.$key.'.name') }}

{{ __('billing.addon.'.$key.'.desc') }}

-

{{ $eur($addon['price_cents']) }} / {{ __('billing.month_short') }}

+ {{-- What they actually pay for it once booked (packs and all), + and today's price while it is still a sale to be made. --}} +

+ {{ $eur($addon['booked'] ? $addon['monthly_cents'] : (int) $addon['price_cents']) }} / {{ __('billing.month_short') }} + @if ($addon['booked'] && $addon['quantity'] > 1) + · {{ __('billing.addon_packs', ['count' => $addon['quantity']]) }} + @endif +

{{ $tax->reverseCharge ? __('billing.net_reverse_charge') : __('billing.net_hint', ['percent' => $tax->percentLabel()]) }}

- - {{ __('billing.addon_cta') }} - + @if ($addon['booked']) + {{-- Already theirs, at the price they booked it for — which + is why this card does not show today's. --}} +

+ + {{ __('billing.addon_booked') }} +

+ @else + + {{ __('billing.addon_cta') }} + + @endif @endforeach diff --git a/tests/Feature/Billing/ProofRegisterTest.php b/tests/Feature/Billing/ProofRegisterTest.php new file mode 100644 index 0000000..f47ef52 --- /dev/null +++ b/tests/Feature/Billing/ProofRegisterTest.php @@ -0,0 +1,307 @@ +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 = App\Models\Instance::factory()->create([ + 'customer_id' => $subscription->customer_id, + 'host_id' => App\Models\Host::factory()->active()->create()->id, + 'plan' => 'team', 'status' => 'active', + ]); + $subscription->update(['instance_id' => $instance->id]); + + app(BookAddon::class)($subscription, 'priority_support'); + + $user = App\Models\User::factory()->create(['email' => $subscription->customer->email]); + $page = Livewire\Livewire::actingAs($user)->test(App\Livewire\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 () { + $customer = Customer::factory()->create([ + 'vat_id' => 'DE123456789', + 'vat_id_verified_at' => now(), + 'vat_id_verified_value' => 'DE123456789', + ]); + $subscription = Subscription::factory()->plan('team')->create(['customer_id' => $customer->id]); + + $record = app(RecordCommercialEvent::class)('purchase', $subscription, 17900); + + // Reverse charge, 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(); +});