$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' => '', 'unit_net_cents' => (int) $order->amount_cents, ])->all(); return $this->issue( customer: $customer, lines: $lines, currency: strtoupper((string) ($orders->first()->currency ?: 'EUR')), series: $series, attributes: ['order_id' => $orders->first()->id], ); } /** * 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 the contract's own frozen price — `price_cents`, the * catalogue's NET figure — and not Stripe's `amount_paid`, which is a gross * total. What the customer is owed is what they agreed to; the register * records beside it what was actually taken, and `stripe_invoice_id` on the * row is how the two are reconciled afterwards. * * Booked modules are NOT on it. They are not items on the Stripe * subscription, so this renewal did not charge for them — putting them on * the document would invoice money nobody was asked for. (That they are * never billed after the month they were booked in is a real gap, and it is * a gap in the booking, not in this document.) * * `$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, ): 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' => (int) $subscription->price_cents, ]]; return $this->issue( customer: $customer, lines: $lines, currency: strtoupper((string) ($subscription->currency ?: 'EUR')), series: $series, 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, ], ); } /** * The part every document has in common: the number, the freeze, the copy * to the archive. * * @param array> $lines * @param array $attributes what this document is FOR */ /** * 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. Nothing here invents a price: the * caller is the operator, and what they wrote is what the customer agreed * to on the phone. * * @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: [], ); } private function issue( Customer $customer, array $lines, string $currency, ?InvoiceSeries $series, 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) ); } $series ??= InvoiceSeries::query()->where('kind', 'invoice')->where('active', true)->firstOrFail(); $treatment = TaxTreatment::for($customer); $rate = (int) round($treatment->rate * 10000); $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), ]; $invoice = DB::transaction(function () use ($series, $customer, $snapshot, $totals, $currency, $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' => now()->addDays((int) CompanyProfile::get('payment_days', 14))->toDateString(), '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, ]; } }