CluPilotCloud/tests/Feature/Admin/ServiceInvoiceTest.php

255 lines
9.6 KiB
PHP

<?php
use App\Livewire\Admin\NewInvoice;
use App\Models\Customer;
use App\Models\Invoice;
use App\Models\InvoiceSeries;
use App\Services\Billing\IssueInvoice;
use App\Support\CompanyProfile;
use Livewire\Livewire;
/**
* An invoice for work that is on no price list.
*
* Somebody asks whether their data can be moved into Nextcloud; we look at it,
* we say what it costs, we do it. That is a real invoice with no order and no
* contract behind it — and writing it somewhere else would put it outside the
* series. A numbered series with a document missing from it is worth nothing at
* an audit, which is why this goes through the same door as every other
* invoice.
*/
beforeEach(function () {
// An invoice without a registered name, address and VAT number is not a
// valid invoice, and IssueInvoice refuses to draw a number for one.
CompanyProfile::put([
'name' => 'CluPilot GmbH',
'address' => 'Musterstraße 1',
'postcode' => '1010',
'city' => 'Wien',
'vat_id' => 'ATU12345678',
]);
InvoiceSeries::query()->firstOrCreate(
['kind' => 'invoice'],
['name' => 'Rechnungen', 'prefix' => 'RE', 'active' => true],
);
});
it('issues a service invoice out of the same consecutive series as every other', function () {
// The whole point. A second way of making invoices would be a second
// numbering, and two numberings are two series with holes in them.
$customer = Customer::factory()->create();
$first = app(IssueInvoice::class)->forService($customer, [[
'description' => 'Datenübernahme aus bestehendem System',
'quantity_milli' => 4000,
'unit' => 'Std.',
'unit_net_cents' => 12000,
]]);
$second = app(IssueInvoice::class)->forService($customer, [[
'description' => 'Beratung',
'quantity_milli' => 1000,
'unit_net_cents' => 9000,
]]);
expect($second->number_sequence)->toBe($first->number_sequence + 1)
->and($second->invoice_series_id)->toBe($first->invoice_series_id);
});
it('bills quantity times price, with the VAT the customer would be charged anyway', function () {
$customer = Customer::factory()->create();
$invoice = app(IssueInvoice::class)->forService($customer, [[
'description' => 'Migration',
'quantity_milli' => 4000, // four hours
'unit' => 'Std.',
'unit_net_cents' => 12000, // 120,00 € each
]]);
expect($invoice->net_cents)->toBe(48000)
->and($invoice->tax_cents)->toBe(9600) // 20 %
->and($invoice->gross_cents)->toBe(57600);
});
it('applies reverse charge to a verified EU business, like any other invoice', function () {
// Not a special case for service work — the treatment is the customer's,
// and this document goes through the same TaxTreatment as the rest.
// Verified means the VALUE was checked, not that a flag was set: editing
// the field must not inherit an old confirmation.
$customer = Customer::factory()->create([
'vat_id' => 'DE123456789',
'vat_id_verified_at' => now(),
'vat_id_verified_value' => 'DE123456789',
]);
$invoice = app(IssueInvoice::class)->forService($customer, [[
'description' => 'Migration',
'quantity_milli' => 1000,
'unit_net_cents' => 50000,
]]);
expect($invoice->tax_cents)->toBe(0)
->and($invoice->gross_cents)->toBe(50000)
// 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.
->and($invoice->snapshot['meta']['closing'])->toBe(__('invoice.reverse_charge'));
});
it('freezes what the document says, so it still says it when the settings change', function () {
$customer = Customer::factory()->create(['name' => 'Kanzlei Berger']);
$invoice = app(IssueInvoice::class)->forService($customer, [[
'description' => 'Datenübernahme',
'quantity_milli' => 1000,
'unit_net_cents' => 30000,
]]);
CompanyProfile::put(['name' => 'Ein ganz anderer Name']);
$customer->update(['name' => 'Auch anders']);
$snapshot = $invoice->fresh()->snapshot;
expect($snapshot['issuer']['name'])->toBe('CluPilot GmbH')
->and($snapshot['customer']['name'])->toBe('Kanzlei Berger')
->and($snapshot['lines'][0]['description'])->toBe('Datenübernahme');
});
it('refuses to draw a number for an invoice with nothing on it', function () {
// A number taken and thrown away is a gap in a series that must not have
// one.
$customer = Customer::factory()->create();
expect(fn () => app(IssueInvoice::class)->forService($customer, []))
->toThrow(RuntimeException::class);
});
// ---- The page an operator actually uses ----
it('writes the invoice the operator typed', function () {
$customer = Customer::factory()->create();
Livewire::actingAs(operator('Owner'), 'operator')
->test(NewInvoice::class)
->set('customerUuid', $customer->uuid)
->set('lines.0.description', 'Datenübernahme aus ownCloud')
->set('lines.0.detail', '42 GB, inkl. Prüfprotokoll')
->set('lines.0.quantity', '4')
->set('lines.0.unit', 'Std.')
->set('lines.0.price', '120')
->call('issue')
->assertRedirect(route('admin.invoices'));
$invoice = Invoice::query()->latest('id')->first();
expect($invoice)->not->toBeNull()
->and($invoice->customer_id)->toBe($customer->id)
->and($invoice->net_cents)->toBe(48000)
->and($invoice->snapshot['lines'][0]['description'])->toBe('Datenübernahme aus ownCloud')
->and($invoice->snapshot['lines'][0]['details'][0])->toBe('42 GB, inkl. Prüfprotokoll');
});
it('reads a price in euro the way a person types it', function () {
// The form is in euro and the document is in cents. (int) (12.95 * 100) is
// 1294 on a binary float, and an invoice a cent short of what was typed is
// the kind of thing nobody finds until a customer does.
$customer = Customer::factory()->create();
Livewire::actingAs(operator('Owner'), 'operator')
->test(NewInvoice::class)
->set('customerUuid', $customer->uuid)
->set('lines.0.description', 'Beratung')
->set('lines.0.quantity', '1')
->set('lines.0.price', '12.95')
->call('issue');
expect(Invoice::query()->latest('id')->first()->net_cents)->toBe(1295);
});
it('bills a quarter of an hour without losing it', function () {
// Quantities are held to a thousandth because hours are billed in quarters.
$customer = Customer::factory()->create();
Livewire::actingAs(operator('Owner'), 'operator')
->test(NewInvoice::class)
->set('customerUuid', $customer->uuid)
->set('lines.0.description', 'Rückfrage')
->set('lines.0.quantity', '0.25')
->set('lines.0.price', '120')
->call('issue');
expect(Invoice::query()->latest('id')->first()->net_cents)->toBe(3000);
});
it('adds up several lines the way the finished document will', function () {
$customer = Customer::factory()->create();
$page = Livewire::actingAs(operator('Owner'), 'operator')
->test(NewInvoice::class)
->set('customerUuid', $customer->uuid)
->set('lines.0.description', 'Migration')
->set('lines.0.quantity', '4')
->set('lines.0.price', '120')
->call('addLine')
->set('lines.1.description', 'Nacharbeit')
->set('lines.1.quantity', '2')
->set('lines.1.price', '90');
// The preview is the same arithmetic the invoice uses, not a second one.
$page->assertViewHas('totals', fn (array $t) => $t['net'] === 66000 && $t['gross'] === 79200);
$page->call('issue');
expect(Invoice::query()->latest('id')->first()->gross_cents)->toBe(79200);
});
it('refuses a line with no work on it and no price', function () {
$customer = Customer::factory()->create();
Livewire::actingAs(operator('Owner'), 'operator')
->test(NewInvoice::class)
->set('customerUuid', $customer->uuid)
->call('issue')
->assertHasErrors(['lines.0.description', 'lines.0.price']);
expect(Invoice::query()->count())->toBe(0);
});
it('never removes the last line, so the form cannot end up with nothing to issue', function () {
Livewire::actingAs(operator('Owner'), 'operator')
->test(NewInvoice::class)
->call('removeLine', 0)
->assertCount('lines', 1);
});
it('keeps the page to operators who may touch money', function () {
Livewire::actingAs(operator('Support'), 'operator')
->test(NewInvoice::class)
->assertForbidden();
});
it('says what is missing before the form is filled in, not after', function () {
// The company profile is another page. Finding out at the last click costs
// the whole form.
CompanyProfile::put(['vat_id' => '']);
Livewire::actingAs(operator('Owner'), 'operator')
->test(NewInvoice::class)
->assertViewHas('missing', fn (array $missing) => in_array('vat_id', $missing, true))
->assertSee(__('new_invoice.to_finance'));
});
it('arrives with the recipient already chosen when a link says who', function () {
// From a customer's own page, or from the support request that asked for
// the work: the operator should not have to find the name again in a
// dropdown they just came from.
$customer = Customer::factory()->create();
Livewire::withQueryParams(['customer' => $customer->uuid])
->actingAs(operator('Owner'), 'operator')
->test(NewInvoice::class)
->assertSet('customerUuid', $customer->uuid)
->assertViewHas('customer', fn ($c) => $c?->id === $customer->id);
});