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