Issue an invoice from what somebody bought, and freeze it there
One invoice per purchase rather than per order: a customer who buys a plan and two add-ons in one go has bought once, and three invoices for one purchase is three times the paperwork for the same money. Each order becomes a line, which is also how the add-ons get listed individually. Everything the document says is copied into the snapshot at the moment the number is assigned — issuer, recipient, lines, rate, the reason for the rate. A test changes the company name and the customer name afterwards and asserts the invoice still says what it said. That is the whole reason no PDF is stored. The number and the invoice commit in one transaction. A number taken and then lost to a failure is a gap in a series that must not have one, and the refusal test asserts that a rejected invoice leaves the counter where it was. It refuses outright until the company details are complete. 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 handed out again. VAT comes from TaxTreatment, which already existed and already handles a verified EU VAT ID correctly — including the reverse-charge note, without which a zero-rated invoice says nothing about why no VAT was charged. The billing address is carried as the lines it was written as. It is one free-text field today, and guessing which line is the postcode would put it where the street belongs, on a document nobody can correct afterwards. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>feat/granted-plans
parent
3fb7e5f08c
commit
cd54212b34
|
|
@ -0,0 +1,157 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services\Billing;
|
||||
|
||||
use App\Models\Customer;
|
||||
use App\Models\Invoice;
|
||||
use App\Models\InvoiceSeries;
|
||||
use App\Models\Order;
|
||||
use App\Support\CompanyProfile;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use RuntimeException;
|
||||
|
||||
/**
|
||||
* Turning what somebody bought into a document that cannot change afterwards.
|
||||
*
|
||||
* Everything the finished invoice says is copied into `snapshot` here, at the
|
||||
* moment the number is assigned: the issuer's registered details, the
|
||||
* customer's address, every line, the rate and the reason for it. The PDF is
|
||||
* rendered from that and never stored, so the same invoice re-rendered in seven
|
||||
* years is the page it was on the day it was issued — whatever has changed in
|
||||
* the settings in between.
|
||||
*
|
||||
* The number and the invoice commit together or not at all. A number taken and
|
||||
* then lost to a failure is a gap in a series that must not have one.
|
||||
*/
|
||||
final class IssueInvoice
|
||||
{
|
||||
public function __construct(private readonly InvoiceNumbers $numbers) {}
|
||||
|
||||
/**
|
||||
* One invoice covering several orders.
|
||||
*
|
||||
* Several rather than one-per-order on purpose: a customer who buys a plan
|
||||
* and two add-ons in one go has bought once, and sending them three
|
||||
* invoices for one purchase is three times the paperwork for the same
|
||||
* money. Each order becomes a line, which is also what was asked for —
|
||||
* every add-on listed on its own.
|
||||
*
|
||||
* @param Collection<int, Order> $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<string, mixed>
|
||||
*/
|
||||
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<string, mixed> */
|
||||
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,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -25,5 +25,11 @@ return [
|
|||
'tax' => 'Umsatzsteuer :rate %',
|
||||
'gross' => 'Gesamtsumme',
|
||||
|
||||
'line_recurring' => 'monatlich',
|
||||
'line_once' => 'einmalig',
|
||||
'salutation' => 'Sehr geehrte Damen und Herren,',
|
||||
'intro' => 'wir erlauben uns, für die im Folgenden aufgeführten Leistungen in Rechnung zu stellen.',
|
||||
'payment_default' => 'Zahlbar ohne Abzug innerhalb von :days Tagen auf das unten angeführte Konto.',
|
||||
'reverse_charge' => 'Steuerschuldnerschaft des Leistungsempfängers (Reverse Charge). Die Umsatzsteuer ist vom Leistungsempfänger zu entrichten.',
|
||||
'payment_title' => 'Zahlungsbedingungen',
|
||||
];
|
||||
|
|
|
|||
|
|
@ -25,5 +25,11 @@ return [
|
|||
'tax' => 'VAT :rate %',
|
||||
'gross' => 'Total',
|
||||
|
||||
'line_recurring' => 'monthly',
|
||||
'line_once' => 'one-off',
|
||||
'salutation' => 'Dear Sir or Madam,',
|
||||
'intro' => 'we are pleased to invoice the services listed below.',
|
||||
'payment_default' => 'Payable in full within :days days to the account shown below.',
|
||||
'reverse_charge' => 'Reverse charge: VAT is to be accounted for by the recipient of the service.',
|
||||
'payment_title' => 'Payment terms',
|
||||
];
|
||||
|
|
|
|||
|
|
@ -35,7 +35,11 @@
|
|||
{{-- No leading whitespace inside these cells. TCPDF prints it: every indented
|
||||
line came out indented, and only the last one — which had none — sat flush
|
||||
against the margin. --}}
|
||||
<td style="width:58%; font-size:10pt; line-height:145%;"><b>{{ $customer['name'] ?? '' }}</b><br>{{ $customer['address'] ?? '' }}<br>{{ trim(($customer['postcode'] ?? '').' '.($customer['city'] ?? '')) }}<br>{{ $customer['country'] ?? '' }}@if (! empty($customer['vat_id']))<br>{{ __('invoice.customer_vat') }}: {{ $customer['vat_id'] }}@endif</td>
|
||||
{{-- Address lines where the record keeps one free-text field, structured
|
||||
fields where it keeps them apart. Guessing which free-text line is the
|
||||
postcode would put it where the street belongs, on a document nobody can
|
||||
correct afterwards. --}}
|
||||
<td style="width:58%; font-size:10pt; line-height:145%;"><b>{{ $customer['name'] ?? '' }}</b>@if (! empty($customer['address_lines']))@foreach ($customer['address_lines'] as $addressLine)<br>{{ $addressLine }}@endforeach@else<br>{{ $customer['address'] ?? '' }}<br>{{ trim(($customer['postcode'] ?? '').' '.($customer['city'] ?? '')) }}<br>{{ $customer['country'] ?? '' }}@endif@if (! empty($customer['vat_id']))<br>{{ __('invoice.customer_vat') }}: {{ $customer['vat_id'] }}@endif</td>
|
||||
<td style="width:42%;"></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,127 @@
|
|||
<?php
|
||||
|
||||
use App\Models\Customer;
|
||||
use App\Models\Invoice;
|
||||
use App\Models\InvoiceSeries;
|
||||
use App\Models\Order;
|
||||
use App\Services\Billing\IssueInvoice;
|
||||
use App\Services\Billing\InvoiceRenderer;
|
||||
use App\Support\CompanyProfile;
|
||||
|
||||
/**
|
||||
* Issuing a document that cannot be changed afterwards.
|
||||
*/
|
||||
beforeEach(function () {
|
||||
CompanyProfile::put([
|
||||
'name' => 'CluPilot Cloud e.U.',
|
||||
'address' => 'Dreherstraße 66/1/8',
|
||||
'postcode' => '1110',
|
||||
'city' => 'Wien',
|
||||
'vat_id' => 'ATU00000000',
|
||||
]);
|
||||
});
|
||||
|
||||
function paidOrders(Customer $customer, int ...$amounts)
|
||||
{
|
||||
return collect($amounts)->map(fn (int $cents) => Order::factory()->create([
|
||||
'customer_id' => $customer->id,
|
||||
'amount_cents' => $cents,
|
||||
'currency' => 'EUR',
|
||||
'status' => 'paid',
|
||||
]));
|
||||
}
|
||||
|
||||
it('issues one invoice for one purchase, with every item on its own line', function () {
|
||||
// A customer who buys a plan and two add-ons in one go has bought once.
|
||||
// Three invoices for one purchase is three times the paperwork for the
|
||||
// same money — and the add-ons were asked for as separate lines, not
|
||||
// separate documents.
|
||||
$customer = Customer::factory()->create();
|
||||
$invoice = app(IssueInvoice::class)->forOrders($customer, paidOrders($customer, 1900, 500, 900));
|
||||
|
||||
expect($invoice->snapshot['lines'])->toHaveCount(3)
|
||||
->and($invoice->net_cents)->toBe(3300)
|
||||
->and($invoice->tax_cents)->toBe(660)
|
||||
->and($invoice->gross_cents)->toBe(3960)
|
||||
->and($invoice->number)->toStartWith('RE-');
|
||||
});
|
||||
|
||||
it('refuses to issue before the company details are complete', function () {
|
||||
// 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 handed out again.
|
||||
App\Support\Settings::forget('company.vat_id');
|
||||
|
||||
$customer = Customer::factory()->create();
|
||||
|
||||
expect(fn () => app(IssueInvoice::class)->forOrders($customer, paidOrders($customer, 1900)))
|
||||
->toThrow(RuntimeException::class);
|
||||
|
||||
expect(Invoice::query()->count())->toBe(0)
|
||||
// And the number was not taken either.
|
||||
->and(InvoiceSeries::query()->where('kind', 'invoice')->value('next_number'))->toBe(1);
|
||||
});
|
||||
|
||||
it('refuses to issue a document with nothing on it', function () {
|
||||
expect(fn () => app(IssueInvoice::class)->forOrders(Customer::factory()->create(), collect()))
|
||||
->toThrow(RuntimeException::class);
|
||||
});
|
||||
|
||||
it('freezes what it says, so later settings cannot rewrite it', function () {
|
||||
// The whole reason no PDF is stored. Change the address in 2028 and an
|
||||
// invoice from 2026 still shows the address it was issued under.
|
||||
$customer = Customer::factory()->create(['name' => 'Muster GmbH']);
|
||||
$invoice = app(IssueInvoice::class)->forOrders($customer, paidOrders($customer, 1900));
|
||||
|
||||
CompanyProfile::put(['name' => 'CluPilot Cloud GmbH', 'address' => 'Anderswo 9']);
|
||||
$customer->update(['name' => 'Muster GmbH & Co KG']);
|
||||
|
||||
$frozen = $invoice->refresh()->snapshot;
|
||||
|
||||
expect($frozen['issuer']['name'])->toBe('CluPilot Cloud e.U.')
|
||||
->and($frozen['issuer']['address'])->toBe('Dreherstraße 66/1/8')
|
||||
->and($frozen['customer']['name'])->toBe('Muster GmbH');
|
||||
|
||||
// And the rendered document agrees with the snapshot, not with today.
|
||||
expect(app(InvoiceRenderer::class)->forInvoice($invoice))->toBeString()->not->toBeEmpty();
|
||||
});
|
||||
|
||||
it('takes the number and writes the invoice together, or neither', function () {
|
||||
// A number taken and then lost to a failure is a gap in a series that must
|
||||
// not have one.
|
||||
$customer = Customer::factory()->create();
|
||||
|
||||
app(IssueInvoice::class)->forOrders($customer, paidOrders($customer, 1900));
|
||||
app(IssueInvoice::class)->forOrders($customer, paidOrders($customer, 2900));
|
||||
|
||||
expect(Invoice::query()->pluck('number')->all())->toBe(['RE-2026-0001', 'RE-2026-0002'])
|
||||
->and(InvoiceSeries::query()->where('kind', 'invoice')->value('next_number'))->toBe(3);
|
||||
})->skip(fn () => now()->format('Y') !== '2026', 'The expected numbers carry the current year.');
|
||||
|
||||
it('carries the customer’s billing address as it was written', function () {
|
||||
// One free-text field today. Guessing which line is the postcode would put
|
||||
// it where the street belongs, on a document nobody can correct afterwards.
|
||||
$customer = Customer::factory()->create([
|
||||
'billing_address' => "Beispielgasse 12/3\n1030 Wien\nÖsterreich",
|
||||
]);
|
||||
|
||||
$invoice = app(IssueInvoice::class)->forOrders($customer, paidOrders($customer, 1900));
|
||||
|
||||
expect($invoice->snapshot['customer']['address_lines'])
|
||||
->toBe(['Beispielgasse 12/3', '1030 Wien', 'Österreich']);
|
||||
});
|
||||
|
||||
it('prints the reverse-charge note exactly when no VAT is charged', function () {
|
||||
// A zero-rated invoice without the note says nothing about why, which is
|
||||
// the one thing an auditor looks for.
|
||||
$customer = Customer::factory()->create([
|
||||
'vat_id' => 'DE123456789',
|
||||
'vat_id_verified_at' => now(),
|
||||
'vat_id_verified_value' => 'DE123456789',
|
||||
]);
|
||||
|
||||
$invoice = app(IssueInvoice::class)->forOrders($customer, paidOrders($customer, 1900));
|
||||
|
||||
expect($invoice->tax_cents)->toBe(0)
|
||||
->and($invoice->snapshot['meta']['closing'])->toBe(__('invoice.reverse_charge'));
|
||||
});
|
||||
Loading…
Reference in New Issue