255 lines
9.6 KiB
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);
|
|
});
|