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