210 lines
8.5 KiB
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 ?? '',
|
|
]);
|
|
}
|
|
}
|