From 79564947d4291e70411bb7e4897823962f12cff7 Mon Sep 17 00:00:00 2001 From: nexxo Date: Wed, 29 Jul 2026 03:25:56 +0200 Subject: [PATCH] Actually issue the invoice when the money arrives MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Everything was built — numbering, the frozen snapshot, the PDF, the mail, the archive and its retention — and none of it was connected to anything. IssueInvoice was called from nowhere but its own tests. So on a real server no invoice had ever been created, the handover directory was empty, and rsync was correctly copying nothing. Reported as "nothing gets copied", which it was. One mail, not two. The invoice is issued the moment the payment lands, so an order confirmation and an invoice mail would otherwise go out in the same second for the same event — a defect rather than a preference. The invoice mail carries the document and says everything the confirmation said, so it replaces it whenever there is an invoice; the confirmation stays for when there cannot be one. Issuing is allowed to fail without taking the purchase with it. IssueInvoice refuses while the company details are incomplete, and that refusal is correct — an invoice without a registered name or a VAT number is not a valid invoice, and issuing one consumes a number that can never be handed out again. The order still stands, the machine is still built, and the invoice can be issued later. Nothing here may throw at the webhook either: Stripe retries anything that is not a 2xx, and a retry would re-enter provisioning and build a second machine over a missing VAT number. Co-Authored-By: Claude Opus 5 --- app/Actions/StartCustomerProvisioning.php | 38 ++++++++++++++++++++--- tests/Feature/MailTemplatesTest.php | 38 ++++++++++++++++++++--- 2 files changed, 67 insertions(+), 9 deletions(-) diff --git a/app/Actions/StartCustomerProvisioning.php b/app/Actions/StartCustomerProvisioning.php index dfb73ba..42673f8 100644 --- a/app/Actions/StartCustomerProvisioning.php +++ b/app/Actions/StartCustomerProvisioning.php @@ -2,7 +2,9 @@ namespace App\Actions; +use App\Mail\InvoiceMail; use App\Mail\OrderConfirmationMail; +use App\Services\Billing\IssueInvoice; use App\Exceptions\IdentityCollisionException; use App\Models\Customer; use App\Models\Order; @@ -114,7 +116,14 @@ class StartCustomerProvisioning } /** - * Tell the customer the payment landed, before anything is running. + * Invoice the payment, and tell the customer once. + * + * ONE mail, not two. The invoice is issued at the moment the money lands, + * so an order confirmation and an invoice mail would otherwise go out in + * the same second for the same event — which is a defect, not a preference. + * The invoice mail carries the document and says everything the + * confirmation said, so it replaces it whenever there is an invoice; the + * confirmation remains for the case where there cannot be one. * * Sent here rather than when provisioning finishes: those are minutes apart * at best and can be much longer, and somebody who has just been charged @@ -126,8 +135,8 @@ class StartCustomerProvisioning * does not exist yet. * * Never allowed to fail the webhook. Stripe retries anything that is not a - * 2xx, and a retry here would re-enter provisioning over a mail server - * having a bad minute. + * 2xx, and a retry here would re-enter provisioning — a second machine + * built — over a mail server or a missing VAT number. */ private function confirmByMail(Order $order, Customer $customer): void { @@ -137,10 +146,29 @@ class StartCustomerProvisioning return; } + $invoice = null; + try { - Mail::to($address)->queue(new OrderConfirmationMail($order, (string) $customer->name)); + // Refuses while the company details are incomplete, which is the + // correct outcome — an invoice without a registered name or a VAT + // number is not a valid invoice, and issuing one consumes a number + // that can never be handed out again. The purchase is unaffected: + // the order stands, the machine gets built, and the invoice can be + // issued from the console once the details are there. + $invoice = app(IssueInvoice::class)->forOrders($customer, collect([$order])); } catch (Throwable $e) { - Log::warning('Could not queue the order confirmation', [ + Log::warning('Could not issue an invoice for this order', [ + 'order' => $order->id, + 'exception' => $e->getMessage(), + ]); + } + + try { + Mail::to($address)->queue($invoice !== null + ? new InvoiceMail($invoice, (string) $customer->name) + : new OrderConfirmationMail($order, (string) $customer->name)); + } catch (Throwable $e) { + Log::warning('Could not queue the purchase confirmation', [ 'order' => $order->id, 'exception' => $e->getMessage(), ]); diff --git a/tests/Feature/MailTemplatesTest.php b/tests/Feature/MailTemplatesTest.php index 6e25a99..4f15d82 100644 --- a/tests/Feature/MailTemplatesTest.php +++ b/tests/Feature/MailTemplatesTest.php @@ -11,10 +11,11 @@ use Illuminate\Support\Facades\Queue; /** * The mails this product sends, and the one layout they all wear. */ -it('sends an order confirmation the moment the payment lands', function () { - // Not when provisioning finishes. Those are minutes apart at best, and - // somebody who has just been charged and heard nothing assumes the worst - // about both the charge and the product. +it('sends an order confirmation when no invoice can be issued', function () { + // The company details are not filled in here, so IssueInvoice refuses — + // correctly, because an invoice without a registered name or VAT number is + // not a valid invoice. The purchase still stands and the customer is still + // told. Mail::fake(); Queue::fake(); @@ -34,6 +35,35 @@ it('sends an order confirmation the moment the payment lands', function () { }); }); +it('issues an invoice and sends it, instead of a bare confirmation, once it can', function () { + // ONE mail, not two. The invoice is issued the moment the money lands, so + // an order confirmation and an invoice mail would otherwise go out in the + // same second for the same event. + Mail::fake(); + Queue::fake(); + + App\Support\CompanyProfile::put([ + 'name' => 'CluPilot Cloud e.U.', 'address' => 'Dreherstraße 66/1/8', + 'postcode' => '1110', 'city' => 'Wien', 'vat_id' => 'ATU00000000', + ]); + + app(StartCustomerProvisioning::class)->fromStripeEvent([ + 'id' => 'evt_'.uniqid(), + 'email' => 'kunde@example.com', + 'name' => 'Beispiel GmbH', + 'stripe_customer_id' => null, + 'plan' => 'start', + 'datacenter' => 'fsn', + 'amount_cents' => 1900, + 'currency' => 'EUR', + ]); + + expect(App\Models\Invoice::query()->count())->toBe(1); + + Mail::assertQueued(App\Mail\InvoiceMail::class); + Mail::assertNotQueued(OrderConfirmationMail::class); +}); + it('does not confirm the same order twice when Stripe delivers twice', function () { // Stripe retries until it gets a 2xx, so duplicate deliveries are ordinary // rather than exceptional. A second confirmation for one payment reads as a