128 lines
5.2 KiB
PHP
128 lines
5.2 KiB
PHP
<?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'));
|
||
});
|