316 lines
13 KiB
PHP
316 lines
13 KiB
PHP
<?php
|
||
|
||
namespace App\Actions;
|
||
|
||
use App\Mail\InvoiceMail;
|
||
use App\Models\Invoice;
|
||
use App\Models\Subscription;
|
||
use App\Services\Billing\IssueInvoice;
|
||
use App\Services\Stripe\StripeClient;
|
||
use Illuminate\Database\UniqueConstraintViolationException;
|
||
use Illuminate\Support\Carbon;
|
||
use Illuminate\Support\Facades\Log;
|
||
use Illuminate\Support\Facades\Mail;
|
||
use Throwable;
|
||
|
||
/**
|
||
* The document a paid Stripe invoice owes the customer, and the one mail that
|
||
* carries it.
|
||
*
|
||
* **One document per charge.** That is the owner's rule and it is a legal one,
|
||
* not a preference: every amount taken from a customer needs its own invoice
|
||
* with its own number. Two months of package, a storage module booked in the
|
||
* middle of month three, and the cycle that follows are four charges and four
|
||
* documents — the fourth being the one that carries package and storage
|
||
* together, because they were charged together.
|
||
*
|
||
* This used to issue for `subscription_cycle` alone, and everything else — a
|
||
* proration, a module, a charge raised by hand — was money received that no
|
||
* paper of ours covered. It was written down as a decision and it was the wrong
|
||
* one: Stripe charged for those, so they need documents.
|
||
*
|
||
* **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 IssueStripeInvoice
|
||
{
|
||
/**
|
||
* @param array<string, mixed> $stripeInvoice the invoice as Stripe sent it
|
||
* @param Carbon|null $from the term this invoice billed, for the fallback below
|
||
* @return Invoice|null the document, or null when there is not one and could not be one
|
||
*/
|
||
public function __invoke(
|
||
Subscription $subscription,
|
||
array $stripeInvoice,
|
||
?Carbon $from = null,
|
||
?Carbon $to = null,
|
||
): ?Invoice {
|
||
$stripeInvoiceId = (string) ($stripeInvoice['id'] ?? '');
|
||
|
||
if ($stripeInvoiceId === '') {
|
||
// Without it there is no way to tell a redelivery from a second
|
||
// charge, and guessing wrong costs an invoice number.
|
||
Log::warning('Not issuing a document: the Stripe invoice carries no id, so issuing it could not be made idempotent.', [
|
||
'subscription' => $subscription->uuid,
|
||
]);
|
||
|
||
return null;
|
||
}
|
||
|
||
$invoice = $this->issue($subscription, $stripeInvoiceId, $stripeInvoice, $from, $to);
|
||
|
||
if ($invoice === null || $invoice->sent_at !== null) {
|
||
return $invoice;
|
||
}
|
||
|
||
$this->send($subscription, $invoice);
|
||
|
||
return $invoice;
|
||
}
|
||
|
||
/** @param array<string, mixed> $stripeInvoice */
|
||
private function issue(
|
||
Subscription $subscription,
|
||
string $stripeInvoiceId,
|
||
array $stripeInvoice,
|
||
?Carbon $from,
|
||
?Carbon $to,
|
||
): ?Invoice {
|
||
$existing = Invoice::query()->where('stripe_invoice_id', $stripeInvoiceId)->first();
|
||
|
||
if ($existing !== null) {
|
||
return $existing;
|
||
}
|
||
|
||
try {
|
||
$lines = $this->lines($stripeInvoiceId, $stripeInvoice);
|
||
} catch (Throwable $e) {
|
||
// Deliberately no document at all rather than a partial one. The
|
||
// total would still be right and the reader would have no way to see
|
||
// what they had paid for, which is the kind of invoice that gets
|
||
// rejected at an audit. A later redelivery or a replay can still
|
||
// issue it.
|
||
Log::error('Could not read the lines of a paid Stripe invoice, so no document was issued for it.', [
|
||
'subscription' => $subscription->uuid,
|
||
'stripe_invoice' => $stripeInvoiceId,
|
||
'exception' => $e->getMessage(),
|
||
]);
|
||
|
||
return null;
|
||
}
|
||
|
||
try {
|
||
if ($lines === []) {
|
||
return $this->fromContract($subscription, $stripeInvoiceId, $stripeInvoice, $from, $to);
|
||
}
|
||
|
||
$unnamed = [];
|
||
|
||
$invoice = app(IssueInvoice::class)->forStripeInvoice(
|
||
subscription: $subscription,
|
||
stripeInvoiceId: $stripeInvoiceId,
|
||
lines: $lines,
|
||
currency: is_string($stripeInvoice['currency'] ?? null) ? $stripeInvoice['currency'] : null,
|
||
unnamed: $unnamed,
|
||
);
|
||
|
||
$this->reportUnnamed($subscription, $stripeInvoiceId, $unnamed);
|
||
$this->reportMismatch($invoice, $stripeInvoice);
|
||
|
||
return $invoice;
|
||
} 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 payment itself stands: it
|
||
// is in the register, and the document can be issued once the
|
||
// details are there.
|
||
Log::warning('Could not issue a document for this paid Stripe invoice.', [
|
||
'subscription' => $subscription->uuid,
|
||
'stripe_invoice' => $stripeInvoiceId,
|
||
'exception' => $e->getMessage(),
|
||
]);
|
||
|
||
return null;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Every line Stripe charged for, complete.
|
||
*
|
||
* The event carries them already. It does not always carry ALL of them — a
|
||
* contract with enough modules on it passes the number Stripe puts in a
|
||
* webhook — and `has_more` is how it says so. A document missing a charged
|
||
* line is worse than an extra API call, so the complete list is fetched
|
||
* whenever Stripe admits to holding one.
|
||
*
|
||
* @param array<string, mixed> $stripeInvoice
|
||
* @return array<int, array<string, mixed>>
|
||
*/
|
||
private function lines(string $stripeInvoiceId, array $stripeInvoice): array
|
||
{
|
||
$lines = (array) ($stripeInvoice['lines']['data'] ?? []);
|
||
|
||
if (($stripeInvoice['lines']['has_more'] ?? false) !== true) {
|
||
return $lines;
|
||
}
|
||
|
||
return app(StripeClient::class)->invoiceLines($stripeInvoiceId);
|
||
}
|
||
|
||
/**
|
||
* The document for a cycle invoice that arrived without any line detail.
|
||
*
|
||
* The contract's own frozen price is an honest description of the term that
|
||
* was billed, and better than leaving a paid renewal with no paper at all.
|
||
* Only for a cycle: a proration or a charge raised by hand describes
|
||
* something the contract cannot answer for, and a line invented for those
|
||
* would be a guess printed on a document nobody can correct afterwards.
|
||
*
|
||
* @param array<string, mixed> $stripeInvoice
|
||
*/
|
||
private function fromContract(
|
||
Subscription $subscription,
|
||
string $stripeInvoiceId,
|
||
array $stripeInvoice,
|
||
?Carbon $from,
|
||
?Carbon $to,
|
||
): ?Invoice {
|
||
$isCycle = (string) ($stripeInvoice['billing_reason'] ?? '') === 'subscription_cycle';
|
||
|
||
if (! $isCycle || $from === null || $to === null) {
|
||
Log::warning('A paid Stripe invoice carried no lines, so no document could be built from it.', [
|
||
'subscription' => $subscription->uuid,
|
||
'stripe_invoice' => $stripeInvoiceId,
|
||
'billing_reason' => $stripeInvoice['billing_reason'] ?? null,
|
||
]);
|
||
|
||
return null;
|
||
}
|
||
|
||
// The total Stripe took, where it said so. A document has to add up to
|
||
// the money, and the contract's own price at today's rate is only the
|
||
// next best answer — it would miss a cycle billed before a rate change.
|
||
$total = $stripeInvoice['total'] ?? $stripeInvoice['amount_paid'] ?? null;
|
||
|
||
return app(IssueInvoice::class)->forBilledPeriod(
|
||
$subscription,
|
||
$from,
|
||
$to,
|
||
$stripeInvoiceId,
|
||
chargedCents: is_numeric($total) ? (int) $total : null,
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Say out loud that a line was charged which the catalogue could not name.
|
||
*
|
||
* The line is on the document either way, carrying whatever description
|
||
* Stripe sent. This is the trail for whoever has to work out what was sold —
|
||
* a Price created by hand in Stripe's dashboard, or a module removed from
|
||
* the catalogue while somebody was still paying for it.
|
||
*
|
||
* @param array<int, string> $unnamed
|
||
*/
|
||
private function reportUnnamed(Subscription $subscription, string $stripeInvoiceId, array $unnamed): void
|
||
{
|
||
if ($unnamed === []) {
|
||
return;
|
||
}
|
||
|
||
Log::warning('A charged line was put on an invoice under Stripe’s own description, because this catalogue could not name it.', [
|
||
'subscription' => $subscription->uuid,
|
||
'stripe_invoice' => $stripeInvoiceId,
|
||
'prices' => $unnamed,
|
||
]);
|
||
}
|
||
|
||
/**
|
||
* Say out loud when the document and the money do not agree.
|
||
*
|
||
* Our catalogue is pushed to Stripe as GROSS, so the line amounts that come
|
||
* back are what the customer paid and the document divides that sum rather
|
||
* than adding a rate to it. Stripe's total and our gross must therefore be
|
||
* the SAME NUMBER, to the cent, for every customer — including a
|
||
* reverse-charge one, whose whole charged amount is stated as net at 0 %.
|
||
*
|
||
* This used to accept our net as an alternative, on the grounds that an
|
||
* account might not collect tax for us. That tolerance is what let the
|
||
* defect run: 179,00 € taken against a document for 214,80 € matched the
|
||
* "net" branch and was never reported. Anything but equality is now worth a
|
||
* warning, and the likeliest cause is a Price that stripe:sync-catalogue has
|
||
* replaced with nobody having run stripe:reprice-subscriptions.
|
||
*
|
||
* Never an exception. The document is issued and the payment is recorded;
|
||
* raising here would only cost Stripe its 2xx and change nothing.
|
||
*
|
||
* @param array<string, mixed> $stripeInvoice
|
||
*/
|
||
private function reportMismatch(Invoice $invoice, array $stripeInvoice): void
|
||
{
|
||
$total = $stripeInvoice['total'] ?? $stripeInvoice['amount_paid'] ?? null;
|
||
|
||
if (! is_numeric($total)) {
|
||
return;
|
||
}
|
||
|
||
$total = (int) $total;
|
||
|
||
if ($total === $invoice->gross_cents) {
|
||
return;
|
||
}
|
||
|
||
Log::warning('An invoice was issued for a sum Stripe did not charge.', [
|
||
'invoice' => $invoice->number,
|
||
'stripe_invoice' => $invoice->stripe_invoice_id,
|
||
'stripe_total' => $total,
|
||
'net' => $invoice->net_cents,
|
||
'gross' => $invoice->gross_cents,
|
||
]);
|
||
}
|
||
|
||
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 payment.', [
|
||
'invoice' => $invoice->number,
|
||
'exception' => $e->getMessage(),
|
||
]);
|
||
}
|
||
}
|
||
}
|