'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); });