CluPilotCloud/app/Actions/IssueStripeInvoice.php

316 lines
13 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

<?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 Stripes 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(),
]);
}
}
}