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