$orders */ public function forOrders(Customer $customer, Collection $orders, ?InvoiceSeries $series = null): Invoice { if ($orders->isEmpty()) { throw new RuntimeException('Refusing to issue an invoice with no lines on it.'); } $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); $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(); $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 DB::transaction(function () use ($series, $customer, $orders, $snapshot, $totals) { [$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. $snapshot['meta']['number'] = $number; return Invoice::create([ 'invoice_series_id' => $series->id, 'customer_id' => $customer->id, 'order_id' => $orders->first()->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' => strtoupper((string) ($orders->first()->currency ?: 'EUR')), ]); }); } /** * 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, ]; } }