feat(billing): a proof register, and modules frozen at their booked price
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 <noreply@anthropic.com>feat/portal-design
parent
f6ddf45dc0
commit
7582df3de6
|
|
@ -0,0 +1,132 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Actions;
|
||||||
|
|
||||||
|
use App\Models\Order;
|
||||||
|
use App\Models\Subscription;
|
||||||
|
use App\Models\SubscriptionAddon;
|
||||||
|
use App\Models\SubscriptionRecord;
|
||||||
|
use App\Services\Billing\AddonCatalogue;
|
||||||
|
use Illuminate\Database\UniqueConstraintViolationException;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use RuntimeException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Books a module onto a contract at today's price, and freezes it there.
|
||||||
|
*
|
||||||
|
* Booking is the moment the module's price stops moving for this customer, for
|
||||||
|
* exactly the reason the plan's price does: they agreed to a figure. What they
|
||||||
|
* have NOT booked stays on the live catalogue — that is a sale still to be
|
||||||
|
* made, at whatever it costs now.
|
||||||
|
*/
|
||||||
|
class BookAddon
|
||||||
|
{
|
||||||
|
public function __construct(private RecordCommercialEvent $record) {}
|
||||||
|
|
||||||
|
public function __invoke(Subscription $subscription, string $addonKey, int $quantity = 1, ?Order $order = null): SubscriptionAddon
|
||||||
|
{
|
||||||
|
$price = app(AddonCatalogue::class)->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;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -4,6 +4,9 @@ namespace App\Actions;
|
||||||
|
|
||||||
use App\Models\Order;
|
use App\Models\Order;
|
||||||
use App\Models\Subscription;
|
use App\Models\Subscription;
|
||||||
|
use App\Models\SubscriptionRecord;
|
||||||
|
use Illuminate\Support\Carbon;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Opens the contract a paid order bought.
|
* Opens the contract a paid order bought.
|
||||||
|
|
@ -24,6 +27,8 @@ use App\Models\Subscription;
|
||||||
*/
|
*/
|
||||||
class OpenSubscription
|
class OpenSubscription
|
||||||
{
|
{
|
||||||
|
public function __construct(private RecordCommercialEvent $record) {}
|
||||||
|
|
||||||
public function __invoke(Order $order, string $term = Subscription::TERM_MONTHLY): Subscription
|
public function __invoke(Order $order, string $term = Subscription::TERM_MONTHLY): Subscription
|
||||||
{
|
{
|
||||||
// A retried webhook must not open a second contract for one purchase.
|
// A retried webhook must not open a second contract for one purchase.
|
||||||
|
|
@ -35,7 +40,16 @@ class OpenSubscription
|
||||||
|
|
||||||
$start = now();
|
$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:
|
// The version the order carries, when the checkout recorded one:
|
||||||
// what the customer saw beats what happens to be on sale by the
|
// what the customer saw beats what happens to be on sale by the
|
||||||
// time their payment reaches us.
|
// time their payment reaches us.
|
||||||
|
|
@ -51,5 +65,29 @@ class OpenSubscription
|
||||||
'status' => 'active',
|
'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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,141 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Actions;
|
||||||
|
|
||||||
|
use App\Models\Order;
|
||||||
|
use App\Models\Subscription;
|
||||||
|
use App\Models\SubscriptionRecord;
|
||||||
|
use App\Services\Billing\TaxTreatment;
|
||||||
|
use Illuminate\Support\Carbon;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Writes one row into the proof register.
|
||||||
|
*
|
||||||
|
* Every commercial event goes through here so that the flat columns are filled
|
||||||
|
* the same way each time — an evidence table where the amount means one thing
|
||||||
|
* in one row and another in the next is not evidence.
|
||||||
|
*
|
||||||
|
* Amounts are stated three ways on purpose. Net is what the catalogue and the
|
||||||
|
* contract deal in; gross is what was actually charged; the tax between them
|
||||||
|
* depends on the customer and on the day, and recomputing it later from a rate
|
||||||
|
* that has since changed would produce a different answer to the one on the
|
||||||
|
* invoice.
|
||||||
|
*/
|
||||||
|
class RecordCommercialEvent
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $extra merged into the JSON snapshot
|
||||||
|
* @param array<string, string|null> $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),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -6,6 +6,7 @@ use App\Livewire\Concerns\ResolvesCustomer;
|
||||||
use App\Models\Customer;
|
use App\Models\Customer;
|
||||||
use App\Models\Order;
|
use App\Models\Order;
|
||||||
use App\Models\Subscription;
|
use App\Models\Subscription;
|
||||||
|
use App\Services\Billing\AddonCatalogue;
|
||||||
use App\Services\Billing\PlanCatalogue;
|
use App\Services\Billing\PlanCatalogue;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
use Livewire\Attributes\Layout;
|
use Livewire\Attributes\Layout;
|
||||||
|
|
@ -167,7 +168,13 @@ class Billing extends Component
|
||||||
// different tax treatments on the same page.
|
// different tax treatments on the same page.
|
||||||
'tax' => \App\Services\Billing\TaxTreatment::for($customer),
|
'tax' => \App\Services\Billing\TaxTreatment::for($customer),
|
||||||
'trafficMeter' => $instance !== null ? \App\Services\Traffic\TrafficMeter::for($instance) : null,
|
'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
|
'pending' => $customer
|
||||||
? $customer->orders()->where('status', 'pending')->latest('id')->get()
|
? $customer->orders()->where('status', 'pending')->latest('id')->get()
|
||||||
: collect(),
|
: collect(),
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,41 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models\Builders;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
use RuntimeException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A query builder that refuses to rewrite history.
|
||||||
|
*
|
||||||
|
* Model events (`updating`, `deleting`) only fire for instance operations, so a
|
||||||
|
* guard on the model alone still lets `Model::query()->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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -7,6 +7,7 @@ use Illuminate\Database\Eloquent\Concerns\HasUuids;
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
use RuntimeException;
|
use RuntimeException;
|
||||||
|
|
||||||
class Subscription extends Model
|
class Subscription extends Model
|
||||||
|
|
@ -85,6 +86,18 @@ class Subscription extends Model
|
||||||
return $this->belongsTo(PlanVersion::class, 'plan_version_id');
|
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
|
public function instance(): BelongsTo
|
||||||
{
|
{
|
||||||
return $this->belongsTo(Instance::class);
|
return $this->belongsTo(Instance::class);
|
||||||
|
|
@ -173,4 +186,18 @@ class Subscription extends Model
|
||||||
? (int) round($this->price_cents / 12)
|
? (int) round($this->price_cents / 12)
|
||||||
: (int) $this->price_cents;
|
: (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());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,84 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
use Illuminate\Database\Eloquent\Concerns\HasUuids;
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
use RuntimeException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One booked module, at the price it was booked at.
|
||||||
|
*
|
||||||
|
* Frozen for the same reason the subscription is: the customer agreed to a
|
||||||
|
* figure, and raising it afterwards would rewrite that agreement. Cancelling
|
||||||
|
* sets `cancelled_at` rather than deleting the row — what someone was paying,
|
||||||
|
* and until when, is evidence.
|
||||||
|
*/
|
||||||
|
class SubscriptionAddon extends Model
|
||||||
|
{
|
||||||
|
use HasFactory;
|
||||||
|
use HasUuids;
|
||||||
|
|
||||||
|
/** What the customer agreed to. */
|
||||||
|
public const FROZEN = ['subscription_id', 'addon_key', 'price_cents', 'currency', 'quantity', 'booked_at'];
|
||||||
|
|
||||||
|
protected $guarded = [];
|
||||||
|
|
||||||
|
protected static function booted(): void
|
||||||
|
{
|
||||||
|
static::updating(function (self $addon) {
|
||||||
|
$frozen = array_intersect(array_keys($addon->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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,105 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use App\Models\Builders\AppendOnlyBuilder;
|
||||||
|
use Illuminate\Database\Eloquent\Concerns\HasUuids;
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
use RuntimeException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One commercial event, written once and never again.
|
||||||
|
*
|
||||||
|
* A register that can be edited proves nothing. So the model refuses updates
|
||||||
|
* and deletes outright: a mistake is corrected by recording the correction,
|
||||||
|
* which is how ledgers have always worked and why they can be trusted.
|
||||||
|
*/
|
||||||
|
class SubscriptionRecord extends Model
|
||||||
|
{
|
||||||
|
use HasFactory;
|
||||||
|
use HasUuids;
|
||||||
|
|
||||||
|
public const EVENT_PURCHASE = 'purchase';
|
||||||
|
|
||||||
|
public const EVENT_UPGRADE = 'upgrade';
|
||||||
|
|
||||||
|
public const EVENT_DOWNGRADE = 'downgrade';
|
||||||
|
|
||||||
|
public const EVENT_ADDON_BOOKED = 'addon_booked';
|
||||||
|
|
||||||
|
public const EVENT_ADDON_CANCELLED = 'addon_cancelled';
|
||||||
|
|
||||||
|
public const EVENT_CANCELLATION = 'cancellation';
|
||||||
|
|
||||||
|
/** The shape of the `snapshot` column. Bump when it changes. */
|
||||||
|
public const SNAPSHOT_VERSION = 1;
|
||||||
|
|
||||||
|
protected $guarded = [];
|
||||||
|
|
||||||
|
protected static function booted(): void
|
||||||
|
{
|
||||||
|
static::updating(function () {
|
||||||
|
throw new RuntimeException(
|
||||||
|
'The proof register is append-only. Record a correcting event instead of editing history.'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
static::deleting(function () {
|
||||||
|
throw new RuntimeException(
|
||||||
|
'The proof register is append-only. A record that can be deleted is not evidence.'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'net_cents' => '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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,89 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Billing;
|
||||||
|
|
||||||
|
use App\Models\Subscription;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What the modules cost today, and what THIS customer pays for the ones they
|
||||||
|
* have already booked.
|
||||||
|
*
|
||||||
|
* Two different questions, and the whole point of Phase 4 is that they have
|
||||||
|
* different answers. A booked module is a price the customer agreed to; an
|
||||||
|
* unbooked one is a sale we have not made yet, at whatever it costs now.
|
||||||
|
*
|
||||||
|
* Storage and the recurring modules live in config/provisioning.php. Unlike the
|
||||||
|
* plans, they were never versioned or scheduled, so moving them into tables
|
||||||
|
* would buy nothing — the freezing that matters happens on
|
||||||
|
* `subscription_addons`, which is where a customer's own price lives.
|
||||||
|
*/
|
||||||
|
final class AddonCatalogue
|
||||||
|
{
|
||||||
|
/** The extra-storage pack is priced separately from the module list. */
|
||||||
|
public const STORAGE = 'storage';
|
||||||
|
|
||||||
|
/** Today's monthly net price for one unit, or null if we do not sell it. */
|
||||||
|
public function priceCents(string $key): ?int
|
||||||
|
{
|
||||||
|
if ($key === self::STORAGE) {
|
||||||
|
return (int) config('provisioning.storage_addon.price_cents', 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
$addon = (array) config("provisioning.addons.{$key}");
|
||||||
|
|
||||||
|
return $addon === [] ? null : (int) ($addon['price_cents'] ?? 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function knows(string $key): bool
|
||||||
|
{
|
||||||
|
return $this->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<string, array{key: string, price_cents: ?int, monthly_cents: int, booked: bool, quantity: int, bookings: array<int, array{price_cents: int, quantity: int}>}>
|
||||||
|
*/
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,70 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Booked modules, each at the price it was booked at.
|
||||||
|
*
|
||||||
|
* The owner's rule, and it is easy to miss: a customer's total is their
|
||||||
|
* subscription plus their modules, and ALL of it is frozen. Price protection
|
||||||
|
* that covered only the plan would let a module quietly double for someone who
|
||||||
|
* booked it two years ago.
|
||||||
|
*
|
||||||
|
* A module the customer has NOT booked is a different question entirely — that
|
||||||
|
* is a sale we have not made yet, and it is shown and sold at today's price.
|
||||||
|
* So the catalogue is consulted only for what is not in this table.
|
||||||
|
*
|
||||||
|
* This table is live booking state, not the archive: it says what a contract is
|
||||||
|
* paying for now. The evidence lives in `subscription_records`, which keeps each
|
||||||
|
* booking and cancellation as its own event and survives erasure of the customer
|
||||||
|
* it belonged to. So these rows go with their subscription — an orphaned booking
|
||||||
|
* pointing at no contract would answer nothing that the register does not answer
|
||||||
|
* better.
|
||||||
|
*/
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('subscription_addons', function (Blueprint $table) {
|
||||||
|
$table->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');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,76 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The proof register: one append-only row per commercial event.
|
||||||
|
*
|
||||||
|
* What a customer was sold, when, on what terms and for how much — kept so it
|
||||||
|
* can be answered years later without reconstructing it from tables that have
|
||||||
|
* moved on. Purchases, upgrades, downgrades and cancellations all land here.
|
||||||
|
*
|
||||||
|
* Flat columns for everything that is searched or relied on as evidence, PLUS a
|
||||||
|
* versioned JSON copy of the whole snapshot. Not JSON alone: you cannot query
|
||||||
|
* it, you cannot index it, and "the amount is in there somewhere" is poor
|
||||||
|
* evidence. The JSON is the long tail — the fields nobody thought to ask about
|
||||||
|
* yet — and `snapshot_version` says which shape it is in, so an old row stays
|
||||||
|
* readable after the shape changes.
|
||||||
|
*/
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('subscription_records', function (Blueprint $table) {
|
||||||
|
$table->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');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -75,6 +75,9 @@ return [
|
||||||
'storage_body' => 'Jederzeit erweiterbar — +:gb GB für :price pro Monat, monatlich kündbar.',
|
'storage_body' => 'Jederzeit erweiterbar — +:gb GB für :price pro Monat, monatlich kündbar.',
|
||||||
'storage_cta' => '+:gb GB buchen',
|
'storage_cta' => '+:gb GB buchen',
|
||||||
'addon_cta' => 'Hinzufügen',
|
'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.',
|
'purchased' => 'Kauf vorgemerkt — wir schalten ihn nach der Zahlung frei.',
|
||||||
'mock_note' => 'Zahlung & Bereitstellung folgen nach Anbindung des Zahlungsanbieters.',
|
'mock_note' => 'Zahlung & Bereitstellung folgen nach Anbindung des Zahlungsanbieters.',
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -75,6 +75,9 @@ return [
|
||||||
'storage_body' => 'Expandable anytime — +:gb GB for :price per month, cancel monthly.',
|
'storage_body' => 'Expandable anytime — +:gb GB for :price per month, cancel monthly.',
|
||||||
'storage_cta' => 'Add :gb GB',
|
'storage_cta' => 'Add :gb GB',
|
||||||
'addon_cta' => 'Add',
|
'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.',
|
'purchased' => 'Purchase noted — we activate it after payment.',
|
||||||
'mock_note' => 'Payment & fulfillment follow once the payment provider is connected.',
|
'mock_note' => 'Payment & fulfillment follow once the payment provider is connected.',
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,13 @@
|
||||||
<div class="text-right">
|
<div class="text-right">
|
||||||
<p class="text-2xl font-semibold text-ink">{{ $eur($current['price_cents'] ?? 0) }}</p>
|
<p class="text-2xl font-semibold text-ink">{{ $eur($current['price_cents'] ?? 0) }}</p>
|
||||||
<p class="text-xs text-muted">{{ __('billing.net_per_month') }}</p>
|
<p class="text-xs text-muted">{{ __('billing.net_per_month') }}</p>
|
||||||
|
@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. --}}
|
||||||
|
<p class="mt-1 text-sm font-medium text-body">
|
||||||
|
{{ __('billing.total_with_addons', ['total' => $eur($totalMonthlyCents)]) }}
|
||||||
|
</p>
|
||||||
|
@endif
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="grid grid-cols-2 gap-px bg-line sm:grid-cols-4">
|
<div class="grid grid-cols-2 gap-px bg-line sm:grid-cols-4">
|
||||||
|
|
@ -185,15 +192,31 @@
|
||||||
<p class="font-semibold text-ink">{{ __('billing.addon.'.$key.'.name') }}</p>
|
<p class="font-semibold text-ink">{{ __('billing.addon.'.$key.'.name') }}</p>
|
||||||
</div>
|
</div>
|
||||||
<p class="mt-2 flex-1 text-sm text-muted">{{ __('billing.addon.'.$key.'.desc') }}</p>
|
<p class="mt-2 flex-1 text-sm text-muted">{{ __('billing.addon.'.$key.'.desc') }}</p>
|
||||||
<p class="mt-3 text-lg font-semibold text-ink">{{ $eur($addon['price_cents']) }}<span class="text-sm font-normal text-muted"> / {{ __('billing.month_short') }}</span></p>
|
{{-- What they actually pay for it once booked (packs and all),
|
||||||
|
and today's price while it is still a sale to be made. --}}
|
||||||
|
<p class="mt-3 text-lg font-semibold text-ink">
|
||||||
|
{{ $eur($addon['booked'] ? $addon['monthly_cents'] : (int) $addon['price_cents']) }}<span class="text-sm font-normal text-muted"> / {{ __('billing.month_short') }}</span>
|
||||||
|
@if ($addon['booked'] && $addon['quantity'] > 1)
|
||||||
|
<span class="text-sm font-normal text-muted">· {{ __('billing.addon_packs', ['count' => $addon['quantity']]) }}</span>
|
||||||
|
@endif
|
||||||
|
</p>
|
||||||
<p class="text-xs text-faint">
|
<p class="text-xs text-faint">
|
||||||
{{ $tax->reverseCharge
|
{{ $tax->reverseCharge
|
||||||
? __('billing.net_reverse_charge')
|
? __('billing.net_reverse_charge')
|
||||||
: __('billing.net_hint', ['percent' => $tax->percentLabel()]) }}
|
: __('billing.net_hint', ['percent' => $tax->percentLabel()]) }}
|
||||||
</p>
|
</p>
|
||||||
|
@if ($addon['booked'])
|
||||||
|
{{-- Already theirs, at the price they booked it for — which
|
||||||
|
is why this card does not show today's. --}}
|
||||||
|
<p class="mt-3 inline-flex items-center gap-1.5 rounded-pill border border-success-border bg-success-bg px-2.5 py-0.5 text-xs font-medium text-success">
|
||||||
|
<span class="size-1.5 rounded-pill bg-current" aria-hidden="true"></span>
|
||||||
|
{{ __('billing.addon_booked') }}
|
||||||
|
</p>
|
||||||
|
@else
|
||||||
<x-ui.button variant="secondary" size="sm" class="mt-3 w-full" wire:click="purchase('addon', '{{ $key }}')" wire:target="purchase('addon', '{{ $key }}')" wire:loading.attr="disabled">
|
<x-ui.button variant="secondary" size="sm" class="mt-3 w-full" wire:click="purchase('addon', '{{ $key }}')" wire:target="purchase('addon', '{{ $key }}')" wire:loading.attr="disabled">
|
||||||
{{ __('billing.addon_cta') }}
|
{{ __('billing.addon_cta') }}
|
||||||
</x-ui.button>
|
</x-ui.button>
|
||||||
|
@endif
|
||||||
</div>
|
</div>
|
||||||
@endforeach
|
@endforeach
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,307 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Actions\BookAddon;
|
||||||
|
use App\Actions\RecordCommercialEvent;
|
||||||
|
use App\Models\Customer;
|
||||||
|
use App\Models\Order;
|
||||||
|
use App\Models\Subscription;
|
||||||
|
use App\Models\SubscriptionAddon;
|
||||||
|
use App\Models\SubscriptionRecord;
|
||||||
|
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.
|
||||||
|
*/
|
||||||
|
|
||||||
|
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 = 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();
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue