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