customer(); // Target the ACTIVE package explicitly — a newer failed/deprovisioned // record must not shadow an older active instance (v1: one active/customer). $instance = $customer?->instances()->where('status', 'active')->latest('id')->first(); if ($instance === null) { // Explain rather than silently closing: no customer behind this login // (e.g. an operator account) or no active package to cancel. $this->addError('confirmName', __($customer === null ? 'dashboard.no_customer_action' : 'settings.no_package')); return null; } // Typed confirmation must match the instance subdomain. if (trim($this->confirmName) !== (string) $instance->subdomain) { $this->addError('confirmName', __('settings.cancel_mismatch')); return; } $contract = $this->contractFor($customer, $instance); if (! $this->stopBilling($contract)) { $this->addError('confirmName', __('settings.cancel_billing_unreachable')); return; } $instance->update([ 'status' => 'cancellation_scheduled', 'cancel_requested_at' => now(), 'service_ends_at' => $this->currentPeriodEnd($instance, $contract), ]); // The contract carries the request too. Without it nothing in the billing // half of the application can tell a cancelled contract from an untouched // one — and the status deliberately stays `active`, because until the term // runs out this is a paying customer. $contract?->update(['cancel_requested_at' => now()]); return $this->redirectRoute('settings', navigate: true); } /** * Ask Stripe to raise no further cycle for this contract. * * @return bool whether the cancellation may now be recorded */ private function stopBilling(?Subscription $contract): bool { // Nothing to stop, and nothing has gone wrong. A granted package was // never sold through Stripe — GrantSubscription leaves the id null on // purpose — and neither was a machine built by hand with no contract // behind it at all. if ($contract?->stripe_subscription_id === null) { return true; } try { app(StripeClient::class)->cancelSubscription( (string) $contract->stripe_subscription_id, // At the period end, because that is what the customer is // promised on this very modal: everything stays available to the // end of the term they have already paid for. StripeClient::CANCEL_AT_PERIOD_END, // Keyed on the contract, because a contract is cancelled once. idempotencyKey: 'clupilot-cancel-'.$contract->uuid, ); return true; } catch (Throwable $e) { Log::error('A customer asked to cancel and Stripe could not be told to stop, so nothing was recorded.', [ 'subscription' => $contract->uuid, 'stripe_subscription' => $contract->stripe_subscription_id, 'error' => $e->getMessage(), ]); return false; } } /** * The contract this machine is billed through. * * `subscriptions.instance_id` is the link and is written when resources are * reserved. The order is the fallback because an order belongs to exactly one * purchase; the customer is the last resort, and only when they have exactly * one contract — guessing between two would cancel the wrong one, which is * worse than cancelling nothing. */ private function contractFor(?Customer $customer, Instance $instance): ?Subscription { if ($instance->subscription !== null) { return $instance->subscription; } if ($instance->order_id !== null) { $byOrder = Subscription::query()->where('order_id', $instance->order_id)->latest('id')->first(); if ($byOrder !== null) { return $byOrder; } } if ($customer === null) { return null; } $contracts = $customer->subscriptions()->where('status', 'active')->get(); return $contracts->count() === 1 ? $contracts->first() : null; } /** * The end of the term the customer has already paid for. * * The contract knows it, and it is the only thing that does: `term` is * monthly or yearly, and `current_period_end` is the boundary Stripe pushes * forward on every renewal — so it is right for both, and right for a * customer who is three months into a yearly contract. Walking months forward * from the order date, which is what this used to do, took eleven months of * paid service off every yearly customer who cancelled. * * The fallback below is only for a machine with NO contract behind it — one * built by hand, or one whose contract failed to open. There is genuinely * nothing else to read there: no term is recorded anywhere, so a month is the * only assumption available, and it is the shorter of the two. */ private function currentPeriodEnd(Instance $instance, ?Subscription $contract): Carbon { if ($contract?->current_period_end !== null) { // Taken as it stands, even when it is already past. A term that has // run out has run out — the customer is in dunning, not in a paid // period — and inventing another month for them would give away // service nobody decided to give. return $contract->current_period_end->copy(); } $start = $instance->order?->created_at ?? $instance->created_at ?? now(); // Always add from the immutable start so the billing-day anchor is kept // (chaining addMonth would drift a Jan-31 start to Feb-28 → Mar-28). $months = 1; while ($start->copy()->addMonthsNoOverflow($months)->lessThanOrEqualTo(now())) { $months++; } return $start->copy()->addMonthsNoOverflow($months); } public function render() { $instance = $this->customer()?->instances()->where('status', 'active')->latest('id')->first(); return view('livewire.confirm-cancel-package', [ 'subdomain' => $instance?->subdomain ?? '', ]); } }