CluPilotCloud/app/Console/Commands
nexxo 8440266ed3 Stop charging for a service that has ended
Nothing in this application could cancel a Stripe subscription — there was
no such method on the client at all. So a customer who cancelled and a
consumer who withdrew both stayed subscribed, the card went on being
charged every month, and every one of those payments arrived as
invoice.paid, drew a real number out of the gapless Austrian series and
mailed the PDF, for a machine that had been switched off. Indefinitely.

StripeClient::cancelSubscription() closes the first half, with the timing
passed explicitly at the call site — CANCEL_AT_PERIOD_END for a
cancellation, because the customer keeps the term they paid for, and
CANCEL_IMMEDIATELY for a withdrawal, because that unwinds the contract and
the whole amount has just gone back. The immediate form states prorate and
invoice_now as false rather than inheriting them: a credit note of Stripe's
beside our own Storno would be the same money twice.

The second half is ApplyStripeBillingEvent::owesADocument(). Refusing every
invoice on a cancelled contract would lose the legitimate final one — the
cycle for a term the customer really did use, unpaid, dunned for three
weeks and settled after the contract ended. So the question is not when the
payment landed but what it is for: an invoice whose billed period STARTED
before the contract ended is documented, one whose period begins at or
after that moment is not. Neither is a contract marked cancelled with no
date, nor an invoice with no period, because a number handed out in error
can never be withdrawn while a missing document can still be issued. The
register entry is written either way — the money moved, and that is where an
operator finds what has to go back.

A cancellation also took its date from the order date plus MONTHS, whatever
the term said, so a yearly customer who cancelled in March lost the nine
months they had already paid for. It reads current_period_end now, which
Stripe keeps current for both terms; the month-walk survives only for a
machine with no contract behind it, where nothing records a term at all.
Stripe is asked first and our rows are written only if it agreed: a
cancellation we could not make effective is the defect above wearing a
"gekündigt" label on the settings page, and a cancellation has no deadline,
so being asked to try again costs nobody anything.

A withdrawal cancelled the OPENING invoice only, found by invoices.order_id
— which a renewal and a module document leave null on purpose. A storage
pack booked on day three and withdrawn from on day ten was therefore
neither cancelled nor refunded. Every charge inside the window is covered
now, one Storno per document and one refund per payment, each against the
payment that actually took it: a single refund of the total against the
opening PaymentIntent is what Stripe would have refused for exceeding it,
and one idempotency key for the whole withdrawal would have had Stripe
replay the first refund's answer for the second.

StartCustomerProvisioning::resume() opened a missing contract and returned,
and the only production call to IssueInvoice::forOrders() is after the order
commits — so a worker killed in between cost that customer their invoice for
good, silently, with nothing anywhere sweeping for it. resume() finishes that
work too, guarded by the invoice already filed against the order and by
invoices.sent_at, so however often Stripe redelivers it comes out as one
invoice, one number, one mail. The order confirmation stays on the first
pass alone: it has nothing to stamp.

subscriptions.cancel_requested_at is new because the billing half of the
application had no way to tell a cancelled contract from an untouched one.
Deliberately not a status: until the term runs out this is a paying
customer, which is the distinction EndInstanceService is built around, and
`cancelled_at` beside it still means the day it genuinely ended.

clupilot:verify-vat-ids is scheduled monthly on the first, and its docblock
no longer points at a note in routes/console.php that never existed. Reverse
charge rested on a one-off VIES answer, a withdrawn registration keeps
earning rate 0 for ever, and the unpaid VAT on those invoices is the
seller's — so the cadence is the width of that window, and the first of the
month is the rhythm the return is filed on.

Two tests that proved nothing are now about behaviour. SettingsTest asserted
service_ends_at was not null, a column the code had just written and which
the broken monthly arithmetic satisfied exactly as well as the right answer;
it states the yearly date, the contract's own record and the order to Stripe.
EndInstanceServiceTest travelled to whatever value the implementation had
computed, so it recomputed the code instead of checking it, and never looked
at billing at all; the boundary is a literal date now and the money stopping
is asserted beside the address coming down.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 00:52:19 +02:00
..
ApplyDuePlanChanges.php Book a downgrade for a date that cannot move, and sell a module once 2026-07-29 17:51:37 +02:00
ApplyStorageQuotas.php Deliver the storage a customer actually buys 2026-07-29 19:13:10 +02:00
ArchiveUnexportedInvoices.php Let the export have as many destinations as somebody wants 2026-07-29 02:43:15 +02:00
AutoUpdate.php Update on a schedule if the owner wants one, and say where incidents go 2026-07-29 15:01:54 +02:00
CheckDnsZone.php Put customer instances on their own domain, and say which domains are ours 2026-07-29 16:03:13 +02:00
CheckPlanCatalogue.php feat(billing): the plan catalogue becomes three tables, and config stops selling 2026-07-26 12:05:56 +02:00
ConsoleAccess.php Make the private hostnames look like nothing is there, and close the way past the proxy 2026-07-27 11:26:48 +02:00
CreateAdmin.php Check users directly for the reverse operator-identity collision, not just customers 2026-07-28 15:36:29 +02:00
EndCancelledAddons.php Bill a booked module every month, and put it on the invoice 2026-07-29 20:22:02 +02:00
EndDueServices.php Restart a machine, enforce the quota that was sold, end a route that ended 2026-07-29 18:28:28 +02:00
FetchInboundMail.php Read the support mailbox into the console 2026-07-29 21:41:29 +02:00
PruneExportFolders.php Let a destination say how it is laid out and how long anything stays 2026-07-29 03:08:27 +02:00
PruneHostDns.php Move host DNS names off the public zone into vpn-dns's internal hostsdir 2026-07-28 23:44:31 +02:00
RepriceStripeSubscriptions.php Charge the price the website shows, and hand a withdrawal back in full 2026-07-29 22:42:02 +02:00
SampleServiceStatus.php Rebuild the status page as a status page 2026-07-29 12:45:18 +02:00
SyncStripeCatalogue.php Charge the price the website shows, and hand a withdrawal back in full 2026-07-29 22:42:02 +02:00
SyncStripeSubscriptions.php Bill a booked module every month, and put it on the invoice 2026-07-29 20:22:02 +02:00
VerifyCustomDomains.php Serve the custom domain, not just announce it 2026-07-29 16:44:52 +02:00
VerifyVatIds.php Stop charging for a service that has ended 2026-07-30 00:52:19 +02:00