From 8f2252e81b96cd590d049b3af4bc577df4e62231 Mon Sep 17 00:00:00 2001
From: nexxo
Date: Wed, 29 Jul 2026 17:51:37 +0200
Subject: [PATCH] Book a downgrade for a date that cannot move, and sell a
module once
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Two ways of charging a customer for something they did not get.
A downgrade was due when `subscriptions.current_period_end` said so, and
Stripe pushes that column forward on every renewal. So a downgrade booked in
March was deferred to the end of April by April's renewal, and to the end of
May by May's — each time by a whole term, and each time the customer was
billed again for the package they had asked to leave. The due date was being
re-derived from a moving target on every scheduler tick.
It is decided once now, at the moment the customer decides, and stamped onto
the contract: `pending_plan` and `pending_effective_at`, the two columns that
have been in the schema since the first migration and were written by nothing
at all. The shop stamps them, PlanChange reads them instead of the period end
when a move is already booked, and clupilot:apply-due-plan-changes finds
contracts by the stamped date rather than orders by a date it recomputes.
The order stays what it always was — the customer's own record of the request,
what the cart shows them and what they remove to change their mind — so it is
still consumed with the change, and removing it unbooks the change. What it no
longer does is decide when. A second downgrade replaces the first rather than
queueing behind it; an upgrade clears one, or it would come due months later
and undo the bigger package; a contract cancelled before the date takes the
booking with it; and a customer with no contract can no longer place a
downgrade at all, which was accepted before and could never be carried out.
A booked change is now visible where the contract is read: on the plan card in
the portal, because the cart entry disappears when it is paid for and the
booking does not, and on the customer row in the console, because that is
where an operator answers "what is this customer on".
The second defect is the same money in one step: BookAddon guarded duplicates
per ORDER — the unique index on (order_id, addon_key) — so two orders for the
same module on one contract both went through. A stale tab or a double click
bought priority support twice.
But storage is deliberately sold in packs, and AddonCatalogue sums the
quantities for exactly that reason, so "refuse the second" is only right for
some of them. Which is which is declared beside each price in
config/provisioning.php (`sold_as`), because it is a commercial decision about
each module and not something to infer from a key: storage is a quantity;
off-site backups are on or off, support is prioritised or it is not, Collabora
Pro is one licence, and a machine answers to one own domain. An undeclared
module counts as an entitlement — the cheaper of the two mistakes — and a test
refuses to let one ship undeclared.
Enforced in the action, where the portal, the console's grant screen and a
webhook all pass, with the sentence the customer is shown rather than a
developer's. A retried webhook for the SAME order still gets its one booking
back; that is one purchase arriving twice, not two purchases. The portal stops
offering what would be refused — booked, or already waiting in the cart — and
goes on offering the pack.
What is missing is on the Stripe side and is not invented here: nothing moves
the subscription's item onto the new Price, so the renewal after a plan change
still bills the old plan at Stripe. StripeClient only creates and archives
catalogue objects, and swapping a price also needs the subscription ITEM id,
which nothing stores.
Co-Authored-By: Claude Opus 5
---
app/Actions/ApplyStripeBillingEvent.php | 8 +
app/Actions/BookAddon.php | 36 ++-
app/Console/Commands/ApplyDuePlanChanges.php | 109 ++++++---
app/Livewire/Admin/Customers.php | 12 +
app/Livewire/Billing.php | 122 ++++++++-
app/Livewire/ConfirmRemoveOrder.php | 14 ++
app/Models/Subscription.php | 47 ++++
app/Services/Billing/AddonCatalogue.php | 108 +++++++-
app/Services/Billing/PlanChange.php | 17 +-
config/provisioning.php | 37 ++-
lang/de/admin.php | 1 +
lang/de/billing.php | 7 +
lang/en/admin.php | 1 +
lang/en/billing.php | 7 +
.../views/livewire/admin/customers.blade.php | 10 +
resources/views/livewire/billing.blade.php | 39 ++-
.../Feature/Billing/AddonEntitlementTest.php | 231 ++++++++++++++++++
tests/Feature/Billing/ApplyPlanChangeTest.php | 12 +-
.../Feature/Billing/PendingPlanChangeTest.php | 217 ++++++++++++++++
19 files changed, 970 insertions(+), 65 deletions(-)
create mode 100644 tests/Feature/Billing/AddonEntitlementTest.php
create mode 100644 tests/Feature/Billing/PendingPlanChangeTest.php
diff --git a/app/Actions/ApplyStripeBillingEvent.php b/app/Actions/ApplyStripeBillingEvent.php
index ded9ed9..1831fc0 100644
--- a/app/Actions/ApplyStripeBillingEvent.php
+++ b/app/Actions/ApplyStripeBillingEvent.php
@@ -219,11 +219,19 @@ class ApplyStripeBillingEvent
// No ordering guard: an ending is final, so a late delivery of it
// is still correct. Only the running picture can go stale.
+ //
+ // A booked plan change goes with the contract it was booked on. It
+ // could never be carried out — clupilot:apply-due-plan-changes only
+ // touches active contracts — but leaving the stamp behind would have
+ // the portal and the console go on announcing that a package which
+ // no longer exists is about to shrink.
$subscription->update([
'status' => 'cancelled',
'stripe_status' => $object['status'] ?? 'canceled',
'cancelled_at' => $endedAt,
'stripe_event_at' => $eventAt,
+ 'pending_plan' => null,
+ 'pending_effective_at' => null,
]);
return ($this->record)(
diff --git a/app/Actions/BookAddon.php b/app/Actions/BookAddon.php
index 917adae..2e9514e 100644
--- a/app/Actions/BookAddon.php
+++ b/app/Actions/BookAddon.php
@@ -20,9 +20,18 @@ use RuntimeException;
* have NOT booked stays on the live catalogue — that is a sale still to be
* made, at whatever it costs now.
*
+ * It is also the one place that decides a module may not be booked at all. Two
+ * rules live here rather than in the pages that call it, because a rule enforced
+ * in markup is not enforced: not every package may have an own domain, and not
+ * every module may be held twice. Both fail closed, with the sentence the
+ * customer would be shown rather than a developer's, so whatever refuses —
+ * the portal, the console's grant screen, a webhook — says the same thing.
+ *
* `$overrides` exists for GrantAddon: a granted module is booked through this
* same action, with its price replaced by what the customer actually pays and
- * its provenance stamped on the row. Empty for every ordinary booking.
+ * its provenance stamped on the row. Empty for every ordinary booking. A grant
+ * is a booking like any other, so it is refused on the same terms — giving away
+ * a module the customer is already paying for would bill them for both.
*/
class BookAddon
{
@@ -77,6 +86,18 @@ class BookAddon
// 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, $overrides) {
+ $catalogue = app(AddonCatalogue::class);
+
+ // Held while we look and write, for the same reason the shop holds
+ // the customer row: two clicks in flight would otherwise both find
+ // nothing booked and both book, which is precisely the double
+ // charge the check below exists to prevent. Only for a module of
+ // which there may be one — a second storage pack is meant to
+ // succeed, and serialising those would buy nothing.
+ if ($catalogue->isEntitlement($addonKey)) {
+ Subscription::query()->whereKey($subscription->getKey())->lockForUpdate()->first();
+ }
+
// Idempotent against a retried webhook: one order books one module.
if ($order !== null) {
$existing = SubscriptionAddon::query()
@@ -89,6 +110,19 @@ class BookAddon
}
}
+ // Asked AFTER the retry above, so a webhook delivered twice still
+ // gets its one booking back rather than an error: that is the same
+ // order arriving again, not a second purchase. What is refused here
+ // is a SECOND order for a module the contract already carries — a
+ // stale tab, a double click, an operator granting something the
+ // customer has bought. The unique index on (order_id, addon_key)
+ // never saw those: two orders are two different rows.
+ $refusal = $catalogue->duplicateRefusal($subscription, $addonKey);
+
+ if ($refusal !== null) {
+ throw new RuntimeException($refusal);
+ }
+
$addon = SubscriptionAddon::create(array_merge([
'subscription_id' => $subscription->id,
'order_id' => $order?->id,
diff --git a/app/Console/Commands/ApplyDuePlanChanges.php b/app/Console/Commands/ApplyDuePlanChanges.php
index b710785..1a000f4 100644
--- a/app/Console/Commands/ApplyDuePlanChanges.php
+++ b/app/Console/Commands/ApplyDuePlanChanges.php
@@ -4,8 +4,7 @@ namespace App\Console\Commands;
use App\Actions\ApplyPlanChange;
use App\Models\Order;
-use App\Services\Billing\CustomDomainAccess;
-use App\Services\Billing\PlanChange;
+use App\Models\Subscription;
use Illuminate\Console\Command;
use Throwable;
@@ -17,20 +16,28 @@ use Throwable;
* down in month three would take away what they have already paid for. It waits
* for the end of the term, and nothing was waiting with it. This is what does.
*
- * **How a pending downgrade is known.** By its order, and only by its order.
- * `subscriptions.pending_plan` and `pending_effective_at` exist in the schema and
- * are written by nothing at all — the shop records a plan change as an Order with
- * type `downgrade` sitting at `pending`, and that order is the customer's own
- * record of the request: it is what the cart shows them, what they remove to
- * change their mind, and what App\Livewire\Billing replaces when they pick a
- * different package. Mirroring it onto the contract would make two sources of
- * truth for one decision, and the second is the one that goes stale.
+ * **How a pending downgrade is known.** By the contract, which carries the
+ * package it is moving to and the date that move comes due — `pending_plan` and
+ * `pending_effective_at`, stamped by the shop the moment the customer books it.
*
- * **Safe to run as often as the scheduler likes.** Nothing here decides whether
- * a change is due — PlanChange does, from the contract's own period — and nothing
- * here applies one twice: ApplyPlanChange consumes the order, refuses a contract
- * already on the target version, and writes its register row under an event key
- * unique to that order.
+ * This used to be read off the pending Order instead, with the due date worked
+ * out from `subscriptions.current_period_end` on every tick. That date is not a
+ * fact about the booking, it is a fact about the billing cycle, and Stripe moves
+ * it forward on every renewal: a downgrade booked in March was pushed to the end
+ * of April by April's renewal, then to the end of May by May's, and the customer
+ * went on paying for the package they had asked to leave. A due date has to be
+ * decided once, at the moment the customer decides — so it is stamped then, and
+ * only read afterwards.
+ *
+ * The order is still the customer's own record of the request — it is what the
+ * cart shows them and what they remove to change their mind — so it is consumed
+ * here alongside the contract, and removing it clears the stamp (see
+ * App\Livewire\ConfirmRemoveOrder). What it no longer does is decide WHEN.
+ *
+ * **Safe to run as often as the scheduler likes.** A contract is picked up only
+ * while its stamped date has passed and it is still active, and the stamp is
+ * cleared the moment the change has landed — so a second tick finds nothing
+ * left to do rather than doing it again.
*/
class ApplyDuePlanChanges extends Command
{
@@ -38,47 +45,50 @@ class ApplyDuePlanChanges extends Command
protected $description = 'Apply scheduled downgrades whose term has run out';
- public function handle(ApplyPlanChange $apply, CustomDomainAccess $contracts): int
+ public function handle(ApplyPlanChange $apply): int
{
$applied = 0;
$due = 0;
- $orders = Order::query()
- ->where('type', 'downgrade')
- ->where('status', 'pending')
+ // Cancelled contracts are left where they are, and not merely because
+ // ApplyPlanChange would refuse them: a customer who has left is not
+ // moved onto a smaller package on their way out, and the booking they
+ // made goes with the contract it was made on.
+ $contracts = Subscription::query()
+ ->whereNotNull('pending_plan')
+ ->whereNotNull('pending_effective_at')
+ ->where('pending_effective_at', '<=', now())
+ ->where('status', 'active')
->with('customer')
->orderBy('id')
->cursor();
- foreach ($orders as $order) {
- $subscription = $contracts->contractOf($order->customer);
-
- if ($subscription === null) {
- continue;
- }
+ foreach ($contracts as $subscription) {
+ $due++;
+ $plan = (string) $subscription->pending_plan;
try {
- // Asked before applying rather than left to ApplyPlanChange's own
- // refusal, only so that a downgrade sitting out a yearly term does
- // not write a line into the log every quarter of an hour for a year.
- if (! PlanChange::evaluate($subscription, (string) $order->plan)->allowedNow) {
+ $apply($subscription, $plan, $this->orderFor($subscription, $plan));
+
+ // Read back rather than assumed: a package the catalogue has
+ // since withdrawn cannot be moved onto, and ApplyPlanChange
+ // says so by logging and leaving the contract alone. Clearing
+ // the stamp there would drop the customer's request silently,
+ // so it stays booked and this says so on every tick.
+ $subscription->refresh();
+
+ if ((string) $subscription->plan !== $plan) {
+ $this->warn("Contract {$subscription->uuid}: still on {$subscription->plan}, {$plan} was not applied.");
+
continue;
}
- $due++;
-
- $apply->forOrder($order);
-
- // Counted from the order rather than from the returned run: a
- // contract whose machine is still being built has no resize to
- // start, and the change has landed all the same.
- if ($order->fresh()?->status === 'applied') {
- $applied++;
- }
+ $subscription->clearPendingPlanChange();
+ $applied++;
} catch (Throwable $e) {
// One customer's withdrawn package must not stop everybody
// else's downgrade from landing.
- $this->warn("Order {$order->uuid}: {$e->getMessage()}");
+ $this->warn("Contract {$subscription->uuid}: {$e->getMessage()}");
}
}
@@ -86,4 +96,23 @@ class ApplyDuePlanChanges extends Command
return self::SUCCESS;
}
+
+ /**
+ * The cart entry this booking came from, so it is consumed with the change
+ * and the register row files under it.
+ *
+ * Null is a perfectly ordinary answer — an operator can book a change
+ * without a purchase, and the contract is the authority on what was agreed
+ * either way.
+ */
+ private function orderFor(Subscription $subscription, string $plan): ?Order
+ {
+ return Order::query()
+ ->where('customer_id', $subscription->customer_id)
+ ->where('type', 'downgrade')
+ ->where('status', 'pending')
+ ->where('plan', $plan)
+ ->orderBy('id')
+ ->first();
+ }
}
diff --git a/app/Livewire/Admin/Customers.php b/app/Livewire/Admin/Customers.php
index c6456d7..9b9d882 100644
--- a/app/Livewire/Admin/Customers.php
+++ b/app/Livewire/Admin/Customers.php
@@ -66,6 +66,18 @@ class Customers extends Component
// inferred from the price, which a genuinely cheap plan could
// also show.
'granted' => (bool) $contract?->isGranted(),
+ // What this package is about to become. An operator answering a
+ // question about a customer's plan has to be able to see that
+ // it is booked to shrink at the end of the term, or they will
+ // quote today's package for a bill that is already scheduled to
+ // change. The MRR beside it is still today's, which is correct:
+ // nothing has moved yet.
+ 'pending_change' => $contract?->hasPendingPlanChange()
+ ? [
+ 'plan' => __('billing.plan.'.$contract->pending_plan),
+ 'at' => $contract->pending_effective_at->local()->isoFormat('LL'),
+ ]
+ : null,
'mrr' => Number::currency($priceCents / 100, in: 'EUR', locale: $locale),
'instance' => $instance->subdomain ?? '—',
// Only an instance that exists can hand out an admin login.
diff --git a/app/Livewire/Billing.php b/app/Livewire/Billing.php
index b05ae91..fa9c381 100644
--- a/app/Livewire/Billing.php
+++ b/app/Livewire/Billing.php
@@ -12,6 +12,7 @@ use App\Services\Billing\DowngradeCheck;
use App\Services\Billing\PlanCatalogue;
use App\Services\Billing\TaxTreatment;
use App\Services\Traffic\TrafficMeter;
+use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use Livewire\Attributes\Layout;
use Livewire\Attributes\On;
@@ -47,6 +48,24 @@ class Billing extends Component
};
$access = app(CustomDomainAccess::class);
+ $contract = $access->contractOf($customer);
+
+ // A module the customer already has — booked, or sitting in the cart
+ // waiting to be paid for — cannot be sold to them again. Answered out
+ // loud rather than folded into $valid below, because silence is the
+ // worst of the possible answers: a click that does nothing cannot be
+ // told from a broken button, so the customer clicks it again, which is
+ // how the second order gets placed in the first place.
+ if ($type === 'addon' && is_string($key)) {
+ $refusal = app(AddonCatalogue::class)->duplicateRefusal($contract, $key)
+ ?? $this->cartRefusal($customer, $key);
+
+ if ($refusal !== null) {
+ $this->dispatch('notify', message: $refusal);
+
+ return;
+ }
+ }
// Guard against invalid keys / non-upgrades.
$valid = match ($type) {
@@ -59,7 +78,13 @@ class Billing extends Component
// hidden and the action still called — a stale tab, a second
// window, anyone with the component name. A limit enforced only in
// markup is not enforced.
+ //
+ // A contract is required, and not as a formality: a downgrade is
+ // booked ONTO the contract, with the date it comes due, and there
+ // is nothing to book it onto for a customer who has none. Before,
+ // such an order was accepted and could never be carried out.
'downgrade' => isset($plans[$key])
+ && $contract !== null
&& (int) ($plans[$key]['tier'] ?? 0) < (int) ($instance?->subscription?->tier ?? $plans[$currentPlan]['tier'] ?? 0)
&& DowngradeCheck::for($customer, $instance, $plans[$key], $key)->allowed,
'storage' => true,
@@ -72,7 +97,7 @@ class Billing extends Component
// not a limit. BookAddon refuses it a third time, at the moment the
// money would actually buy something.
'addon' => isset($addons[$key])
- && ($key !== CustomDomainAccess::ADDON || $access->bookable($access->contractOf($customer))),
+ && ($key !== CustomDomainAccess::ADDON || $access->bookable($contract)),
default => false,
};
if (! $valid || $plan === null) {
@@ -90,7 +115,7 @@ class Billing extends Component
// Replacement and insert run together with the customer row locked:
// two clicks in flight could otherwise both delete and then both
// insert, leaving exactly the two upgrades this rule exists to prevent.
- $replaced = DB::transaction(function () use ($customer, $type, $plan, $addonKey, $amount, $datacenter) {
+ $replaced = DB::transaction(function () use ($customer, $contract, $type, $plan, $addonKey, $amount, $datacenter) {
Customer::query()->whereKey($customer->id)->lockForUpdate()->first();
// Up and down are the same kind of change and contradict each
@@ -114,6 +139,24 @@ class Billing extends Component
'status' => 'pending',
]);
+ // The contract carries the booking, and it is booked HERE — at the
+ // moment the customer decides — with the period end as it stands
+ // right now. Reading that date again later would be reading a
+ // moving target: Stripe pushes it forward on every renewal.
+ //
+ // An upgrade clears a booked downgrade instead of standing beside
+ // it, for the same reason the order above was just deleted: the two
+ // contradict each other, and a downgrade left booked would come
+ // due months later and quietly undo the bigger package the customer
+ // had moved to in the meantime.
+ if ($contract !== null) {
+ match ($type) {
+ 'downgrade' => $contract->bookPendingPlanChange((string) $plan),
+ 'upgrade' => $contract->clearPendingPlanChange(),
+ default => null,
+ };
+ }
+
return $replaced;
});
@@ -130,6 +173,36 @@ class Billing extends Component
$this->dispatch('notify', message: __('billing.cart.removed'));
}
+ /**
+ * Why a module cannot go into the cart a second time. Null when it can.
+ *
+ * The contract answers "already booked"; this answers the half hour before
+ * that, when the purchase is placed and not yet paid for. Both are the same
+ * mistake to the customer — being charged twice for one thing — and a
+ * double click or a stale tab produces exactly this one.
+ *
+ * Quantities are not asked about: two storage packs in one cart are two
+ * hundred gigabytes, and that is a sale, not an accident.
+ */
+ private function cartRefusal(Customer $customer, string $key): ?string
+ {
+ $catalogue = app(AddonCatalogue::class);
+
+ if (! $catalogue->isEntitlement($key)) {
+ return null;
+ }
+
+ $inCart = $customer->orders()
+ ->where('status', 'pending')
+ ->where('type', 'addon')
+ ->where('addon_key', $key)
+ ->exists();
+
+ return $inCart
+ ? __('billing.addon_in_cart', ['module' => $catalogue->name($key)])
+ : null;
+ }
+
/**
* The terms to show as "your plan": the contract if there is one, and only
* otherwise the catalogue — someone browsing before they have bought.
@@ -174,11 +247,22 @@ class Billing extends Component
* A module already booked keeps its card whatever the package says, because
* it is still on the bill and hiding it would hide the charge.
*
+ * A module already in the CART keeps its card too, and loses its button:
+ * the purchase is placed and waiting to be paid for, and offering it again
+ * is how one double click turns into two charges for one thing.
+ *
* @param array> $rows
+ * @param Collection $pending
* @return array>
*/
- private function offerable(array $rows, ?Customer $customer): array
+ private function offerable(array $rows, ?Customer $customer, Collection $pending): array
{
+ $inCart = $pending->where('type', 'addon')->pluck('addon_key')->filter()->all();
+
+ foreach ($rows as $key => $row) {
+ $rows[$key]['in_cart'] = in_array($key, $inCart, true);
+ }
+
$access = app(CustomDomainAccess::class);
$key = CustomDomainAccess::ADDON;
@@ -196,6 +280,24 @@ class Billing extends Component
return $rows;
}
+ /**
+ * The booked change, in the words and the wall-clock date the customer
+ * reads. Null when nothing is booked.
+ *
+ * @return array{plan: string, at: string}|null
+ */
+ private function pendingChange(?Subscription $contract): ?array
+ {
+ if ($contract === null || ! $contract->hasPendingPlanChange()) {
+ return null;
+ }
+
+ return [
+ 'plan' => __('billing.plan.'.$contract->pending_plan),
+ 'at' => $contract->pending_effective_at->local()->isoFormat('LL'),
+ ];
+ }
+
public function render()
{
$customer = $this->customer();
@@ -225,6 +327,10 @@ class Billing extends Component
->filter(fn ($p) => (int) ($p['tier'] ?? 0) > $currentTier)
->keys()->all();
+ $pending = $customer
+ ? $customer->orders()->where('status', 'pending')->latest('id')->get()
+ : collect();
+
return view('livewire.billing', [
'currentKey' => $currentKey,
// What they HAVE comes from their contract; only what they could
@@ -251,11 +357,15 @@ class Billing extends Component
->except(AddonCatalogue::STORAGE)
->all(),
$customer,
+ $pending,
),
'totalMonthlyCents' => $instance?->subscription?->totalMonthlyCents(),
- 'pending' => $customer
- ? $customer->orders()->where('status', 'pending')->latest('id')->get()
- : collect(),
+ 'pending' => $pending,
+ // The change this customer has already booked, so the page they
+ // booked it on says so. A contract quietly due to shrink in three
+ // months is exactly the thing a customer should not have to
+ // remember on their own.
+ 'pendingChange' => $this->pendingChange(app(CustomDomainAccess::class)->contractOf($customer)),
]);
}
}
diff --git a/app/Livewire/ConfirmRemoveOrder.php b/app/Livewire/ConfirmRemoveOrder.php
index e541214..c442fd5 100644
--- a/app/Livewire/ConfirmRemoveOrder.php
+++ b/app/Livewire/ConfirmRemoveOrder.php
@@ -4,6 +4,7 @@ namespace App\Livewire;
use App\Livewire\Concerns\ResolvesCustomer;
use App\Models\Order;
+use App\Services\Billing\CustomDomainAccess;
use LivewireUI\Modal\ModalComponent;
/**
@@ -45,6 +46,19 @@ class ConfirmRemoveOrder extends ModalComponent
return;
}
+ // A downgrade is booked in two places — the cart entry the customer can
+ // see, and the date stamped on the contract that actually carries it
+ // out. Taking the entry back out has to unbook it, or the package would
+ // shrink at the end of the term for a request the customer had already
+ // withdrawn, with nothing left on the page to explain why.
+ if ($order->type === 'downgrade') {
+ $contract = app(CustomDomainAccess::class)->contractOf($this->customer());
+
+ if ($contract?->pending_plan === $order->plan) {
+ $contract->clearPendingPlanChange();
+ }
+ }
+
$order->delete();
$this->dispatch('order-removed');
diff --git a/app/Models/Subscription.php b/app/Models/Subscription.php
index ac60c7e..c039064 100644
--- a/app/Models/Subscription.php
+++ b/app/Models/Subscription.php
@@ -231,6 +231,53 @@ class Subscription extends Model
$this->unsetRelation('planVersion');
}
+ /**
+ * Book a change of package for the end of the term the customer has already
+ * paid for.
+ *
+ * The date is stamped, not derived. `current_period_end` is where it comes
+ * FROM, but it is not where it can be READ from afterwards: Stripe pushes
+ * that column forward on every renewal, so a downgrade whose due date was
+ * re-read from it would be deferred by another whole term each time the
+ * customer was billed — for the package they had asked to leave. Booked once
+ * means booked once, on the date it was booked for.
+ *
+ * One at a time, deliberately. Choosing another package replaces the
+ * booking rather than queueing behind it, exactly as the cart replaces the
+ * pending order: two scheduled changes contradict each other and nothing
+ * could resolve which one the customer meant.
+ */
+ public function bookPendingPlanChange(string $plan): void
+ {
+ $this->update([
+ 'pending_plan' => $plan,
+ 'pending_effective_at' => $this->current_period_end?->copy(),
+ ]);
+ }
+
+ /**
+ * Drop a booked change: the customer withdrew it, moved the other way, or
+ * it has just landed.
+ *
+ * Always both columns. A plan with no date is a change that can never come
+ * due, and a date with no plan is a contract that says it is going to shrink
+ * into nothing.
+ */
+ public function clearPendingPlanChange(): void
+ {
+ if ($this->pending_plan === null && $this->pending_effective_at === null) {
+ return;
+ }
+
+ $this->update(['pending_plan' => null, 'pending_effective_at' => null]);
+ }
+
+ /** Is a change booked on this contract that has not landed yet? */
+ public function hasPendingPlanChange(): bool
+ {
+ return $this->pending_plan !== null && $this->pending_effective_at !== null;
+ }
+
public function isYearly(): bool
{
return $this->term === self::TERM_YEARLY;
diff --git a/app/Services/Billing/AddonCatalogue.php b/app/Services/Billing/AddonCatalogue.php
index ec89ab9..863959d 100644
--- a/app/Services/Billing/AddonCatalogue.php
+++ b/app/Services/Billing/AddonCatalogue.php
@@ -16,22 +16,31 @@ use App\Models\Subscription;
* 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.
+ *
+ * It is also where the third question about a module is answered: what a SECOND
+ * booking of it means. Storage is sold in packs and two of them are twice as
+ * much storage; the rest are things a contract either has or has not, and a
+ * second one of those is a second charge for the first. That is declared per
+ * module beside its price (`sold_as`) and read from here by everything that
+ * books, offers or grants one — see soldAs() and duplicateRefusal().
*/
final class AddonCatalogue
{
/** The extra-storage pack is priced separately from the module list. */
public const STORAGE = 'storage';
+ /** Sold in packs: booking another one gives the customer more of it. */
+ public const QUANTITY = 'quantity';
+
+ /** Had or not had: booking another one gives the customer nothing. */
+ public const ENTITLEMENT = 'entitlement';
+
/** 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);
- }
+ $price = $this->definition($key)['price_cents'] ?? null;
- $addon = (array) config("provisioning.addons.{$key}");
-
- return $addon === [] ? null : (int) ($addon['price_cents'] ?? 0);
+ return $price === null ? null : (int) $price;
}
public function knows(string $key): bool
@@ -39,6 +48,86 @@ final class AddonCatalogue
return $this->priceCents($key) !== null;
}
+ /**
+ * How this module is sold — see the `sold_as` block in
+ * config/provisioning.php for what each answer means commercially.
+ *
+ * A module that declares nothing counts as an entitlement, which is the
+ * cheaper of the two mistakes: a lost sale can be made again tomorrow, a
+ * customer charged twice for one thing has to be found and refunded. The
+ * declaration is not optional all the same — AddonEntitlementTest fails on
+ * a module that ships without one, so the default is a safety net rather
+ * than a way of leaving the decision unmade.
+ */
+ public function soldAs(string $key): string
+ {
+ return ($this->definition($key)['sold_as'] ?? null) === self::QUANTITY
+ ? self::QUANTITY
+ : self::ENTITLEMENT;
+ }
+
+ /** May a contract hold only one of these at a time? */
+ public function isEntitlement(string $key): bool
+ {
+ return $this->soldAs($key) === self::ENTITLEMENT;
+ }
+
+ /** Is this module booked and still running on this contract? */
+ public function hasBooked(?Subscription $subscription, string $key): bool
+ {
+ return $subscription !== null
+ && $subscription->addons()->active()->where('addon_key', $key)->exists();
+ }
+
+ /**
+ * Why this module may not be booked onto this contract AGAIN. Null when it
+ * may.
+ *
+ * The one place the rule is written down, in the sentence the customer is
+ * shown: the booking action refuses with it, and the portal asks it before
+ * offering the button, so nobody is invited to buy something we would then
+ * turn down. A quantity module never refuses — a second storage pack is a
+ * second hundred gigabytes, and that is the whole point of selling it in
+ * packs.
+ */
+ public function duplicateRefusal(?Subscription $subscription, string $key): ?string
+ {
+ if (! $this->isEntitlement($key) || ! $this->hasBooked($subscription, $key)) {
+ return null;
+ }
+
+ return __('billing.addon_already_booked', ['module' => $this->name($key)]);
+ }
+
+ /** The module's name as the customer reads it, never its key. */
+ public function name(string $key): string
+ {
+ return $key === self::STORAGE
+ ? __('billing.storage_title')
+ : __('billing.addon.'.$key.'.name');
+ }
+
+ /**
+ * One module's entry, whichever side of the catalogue it lives on.
+ *
+ * Storage is priced apart from the module list — it is the pack everything
+ * else is not — but every question asked of a module has to be answerable
+ * about it too, so the two shapes are resolved here once instead of at each
+ * caller.
+ *
+ * @return array
+ */
+ private function definition(string $key): array
+ {
+ if ($key === self::STORAGE) {
+ return (array) config('provisioning.storage_addon');
+ }
+
+ $addon = (array) config("provisioning.addons.{$key}");
+
+ return $addon === [] ? [] : $addon + ['price_cents' => 0];
+ }
+
/**
* Every module we sell, each answered for this customer: booked ones at
* the price they were booked at, the rest at today's.
@@ -49,7 +138,7 @@ final class AddonCatalogue
* arbitrary price while charging for all of them would let the page and the
* bill say different things.
*
- * @return array}>
+ * @return array}>
*/
public function forSubscription(?Subscription $subscription): array
{
@@ -76,6 +165,11 @@ final class AddonCatalogue
},
'monthly_cents' => (int) $own->sum(fn ($addon) => $addon->monthlyCents()),
'booked' => $own->isNotEmpty(),
+ // Whether booking it again would be a second helping or a second
+ // charge for the first one. The card reads this to decide
+ // whether it may still offer the module, so the page and
+ // BookAddon answer from the same declaration.
+ 'entitlement' => $this->isEntitlement($key),
// A granted module shows without a price too, the same rule as
// a granted plan — "ein Plugin schenken" should not read as
// "kostenlos" on the very page that sells it to everyone else.
diff --git a/app/Services/Billing/PlanChange.php b/app/Services/Billing/PlanChange.php
index 6fc6fe6..0da2350 100644
--- a/app/Services/Billing/PlanChange.php
+++ b/app/Services/Billing/PlanChange.php
@@ -85,14 +85,27 @@ final readonly class PlanChange
// Downgrade: nothing changes and nothing is owed until the term
// ends — but once it HAS ended it must actually be applicable, or
// the job that is supposed to carry it out never can.
- $termOver = $at->greaterThanOrEqualTo($subscription->current_period_end);
+ //
+ // When the move is already BOOKED, the date it comes due is the one
+ // stamped on the contract at the moment it was booked, not today's
+ // period end. Stripe moves the period end on every renewal, and a
+ // due date that moved with it could never arrive: each renewal
+ // pushed the downgrade out by another term and billed the customer
+ // again for the package they had asked to leave. An unbooked move —
+ // the preview on the shop page — has no stamp and is answered from
+ // the period the customer is in, which is what they are being shown.
+ $dueAt = $subscription->pending_plan === $targetPlan && $subscription->pending_effective_at !== null
+ ? $subscription->pending_effective_at
+ : $subscription->current_period_end;
+
+ $termOver = $at->greaterThanOrEqualTo($dueAt);
return new self(
isUpgrade: false,
allowedNow: $termOver,
chargeCents: 0,
creditCents: 0,
- effectiveAt: $termOver ? $at->copy() : $subscription->current_period_end->copy(),
+ effectiveAt: $termOver ? $at->copy() : $dueAt->copy(),
remainingDays: $remainingDays,
termDays: $termDays,
);
diff --git a/config/provisioning.php b/config/provisioning.php
index d6123a4..a137368 100644
--- a/config/provisioning.php
+++ b/config/provisioning.php
@@ -171,12 +171,36 @@ return [
'seller_country' => env('CLUPILOT_TAX_COUNTRY', 'AT'),
],
- // Extra storage add-on (per unit) and the add-on catalogue (labels in lang/*/billing.php).
- 'storage_addon' => ['gb' => 100, 'price_cents' => 1000],
+ /*
+ | Extra storage add-on (per unit) and the add-on catalogue (labels in the
+ | billing translation files).
+ |
+ | `sold_as` says what a SECOND booking of the same module on one contract
+ | means, and it is declared here rather than derived anywhere, because it is
+ | a commercial decision about each module and not a property of its key:
+ |
+ | - `quantity` — the module is sold in packs and buying another one gives
+ | the customer more of it. Storage is the only one today: two 100 GB
+ | packs are 200 GB, and AddonCatalogue sums the bookings for exactly that
+ | reason.
+ | - `entitlement` — the customer either has it or does not, so a second
+ | booking buys them nothing and charges them a second time for the same
+ | thing. Off-site backups are on or off; support is prioritised or it is
+ | not; Collabora Pro is one licence for the instance; and a machine
+ | answers to one own domain.
+ |
+ | Both mistakes cost real money in opposite directions — an entitlement
+ | booked twice is a double charge, a quantity refused twice is a lost sale —
+ | so a module that declares nothing is treated as an entitlement (the
+ | cheaper mistake) and tests/Feature/Billing/AddonEntitlementTest.php
+ | refuses to let one ship undeclared. Read by
+ | App\Services\Billing\AddonCatalogue, enforced by App\Actions\BookAddon.
+ */
+ 'storage_addon' => ['gb' => 100, 'price_cents' => 1000, 'sold_as' => 'quantity'],
'addons' => [
- 'extra_backups' => ['price_cents' => 500],
- 'priority_support' => ['price_cents' => 2900],
- 'collabora_pro' => ['price_cents' => 1900],
+ 'extra_backups' => ['price_cents' => 500, 'sold_as' => 'entitlement'],
+ 'priority_support' => ['price_cents' => 2900, 'sold_as' => 'entitlement'],
+ 'collabora_pro' => ['price_cents' => 1900, 'sold_as' => 'entitlement'],
// The owner's commercial figure, to be adjusted once the first ones are
// sold. Listed here because the public sheet has to answer what a plan
// without an own domain costs to give one — an unpriced feature can only
@@ -184,6 +208,9 @@ return [
// we would most like to sell it to.
'custom_domain' => [
'price_cents' => 900,
+ // One machine, one address: a second booking would charge nine euros
+ // a month for a domain the customer already has.
+ 'sold_as' => 'entitlement',
// The packages on which an own domain is not possible AT ALL: not
// bookable, not upgradable, not even offered. Named by plan key,
// and named HERE, because the catalogue cannot answer this. Plan
diff --git a/lang/de/admin.php b/lang/de/admin.php
index a0d67b7..9c80cbd 100644
--- a/lang/de/admin.php
+++ b/lang/de/admin.php
@@ -94,6 +94,7 @@ return [
'customer_reactivated' => 'Kunde entsperrt.',
'grant_action' => 'Schenken',
'granted_badge' => 'Verschenkt',
+ 'pending_change' => 'Wechsel auf :plan am :date',
'by_plan' => 'Nach Paket',
'instances_sub' => 'Alle bereitgestellten Cloud-Instanzen.',
'instances_label' => 'Instanzen',
diff --git a/lang/de/billing.php b/lang/de/billing.php
index d0d358e..cc98095 100644
--- a/lang/de/billing.php
+++ b/lang/de/billing.php
@@ -99,6 +99,13 @@ return [
'total_with_addons' => 'Gesamt inkl. Module: :total',
'addon_packs' => ':count Pakete',
'addon_booked' => 'Gebucht — Preis fest',
+ // Warum ein Modul kein zweites Mal gekauft werden kann: Speicher wird in
+ // Paketen verkauft, alles andere hat man oder hat man nicht.
+ 'addon_already_booked' => '„:module“ ist bereits gebucht — ein zweites Mal würde denselben Leistungsumfang ein zweites Mal berechnen.',
+ 'addon_in_cart' => '„:module“ liegt bereits im Warenkorb und wird nach der Zahlung freigeschaltet.',
+ 'addon_in_cart_badge' => 'Im Warenkorb',
+ // Der gebuchte Wechsel, auf der Karte, die sagt, was der Kunde hat.
+ 'pending_change_note' => 'Wechsel auf :plan zum :date vorgemerkt. Bis dahin ändert sich nichts an Ihrem Paket.',
'purchased' => 'Kauf vorgemerkt — wir schalten ihn nach der Zahlung frei.',
'mock_note' => 'Zahlung & Bereitstellung folgen nach Anbindung des Zahlungsanbieters.',
diff --git a/lang/en/admin.php b/lang/en/admin.php
index 5ada413..f3c9597 100644
--- a/lang/en/admin.php
+++ b/lang/en/admin.php
@@ -94,6 +94,7 @@ return [
'customer_reactivated' => 'Customer reactivated.',
'grant_action' => 'Grant',
'granted_badge' => 'Granted',
+ 'pending_change' => 'Moves to :plan on :date',
'by_plan' => 'By plan',
'instances_sub' => 'All provisioned cloud instances.',
'instances_label' => 'instances',
diff --git a/lang/en/billing.php b/lang/en/billing.php
index d8d8385..1680575 100644
--- a/lang/en/billing.php
+++ b/lang/en/billing.php
@@ -99,6 +99,13 @@ return [
'total_with_addons' => 'Total incl. modules: :total',
'addon_packs' => ':count packs',
'addon_booked' => 'Booked — price fixed',
+ // Why a module cannot be bought twice: storage is sold in packs, everything
+ // else is something a contract either has or has not.
+ 'addon_already_booked' => 'The :module module is already booked — booking it again would charge you a second time for the same thing.',
+ 'addon_in_cart' => 'The :module module is already in your cart and will be activated after payment.',
+ 'addon_in_cart_badge' => 'In cart',
+ // The booked change, on the card that says what the customer has.
+ 'pending_change_note' => 'A move to :plan is booked for :date. Nothing about your package changes until then.',
'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/admin/customers.blade.php b/resources/views/livewire/admin/customers.blade.php
index d23dfcc..9dc80b1 100644
--- a/resources/views/livewire/admin/customers.blade.php
+++ b/resources/views/livewire/admin/customers.blade.php
@@ -32,6 +32,16 @@
{{ __('admin.granted_badge') }}
@endif
+ @if ($r['pending_change'])
+ {{-- Booked, not yet landed. Said on the
+ row that answers "what is this
+ customer on", because that is where
+ the question is asked. --}}
+
+
+ {{ __('admin.pending_change', ['plan' => $r['pending_change']['plan'], 'date' => $r['pending_change']['at']]) }}
+
+ @endif
{{ $r['mrr'] }} |
{{ __('admin.status.'.$r['status']) }} |
diff --git a/resources/views/livewire/billing.blade.php b/resources/views/livewire/billing.blade.php
index 9fd2f11..c439608 100644
--- a/resources/views/livewire/billing.blade.php
+++ b/resources/views/livewire/billing.blade.php
@@ -45,6 +45,20 @@
@endforeach
+
+ {{-- A change already booked, on the card that states what they have. A
+ contract quietly due to shrink at the end of the term is not
+ something a customer should have to remember on their own — and it
+ is announced here rather than only in the cart, because the cart
+ entry goes the moment it is paid for and the booking does not. --}}
+ @if ($pendingChange)
+
+
+
+ {{ __('billing.pending_change_note', ['plan' => $pendingChange['plan'], 'date' => $pendingChange['at']]) }}
+
+
+ @endif
{{-- Cart: "5 purchases pending" told nobody what they had ordered, and
@@ -302,9 +316,28 @@
{{ __('billing.addon_booked') }}
- {{-- A module the package includes has no button either: the
- badge above is the whole of what this card has to say. --}}
- @elseif (! ($addon['included'] ?? false))
+ @elseif ($addon['in_cart'] ?? false)
+ {{-- Placed and waiting to be paid for. Saying so is what
+ stops the second click: an unchanged card invites one,
+ and a second order for the same module is a second
+ charge for one thing. --}}
+
+
+ {{ __('billing.addon_in_cart_badge') }}
+
+ @endif
+
+ {{-- The button appears only where the purchase would actually
+ go through. A module the package includes has nothing to
+ sell; one already booked is a second charge for the same
+ thing, unless it is sold in packs, where a second one is
+ genuinely a second helping (see `sold_as` in
+ config/provisioning.php); and one already in the cart is
+ that second charge one step earlier. An offer that would be
+ refused is worse than no offer. --}}
+ @if (! ($addon['included'] ?? false)
+ && ! ($addon['in_cart'] ?? false)
+ && ! ($addon['booked'] && ($addon['entitlement'] ?? true)))
{{ __('billing.addon_cta') }}
diff --git a/tests/Feature/Billing/AddonEntitlementTest.php b/tests/Feature/Billing/AddonEntitlementTest.php
new file mode 100644
index 0000000..a970f59
--- /dev/null
+++ b/tests/Feature/Billing/AddonEntitlementTest.php
@@ -0,0 +1,231 @@
+create(['plan' => $plan, 'datacenter' => 'fsn', 'status' => 'paid']);
+
+ return app(OpenSubscription::class)($order);
+}
+
+/** A portal customer on a package, with the machine their contract pays for. */
+function entitlementShopper(string $plan = 'team'): array
+{
+ $customer = Customer::factory()->create();
+ $user = User::factory()->create(['email' => $customer->email]);
+ $order = Order::factory()->withSubscription()->for($customer)->create(['plan' => $plan]);
+ $instance = Instance::factory()->for($customer)->create([
+ 'order_id' => $order->id,
+ 'plan' => $plan,
+ 'status' => 'active',
+ ]);
+
+ $order->subscription->update(['instance_id' => $instance->id]);
+
+ return [$customer, $user, $order->subscription->fresh()];
+}
+
+it('declares for every module it sells whether it may be held once or stacked', function () {
+ $catalogue = app(AddonCatalogue::class);
+ $declared = array_merge(
+ collect((array) config('provisioning.addons'))->map(fn ($a) => $a['sold_as'] ?? null)->all(),
+ [AddonCatalogue::STORAGE => config('provisioning.storage_addon.sold_as')],
+ );
+
+ // Undeclared is not a state this catalogue may ship in. It costs money in
+ // both directions — a double charge or a lost sale — and the fallback in
+ // soldAs() is a safety net, not somewhere to leave the decision.
+ foreach ($declared as $key => $soldAs) {
+ expect($soldAs)->toBeIn([AddonCatalogue::ENTITLEMENT, AddonCatalogue::QUANTITY], "module {$key}");
+ }
+
+ // And the one that is genuinely a pack is the one declared as a pack.
+ expect($catalogue->isEntitlement(AddonCatalogue::STORAGE))->toBeFalse()
+ ->and($catalogue->isEntitlement('priority_support'))->toBeTrue()
+ ->and($catalogue->isEntitlement('extra_backups'))->toBeTrue()
+ ->and($catalogue->isEntitlement('collabora_pro'))->toBeTrue()
+ ->and($catalogue->isEntitlement('custom_domain'))->toBeTrue();
+});
+
+it('refuses to book an entitlement a second time on the same contract', function () {
+ $subscription = entitlementContract();
+
+ app(BookAddon::class)($subscription, 'priority_support');
+
+ // A second, different order — which is exactly what a stale tab produces,
+ // and what the per-order unique index never saw.
+ $second = Order::factory()->for($subscription->customer)->create(['type' => 'addon', 'addon_key' => 'priority_support', 'status' => 'paid']);
+
+ expect(fn () => app(BookAddon::class)($subscription, 'priority_support', 1, $second))
+ ->toThrow(RuntimeException::class, __('billing.addon_already_booked', [
+ 'module' => __('billing.addon.priority_support.name'),
+ ]));
+
+ expect($subscription->addons()->count())->toBe(1);
+});
+
+it('still books the same order twice as one module, because that is a retry', function () {
+ $subscription = entitlementContract();
+ $order = Order::factory()->for($subscription->customer)->create(['type' => 'addon', 'addon_key' => 'collabora_pro', 'status' => 'paid']);
+
+ $first = app(BookAddon::class)($subscription, 'collabora_pro', 1, $order);
+ $again = app(BookAddon::class)($subscription, 'collabora_pro', 1, $order);
+
+ // A webhook delivered twice is one purchase arriving again, not a second
+ // one: it gets its booking back rather than an error.
+ expect($again->id)->toBe($first->id)
+ ->and($subscription->addons()->count())->toBe(1);
+});
+
+it('lets an entitlement be booked again once the customer has cancelled it', function () {
+ $subscription = entitlementContract();
+
+ $addon = app(BookAddon::class)($subscription, 'extra_backups');
+ app(BookAddon::class)->cancel($addon);
+
+ // The rule is about what a contract HAS, not about what it has ever had —
+ // otherwise cancelling a module would be irreversible.
+ app(BookAddon::class)($subscription, 'extra_backups');
+
+ expect($subscription->addons()->active()->count())->toBe(1)
+ ->and(SubscriptionAddon::query()->count())->toBe(2);
+});
+
+it('stacks a module that is sold in packs, and totals it as the sum of its bookings', function () {
+ $subscription = entitlementContract();
+ $pack = (int) app(AddonCatalogue::class)->priceCents(AddonCatalogue::STORAGE);
+
+ app(BookAddon::class)($subscription, AddonCatalogue::STORAGE);
+ app(BookAddon::class)($subscription, AddonCatalogue::STORAGE, 2);
+
+ $row = app(AddonCatalogue::class)->forSubscription($subscription->fresh())[AddonCatalogue::STORAGE];
+
+ expect($subscription->addons()->active()->count())->toBe(2)
+ ->and($row['quantity'])->toBe(3)
+ ->and($row['monthly_cents'])->toBe($pack * 3)
+ ->and($row['entitlement'])->toBeFalse();
+});
+
+it('refuses a second purchase of an entitlement through the shop, in words', function () {
+ [$customer, $user, $subscription] = entitlementShopper();
+
+ app(BookAddon::class)($subscription, 'collabora_pro');
+
+ Livewire::actingAs($user)->test(Billing::class)
+ ->call('purchase', 'addon', 'collabora_pro')
+ ->assertDispatched('notify', message: __('billing.addon_already_booked', [
+ 'module' => __('billing.addon.collabora_pro.name'),
+ ]));
+
+ // No order, so nothing to charge for: the refusal is the point, the
+ // sentence is only how the customer finds out.
+ expect(Order::query()->where('customer_id', $customer->id)->where('type', 'addon')->exists())->toBeFalse();
+});
+
+it('refuses a second purchase while the first is still sitting in the cart', function () {
+ [$customer, $user] = entitlementShopper();
+
+ $component = Livewire::actingAs($user)->test(Billing::class);
+ $component->call('purchase', 'addon', 'priority_support');
+ $component->call('purchase', 'addon', 'priority_support')
+ ->assertDispatched('notify', message: __('billing.addon_in_cart', [
+ 'module' => __('billing.addon.priority_support.name'),
+ ]));
+
+ // The double click, which is where the second charge came from.
+ expect(Order::query()->where('customer_id', $customer->id)->where('type', 'addon')->count())->toBe(1);
+});
+
+it('stops offering an entitlement the customer already has, and keeps offering packs', function () {
+ [, $user, $subscription] = entitlementShopper();
+
+ app(BookAddon::class)($subscription, 'collabora_pro');
+
+ Livewire::actingAs($user)->test(Billing::class)
+ ->assertSee(__('billing.addon_booked'))
+ // Storage is a pack and has its own card, which never stops offering.
+ ->assertSee(__('billing.storage_cta', ['gb' => config('provisioning.storage_addon.gb')]));
+
+ $rows = Livewire::actingAs($user)->test(Billing::class)->viewData('addons');
+
+ expect($rows['collabora_pro']['booked'])->toBeTrue()
+ ->and($rows['collabora_pro']['entitlement'])->toBeTrue();
+});
+
+it('marks a module waiting in the cart so the card stops offering it', function () {
+ [, $user] = entitlementShopper();
+
+ Livewire::actingAs($user)->test(Billing::class)->call('purchase', 'addon', 'extra_backups');
+
+ $component = Livewire::actingAs($user)->test(Billing::class);
+
+ expect($component->viewData('addons')['extra_backups']['in_cart'])->toBeTrue();
+
+ $component->assertSee(__('billing.addon_in_cart_badge'));
+});
+
+it('refuses to grant an entitlement the customer is already paying for', function () {
+ $subscription = entitlementContract();
+ $owner = Operator::factory()->create();
+
+ app(BookAddon::class)($subscription, 'priority_support');
+
+ expect(fn () => app(GrantAddon::class)(
+ subscription: $subscription,
+ grantedBy: $owner,
+ addonKey: 'priority_support',
+ priceCents: 0,
+ ))->toThrow(RuntimeException::class, __('billing.addon_already_booked', [
+ 'module' => __('billing.addon.priority_support.name'),
+ ]));
+
+ // And the synthetic order the grant would have hung off it is gone with it:
+ // an order left behind is a charge for a module nobody got.
+ expect($subscription->addons()->count())->toBe(1)
+ ->and(Order::query()->where('type', 'addon')->count())->toBe(0);
+});
+
+it('still lets an operator grant a second pack of storage', function () {
+ $subscription = entitlementContract();
+ $owner = Operator::factory()->create();
+
+ app(BookAddon::class)($subscription, AddonCatalogue::STORAGE);
+
+ app(GrantAddon::class)(
+ subscription: $subscription,
+ grantedBy: $owner,
+ addonKey: AddonCatalogue::STORAGE,
+ priceCents: 0,
+ );
+
+ expect($subscription->addons()->active()->count())->toBe(2);
+});
diff --git a/tests/Feature/Billing/ApplyPlanChangeTest.php b/tests/Feature/Billing/ApplyPlanChangeTest.php
index c9a5763..4dfd691 100644
--- a/tests/Feature/Billing/ApplyPlanChangeTest.php
+++ b/tests/Feature/Billing/ApplyPlanChangeTest.php
@@ -63,9 +63,19 @@ function planChangeFixture(string $plan = 'business', array $instanceAttributes
return ['host' => $host, 'order' => $order, 'subscription' => $subscription->fresh(), 'instance' => $instance];
}
-/** What the shop writes when somebody picks another package. */
+/**
+ * What the shop writes when somebody picks another package: the cart entry, and
+ * — for a downgrade — the booking stamped onto the contract with the date it
+ * comes due. Both, because the scheduler reads the contract and the customer
+ * reads the cart, and a fixture that wrote only one of them would be testing a
+ * state the shop never produces.
+ */
function planChangeOrder(array $fixture, string $plan, string $type): Order
{
+ if ($type === 'downgrade') {
+ $fixture['subscription']->bookPendingPlanChange($plan);
+ }
+
return Order::create([
'customer_id' => $fixture['order']->customer_id,
'plan' => $plan,
diff --git a/tests/Feature/Billing/PendingPlanChangeTest.php b/tests/Feature/Billing/PendingPlanChangeTest.php
new file mode 100644
index 0000000..90c459f
--- /dev/null
+++ b/tests/Feature/Billing/PendingPlanChangeTest.php
@@ -0,0 +1,217 @@
+create();
+ $user = User::factory()->create(['email' => $customer->email]);
+ $order = Order::factory()->withSubscription()->for($customer)->create(['plan' => $plan]);
+ $instance = Instance::factory()->for($customer)->create([
+ 'order_id' => $order->id,
+ 'plan' => $plan,
+ 'status' => 'active',
+ ]);
+
+ $subscription = $order->subscription;
+ // The contract Stripe would be billing: the renewal that used to move the
+ // due date arrives against this id, so there has to be one.
+ $subscription->update(['instance_id' => $instance->id, 'stripe_subscription_id' => 'sub_'.$customer->id]);
+
+ return [$customer, $user, $subscription->fresh(), $instance];
+}
+
+it('applies a booked downgrade on its own date, even after a renewal has moved the period', function () {
+ Queue::fake();
+ [, $user, $subscription] = pendingChangeCustomer();
+
+ $bookedFor = $subscription->current_period_end;
+
+ Livewire::actingAs($user)->test(Billing::class)->call('purchase', 'downgrade', 'team');
+
+ expect($subscription->fresh()->pending_plan)->toBe('team')
+ ->and($subscription->fresh()->pending_effective_at->eq($bookedFor))->toBeTrue();
+
+ // The renewal the defect turned on: Stripe bills the term and pushes the
+ // period out by a month. Nothing about the booking may move with it.
+ app(ApplyStripeBillingEvent::class)->invoicePaid([
+ 'id' => 'in_renewal',
+ 'subscription' => $subscription->stripe_subscription_id,
+ 'billing_reason' => 'subscription_cycle',
+ 'period_start' => $bookedFor->getTimestamp(),
+ 'period_end' => $bookedFor->copy()->addMonth()->getTimestamp(),
+ 'amount_paid' => 39900,
+ ]);
+
+ $renewed = $subscription->fresh();
+
+ expect($renewed->current_period_end->gt($bookedFor))->toBeTrue()
+ ->and($renewed->pending_effective_at->eq($bookedFor))->toBeTrue();
+
+ // The moment it was booked for, which is now in the middle of the term the
+ // renewal opened. Before, the customer would have kept Business — and paid
+ // for it — for another whole month.
+ Carbon::setTestNow($bookedFor->copy()->addHour());
+ $this->artisan('clupilot:apply-due-plan-changes')->assertSuccessful();
+
+ $after = $subscription->fresh();
+
+ expect($after->plan)->toBe('team')
+ ->and($after->pending_plan)->toBeNull()
+ ->and($after->pending_effective_at)->toBeNull()
+ ->and(Order::query()->where('type', 'downgrade')->value('status'))->toBe('applied');
+
+ Carbon::setTestNow();
+});
+
+it('replaces a booked downgrade when the customer picks a different package', function () {
+ [, $user, $subscription] = pendingChangeCustomer();
+
+ $component = Livewire::actingAs($user)->test(Billing::class);
+ $component->call('purchase', 'downgrade', 'team');
+ $component->call('purchase', 'downgrade', 'start');
+
+ // One booking and one cart entry, not two of either: the second decision is
+ // the customer's decision, and queueing it behind the first would move them
+ // down twice.
+ expect($subscription->fresh()->pending_plan)->toBe('start')
+ ->and(Order::query()->where('type', 'downgrade')->count())->toBe(1)
+ ->and(Order::query()->where('type', 'downgrade')->value('plan'))->toBe('start');
+});
+
+it('cancels a booked downgrade when the customer upgrades instead', function () {
+ Queue::fake();
+ [, $user, $subscription] = pendingChangeCustomer('team');
+
+ $bookedFor = $subscription->current_period_end;
+
+ $component = Livewire::actingAs($user)->test(Billing::class);
+ $component->call('purchase', 'downgrade', 'start');
+
+ expect($subscription->fresh()->pending_plan)->toBe('start');
+
+ $component->call('purchase', 'upgrade', 'business');
+
+ // Left booked, it would have come due months later and undone the bigger
+ // package the customer had just paid for.
+ expect($subscription->fresh()->pending_plan)->toBeNull()
+ ->and($subscription->fresh()->pending_effective_at)->toBeNull();
+
+ Carbon::setTestNow($bookedFor->copy()->addHour());
+ $this->artisan('clupilot:apply-due-plan-changes')->assertSuccessful();
+
+ expect($subscription->fresh()->plan)->toBe('team');
+
+ Carbon::setTestNow();
+});
+
+it('unbooks the downgrade when the customer takes it back out of the cart', function () {
+ [, $user, $subscription] = pendingChangeCustomer();
+
+ Livewire::actingAs($user)->test(Billing::class)->call('purchase', 'downgrade', 'team');
+
+ $order = Order::query()->where('type', 'downgrade')->sole();
+
+ Livewire::actingAs($user)
+ ->test(ConfirmRemoveOrder::class, ['uuid' => $order->uuid])
+ ->call('remove');
+
+ expect($subscription->fresh()->pending_plan)->toBeNull()
+ ->and($subscription->fresh()->pending_effective_at)->toBeNull();
+
+ Carbon::setTestNow($subscription->current_period_end->copy()->addHour());
+ $this->artisan('clupilot:apply-due-plan-changes')->assertSuccessful();
+
+ expect($subscription->fresh()->plan)->toBe('business');
+
+ Carbon::setTestNow();
+});
+
+it('shows the booked downgrade to the customer and to the operator', function () {
+ [, $user, $subscription] = pendingChangeCustomer();
+
+ Livewire::actingAs($user)->test(Billing::class)->call('purchase', 'downgrade', 'team');
+
+ $when = $subscription->fresh()->pending_effective_at->local()->isoFormat('LL');
+
+ // The customer, on the card that states what they have — the cart entry
+ // disappears when it is paid for, and the booking does not.
+ Livewire::actingAs($user)->test(Billing::class)
+ ->assertSee(__('billing.pending_change_note', ['plan' => __('billing.plan.team'), 'date' => $when]));
+
+ // And the operator, on the row that answers "what is this customer on",
+ // because that is where the question gets asked.
+ Livewire::actingAs(admin(), 'operator')->test(Customers::class)
+ ->assertSee(__('admin.pending_change', ['plan' => __('billing.plan.team'), 'date' => $when]));
+});
+
+it('never applies a booked downgrade to a contract that has been cancelled', function () {
+ Queue::fake();
+ [, $user, $subscription] = pendingChangeCustomer();
+
+ Livewire::actingAs($user)->test(Billing::class)->call('purchase', 'downgrade', 'team');
+
+ $bookedFor = $subscription->fresh()->pending_effective_at;
+
+ // The customer leaves before the date arrives. Stripe ends the subscription
+ // and the booking goes with the contract it was made on.
+ app(ApplyStripeBillingEvent::class)->subscriptionDeleted([
+ 'id' => $subscription->stripe_subscription_id,
+ 'status' => 'canceled',
+ 'ended_at' => now()->getTimestamp(),
+ ]);
+
+ Carbon::setTestNow($bookedFor->copy()->addHour());
+ $this->artisan('clupilot:apply-due-plan-changes')->assertSuccessful();
+
+ $after = $subscription->fresh();
+
+ expect($after->status)->toBe('cancelled')
+ // Not moved onto a smaller package on the way out, and not left
+ // announcing a change that can never happen.
+ ->and($after->plan)->toBe('business')
+ ->and($after->pending_plan)->toBeNull()
+ ->and($after->pending_effective_at)->toBeNull();
+
+ Carbon::setTestNow();
+});
+
+it('refuses to book a downgrade for someone who has no contract to book it on', function () {
+ $customer = Customer::factory()->create();
+ $user = User::factory()->create(['email' => $customer->email]);
+ Instance::factory()->for($customer)->create(['plan' => 'business', 'status' => 'active']);
+
+ Livewire::actingAs($user)->test(Billing::class)->call('purchase', 'downgrade', 'team');
+
+ // Accepted before, and it could never be carried out: there was nothing to
+ // stamp the date on and nothing for the scheduler to find.
+ expect(Order::query()->where('type', 'downgrade')->exists())->toBeFalse()
+ ->and(Subscription::query()->count())->toBe(0);
+});