CluPilotCloud/app/Actions/IssueRenewalInvoice.php

128 lines
5.2 KiB
PHP

<?php
namespace App\Actions;
use App\Mail\InvoiceMail;
use App\Models\Invoice;
use App\Models\Subscription;
use App\Services\Billing\IssueInvoice;
use Illuminate\Database\UniqueConstraintViolationException;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Mail;
use Throwable;
/**
* The document a renewal owes the customer, and the one mail that carries it.
*
* The first purchase has had this since it was built — StartCustomerProvisioning
* issues an invoice the moment the money lands and mails it. A renewal had
* nothing: the payment went into the proof register, the term moved on, and a
* customer paying every month for a year received exactly one invoice, for month
* one. This is the missing half, and it is deliberately shaped like
* confirmByMail(): money landed → a document → one mail.
*
* **Never allowed to fail the webhook.** Stripe retries anything that is not a
* 2xx, and there is nothing a retry could fix about an incomplete company
* profile or a mail server that is down — it would simply arrive again, fail
* again, and eventually be given up on, taking the register entry's retry with
* it. So everything here is caught and logged. The payment is recorded whatever
* happens to the paperwork; a missing document is recoverable, a lost payment is
* not.
*
* **Idempotent against the Stripe invoice.** An invoice number comes out of a
* gapless series and can never be handed out twice, so the guard is the unique
* `invoices.stripe_invoice_id` rather than a check — two deliveries arriving
* together would both pass a check. The number is drawn inside the same
* transaction the row is written in, so a collision takes the number back with
* it and the series keeps its sequence.
*
* Issuing and mailing are separate steps on purpose. `sent_at` records that the
* mail was handed to the mailer, so a delivery arriving after the document was
* written but before the mail went out finishes the job instead of skipping it —
* and one that arrives afterwards does neither twice.
*/
class IssueRenewalInvoice
{
/**
* @return Invoice|null the document, or null when there is not one and
* could not be one
*/
public function __invoke(Subscription $subscription, string $stripeInvoiceId, Carbon $from, Carbon $to): ?Invoice
{
if ($stripeInvoiceId === '') {
// Without it there is no way to tell a redelivery from a second
// renewal, and guessing wrong costs an invoice number.
Log::warning('Not issuing a renewal invoice: the Stripe invoice carries no id, so issuing it could not be made idempotent.', [
'subscription' => $subscription->uuid,
]);
return null;
}
$invoice = $this->issue($subscription, $stripeInvoiceId, $from, $to);
if ($invoice === null || $invoice->sent_at !== null) {
return $invoice;
}
$this->send($subscription, $invoice);
return $invoice;
}
private function issue(Subscription $subscription, string $stripeInvoiceId, Carbon $from, Carbon $to): ?Invoice
{
$existing = Invoice::query()->where('stripe_invoice_id', $stripeInvoiceId)->first();
if ($existing !== null) {
return $existing;
}
try {
return app(IssueInvoice::class)->forBilledPeriod($subscription, $from, $to, $stripeInvoiceId);
} catch (UniqueConstraintViolationException) {
// A concurrent delivery won the race. Its document is the one to
// send; this one's invoice number went back into the series when the
// transaction rolled back.
return Invoice::query()->where('stripe_invoice_id', $stripeInvoiceId)->first();
} catch (Throwable $e) {
// Refuses while the company details are incomplete, which is the
// correct outcome — see IssueInvoice. The renewal itself stands: the
// term has moved and the payment is in the register, and the
// document can be issued once the details are there.
Log::warning('Could not issue an invoice for this renewal.', [
'subscription' => $subscription->uuid,
'stripe_invoice' => $stripeInvoiceId,
'exception' => $e->getMessage(),
]);
return null;
}
}
private function send(Subscription $subscription, Invoice $invoice): void
{
$customer = $subscription->customer;
$address = $customer?->email;
if (! is_string($address) || $address === '') {
return;
}
try {
Mail::to($address)->queue(new InvoiceMail($invoice, (string) $customer->name));
// Only once it is actually with the mailer. Stamped so a redelivery
// does not send the same invoice a second time, and left unstamped
// on a failure so that one still can.
$invoice->update(['sent_at' => now()]);
} catch (Throwable $e) {
Log::warning('Could not queue the invoice mail for this renewal.', [
'invoice' => $invoice->number,
'exception' => $e->getMessage(),
]);
}
}
}