open) { // The refusal is the customer's own sentence, not a developer's: // whatever refuses — the portal, the console, a stale tab — says // the same thing, in the same words. throw new RuntimeException((string) ($right->refusal ?? __('withdrawal.refusal_expired'))); } // The one atomic gate. Everything below happens exactly once because // exactly one caller can turn this column over. $claimed = Subscription::query() ->whereKey($subscription->getKey()) ->whereNull('withdrawn_at') ->update([ 'withdrawn_at' => $at, 'withdrawal_channel' => $channel, 'withdrawal_recorded_by' => $by?->id, 'updated_at' => now(), ]); $subscription->refresh(); if ($claimed === 0) { throw new RuntimeException(__('withdrawal.refusal_already')); } $refunded = $this->settle($subscription, $at); $this->endEverything($subscription, $at); $this->enterInRegister($subscription, $refunded, $at); return $subscription->refresh(); } /** * The paperwork and the money. * * @return int what actually went back to the customer, gross, in cents */ private function settle(Subscription $subscription, Carbon $at): int { try { $original = $this->openingInvoice($subscription); $owedNet = WithdrawalRight::owedNetCents($subscription, $at); // No document was ever issued for this purchase — the company // details were incomplete when it was made, or it was a grant. There // is nothing to cancel and nothing to correct, so the refund is // worked out from what was charged instead. Stated here rather than // silently skipped: a withdrawal without paperwork is a withdrawal // an accountant will ask about. if ($original === null) { $paid = (int) ($subscription->order?->amount_cents ?? 0); $owedGross = $owedNet === 0 ? 0 : $this->grossFor($subscription, $owedNet); return $this->sendBack($subscription, max(0, $paid - $owedGross), $at); } // Taken back in full, with its own gapless number, pointing at the // one it cancels. The original keeps its number for ever. $this->invoices->cancelling($original); // What the consumer owes for the days the cloud actually ran. Its // own document, because the money is real and an amount kept with no // invoice behind it is money we cannot account for. $remainder = $owedNet > 0 ? $this->invoices->forService( customer: $original->customer, lines: [[ 'description' => __('withdrawal.invoice_line', [ 'plan' => ucfirst((string) $subscription->plan), ]), 'details' => [__('withdrawal.invoice_detail', [ 'days' => WithdrawalRight::deliveredDays($subscription, $at), 'term' => WithdrawalRight::termDays($subscription), ])], 'quantity_milli' => 1000, 'unit' => '', 'unit_net_cents' => $owedNet, ]], currency: (string) $original->currency, ) : null; // The refund IS the difference between the two documents. Computing // it separately would produce a second figure that has to agree with // the first, and one day would not. return $this->sendBack( $subscription, max(0, (int) $original->gross_cents - (int) ($remainder?->gross_cents ?? 0)), $at, ); } catch (Throwable $e) { $this->parkMoneyFailure($subscription, $e); return 0; } } /** * Put the money back where it came from. * * Stripe refunds a PAYMENT, not a subscription and not an invoice, so the * handle is whatever the checkout reported — a PaymentIntent if there was * one, otherwise the invoice that charged the card, resolved through Stripe * once. A contract with neither was never paid for through Stripe at all (a * grant, a contract opened by hand), and there is nothing to send back. */ private function sendBack(Subscription $subscription, int $grossCents, Carbon $at): int { $subscription->update(['withdrawal_refund_cents' => $grossCents]); if ($grossCents <= 0) { return 0; } $order = $subscription->order; $reference = $order?->stripe_payment_intent_id; if ($reference === null && $order?->stripe_invoice_id !== null) { $reference = $this->stripe->invoicePaymentReference((string) $order->stripe_invoice_id); } if ($reference === null) { throw new RuntimeException( 'No Stripe payment is recorded for this contract, so the refund has to be sent by hand.' ); } $refundId = $this->stripe->refund( $reference, $grossCents, // Keyed on the contract, because a contract is withdrawn from once. // A retry after a timeout that in fact went through replays Stripe's // first answer rather than sending the money a second time — which // is the single most expensive mistake this file could make. idempotencyKey: 'clupilot-withdrawal-'.$subscription->uuid, ); $subscription->update([ 'withdrawal_refund_reference' => $refundId, 'withdrawal_refund_error' => null, ]); Log::info('Refunded a withdrawal.', [ 'subscription' => $subscription->uuid, 'refund' => $refundId, 'gross_cents' => $grossCents, 'withdrawn_at' => $at->toIso8601String(), ]); return $grossCents; } /** * A withdrawn contract ends: the modules stop, the address comes down and * the contract is closed. * * Through the machinery that already does this, never beside it. The * instance is put into `cancellation_scheduled` with its service ending NOW * — which is what a withdrawal means, unlike a cancellation, where the * customer keeps the term they paid for — and EndInstanceService is asked * the same question it is asked by the nightly sweep. * * The modules are ended outright rather than at the period end, for the same * reason: there is no term left to keep. The customer has withdrawn from the * contract the modules hang off, and BookAddon::cancel() takes them off * Stripe and out of the register. */ private function endEverything(Subscription $subscription, Carbon $at): void { foreach (SubscriptionAddon::query()->where('subscription_id', $subscription->id)->active()->get() as $addon) { $this->bookAddon->cancel($addon); } $instance = $this->instanceOf($subscription); if ($instance !== null && $instance->status !== 'ended') { $instance->update([ 'status' => 'cancellation_scheduled', 'cancel_requested_at' => $at, // Now, not the end of the term. A withdrawal unwinds the // contract; there is no paid-up period to run out. 'service_ends_at' => $at, ]); ($this->endService)($instance->refresh()); } $subscription->update([ 'status' => 'cancelled', 'cancelled_at' => $at, ]); } /** * The machine this contract pays for. * * `subscriptions.instance_id` is the link, and it is written when resources * are reserved — so a contract withdrawn from before the build got that far * has none. The order and then the customer are the fallbacks, in that * order, because an order belongs to exactly one purchase and a customer can * have more than one instance behind them. */ private function instanceOf(Subscription $subscription): ?Instance { if ($subscription->instance !== null) { return $subscription->instance; } if ($subscription->order_id !== null) { $byOrder = Instance::query()->where('order_id', $subscription->order_id)->latest('id')->first(); if ($byOrder !== null) { return $byOrder; } } return Instance::query() ->where('customer_id', $subscription->customer_id) ->whereIn('status', ['active', 'provisioning', 'cancellation_scheduled']) ->latest('id') ->first(); } /** * The document issued for the purchase that opened this contract. * * Cancellations are excluded by construction — one is not an invoice that * can be taken back — and so is anything that already has a Storno against * it, which IssueInvoice::cancelling() refuses in its own right. */ private function openingInvoice(Subscription $subscription): ?Invoice { if ($subscription->order_id === null) { return null; } return Invoice::query() ->where('order_id', $subscription->order_id) ->whereNull('cancels_invoice_id') ->orderBy('id') ->first(); } /** Gross for a net figure, at this customer's own treatment. */ private function grossFor(Subscription $subscription, int $netCents): int { return TaxTreatment::for($subscription->customer)->grossCents($netCents); } /** * The refund did not go out. Said on the contract, not only in a log. * * What this describes is a customer who has withdrawn, whose service has * stopped, and who has not had their money back — the one state nobody may * discover from a log file three weeks later. */ private function parkMoneyFailure(Subscription $subscription, Throwable $e): void { Log::error('A withdrawal was declared but the money did not go back.', [ 'subscription' => $subscription->uuid, 'customer' => $subscription->customer_id, 'error' => $e->getMessage(), ]); $subscription->update([ 'withdrawal_refund_error' => mb_substr($e->getMessage(), 0, 250), ]); } /** * The entry that says this happened, in the register that cannot be edited. * * Negative, because it is revenue leaving. The gross is what actually went * back, so a withdrawal that could not be refunded records a zero and the * `withdrawal_refund_error` beside it says why — rather than claiming money * moved that did not. */ private function enterInRegister(Subscription $subscription, int $refundedGross, Carbon $at): void { ($this->record)( event: SubscriptionRecord::EVENT_WITHDRAWAL, subscription: $subscription->refresh(), netCents: -(int) $subscription->price_cents, at: $at, extra: ['withdrawal' => [ 'channel' => $subscription->withdrawal_channel, 'delivered_days' => WithdrawalRight::deliveredDays($subscription, $at), 'term_days' => WithdrawalRight::termDays($subscription), 'owed_net_cents' => WithdrawalRight::owedNetCents($subscription, $at), 'refunded_gross_cents' => $refundedGross, 'immediate_start_consent_at' => $subscription->immediate_start_consent_at?->toIso8601String(), ]], chargedGrossCents: -$refundedGross, eventKey: 'withdrawal:'.$subscription->uuid, ); } }