CluPilotCloud/app/Livewire/ConfirmCancelPackage.php

210 lines
8.5 KiB
PHP

<?php
namespace App\Livewire;
use App\Livewire\Concerns\ResolvesCustomer;
use App\Models\Customer;
use App\Models\Instance;
use App\Models\Subscription;
use App\Services\Stripe\StripeClient;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Log;
use LivewireUI\Modal\ModalComponent;
use Throwable;
/**
* Cancel the customer's package (R5). Per the founder's decision: effective at
* the end of the billing term, irreversible once confirmed; at term end the
* customer receives a finished data export, then the instance is deprovisioned
* (both mocked for now). Requires typing the instance subdomain to confirm.
*
* ## Stripe is told first, and everything else follows from its answer
*
* This used to write three columns on the instance and stop there — the contract
* was untouched and Stripe was never told anything at all, so the card went on
* being charged every month for a package that had ended, for ever. The order of
* the two writes is therefore the design:
*
* 1. **Stripe is asked to stop at the period end.** If it refuses or cannot be
* reached, NOTHING here is recorded and the customer is asked to try again.
* Recording a cancellation we could not make effective is the defect this
* class was built out of: the customer sees "gekündigt" on their settings page
* and keeps paying. A cancellation has no deadline — unlike the statutory
* withdrawal in App\Actions\WithdrawContract, which stands whatever Stripe
* says — so a retry a minute later costs nobody anything.
* 2. **Then our own rows.** The reverse failure — Stripe stopped, our write lost
* — is the harmless direction: the customer is not charged again, and
* Stripe's `customer.subscription.deleted` at the period end closes the
* contract here through the machinery that already reads it.
*
* The order to stop carries an idempotency key on the contract, so the retry
* after a timeout that in fact went through is provably a no-op.
*/
class ConfirmCancelPackage extends ModalComponent
{
use ResolvesCustomer;
public string $confirmName = '';
public function cancelPackage()
{
$customer = $this->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 ?? '',
]);
}
}