$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 $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 $stripeInvoice * @return array> */ 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 $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; } return app(IssueInvoice::class)->forBilledPeriod($subscription, $from, $to, $stripeInvoiceId); } /** * 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 $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 NET, so the line amounts that come * back are net and the document adds VAT on top: Stripe's total should be * our gross. It may legitimately be our NET instead, on an account that does * not collect tax on our behalf. A figure that is neither is one nobody can * explain, and it is exactly what an operator needs to be told about. * * 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 $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 || $total === $invoice->net_cents) { return; } Log::warning('An invoice was issued for a sum that is neither the net nor the gross Stripe reported.', [ '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(), ]); } } }