$orders */ public function forOrders(Customer $customer, Collection $orders, ?InvoiceSeries $series = null): Invoice { // A full gift produces no invoice — no amount, no number drawn from // the series. A zero invoice with a sequential number is a bookkeeping // foreign body. A discounted grant is unaffected: it still charges // something and is invoiced exactly like an ordinary sale below. $orders = $orders->reject(fn (Order $order) => $order->isFreeGrant()); if ($orders->isEmpty()) { throw new RuntimeException('Refusing to issue an invoice with no lines on it.'); } $lines = $orders->map(fn (Order $order) => [ 'description' => $order->label(), 'details' => array_values(array_filter([ $order->isRecurring() ? __('invoice.line_recurring') : __('invoice.line_once'), ])), 'quantity_milli' => 1000, 'unit' => '', // The package alone. A first purchase can carry the one-off setup fee // on the same Stripe session, and it gets a line of its own below — // folded into this one it would be a charge the customer can see on // their statement and cannot find on their invoice. 'unit_net_cents' => $order->packageChargedCents(), ])->all(); // The setup fee, once, after the packages. Its own line because it is its // own thing: charged one time, not monthly, and named on the document with // the same words the price sheet and the checkout used. foreach ($orders as $order) { if ((int) $order->setup_fee_cents <= 0) { continue; } $lines[] = [ 'description' => __('checkout.setup_fee_line'), 'details' => [__('invoice.line_once')], 'quantity_milli' => 1000, 'unit' => '', 'unit_net_cents' => (int) $order->setup_fee_cents, ]; } return $this->issue( customer: $customer, lines: $lines, currency: strtoupper((string) ($orders->first()->currency ?: 'EUR')), series: $series, attributes: ['order_id' => $orders->first()->id], linesAreCharged: true, ); } /** * One invoice for a term that was billed — a renewal. * * Deliberately NOT forOrders() with an Order made up for the occasion. An * Order is something a customer bought: it is created at a checkout, it sits * in their cart until it is paid, and provisioning and plan changes consume * it. A month rolling over is none of those things, and writing a purchase * nobody made would corrupt the one record that says what this customer has * actually ordered. The document points at the CONTRACT instead, which is * what a renewal renews. * * The line is what the cycle CHARGED. Where Stripe reported a total it is * that total, to the cent; where it did not, it is the contract's own frozen * net price at the till (TaxTreatment::chargedCents), which is the amount the * Stripe Price for that contract carries. `price_cents` itself stays the * catalogue's net figure and is not what a document totals to — the customer * was charged the gross of it. * * **The fallback, not the ordinary path.** Modules are items on the Stripe * subscription now, so what a cycle actually charged for is on Stripe's own * invoice — package line, every module line, any proration — and * forStripeInvoice() below builds the document from those. This remains for * the cycle invoice that arrives carrying no line detail at all, where the * contract's own frozen price is the honest description of the term that was * billed. Better a document from the contract than none. * * `$stripeInvoiceId` is what makes issuing this idempotent: the column is * unique, so a redelivered webhook collides in the database and the * transaction takes its invoice number back with it. */ public function forBilledPeriod( Subscription $subscription, Carbon $from, Carbon $to, ?string $stripeInvoiceId = null, ?InvoiceSeries $series = null, ?int $chargedCents = null, ): Invoice { $customer = $subscription->customer; if ($customer === null) { throw new RuntimeException('Refusing to issue an invoice for a contract with nobody on it.'); } $lines = [[ 'description' => __('billing.cart.plan', ['plan' => ucfirst((string) $subscription->plan)]), 'details' => [__('invoice.line_period', [ 'from' => $from->local()->format('d.m.Y'), // Stripe's period_end is the EXCLUSIVE boundary — the first // moment of the next term, which the next invoice will claim in // turn. Printed as it comes, the same day would appear on two // consecutive documents. 'to' => $to->greaterThan($from) ? $to->copy()->subDay()->local()->format('d.m.Y') : $to->local()->format('d.m.Y'), ])], 'quantity_milli' => 1000, 'unit' => '', 'unit_net_cents' => $chargedCents ?? TaxTreatment::chargedCents((int) $subscription->price_cents), ]]; return $this->issue( customer: $customer, lines: $lines, currency: strtoupper((string) ($subscription->currency ?: 'EUR')), series: $series, linesAreCharged: true, attributes: [ 'subscription_id' => $subscription->id, // Left null on purpose. The contract's opening order is the // FIRST purchase and already has its own invoice; pointing this // one at it would file two documents under one sale. 'order_id' => null, 'stripe_invoice_id' => $stripeInvoiceId, ], ); } /** * One document for one paid Stripe invoice, carrying every line Stripe * charged for. * * This is the ordinary path now, and it replaces a rule that had stopped * being true. A cycle invoice used to be one line at the contract's frozen * price, because that was all Stripe billed; modules are items on the * subscription today, so a cycle carries the package AND every module still * running, a mid-period booking carries that module's prorated line on its * own, and an upgrade carries its proration. Each of those is money that was * taken, so each of them owes the customer a document — and the owner's * rule is one document per charge, with the package and the modules of one * cycle together on a single one. * * **Built from Stripe's lines, not from our snapshot.** Stripe is what * actually charged the customer: it worked the pro-rata out against its own * period boundaries and its own view of what is already on the account, and * a document assembled from the contract would state a sum that is not the * one taken. Only the WORDING is ours — see StripeInvoiceLines, which names * each line from the catalogue instead of printing a Price id. * * **Charged in, split here.** Our catalogue is pushed to Stripe as GROSS, so * the line amounts that come back are what the customer actually paid, and * the document divides that sum into net and VAT rather than adding a rate * on top of it. That distinction is not cosmetic: Stripe prorates a term by * days and hands back figures that are not the gross of any whole-cent net, * and a document rebuilt from a net figure would miss those by a cent. * * @param array> $lines Stripe's `lines.data` * @param array $unnamed filled with the lines the catalogue could not name */ public function forStripeInvoice( Subscription $subscription, string $stripeInvoiceId, array $lines, ?string $currency = null, ?InvoiceSeries $series = null, array &$unnamed = [], ): Invoice { $customer = $subscription->customer; if ($customer === null) { throw new RuntimeException('Refusing to issue an invoice for a contract with nobody on it.'); } $built = app(StripeInvoiceLines::class)->build($lines); $unnamed = $built['unnamed']; if ($built['lines'] === []) { throw new RuntimeException('Refusing to issue an invoice with no lines on it.'); } return $this->issue( customer: $customer, lines: $built['lines'], currency: strtoupper((string) ($currency ?: $subscription->currency ?: 'EUR')), series: $series, linesAreCharged: true, attributes: [ 'subscription_id' => $subscription->id, // Left null for the same reason as on a renewal: the contract's // opening order is the first purchase and already has its own // document. 'order_id' => null, 'stripe_invoice_id' => $stripeInvoiceId, ], ); } /** * One invoice for work that was done, typed by a person. * * Not everything we are paid for is a package. Somebody asks whether their * data can be moved into Nextcloud, we look at it, we say yes, we do it — * and that is an invoice with no order and no contract behind it, because * nobody bought it from a price list. * * It goes through the same door as every other invoice on purpose: the same * series, so the number stays consecutive; the same tax treatment, so a * business in another member state gets its reverse charge here too; the * same immutable snapshot and the same archive jobs. A second way of making * invoices would be a second numbering — and a series with a document * missing from it is worth nothing at an audit. * * Lines arrive as a person typed them, NET, with the VAT put on top — the * one door into this class that still works that way, and deliberately. A * catalogue sale has already been charged a gross figure that the document * has to divide; a service invoice charges nothing yet, and the console page * asks the operator for net amounts because that is what was agreed on the * telephone. * * @param array, quantity_milli: int, unit?: string, unit_net_cents: int}> $lines */ public function forService( Customer $customer, array $lines, string $currency = 'EUR', ?InvoiceSeries $series = null, ): Invoice { if ($lines === []) { throw new RuntimeException('Refusing to issue an invoice with no lines on it.'); } return $this->issue( customer: $customer, // Normalised here rather than trusted: the optional keys are // optional to the caller, and InvoiceMath and the renderer both // read them without asking whether they are there. lines: array_map(fn (array $line) => [ 'description' => (string) $line['description'], 'details' => array_values(array_filter((array) ($line['details'] ?? []))), 'quantity_milli' => (int) $line['quantity_milli'], 'unit' => (string) ($line['unit'] ?? ''), 'unit_net_cents' => (int) $line['unit_net_cents'], ], array_values($lines)), currency: strtoupper($currency), series: $series, // No order and no contract: this document points at neither, and // saying so with a null is more honest than inventing an Order // nobody placed just to have something to point at. attributes: [], ); } /** * Take an issued invoice back, by issuing the document that says so. * * The only lawful way to undo an invoice. Nothing is edited and nothing is * deleted — an issued number has been on a document somebody has, and it can * never be reused or withdrawn — so the original stays exactly as it is and * a SECOND document, in the Storno series with its own gapless number, * states the same figures with the opposite sign. `cancels_invoice_id` is * what ties the pair together. * * Mirrored from the original's own snapshot rather than rebuilt from * today's settings: a cancellation has to state the same issuer, the same * recipient, the same lines and above all the same TAX RATE as the document * it cancels. Recomputing the rate would produce a cancellation that does * not cancel — a 20 % invoice taken back at 21 % leaves a cent standing for * ever. * * Only the meta changes: its own number, today's date, the Storno wording * and a reference to the number being taken back. The reverse-charge note * is carried over with everything else, because a zero-rated cancellation * needs the same justification on it that the zero-rated invoice did. * * Refuses to cancel a cancellation. Two Stornos of one invoice would take * the same money back twice on paper, and a Storno of a Storno is a * re-issue, which is a new invoice and not this. */ public function cancelling(Invoice $original, ?InvoiceSeries $series = null): Invoice { $customer = $original->customer; if ($customer === null) { throw new RuntimeException('Refusing to cancel an invoice with nobody on it.'); } if ($original->cancels_invoice_id !== null) { throw new RuntimeException('Refusing to cancel a cancellation: re-issue the document instead.'); } if (Invoice::query()->where('cancels_invoice_id', $original->id)->exists()) { throw new RuntimeException("Invoice {$original->number} has already been cancelled."); } $series ??= InvoiceSeries::query()->where('kind', 'cancellation')->where('active', true)->firstOrFail(); $snapshot = (array) $original->snapshot; $rate = (int) ($snapshot['totals']['rate_basis_points'] ?? 0); // Every line at its own price with the sign turned over. Quantities are // left alone: a negative quantity of a positive price adds up to the // same figure and reads, on the page, as though we had delivered minus // one of something. $lines = array_map(function (array $line) { $line['unit_net_cents'] = -(int) $line['unit_net_cents']; return $line; }, (array) ($snapshot['lines'] ?? [])); // The original's own figures, negated — not recomputed from the lines. // A document built from a charged amount holds a split that its own // lines cannot be made to reproduce (the odd cent lives on one of them), // so recomputing would take back a cent more or less than was invoiced, // and a cancellation that does not cancel leaves that cent standing for // ever. $totals = array_map( fn ($value) => -(int) $value, array_diff_key((array) ($snapshot['totals'] ?? []), ['rate_basis_points' => null]), ) + ['subtotal' => 0, 'lines_net' => 0, 'adjustment' => 0, 'net' => 0, 'tax' => 0, 'gross' => 0] + ['rate_basis_points' => $rate]; $snapshot['lines'] = $lines; $snapshot['totals'] = $totals; $snapshot['meta'] = (array) ($snapshot['meta'] ?? []); $snapshot['meta']['title'] = __('invoice.cancellation_title'); $snapshot['meta']['intro'] = __('invoice.cancellation_intro', ['number' => (string) $original->number]); $snapshot['meta']['issued_on'] = now()->local()->format('d.m.Y'); // A Storno asks for no money, so it has no due date and no payment // terms. Leaving the original's there would tell the customer to pay a // negative amount within fourteen days. $snapshot['meta']['due_on'] = null; $snapshot['meta']['payment_terms'] = null; return $this->persist( series: $series, customer: $customer, snapshot: $snapshot, totals: $totals, currency: (string) $original->currency, dueOn: null, attributes: [ 'cancels_invoice_id' => $original->id, // The same sale, so the same order and contract: a cancellation // that pointed at nothing could not be found from the thing it // undoes. 'order_id' => $original->order_id, 'subscription_id' => $original->subscription_id, ], ); } /** * @param bool $linesAreCharged true when `unit_net_cents` holds what the * customer was actually charged, tax included — * every catalogue sale. False for the service * invoice an operator types net. */ private function issue( Customer $customer, array $lines, string $currency, ?InvoiceSeries $series, array $attributes, bool $linesAreCharged = false, ): Invoice { $series ??= InvoiceSeries::query()->where('kind', 'invoice')->where('active', true)->firstOrFail(); $treatment = TaxTreatment::for($customer); $rate = $treatment->basisPoints(); if ($linesAreCharged) { // The charged figure is the fixed point: a reverse-charge customer // paid the same total as everybody else, so at a rate of zero the // whole of it becomes net rather than the document shrinking by the // VAT that was in the price. ['lines' => $lines, 'totals' => $totals] = InvoiceMath::fromCharged($lines, $rate); $totals += ['rate_basis_points' => $rate]; } else { $totals = InvoiceMath::totals($lines, null, $rate) + ['rate_basis_points' => $rate]; } $snapshot = [ 'issuer' => CompanyProfile::all(), 'customer' => $this->customerBlock($customer), 'lines' => $lines, 'totals' => $totals, 'meta' => $this->meta($customer, $treatment), ]; return $this->persist( series: $series, customer: $customer, snapshot: $snapshot, totals: $totals, currency: $currency, dueOn: now()->addDays((int) CompanyProfile::get('payment_days', 14))->toDateString(), attributes: $attributes, ); } /** * The part that is the same for every document there will ever be: refuse * while the company details are incomplete, take the number, write the row, * copy it to the archive. * * One method rather than one per kind, because the number and the row have * to commit together — and a second copy of that rule is how one kind of * document would end up leaving a gap in a series the other kind keeps * intact. * * @param array $snapshot * @param array $totals * @param array $attributes */ private function persist( InvoiceSeries $series, Customer $customer, array $snapshot, array $totals, string $currency, ?string $dueOn, array $attributes, ): Invoice { $missing = CompanyProfile::missingForInvoicing(); if ($missing !== []) { // Refusing is the correct outcome. An invoice without a registered // name, an address or a VAT number is not a valid invoice here, and // issuing one consumes a number that can never be reused. throw new RuntimeException( 'Refusing to issue an invoice before the company details are complete: '.implode(', ', $missing) ); } $invoice = DB::transaction(function () use ($series, $customer, $snapshot, $totals, $currency, $dueOn, $attributes) { [$number, $sequence, $year] = $this->numbers->next($series); // The number is only true once it is on the document, so both are // written inside the same transaction the counter was advanced in. // That is also what keeps a redelivered Stripe invoice from eating a // number: the unique index rejects the row, the transaction rolls // back, and the counter comes back with it. $snapshot['meta']['number'] = $number; return Invoice::create([ 'invoice_series_id' => $series->id, 'customer_id' => $customer->id, 'number' => $number, 'number_year' => $year, 'number_sequence' => $sequence, 'issued_on' => now()->toDateString(), 'due_on' => $dueOn, 'snapshot' => $snapshot, 'net_cents' => $totals['net'], 'tax_cents' => $totals['tax'], 'gross_cents' => $totals['gross'], 'currency' => $currency, ...$attributes, ]); }); // After the commit, never inside it: a worker can pick a job up before // the transaction lands, and it would then look for an invoice that // does not exist yet. // // One job per destination. The reason for having a second is that the // first can fail, and a single job writing to both would retry the one // that worked every time the other did not. // // Dispatched rather than done here because a destination can be a // network mount that blocks for its whole timeout, and an invoice must // not fail to be issued because a NAS in an office is rebooting. foreach (ExportTarget::query()->where('active', true)->get() as $target) { ArchiveInvoice::dispatch($invoice, $target); } return $invoice; } /** * The recipient, as the document will show them. * * The billing address is one free-text field today, so it is carried as * lines rather than pulled apart into street and city here — guessing which * line is which would put a postcode where a street belongs on a document * nobody can correct afterwards. * * @return array */ private function customerBlock(Customer $customer): array { $address = trim((string) $customer->billing_address); return [ 'name' => (string) $customer->name, 'address_lines' => $address === '' ? [] : array_values(array_filter(array_map('trim', preg_split('/\R/', $address) ?: []))), 'vat_id' => (string) $customer->vat_id, 'email' => (string) $customer->email, ]; } /** @return array */ private function meta(Customer $customer, TaxTreatment $treatment): array { $days = (int) CompanyProfile::get('payment_days', 14); return [ 'title' => __('invoice.title'), 'number' => '', // filled inside the transaction, once it is real 'customer_number' => 'KN-'.str_pad((string) $customer->id, 4, '0', STR_PAD_LEFT), 'issued_on' => now()->local()->format('d.m.Y'), 'due_on' => now()->local()->addDays($days)->format('d.m.Y'), 'currency' => 'EUR', 'salutation' => __('invoice.salutation'), 'intro' => __('invoice.intro'), 'adjustment_label' => __('invoice.adjustment'), 'payment_terms' => trim((string) CompanyProfile::get('payment_terms')) !== '' ? (string) CompanyProfile::get('payment_terms') : __('invoice.payment_default', ['days' => $days]), // The note that makes a zero-rated invoice lawful. Without it the // document says nothing about why no VAT was charged, which is the // one thing an auditor looks for. 'closing' => $treatment->reverseCharge ? __('invoice.reverse_charge') : null, ]; } }