diff --git a/tests/Feature/Admin/AdminSettingsTest.php b/tests/Feature/Admin/AdminSettingsTest.php
new file mode 100644
index 00000000..274de0aa
--- /dev/null
+++ b/tests/Feature/Admin/AdminSettingsTest.php
@@ -0,0 +1,342 @@
+ true, '--class' => 'DatabaseSeeder']);
+ Artisan::call('db:seed', ['--force' => true, '--class' => 'DemoSeeder']);
+
+ $user = User::query()->find(1);
+ $companyId = $user->companies()->first()->getKey();
+ $this->withHeaders(['company' => $companyId]);
+ Sanctum::actingAs($user, ['*']);
+});
+
+test('company settings config uses canonical v2 links', function () {
+ $links = collect(config('invoiceshelf.setting_menu'))->pluck('link');
+
+ expect($links)->toContain('/admin/settings/exchange-rate');
+ expect($links)->toContain('/admin/settings/payment-modes');
+ expect($links)->toContain('/admin/settings/expense-categories');
+ expect($links)->toContain('/admin/settings/roles');
+ expect($links)->toContain('/admin/settings/mail-config');
+
+ expect($links)->not->toContain('/admin/settings/roles-settings');
+ expect($links)->not->toContain('/admin/settings/exchange-rate-provider');
+ expect($links)->not->toContain('/admin/settings/payment-mode');
+ expect($links)->not->toContain('/admin/settings/expense-category');
+ expect($links)->not->toContain('/admin/settings/mail-configuration');
+
+ $bootstrapMenu = json_encode(getJson('/api/v1/bootstrap')
+ ->assertOk()
+ ->json('setting_menu'));
+
+ expect($bootstrapMenu)->not->toContain('roles-settings');
+ expect($bootstrapMenu)->not->toContain('exchange-rate-provider');
+ expect($bootstrapMenu)->not->toContain('payment-mode');
+ expect($bootstrapMenu)->not->toContain('expense-category');
+ expect($bootstrapMenu)->not->toContain('mail-configuration');
+});
+
+test('super admin bootstrap uses administration mode when requested', function () {
+ getJson('/api/v1/bootstrap?admin_mode=1')
+ ->assertOk()
+ ->assertJsonPath('admin_mode', true)
+ ->assertJsonPath('current_company', null)
+ ->assertJsonCount(0, 'setting_menu');
+});
+
+test('bootstrap without administration mode hydrates the selected company', function () {
+ $companyId = User::find(1)->companies()->first()->id;
+
+ getJson('/api/v1/bootstrap')
+ ->assertOk()
+ ->assertJsonPath('current_company.id', $companyId);
+});
+
+test('get global mail configuration', function () {
+ getJson('/api/v1/mail/config')
+ ->assertOk()
+ ->assertJsonStructure([
+ 'mail_driver',
+ 'from_name',
+ 'from_mail',
+ ]);
+});
+
+test('get global mail drivers returns capability-backed drivers', function () {
+ $drivers = getJson('/api/v1/mail/drivers')
+ ->assertOk()
+ ->json();
+
+ expect($drivers)->toContain('smtp', 'mail', 'sendmail', 'ses');
+
+ if (class_exists(HttpClient::class) && class_exists(MailgunTransportFactory::class)) {
+ expect($drivers)->toContain('mailgun');
+ } else {
+ expect($drivers)->not->toContain('mailgun');
+ }
+
+ if (class_exists(HttpClient::class) && class_exists(PostmarkTransportFactory::class)) {
+ expect($drivers)->toContain('postmark');
+ } else {
+ expect($drivers)->not->toContain('postmark');
+ }
+});
+
+test('save global mail configuration', function () {
+ postJson('/api/v1/mail/config', [
+ 'mail_driver' => 'smtp',
+ 'mail_host' => 'smtp.example.com',
+ 'mail_port' => 587,
+ 'mail_username' => 'demo-user',
+ 'mail_password' => 'secret',
+ 'mail_encryption' => 'tls',
+ 'mail_scheme' => 'smtp',
+ 'mail_url' => 'smtp://smtp.example.com',
+ 'mail_timeout' => 30,
+ 'mail_local_domain' => 'invoiceshelf.test',
+ 'from_name' => 'InvoiceShelf',
+ 'from_mail' => 'hello@example.com',
+ ])
+ ->assertOk()
+ ->assertJson([
+ 'success' => 'mail_variables_save_successfully',
+ ]);
+
+ $this->assertDatabaseHas('settings', [
+ 'option' => 'mail_driver',
+ 'value' => 'smtp',
+ ]);
+
+ $this->assertDatabaseHas('settings', [
+ 'option' => 'from_mail',
+ 'value' => 'hello@example.com',
+ ]);
+
+ $this->assertDatabaseHas('settings', [
+ 'option' => 'mail_timeout',
+ 'value' => '30',
+ ]);
+});
+
+test('save global postmark configuration', function () {
+ postJson('/api/v1/mail/config', [
+ 'mail_driver' => 'postmark',
+ 'mail_postmark_token' => 'postmark-token',
+ 'mail_postmark_message_stream_id' => 'outbound',
+ 'from_name' => 'InvoiceShelf',
+ 'from_mail' => 'billing@example.com',
+ ])
+ ->assertOk()
+ ->assertJson([
+ 'success' => 'mail_variables_save_successfully',
+ ]);
+
+ $this->assertDatabaseHas('settings', [
+ 'option' => 'mail_driver',
+ 'value' => 'postmark',
+ ]);
+
+ $this->assertDatabaseHas('settings', [
+ 'option' => 'mail_postmark_token',
+ 'value' => 'postmark-token',
+ ]);
+});
+
+test('get pdf configuration', function () {
+ getJson('/api/v1/pdf/config')
+ ->assertOk()
+ ->assertJsonStructure([
+ 'pdf_driver',
+ 'gotenberg_host',
+ 'pdf_paper_width',
+ 'pdf_paper_height',
+ 'pdf_orientation',
+ 'pdf_margin_top',
+ 'pdf_margin_right',
+ 'pdf_margin_bottom',
+ 'pdf_margin_left',
+ ]);
+});
+
+/**
+ * Page geometry is saved for whichever driver is selected. It used to hang off
+ * gotenberg_papersize, so picking dompdf meant having no paper size at all and
+ * switching drivers threw the setting away.
+ */
+test('save pdf configuration stores the page setup for dompdf too', function () {
+ postJson('/api/v1/pdf/config', [
+ 'pdf_driver' => 'dompdf',
+ 'pdf_paper_width' => '8.5in',
+ 'pdf_paper_height' => '14in',
+ 'pdf_orientation' => 'landscape',
+ 'pdf_margin_top' => '5mm',
+ 'pdf_margin_right' => '6mm',
+ 'pdf_margin_bottom' => '7mm',
+ 'pdf_margin_left' => '8mm',
+ ])
+ ->assertOk()
+ ->assertJson(['success' => 'pdf_variables_save_successfully']);
+
+ foreach ([
+ 'pdf_driver' => 'dompdf',
+ 'pdf_paper_width' => '8.5in',
+ 'pdf_paper_height' => '14in',
+ 'pdf_orientation' => 'landscape',
+ 'pdf_margin_top' => '5mm',
+ 'pdf_margin_right' => '6mm',
+ 'pdf_margin_bottom' => '7mm',
+ 'pdf_margin_left' => '8mm',
+ ] as $option => $value) {
+ $this->assertDatabaseHas('settings', compact('option', 'value'));
+ }
+});
+
+test('pdf configuration rejects a length with no unit', function () {
+ postJson('/api/v1/pdf/config', [
+ 'pdf_driver' => 'dompdf',
+ 'pdf_paper_width' => '210',
+ 'pdf_paper_height' => '297mm',
+ 'pdf_orientation' => 'portrait',
+ ])->assertStatus(422)->assertJsonValidationErrors('pdf_paper_width');
+});
+
+test('pdf configuration rejects an unknown orientation', function () {
+ postJson('/api/v1/pdf/config', [
+ 'pdf_driver' => 'dompdf',
+ 'pdf_paper_width' => '210mm',
+ 'pdf_paper_height' => '297mm',
+ 'pdf_orientation' => 'sideways',
+ ])->assertStatus(422)->assertJsonValidationErrors('pdf_orientation');
+});
+
+/**
+ * A zero margin is a deliberate choice. Runtime configuration must not discard
+ * it as empty, so this pins the behavior through the API round trip.
+ */
+test('page numbers can be turned on and read back', function () {
+ postJson('/api/v1/pdf/config', [
+ 'pdf_driver' => 'gotenberg',
+ 'gotenberg_host' => 'https://pdf.example.com',
+ 'pdf_paper_width' => '210mm',
+ 'pdf_paper_height' => '297mm',
+ 'pdf_orientation' => 'portrait',
+ 'pdf_page_numbers' => true,
+ ])->assertOk();
+
+ getJson('/api/v1/pdf/config')->assertOk()->assertJson(['pdf_page_numbers' => true]);
+});
+
+/**
+ * The dompdf form does not render the page-numbers control, since dompdf cannot
+ * repeat a footer. Saving from it must leave the stored choice alone rather than
+ * writing an absent field as false.
+ */
+test('saving from the dompdf form leaves the page-number choice alone', function () {
+ postJson('/api/v1/pdf/config', [
+ 'pdf_driver' => 'gotenberg',
+ 'gotenberg_host' => 'https://pdf.example.com',
+ 'pdf_paper_width' => '210mm',
+ 'pdf_paper_height' => '297mm',
+ 'pdf_orientation' => 'portrait',
+ 'pdf_page_numbers' => true,
+ ])->assertOk();
+
+ postJson('/api/v1/pdf/config', [
+ 'pdf_driver' => 'dompdf',
+ 'pdf_paper_width' => '210mm',
+ 'pdf_paper_height' => '297mm',
+ 'pdf_orientation' => 'portrait',
+ ])->assertOk();
+
+ getJson('/api/v1/pdf/config')->assertOk()->assertJson(['pdf_page_numbers' => true]);
+});
+
+test('page numbers can be turned back off', function () {
+ postJson('/api/v1/pdf/config', [
+ 'pdf_driver' => 'gotenberg',
+ 'gotenberg_host' => 'https://pdf.example.com',
+ 'pdf_paper_width' => '210mm',
+ 'pdf_paper_height' => '297mm',
+ 'pdf_orientation' => 'portrait',
+ 'pdf_page_numbers' => false,
+ ])->assertOk();
+
+ // Stored as the string '0', which !empty() would have discarded.
+ $this->assertDatabaseHas('settings', ['option' => 'pdf_page_numbers', 'value' => '0']);
+
+ getJson('/api/v1/pdf/config')->assertOk()->assertJson(['pdf_page_numbers' => false]);
+});
+
+test('a zero margin survives the round trip', function () {
+ postJson('/api/v1/pdf/config', [
+ 'pdf_driver' => 'dompdf',
+ 'pdf_paper_width' => '210mm',
+ 'pdf_paper_height' => '297mm',
+ 'pdf_orientation' => 'portrait',
+ 'pdf_margin_top' => '0mm',
+ 'pdf_margin_right' => '0mm',
+ 'pdf_margin_bottom' => '0mm',
+ 'pdf_margin_left' => '0mm',
+ ])->assertOk();
+
+ getJson('/api/v1/pdf/config')->assertOk()->assertJson(['pdf_margin_top' => '0mm']);
+});
+
+test('get app version', function () {
+ getJson('/api/v1/app/version')
+ ->assertOk()
+ ->assertJsonStructure([
+ 'version',
+ 'channel',
+ ]);
+});
+
+/**
+ * The SDK forwards the pdfa value unvalidated, so an unsupported one would only
+ * fail later as an HTTP error from the Gotenberg service. The setting is a fixed
+ * list checked against what gotenberg:8 can actually produce.
+ */
+test('the archival format must be one gotenberg can produce', function () {
+ postJson('/api/v1/pdf/config', [
+ 'pdf_driver' => 'gotenberg',
+ 'gotenberg_host' => 'https://pdf.example.com',
+ 'gotenberg_pdfa' => 'PDF/A-9z',
+ 'pdf_paper_width' => '210mm',
+ 'pdf_paper_height' => '297mm',
+ 'pdf_orientation' => 'portrait',
+ ])->assertStatus(422)->assertJsonValidationErrors('gotenberg_pdfa');
+});
+
+test('the archival format round trips, and off is a real choice', function () {
+ postJson('/api/v1/pdf/config', [
+ 'pdf_driver' => 'gotenberg',
+ 'gotenberg_host' => 'https://pdf.example.com',
+ 'gotenberg_pdfa' => 'PDF/A-3b',
+ 'pdf_paper_width' => '210mm',
+ 'pdf_paper_height' => '297mm',
+ 'pdf_orientation' => 'portrait',
+ ])->assertOk();
+
+ getJson('/api/v1/pdf/config')->assertOk()->assertJson(['gotenberg_pdfa' => 'PDF/A-3b']);
+
+ postJson('/api/v1/pdf/config', [
+ 'pdf_driver' => 'gotenberg',
+ 'gotenberg_host' => 'https://pdf.example.com',
+ 'gotenberg_pdfa' => '',
+ 'pdf_paper_width' => '210mm',
+ 'pdf_paper_height' => '297mm',
+ 'pdf_orientation' => 'portrait',
+ ])->assertOk();
+
+ getJson('/api/v1/pdf/config')->assertOk()->assertJson(['gotenberg_pdfa' => '']);
+});
diff --git a/tests/Feature/Admin/CreditNoteSchemaTest.php b/tests/Feature/Admin/CreditNoteSchemaTest.php
new file mode 100644
index 00000000..52a1725b
--- /dev/null
+++ b/tests/Feature/Admin/CreditNoteSchemaTest.php
@@ -0,0 +1,95 @@
+ true, '--class' => 'DatabaseSeeder']);
+ Artisan::call('db:seed', ['--force' => true, '--class' => 'DemoSeeder']);
+});
+
+/**
+ * Resolve a column definition from the information schema so nullability can
+ * be asserted without reaching for driver-specific SQL.
+ */
+function creditNoteColumn(string $table, string $column): array
+{
+ $definition = collect(Schema::getColumns($table))
+ ->firstWhere('name', $column);
+
+ expect($definition)->not->toBeNull();
+
+ return $definition;
+}
+
+test('the credit note columns exist with the expected nullability', function () {
+ expect(Schema::hasColumn('invoices', 'type'))->toBeTrue();
+ expect(Schema::hasColumn('invoices', 'related_invoice_id'))->toBeTrue();
+ expect(Schema::hasColumn('invoices', 'credit_reason'))->toBeTrue();
+ expect(Schema::hasColumn('invoice_items', 'source_invoice_item_id'))->toBeTrue();
+
+ // "type" is non-nullable and defaults to INVOICE so pre-existing rows fall
+ // inside the type-scoped serial-number queries.
+ expect(creditNoteColumn('invoices', 'type')['nullable'])->toBeFalse();
+
+ expect(creditNoteColumn('invoices', 'related_invoice_id')['nullable'])->toBeTrue();
+ expect(creditNoteColumn('invoices', 'credit_reason')['nullable'])->toBeTrue();
+ expect(creditNoteColumn('invoice_items', 'source_invoice_item_id')['nullable'])->toBeTrue();
+});
+
+test('invoices created without a type default to INVOICE', function () {
+ $invoice = Invoice::factory()->create();
+
+ expect($invoice->fresh()->type)->toBe(Invoice::TYPE_INVOICE);
+});
+
+test('a tax row persists a negative base amount', function () {
+ $tax = Tax::factory()->create([
+ 'amount' => -2500,
+ 'base_amount' => -2500,
+ ]);
+
+ $this->assertDatabaseHas('taxes', [
+ 'id' => $tax->id,
+ 'amount' => -2500,
+ 'base_amount' => -2500,
+ ]);
+
+ expect((int) $tax->fresh()->base_amount)->toBe(-2500);
+});
+
+test('an invoice item persists a negative price and links its source line', function () {
+ $invoice = Invoice::factory()->hasItems(1)->create();
+ $sourceItem = $invoice->items()->first();
+
+ $creditLine = InvoiceItem::factory()->create([
+ 'invoice_id' => $invoice->id,
+ 'source_invoice_item_id' => $sourceItem->id,
+ 'price' => -1500,
+ 'base_price' => -1500,
+ 'total' => -1500,
+ ]);
+
+ $this->assertDatabaseHas('invoice_items', [
+ 'id' => $creditLine->id,
+ 'source_invoice_item_id' => $sourceItem->id,
+ 'price' => -1500,
+ 'base_price' => -1500,
+ ]);
+});
+
+test('every seeded company has a credit note number format', function () {
+ $companies = Company::all();
+
+ expect($companies)->not->toBeEmpty();
+
+ $companies->each(function ($company) {
+ expect(CompanySetting::getSetting('credit_note_number_format', $company->id))
+ ->toBe('{{SERIES:CN}}{{DELIMITER:-}}{{SEQUENCE:6}}');
+ });
+});
diff --git a/tests/Feature/Admin/CreditNoteTest.php b/tests/Feature/Admin/CreditNoteTest.php
new file mode 100644
index 00000000..f75a89cf
--- /dev/null
+++ b/tests/Feature/Admin/CreditNoteTest.php
@@ -0,0 +1,1499 @@
+ true, '--class' => 'DatabaseSeeder']);
+ Artisan::call('db:seed', ['--force' => true, '--class' => 'DemoSeeder']);
+
+ $user = User::query()->find(1);
+ $companyId = $user->companies()->first()->getKey();
+ $this->withHeaders(['company' => $companyId]);
+ Sanctum::actingAs($user, ['*']);
+});
+
+/**
+ * Create an invoice whose stored document totals agree with its line items.
+ *
+ * That agreement is what every invoice the app writes has and what the
+ * credit-note calculator reads: it derives the credit from the ORIGINAL
+ * invoice's stored figures, so a fixture whose total has nothing to do with its
+ * items describes an invoice that could not exist and produces credit notes to
+ * match.
+ *
+ * @param array $lines [['price' => int, 'quantity' => float, 'taxes' => [['amount' => int, 'percent' => float]]], ...]
+ * @param array $attributes invoice overrides (status, exchange_rate, discount_val, tax_per_item, tax_included, ...)
+ * @param array $documentTaxes document-level tax rows: [['amount' => int, 'percent' => float], ...]
+ */
+function creditableInvoice(array $lines = [['price' => 10000, 'quantity' => 1]], array $attributes = [], array $documentTaxes = []): Invoice
+{
+ $rate = $attributes['exchange_rate'] ?? 1;
+ $taxPerItem = $attributes['tax_per_item'] ?? 'NO';
+ $taxIncluded = $attributes['tax_included'] ?? false;
+ $discountVal = $attributes['discount_val'] ?? 0;
+
+ $subTotal = 0;
+ $itemTaxTotal = 0;
+
+ foreach ($lines as $line) {
+ $subTotal += (int) round($line['price'] * $line['quantity']);
+ $itemTaxTotal += array_sum(array_column($line['taxes'] ?? [], 'amount'));
+ }
+
+ $documentTaxTotal = array_sum(array_column($documentTaxes, 'amount'));
+ $tax = $taxPerItem === 'YES' ? $itemTaxTotal : $documentTaxTotal;
+ $total = $taxIncluded ? $subTotal - $discountVal : $subTotal - $discountVal + $tax;
+
+ $invoice = Invoice::factory()->create(array_merge([
+ 'status' => Invoice::STATUS_SENT,
+ 'sent' => true,
+ 'paid_status' => Invoice::STATUS_UNPAID,
+ 'tax_per_item' => 'NO',
+ 'discount_per_item' => 'NO',
+ 'tax_included' => false,
+ 'discount' => 0,
+ 'discount_type' => 'fixed',
+ ], $attributes, [
+ 'sub_total' => $subTotal,
+ 'discount_val' => $discountVal,
+ 'tax' => $tax,
+ 'total' => $total,
+ 'due_amount' => $total,
+ 'exchange_rate' => $rate,
+ 'base_sub_total' => (int) round($subTotal * $rate),
+ 'base_discount_val' => (int) round($discountVal * $rate),
+ 'base_tax' => (int) round($tax * $rate),
+ 'base_total' => (int) round($total * $rate),
+ 'base_due_amount' => (int) round($total * $rate),
+ ]));
+
+ foreach ($lines as $index => $line) {
+ $amount = (int) round($line['price'] * $line['quantity']);
+ $lineTax = array_sum(array_column($line['taxes'] ?? [], 'amount'));
+
+ $item = $invoice->items()->create([
+ 'name' => $line['name'] ?? 'Line '.($index + 1),
+ 'quantity' => $line['quantity'],
+ 'price' => $line['price'],
+ 'discount_type' => 'fixed',
+ 'discount' => 0,
+ 'discount_val' => 0,
+ 'tax' => $lineTax,
+ 'total' => $amount,
+ 'company_id' => $invoice->company_id,
+ 'exchange_rate' => $rate,
+ 'base_price' => (int) round($line['price'] * $rate),
+ 'base_discount_val' => 0,
+ 'base_tax' => (int) round($lineTax * $rate),
+ 'base_total' => (int) round($amount * $rate),
+ ]);
+
+ foreach ($line['taxes'] ?? [] as $taxRow) {
+ creditableTax($invoice, $taxRow, ['invoice_item_id' => $item->id]);
+ }
+ }
+
+ foreach ($documentTaxes as $taxRow) {
+ creditableTax($invoice, $taxRow, ['invoice_id' => $invoice->id]);
+ }
+
+ return $invoice->fresh();
+}
+
+function creditableTax(Invoice $invoice, array $tax, array $owner): Tax
+{
+ return Tax::factory()->create(array_merge($owner, [
+ 'company_id' => $invoice->company_id,
+ 'amount' => $tax['amount'],
+ 'base_amount' => (int) round($tax['amount'] * $invoice->exchange_rate),
+ 'percent' => $tax['percent'] ?? 0,
+ 'exchange_rate' => $invoice->exchange_rate,
+ ]));
+}
+
+/**
+ * Record a payment against an invoice and settle its balance the way the
+ * payment flow would.
+ */
+function creditablePayment(Invoice $invoice, int $amount): Payment
+{
+ $payment = Payment::factory()->create([
+ 'company_id' => $invoice->company_id,
+ 'customer_id' => $invoice->customer_id,
+ 'amount' => $amount,
+ ]);
+
+ PaymentAllocation::factory()->create([
+ 'payment_id' => $payment->id,
+ 'invoice_id' => $invoice->id,
+ 'amount' => $amount,
+ 'base_amount' => (int) round($amount * $invoice->exchange_rate),
+ ]);
+
+ $due = (int) $invoice->due_amount - $amount;
+
+ $invoice->due_amount = $due;
+ $invoice->base_due_amount = (int) round($due * $invoice->exchange_rate);
+ $invoice->paid_status = $due === 0 ? Invoice::STATUS_PAID : Invoice::STATUS_PARTIALLY_PAID;
+ $invoice->save();
+
+ return $payment;
+}
+
+/**
+ * The ids of an invoice's line items, in creation order.
+ */
+function creditableItemIds(Invoice $invoice): array
+{
+ return $invoice->items()->orderBy('id')->pluck('id')->all();
+}
+
+test('creates a credit note from an invoice with negated totals', function () {
+ $invoice = creditableInvoice([['price' => 10000, 'quantity' => 1]]);
+
+ $response = postJson("api/v1/invoices/{$invoice->id}/credit-note");
+
+ $response->assertStatus(201);
+
+ $creditNoteId = $response->json('data.id');
+
+ $this->assertDatabaseHas('invoices', [
+ 'id' => $creditNoteId,
+ 'type' => Invoice::TYPE_CREDIT_NOTE,
+ 'related_invoice_id' => $invoice->id,
+ ]);
+
+ $creditNote = Invoice::find($creditNoteId);
+
+ // Money stays integer cents and is negated.
+ expect($creditNote->total)->toBe(-10000);
+ expect($creditNote->sub_total)->toBe(-10000);
+ // creator_id is set from the authenticated user (issue #7 from PR #536).
+ expect($creditNote->creator_id)->toBe(1);
+ // The credit note gets its own document number, distinct from the source.
+ expect($creditNote->invoice_number)->not->toBe($invoice->invoice_number);
+});
+
+test('negates the line item amounts of the source invoice', function () {
+ $invoice = creditableInvoice([['price' => 5000, 'quantity' => 2]]);
+
+ $creditNoteId = postJson("api/v1/invoices/{$invoice->id}/credit-note")
+ ->assertStatus(201)
+ ->json('data.id');
+
+ $item = Invoice::find($creditNoteId)->items->first();
+
+ // Unit price and computed total are negative; amounts remain integer cents.
+ expect($item->price)->toBe(-5000);
+ expect($item->total)->toBe(-10000);
+ expect($item->base_price)->toBeLessThan(0);
+ // Every credit-note line names the invoice line it credits.
+ expect((int) $item->source_invoice_item_id)->toBe($invoice->items->first()->id);
+ // The quantity itself stays positive: the negative price is what makes the
+ // line a credit.
+ expect((float) $item->quantity)->toBe(2.0);
+});
+
+test('an empty request body reverses the whole invoice to the cent', function () {
+ $invoice = creditableInvoice(
+ [['price' => 2500, 'quantity' => 4]],
+ ['discount_val' => 1000],
+ [['amount' => 630, 'percent' => 7]]
+ );
+
+ expect($invoice->total)->toBe(9630);
+
+ $creditNoteId = postJson("api/v1/invoices/{$invoice->id}/credit-note")
+ ->assertStatus(201)
+ ->json('data.id');
+
+ $creditNote = Invoice::with('items', 'taxes')->find($creditNoteId);
+
+ // Field for field the negation of the invoice, which is what a full
+ // reversal has always produced and must keep producing.
+ expect($creditNote->sub_total)->toBe(-10000)
+ ->and($creditNote->discount_val)->toBe(-1000)
+ ->and($creditNote->tax)->toBe(-630)
+ ->and($creditNote->total)->toBe(-9630)
+ ->and((int) $creditNote->base_sub_total)->toBe(-10000)
+ ->and((int) $creditNote->base_discount_val)->toBe(-1000)
+ ->and((int) $creditNote->base_tax)->toBe(-630)
+ ->and((int) $creditNote->base_total)->toBe(-9630);
+
+ $item = $creditNote->items->first();
+
+ expect($item->price)->toBe(-2500)
+ ->and($item->total)->toBe(-10000)
+ ->and((int) $item->base_total)->toBe(-10000)
+ ->and((float) $item->quantity)->toBe(4.0)
+ ->and((int) $item->source_invoice_item_id)->toBe($invoice->items->first()->id);
+
+ expect((int) $creditNote->taxes->first()->amount)->toBe(-630);
+});
+
+test('credits a single line of a three line invoice', function () {
+ $invoice = creditableInvoice(
+ [
+ ['price' => 1000, 'quantity' => 1],
+ ['price' => 1000, 'quantity' => 1],
+ ['price' => 1000, 'quantity' => 1],
+ ],
+ ['discount_val' => 300],
+ [['amount' => 189, 'percent' => 7]]
+ );
+
+ expect($invoice->total)->toBe(2889);
+
+ [$first] = creditableItemIds($invoice);
+
+ $creditNoteId = postJson("api/v1/invoices/{$invoice->id}/credit-note", [
+ 'items' => [['id' => $first, 'quantity' => 1]],
+ ])->assertStatus(201)->json('data.id');
+
+ $creditNote = Invoice::with('items')->find($creditNoteId);
+
+ // One third of the lines credited, so one third of the document-level
+ // discount and tax come back with it.
+ expect($creditNote->sub_total)->toBe(-1000)
+ ->and($creditNote->discount_val)->toBe(-100)
+ ->and($creditNote->tax)->toBe(-63)
+ ->and($creditNote->total)->toBe(-963)
+ ->and((int) $creditNote->base_total)->toBe(-963);
+
+ expect($creditNote->items)->toHaveCount(1);
+ expect((int) $creditNote->items->first()->source_invoice_item_id)->toBe($first);
+
+ $invoice->refresh();
+
+ // The balance drops by exactly the credited amount and no more.
+ expect((int) $invoice->due_amount)->toBe(1926)
+ ->and((int) $invoice->base_due_amount)->toBe(1926)
+ // A credit is not a payment: nothing was paid, so the invoice is still
+ // unpaid, just for less.
+ ->and($invoice->paid_status)->toBe(Invoice::STATUS_UNPAID)
+ ->and($invoice->status)->toBe(Invoice::STATUS_SENT);
+});
+
+test('a second credit note credits the remaining quantity', function () {
+ $invoice = creditableInvoice(
+ [
+ ['price' => 1000, 'quantity' => 1],
+ ['price' => 1000, 'quantity' => 1],
+ ['price' => 1000, 'quantity' => 1],
+ ],
+ ['discount_val' => 300],
+ [['amount' => 189, 'percent' => 7]]
+ );
+
+ [$first, $second, $third] = creditableItemIds($invoice);
+
+ postJson("api/v1/invoices/{$invoice->id}/credit-note", [
+ 'items' => [['id' => $first, 'quantity' => 1]],
+ ])->assertStatus(201);
+
+ postJson("api/v1/invoices/{$invoice->id}/credit-note", [
+ 'items' => [
+ ['id' => $second, 'quantity' => 1],
+ ['id' => $third, 'quantity' => 1],
+ ],
+ ])->assertStatus(201);
+
+ // Telescoping: the chain of credits sums to exactly the invoice, to the
+ // cent, in every field.
+ expect((int) $invoice->creditNotes()->sum('total'))->toBe(-$invoice->total)
+ ->and((int) $invoice->creditNotes()->sum('sub_total'))->toBe(-$invoice->sub_total)
+ ->and((int) $invoice->creditNotes()->sum('tax'))->toBe(-$invoice->tax)
+ ->and((int) $invoice->creditNotes()->sum('discount_val'))->toBe(-$invoice->discount_val);
+
+ $invoice->refresh();
+
+ expect((int) $invoice->due_amount)->toBe(0)
+ ->and($invoice->paid_status)->toBe(Invoice::STATUS_PAID)
+ ->and($invoice->status)->toBe(Invoice::STATUS_COMPLETED);
+
+ getJson("api/v1/invoices/{$invoice->id}")
+ ->assertOk()
+ ->assertJsonPath('data.credited_status', 'FULL')
+ ->assertJsonPath('data.credited_total', 2889);
+});
+
+test('a credit note may not exceed the unpaid balance of the invoice', function () {
+ // 100 units at 1.00 each: crediting n units credits exactly n cents.
+ $invoice = creditableInvoice([['price' => 100, 'quantity' => 100]]);
+
+ creditablePayment($invoice, 4000);
+
+ [$line] = creditableItemIds($invoice);
+
+ postJson("api/v1/invoices/{$invoice->id}/credit-note", [
+ 'items' => [['id' => $line, 'quantity' => 50]],
+ ])->assertStatus(201);
+
+ $invoice->refresh();
+
+ expect((int) $invoice->due_amount)->toBe(1000)
+ // Money was received, so the invoice stays partially paid even though
+ // part of it was credited away.
+ ->and($invoice->paid_status)->toBe(Invoice::STATUS_PARTIALLY_PAID);
+
+ // One cent past the unpaid balance: the invoice would end up owing the
+ // customer money it was never paid.
+ postJson("api/v1/invoices/{$invoice->id}/credit-note", [
+ 'items' => [['id' => $line, 'quantity' => 10.01]],
+ ])
+ ->assertStatus(422)
+ ->assertJsonPath('errors.invoice.0', 'credit_amount_exceeds_invoice_balance');
+
+ // Exactly the unpaid balance is fine.
+ postJson("api/v1/invoices/{$invoice->id}/credit-note", [
+ 'items' => [['id' => $line, 'quantity' => 10]],
+ ])->assertStatus(201);
+
+ $invoice->refresh();
+
+ expect((int) $invoice->due_amount)->toBe(0)
+ ->and($invoice->paid_status)->toBe(Invoice::STATUS_PAID)
+ ->and($invoice->status)->toBe(Invoice::STATUS_COMPLETED);
+});
+
+test('a line cannot be credited beyond the quantity that was invoiced', function () {
+ $invoice = creditableInvoice([['price' => 1000, 'quantity' => 3]]);
+
+ [$line] = creditableItemIds($invoice);
+
+ postJson("api/v1/invoices/{$invoice->id}/credit-note", [
+ 'items' => [['id' => $line, 'quantity' => 4]],
+ ])
+ ->assertStatus(422)
+ ->assertJsonPath('errors.invoice.0', 'credit_quantity_exceeds_remaining');
+
+ postJson("api/v1/invoices/{$invoice->id}/credit-note", [
+ 'items' => [['id' => $line, 'quantity' => 2]],
+ ])->assertStatus(201);
+
+ // Two of the three units are gone, so only one is still creditable.
+ postJson("api/v1/invoices/{$invoice->id}/credit-note", [
+ 'items' => [['id' => $line, 'quantity' => 2]],
+ ])
+ ->assertStatus(422)
+ ->assertJsonPath('errors.invoice.0', 'credit_quantity_exceeds_remaining');
+
+ expect($invoice->creditNotes()->count())->toBe(1);
+});
+
+test('cannot credit a line that belongs to another invoice', function () {
+ $invoice = creditableInvoice([['price' => 1000, 'quantity' => 1]]);
+ $other = creditableInvoice([['price' => 1000, 'quantity' => 1]]);
+
+ [$foreign] = creditableItemIds($other);
+
+ postJson("api/v1/invoices/{$invoice->id}/credit-note", [
+ 'items' => [['id' => $foreign, 'quantity' => 1]],
+ ])
+ ->assertStatus(422)
+ ->assertJsonValidationErrors(['items.0.id']);
+
+ expect($invoice->creditNotes()->count())->toBe(0);
+});
+
+test('a credit note must credit something', function () {
+ $invoice = creditableInvoice([['price' => 1000, 'quantity' => 1]]);
+
+ [$line] = creditableItemIds($invoice);
+
+ // Quantities are carried in hundredths, so anything below half a hundredth
+ // credits nothing at all and must not mint an empty document.
+ postJson("api/v1/invoices/{$invoice->id}/credit-note", [
+ 'items' => [['id' => $line, 'quantity' => 0.001]],
+ ])
+ ->assertStatus(422)
+ ->assertJsonPath('errors.invoice.0', 'credit_note_must_credit_something');
+
+ // A zero or negative quantity does not even reach the service.
+ postJson("api/v1/invoices/{$invoice->id}/credit-note", [
+ 'items' => [['id' => $line, 'quantity' => 0]],
+ ])
+ ->assertStatus(422)
+ ->assertJsonValidationErrors(['items.0.quantity']);
+
+ expect($invoice->creditNotes()->count())->toBe(0);
+});
+
+test('a fully credited invoice cannot be credited again', function () {
+ $invoice = creditableInvoice([['price' => 1000, 'quantity' => 2]]);
+
+ [$line] = creditableItemIds($invoice);
+
+ postJson("api/v1/invoices/{$invoice->id}/credit-note")->assertStatus(201);
+
+ postJson("api/v1/invoices/{$invoice->id}/credit-note")
+ ->assertStatus(422)
+ ->assertJsonPath('errors.invoice.0', 'invoice_already_fully_credited');
+
+ postJson("api/v1/invoices/{$invoice->id}/credit-note", [
+ 'items' => [['id' => $line, 'quantity' => 1]],
+ ])
+ ->assertStatus(422)
+ ->assertJsonPath('errors.invoice.0', 'invoice_already_fully_credited');
+
+ expect($invoice->creditNotes()->count())->toBe(1);
+});
+
+test('stores the reason a credit note was issued and returns it', function () {
+ $invoice = creditableInvoice([['price' => 1000, 'quantity' => 1]]);
+
+ $response = postJson("api/v1/invoices/{$invoice->id}/credit-note", [
+ 'reason' => 'Goods returned damaged',
+ ])->assertStatus(201);
+
+ $response->assertJsonPath('data.credit_reason', 'Goods returned damaged');
+
+ expect(Invoice::find($response->json('data.id'))->credit_reason)
+ ->toBe('Goods returned damaged');
+});
+
+test('the credit reason cannot be set through the invoice endpoints', function () {
+ $payload = Invoice::factory()->raw([
+ 'credit_reason' => 'Written by a client',
+ 'taxes' => [Tax::factory()->raw()],
+ 'items' => [InvoiceItem::factory()->raw()],
+ ]);
+
+ $created = Invoice::find(postJson('api/v1/invoices', $payload)->assertOk()->json('data.id'));
+
+ // The reason belongs to the credit-note flow; the invoice form must not be
+ // able to write it.
+ expect($created->credit_reason)->toBeNull();
+
+ putJson("api/v1/invoices/{$created->id}", array_merge($payload, [
+ 'invoice_number' => $payload['invoice_number'].'-B',
+ 'credit_reason' => 'Written by a client',
+ ]))->assertOk();
+
+ expect($created->fresh()->credit_reason)->toBeNull();
+});
+
+test('a credited invoice can no longer be edited', function () {
+ $invoice = creditableInvoice([['price' => 1000, 'quantity' => 2]]);
+
+ getJson("api/v1/invoices/{$invoice->id}")
+ ->assertOk()
+ ->assertJsonPath('data.allow_edit', true);
+
+ [$line] = creditableItemIds($invoice);
+
+ postJson("api/v1/invoices/{$invoice->id}/credit-note", [
+ 'items' => [['id' => $line, 'quantity' => 1]],
+ ])->assertStatus(201);
+
+ // The credit note's lines are anchored to this invoice's item ids, so the
+ // invoice is frozen from the first credit note on, partial or not.
+ getJson("api/v1/invoices/{$invoice->id}")
+ ->assertOk()
+ ->assertJsonPath('data.allow_edit', false);
+
+ $payload = Invoice::factory()->raw([
+ 'taxes' => [Tax::factory()->raw()],
+ 'items' => [InvoiceItem::factory()->raw()],
+ ]);
+
+ putJson("api/v1/invoices/{$invoice->id}", $payload)->assertStatus(403);
+});
+
+test('exposes how much of an invoice and of each line has been credited', function () {
+ $invoice = creditableInvoice([
+ ['price' => 1000, 'quantity' => 2],
+ ['price' => 500, 'quantity' => 4],
+ ]);
+
+ [$first] = creditableItemIds($invoice);
+
+ postJson("api/v1/invoices/{$invoice->id}/credit-note", [
+ 'items' => [['id' => $first, 'quantity' => 1.5]],
+ ])->assertStatus(201);
+
+ getJson("api/v1/invoices/{$invoice->id}")
+ ->assertOk()
+ ->assertJsonPath('data.credited_total', 1500)
+ ->assertJsonPath('data.credited_status', 'PARTIAL')
+ ->assertJsonPath("data.credited_quantities.{$first}", 1.5);
+
+ // The list carries the totals for the badge, but not the per-line
+ // quantities: those need the credit notes' items and the list does not pay
+ // for them.
+ $row = collect(getJson("api/v1/invoices?invoice_id={$invoice->id}")->assertOk()->json('data'))
+ ->firstWhere('id', $invoice->id);
+
+ expect($row['credited_total'])->toBe(1500)
+ ->and($row['credited_status'])->toBe('PARTIAL')
+ ->and($row)->not->toHaveKey('credited_quantities');
+});
+
+test('reports an uncredited invoice as uncredited', function () {
+ $invoice = creditableInvoice([['price' => 1000, 'quantity' => 1]]);
+
+ getJson("api/v1/invoices/{$invoice->id}")
+ ->assertOk()
+ ->assertJsonPath('data.credited_total', 0)
+ ->assertJsonPath('data.credited_status', 'NONE')
+ ->assertJsonPath('data.credit_reason', null)
+ ->assertJsonPath('data.allow_edit', true);
+});
+
+test('pro-rates per item taxes and writes no document level tax', function () {
+ $invoice = creditableInvoice(
+ [['price' => 1000, 'quantity' => 2, 'taxes' => [['amount' => 140, 'percent' => 7]]]],
+ ['tax_per_item' => 'YES']
+ );
+
+ expect($invoice->total)->toBe(2140);
+
+ [$line] = creditableItemIds($invoice);
+
+ $creditNoteId = postJson("api/v1/invoices/{$invoice->id}/credit-note", [
+ 'items' => [['id' => $line, 'quantity' => 1]],
+ ])->assertStatus(201)->json('data.id');
+
+ $creditNote = Invoice::with('items.taxes', 'taxes')->find($creditNoteId);
+
+ expect($creditNote->sub_total)->toBe(-1000)
+ ->and($creditNote->tax)->toBe(-70)
+ ->and($creditNote->total)->toBe(-1070);
+
+ $item = $creditNote->items->first();
+
+ expect($item->tax)->toBe(-70)
+ ->and($item->taxes)->toHaveCount(1)
+ ->and((int) $item->taxes->first()->amount)->toBe(-70)
+ ->and((int) $item->taxes->first()->base_amount)->toBe(-70)
+ // The descriptive fields travel with the amount so the credit note can
+ // be read on its own.
+ ->and((float) $item->taxes->first()->percent)->toBe(7.0)
+ ->and($item->taxes->first()->tax_type_id)->toBe($invoice->items->first()->taxes->first()->tax_type_id);
+
+ // Per-item tax means no document-level tax row exists to credit.
+ expect($creditNote->taxes)->toHaveCount(0);
+});
+
+test('follows the tax inclusive total when crediting part of an invoice', function () {
+ $invoice = creditableInvoice(
+ [['price' => 1000, 'quantity' => 2]],
+ ['tax_included' => true],
+ [['amount' => 140, 'percent' => 7]]
+ );
+
+ // Tax included: the total is the sub total, the tax is already inside it.
+ expect($invoice->total)->toBe(2000);
+
+ [$line] = creditableItemIds($invoice);
+
+ $creditNoteId = postJson("api/v1/invoices/{$invoice->id}/credit-note", [
+ 'items' => [['id' => $line, 'quantity' => 1]],
+ ])->assertStatus(201)->json('data.id');
+
+ $creditNote = Invoice::find($creditNoteId);
+
+ expect($creditNote->sub_total)->toBe(-1000)
+ ->and($creditNote->tax)->toBe(-70)
+ // Not -1070: the credited tax is inside the credited total.
+ ->and($creditNote->total)->toBe(-1000);
+});
+
+test('pro-rates the base amounts of a foreign currency invoice and telescopes exactly', function () {
+ $invoice = creditableInvoice(
+ [['price' => 1000, 'quantity' => 3]],
+ ['exchange_rate' => 1.37],
+ [['amount' => 210, 'percent' => 7]]
+ );
+
+ expect($invoice->total)->toBe(3210)
+ ->and((int) $invoice->base_total)->toBe(4398)
+ ->and((int) $invoice->base_tax)->toBe(288);
+
+ [$line] = creditableItemIds($invoice);
+
+ $creditNoteId = postJson("api/v1/invoices/{$invoice->id}/credit-note", [
+ 'items' => [['id' => $line, 'quantity' => 1]],
+ ])->assertStatus(201)->json('data.id');
+
+ $creditNote = Invoice::find($creditNoteId);
+
+ // Pro-rated from the STORED base amounts, not recomputed through the rate:
+ // 4398 / 3 is 1466, while round(1070 * 1.37) would be 1466 by luck and
+ // round(70 * 1.37) would be 96 here but not everywhere.
+ expect($creditNote->total)->toBe(-1070)
+ ->and((int) $creditNote->base_sub_total)->toBe(-1370)
+ ->and((int) $creditNote->base_tax)->toBe(-96)
+ ->and((int) $creditNote->base_total)->toBe(-1466);
+
+ postJson("api/v1/invoices/{$invoice->id}/credit-note", [
+ 'items' => [['id' => $line, 'quantity' => 2]],
+ ])->assertStatus(201);
+
+ // Two chunks, and the books balance to the cent in the company currency
+ // just as they do in the document currency.
+ expect((int) $invoice->creditNotes()->sum('total'))->toBe(-3210)
+ ->and((int) $invoice->creditNotes()->sum('base_total'))->toBe(-4398)
+ ->and((int) $invoice->creditNotes()->sum('base_tax'))->toBe(-288)
+ ->and((int) $invoice->creditNotes()->sum('base_sub_total'))->toBe(-4110);
+
+ expect((int) $invoice->fresh()->due_amount)->toBe(0);
+});
+
+test('sets the related invoice relationship on the credit note', function () {
+ $invoice = creditableInvoice();
+
+ $response = postJson("api/v1/invoices/{$invoice->id}/credit-note")
+ ->assertStatus(201);
+
+ $creditNote = Invoice::find($response->json('data.id'));
+
+ expect($creditNote->relatedInvoice->id)->toBe($invoice->id);
+ expect($invoice->fresh()->creditNotes->pluck('id'))->toContain($creditNote->id);
+
+ // The resource exposes the original invoice reference.
+ $response->assertJsonPath('data.related_invoice.id', $invoice->id);
+ $response->assertJsonPath('data.related_invoice.invoice_number', $invoice->invoice_number);
+ $response->assertJsonPath('data.type', Invoice::TYPE_CREDIT_NOTE);
+});
+
+test('cannot create a credit note from another credit note', function () {
+ $invoice = creditableInvoice();
+
+ $creditNoteId = postJson("api/v1/invoices/{$invoice->id}/credit-note")
+ ->assertStatus(201)
+ ->json('data.id');
+
+ // Reversing a credit note is a domain rule violation, not an auth failure.
+ postJson("api/v1/invoices/{$creditNoteId}/credit-note")
+ ->assertStatus(422);
+});
+
+test('cannot create a credit note for an invoice of another company', function () {
+ $invoice = Invoice::factory()
+ ->hasItems(1)
+ ->create(['company_id' => Company::factory()->create()->id]);
+
+ postJson("api/v1/invoices/{$invoice->id}/credit-note")
+ ->assertStatus(403);
+});
+
+test('generates a pdf for a credit note', function () {
+ $invoice = creditableInvoice();
+
+ $creditNoteId = postJson("api/v1/invoices/{$invoice->id}/credit-note")
+ ->assertStatus(201)
+ ->json('data.id');
+
+ $creditNote = Invoice::find($creditNoteId);
+
+ $pdf = $creditNote->getPDFData();
+ $output = $pdf->output();
+
+ // A real PDF document was produced by the credit-note template.
+ expect(substr($output, 0, 4))->toBe('%PDF');
+});
+
+test('settles the original invoice when a credit note is created', function () {
+ $invoice = creditableInvoice();
+
+ postJson("api/v1/invoices/{$invoice->id}/credit-note")->assertStatus(201);
+
+ $invoice->refresh();
+
+ // A full reversal nets the original invoice's balance to exactly zero, so
+ // it drops out of every "awaiting payment" view (issue #317 community ask;
+ // same behavior sevDesk applies and @gdarko praised in PR #536).
+ expect((int) $invoice->due_amount)->toBe(0);
+ expect((int) $invoice->base_due_amount)->toBe(0);
+ expect($invoice->paid_status)->toBe(Invoice::STATUS_PAID);
+ expect($invoice->status)->toBe(Invoice::STATUS_COMPLETED);
+});
+
+test('the credit note itself is created settled but still a draft', function () {
+ $invoice = creditableInvoice();
+
+ $creditNoteId = postJson("api/v1/invoices/{$invoice->id}/credit-note")
+ ->assertStatus(201)
+ ->json('data.id');
+
+ $creditNote = Invoice::find($creditNoteId);
+
+ // The credit note pairs with the original invoice and nothing is owed on
+ // it, so it must never appear as an open (negative) balance anywhere.
+ expect((int) $creditNote->due_amount)->toBe(0);
+ expect((int) $creditNote->base_due_amount)->toBe(0);
+ expect($creditNote->paid_status)->toBe(Invoice::STATUS_PAID);
+ // A reversal is never owed, so it carries no due date at all.
+ expect($creditNote->due_date)->toBeNull();
+ // Settled is not the same as finished: the credit note still has to be
+ // reviewed and emailed, so it is born DRAFT and gets the ordinary Send
+ // affordances. send() promotes it to SENT.
+ expect($creditNote->status)->toBe(Invoice::STATUS_DRAFT);
+ // Totals stay fully negated, though.
+ expect($creditNote->total)->toBe(-10000);
+});
+
+test('the original invoice exposes its credit notes for the UI banner', function () {
+ $invoice = creditableInvoice();
+
+ $creditNoteId = postJson("api/v1/invoices/{$invoice->id}/credit-note")
+ ->assertStatus(201)
+ ->json('data.id');
+
+ $creditNoteNumber = Invoice::find($creditNoteId)->invoice_number;
+
+ // Mirror of the credit note's related_invoice back-link: the original
+ // invoice must reference the storno document ("Storniert via ST-XXXX").
+ getJson("api/v1/invoices/{$invoice->id}")
+ ->assertOk()
+ ->assertJsonPath('data.credit_notes.0.id', $creditNoteId)
+ ->assertJsonPath('data.credit_notes.0.invoice_number', $creditNoteNumber);
+});
+
+test('deleting a credit note restores the original invoice balance', function () {
+ $invoice = creditableInvoice();
+
+ $creditNoteId = postJson("api/v1/invoices/{$invoice->id}/credit-note")
+ ->assertStatus(201)
+ ->json('data.id');
+
+ expect((int) $invoice->fresh()->due_amount)->toBe(0);
+
+ postJson('api/v1/invoices/delete', ['ids' => [$creditNoteId]])
+ ->assertOk()
+ ->assertJson(['success' => true]);
+
+ $invoice->refresh();
+
+ // Mirror of the create-side adjustment (PR #536's delete reversal).
+ expect((int) $invoice->due_amount)->toBe(10000);
+ expect((int) $invoice->base_due_amount)->toBe(10000);
+ expect($invoice->paid_status)->toBe(Invoice::STATUS_UNPAID);
+ expect($invoice->status)->toBe(Invoice::STATUS_SENT);
+});
+
+test('deleting a credit note restores a partially paid balance from payments', function () {
+ $invoice = creditableInvoice([['price' => 100, 'quantity' => 100]]);
+
+ creditablePayment($invoice, 4000);
+
+ [$line] = creditableItemIds($invoice);
+
+ // Crediting the whole unpaid balance settles the invoice.
+ $creditNoteId = postJson("api/v1/invoices/{$invoice->id}/credit-note", [
+ 'items' => [['id' => $line, 'quantity' => 60]],
+ ])->assertStatus(201)->json('data.id');
+
+ expect((int) $invoice->fresh()->due_amount)->toBe(0);
+
+ postJson('api/v1/invoices/delete', ['ids' => [$creditNoteId]])
+ ->assertOk();
+
+ $invoice->refresh();
+
+ // due = total - recorded payments - surviving credit notes, never a stale
+ // pre-storno snapshot.
+ expect((int) $invoice->due_amount)->toBe(6000);
+ expect($invoice->paid_status)->toBe(Invoice::STATUS_PARTIALLY_PAID);
+});
+
+test('deleting one of two credit notes gives back only that credit', function () {
+ $invoice = creditableInvoice([['price' => 100, 'quantity' => 100]]);
+
+ creditablePayment($invoice, 1000);
+
+ [$line] = creditableItemIds($invoice);
+
+ $first = postJson("api/v1/invoices/{$invoice->id}/credit-note", [
+ 'items' => [['id' => $line, 'quantity' => 20]],
+ ])->assertStatus(201)->json('data.id');
+
+ $second = postJson("api/v1/invoices/{$invoice->id}/credit-note", [
+ 'items' => [['id' => $line, 'quantity' => 30]],
+ ])->assertStatus(201)->json('data.id');
+
+ expect((int) $invoice->fresh()->due_amount)->toBe(4000);
+
+ postJson('api/v1/invoices/delete', ['ids' => [$first]])->assertOk();
+
+ // 10000 - 1000 paid - 3000 still credited.
+ expect((int) $invoice->fresh()->due_amount)->toBe(6000);
+
+ postJson('api/v1/invoices/delete', ['ids' => [$second]])->assertOk();
+
+ expect((int) $invoice->fresh()->due_amount)->toBe(9000);
+ expect($invoice->fresh()->paid_status)->toBe(Invoice::STATUS_PARTIALLY_PAID);
+});
+
+test('deleting two credit notes of one invoice in a single request settles it once', function () {
+ $invoice = creditableInvoice([['price' => 100, 'quantity' => 100]]);
+
+ [$line] = creditableItemIds($invoice);
+
+ $first = postJson("api/v1/invoices/{$invoice->id}/credit-note", [
+ 'items' => [['id' => $line, 'quantity' => 20]],
+ ])->assertStatus(201)->json('data.id');
+
+ $second = postJson("api/v1/invoices/{$invoice->id}/credit-note", [
+ 'items' => [['id' => $line, 'quantity' => 30]],
+ ])->assertStatus(201)->json('data.id');
+
+ postJson('api/v1/invoices/delete', ['ids' => [$first, $second]])->assertOk();
+
+ $invoice->refresh();
+
+ expect((int) $invoice->due_amount)->toBe(10000)
+ ->and($invoice->paid_status)->toBe(Invoice::STATUS_UNPAID)
+ ->and($invoice->status)->toBe(Invoice::STATUS_SENT);
+});
+
+test('deleting the original invoice and its credit note together succeeds', function () {
+ $invoice = creditableInvoice();
+
+ $creditNoteId = postJson("api/v1/invoices/{$invoice->id}/credit-note")
+ ->assertStatus(201)
+ ->json('data.id');
+
+ postJson('api/v1/invoices/delete', ['ids' => [$invoice->id, $creditNoteId]])
+ ->assertOk()
+ ->assertJson(['success' => true]);
+
+ $this->assertDatabaseMissing('invoices', ['id' => $invoice->id]);
+ $this->assertDatabaseMissing('invoices', ['id' => $creditNoteId]);
+});
+
+test('cannot delete an invoice while a credit note still reverses it', function () {
+ $invoice = creditableInvoice();
+
+ $creditNoteId = postJson("api/v1/invoices/{$invoice->id}/credit-note")
+ ->assertStatus(201)
+ ->json('data.id');
+
+ // Deleting only the original would leave the credit note pointing at a row
+ // that no longer exists.
+ postJson('api/v1/invoices/delete', ['ids' => [$invoice->id]])
+ ->assertStatus(422);
+
+ $this->assertDatabaseHas('invoices', ['id' => $invoice->id]);
+ $this->assertDatabaseHas('invoices', ['id' => $creditNoteId]);
+});
+
+test('no surviving row keeps a dangling related invoice reference', function () {
+ $invoice = creditableInvoice();
+
+ $creditNote = app(CreditNoteService::class)->create($invoice, [], null);
+
+ // There is no DB foreign key, so the cascade is the service's job. Deleting
+ // the original directly (the request layer blocks this) must still not
+ // leave the credit note pointing at a missing invoice.
+ app(InvoiceService::class)->delete(collect([$invoice->id]));
+
+ expect(Invoice::find($creditNote->id)->related_invoice_id)->toBeNull();
+});
+
+test('completing a fully credited invoice is idempotent', function () {
+ $invoice = creditableInvoice();
+
+ postJson("api/v1/invoices/{$invoice->id}/credit-note")
+ ->assertStatus(201);
+
+ postJson("api/v1/invoices/{$invoice->id}/status", ['status' => Invoice::STATUS_COMPLETED])
+ ->assertOk();
+
+ $invoice->refresh();
+
+ // Completion verifies the recorded credit note and does not disturb the
+ // already-settled balance.
+ expect((int) $invoice->due_amount)->toBe(0)
+ ->and((int) $invoice->base_due_amount)->toBe(0)
+ ->and($invoice->status)->toBe(Invoice::STATUS_COMPLETED)
+ ->and($invoice->paid_status)->toBe(Invoice::STATUS_PAID)
+ ->and($invoice->payments)->toHaveCount(0);
+});
+
+test('renders a credit note pdf through the original invoice template family, not a hardcoded layout', function () {
+ // Regression for: credit notes always rendered through one hardcoded
+ // generic layout regardless of which of the 3 invoice templates the
+ // company actually uses. invoice2 has a distinctive purple header
+ // markup ("header-section-right") that the old standalone
+ // credit-note.blade.php never contained.
+ $invoice = creditableInvoice([['price' => 10000, 'quantity' => 1]], ['template_name' => 'invoice2']);
+
+ $creditNoteId = postJson("api/v1/invoices/{$invoice->id}/credit-note")
+ ->assertStatus(201)
+ ->json('data.id');
+
+ $creditNote = Invoice::find($creditNoteId);
+
+ $response = get("/invoices/pdf/{$creditNote->unique_hash}?preview=1");
+
+ $response->assertOk();
+ $response->assertSee('header-section-right', false);
+ $response->assertSee('Credit Note');
+ $response->assertSee($invoice->invoice_number);
+});
+
+test('renders a credit note pdf under the invoice3 template family', function () {
+ $invoice = creditableInvoice([['price' => 10000, 'quantity' => 1]], ['template_name' => 'invoice3']);
+
+ $creditNoteId = postJson("api/v1/invoices/{$invoice->id}/credit-note")
+ ->assertStatus(201)
+ ->json('data.id');
+
+ $creditNote = Invoice::find($creditNoteId);
+
+ $response = get("/invoices/pdf/{$creditNote->unique_hash}?preview=1");
+
+ $response->assertOk();
+ // "main-content" is a structural marker unique to invoice3.blade.php.
+ $response->assertSee('main-content', false);
+ $response->assertSee('Credit Note');
+});
+
+test('shows a cancellation banner on the original invoice pdf under a non-default template', function () {
+ // Regression for: the actual generated/printed/emailed PDF of a
+ // cancelled invoice showed zero indication it had been reversed by a
+ // credit note (only the Vue UI banner existed).
+ $invoice = creditableInvoice([['price' => 10000, 'quantity' => 1]], ['template_name' => 'invoice3']);
+
+ $creditNoteId = postJson("api/v1/invoices/{$invoice->id}/credit-note")
+ ->assertStatus(201)
+ ->json('data.id');
+
+ $creditNote = Invoice::find($creditNoteId);
+
+ $response = get("/invoices/pdf/{$invoice->unique_hash}?preview=1");
+
+ $response->assertOk();
+ $response->assertSee('Cancelled');
+ $response->assertSee($creditNote->invoice_number);
+});
+
+test('shows a cancellation banner on the original invoice pdf under the default template', function () {
+ $invoice = creditableInvoice();
+
+ $creditNoteId = postJson("api/v1/invoices/{$invoice->id}/credit-note")
+ ->assertStatus(201)
+ ->json('data.id');
+
+ $creditNote = Invoice::find($creditNoteId);
+
+ $response = get("/invoices/pdf/{$invoice->unique_hash}?preview=1");
+
+ $response->assertOk();
+ $response->assertSee('Cancelled');
+ $response->assertSee($creditNote->invoice_number);
+});
+
+test('prints the credit reason on the credit note pdf and escapes it', function () {
+ $invoice = creditableInvoice();
+
+ $creditNoteId = postJson("api/v1/invoices/{$invoice->id}/credit-note", [
+ 'reason' => 'Goods returned damaged',
+ ])->assertStatus(201)->json('data.id');
+
+ $creditNote = Invoice::find($creditNoteId);
+
+ $response = get("/invoices/pdf/{$creditNote->unique_hash}?preview=1");
+
+ $response->assertOk();
+ // assertSee escapes by default, so this is the escaped rendering.
+ $response->assertSee('Reason: Goods returned damaged');
+ // The operator's text is data, never markup: the raw tags must not reach
+ // the document, where Chromium would happily render them as bold.
+ $response->assertDontSee('Goods returned damaged', false);
+});
+
+test('omits the reason line from a credit note pdf that has no reason', function () {
+ $invoice = creditableInvoice();
+
+ $creditNoteId = postJson("api/v1/invoices/{$invoice->id}/credit-note")
+ ->assertStatus(201)
+ ->json('data.id');
+
+ $creditNote = Invoice::find($creditNoteId);
+
+ get("/invoices/pdf/{$creditNote->unique_hash}?preview=1")
+ ->assertOk()
+ ->assertDontSee('Reason:');
+});
+
+test('shows a partially credited banner naming the amount and the credit note', function () {
+ $invoice = creditableInvoice([
+ ['price' => 1000, 'quantity' => 1],
+ ['price' => 1000, 'quantity' => 1],
+ ]);
+
+ [$first] = creditableItemIds($invoice);
+
+ $creditNoteId = postJson("api/v1/invoices/{$invoice->id}/credit-note", [
+ 'items' => [['id' => $first, 'quantity' => 1]],
+ ])->assertStatus(201)->json('data.id');
+
+ $creditNote = Invoice::find($creditNoteId);
+
+ $response = get("/invoices/pdf/{$invoice->unique_hash}?preview=1");
+
+ $response->assertOk();
+ $response->assertSee('Partially Credited');
+ $response->assertSee($creditNote->invoice_number);
+ $response->assertSee(format_money_pdf(1000, $invoice->customer->currency), false);
+ // Half an invoice is not a cancelled invoice.
+ $response->assertDontSee('Cancelled via credit note');
+});
+
+test('lists every credit note on the cancelled banner once the invoice is fully credited', function () {
+ $invoice = creditableInvoice([
+ ['price' => 1000, 'quantity' => 1],
+ ['price' => 1000, 'quantity' => 1],
+ ]);
+
+ [$first, $second] = creditableItemIds($invoice);
+
+ $firstNote = Invoice::find(
+ postJson("api/v1/invoices/{$invoice->id}/credit-note", [
+ 'items' => [['id' => $first, 'quantity' => 1]],
+ ])->assertStatus(201)->json('data.id')
+ );
+
+ $secondNote = Invoice::find(
+ postJson("api/v1/invoices/{$invoice->id}/credit-note", [
+ 'items' => [['id' => $second, 'quantity' => 1]],
+ ])->assertStatus(201)->json('data.id')
+ );
+
+ $response = get("/invoices/pdf/{$invoice->unique_hash}?preview=1");
+
+ $response->assertOk();
+ $response->assertSee('Cancelled');
+ // Naming only the first credit note would leave the reader unable to tie
+ // the reversal to the documents that produced it.
+ $response->assertSee($firstNote->invoice_number);
+ $response->assertSee($secondNote->invoice_number);
+ $response->assertDontSee('Partially Credited');
+});
+
+test('a partially credited invoice pdf reports a credit, not a payment', function () {
+ // The hazard this pins: crediting an invoice moves its balance, so a totals
+ // block driven by paid_status alone announces "Amount Paid" for money that
+ // was never received.
+ $invoice = creditableInvoice([
+ ['price' => 1000, 'quantity' => 1],
+ ['price' => 1000, 'quantity' => 1],
+ ]);
+
+ [$first] = creditableItemIds($invoice);
+
+ postJson("api/v1/invoices/{$invoice->id}/credit-note", [
+ 'items' => [['id' => $first, 'quantity' => 1]],
+ ])->assertStatus(201);
+
+ $response = get("/invoices/pdf/{$invoice->unique_hash}?preview=1");
+
+ $response->assertOk();
+ $response->assertSee('Amount Credited');
+ $response->assertSee('Amount Due');
+ $response->assertDontSee('Amount Paid');
+});
+
+test('an invoice both paid and credited pdf reports the two separately', function () {
+ $invoice = creditableInvoice([
+ ['price' => 1000, 'quantity' => 1],
+ ['price' => 1000, 'quantity' => 1],
+ ]);
+
+ creditablePayment($invoice, 500);
+
+ [$first] = creditableItemIds($invoice);
+
+ postJson("api/v1/invoices/{$invoice->id}/credit-note", [
+ 'items' => [['id' => $first, 'quantity' => 1]],
+ ])->assertStatus(201);
+
+ $invoice->refresh();
+
+ $response = get("/invoices/pdf/{$invoice->unique_hash}?preview=1");
+
+ $response->assertOk();
+ $response->assertSee('Amount Credited');
+ $response->assertSee('Amount Paid');
+ $response->assertSee(format_money_pdf(1000, $invoice->customer->currency), false);
+ $response->assertSee(format_money_pdf(500, $invoice->customer->currency), false);
+});
+
+test('an ordinary partially paid invoice pdf still shows the amount paid', function () {
+ $invoice = creditableInvoice([['price' => 10000, 'quantity' => 1]]);
+
+ creditablePayment($invoice, 4000);
+
+ $invoice->refresh();
+
+ $response = get("/invoices/pdf/{$invoice->unique_hash}?preview=1");
+
+ $response->assertOk();
+ $response->assertSee('Amount Paid');
+ $response->assertSee('Amount Due');
+ $response->assertDontSee('Amount Credited');
+ $response->assertSee(format_money_pdf(4000, $invoice->customer->currency), false);
+ $response->assertSee(format_money_pdf(6000, $invoice->customer->currency), false);
+});
+
+test('an unpaid invoice pdf shows neither a paid nor a credited row', function () {
+ $invoice = creditableInvoice([['price' => 10000, 'quantity' => 1]]);
+
+ $response = get("/invoices/pdf/{$invoice->unique_hash}?preview=1");
+
+ $response->assertOk();
+ $response->assertDontSee('Amount Paid');
+ $response->assertDontSee('Amount Credited');
+ $response->assertDontSee('Amount Due');
+});
+
+test('a credit note pdf shows no amount paid row', function () {
+ $invoice = creditableInvoice();
+
+ $creditNoteId = postJson("api/v1/invoices/{$invoice->id}/credit-note")
+ ->assertStatus(201)
+ ->json('data.id');
+
+ $creditNote = Invoice::find($creditNoteId);
+
+ $response = get("/invoices/pdf/{$creditNote->unique_hash}?preview=1");
+
+ $response->assertOk();
+ // A credit note settles nothing: its own totals block is the negated
+ // document, and a paid line there would be read as a refund.
+ $response->assertDontSee('Amount Paid');
+ $response->assertDontSee('Amount Credited');
+});
+
+test('every credit note phrase is translated in all five maintained locales', function () {
+ $locales = ['en', 'de', 'fr', 'it', 'mk'];
+
+ $catalogues = [];
+
+ foreach ($locales as $locale) {
+ $catalogues[$locale] = json_decode(file_get_contents(base_path("lang/{$locale}.json")), true);
+ }
+
+ $english = $catalogues['en'];
+
+ $expected = [];
+
+ foreach (array_keys($english['invoices']) as $key) {
+ if (str_contains($key, 'credit')) {
+ $expected[] = ['invoices', $key];
+ }
+ }
+
+ foreach (array_keys($english['errors']) as $key) {
+ if (str_starts_with($key, 'credit_') || $key === 'invoice_already_fully_credited') {
+ $expected[] = ['errors', $key];
+ }
+ }
+
+ foreach (array_keys($english) as $key) {
+ if (str_starts_with($key, 'pdf_') && (str_contains($key, 'credit') || str_contains($key, 'cancelled'))) {
+ $expected[] = [null, $key];
+ }
+ }
+
+ expect($expected)->not->toBeEmpty();
+
+ $missing = [];
+
+ foreach ($expected as [$section, $key]) {
+ foreach ($locales as $locale) {
+ $bag = $section === null ? $catalogues[$locale] : ($catalogues[$locale][$section] ?? []);
+
+ if (! array_key_exists($key, $bag)) {
+ $missing[] = $locale.': '.($section === null ? $key : $section.'.'.$key);
+ }
+ }
+ }
+
+ expect($missing)->toBe([]);
+
+ // Guards that partial crediting removed: one credit note per invoice, and
+ // no crediting an invoice with payments. A stale string in any catalogue
+ // would still be shown by a translated install.
+ $retired = [
+ ['invoices', 'confirm_create_credit_note'],
+ ['errors', 'invoice_already_has_credit_note'],
+ ['errors', 'invoice_with_payments_cannot_be_credited'],
+ ];
+
+ $leftovers = [];
+
+ foreach ($retired as [$section, $key]) {
+ foreach ($locales as $locale) {
+ if (array_key_exists($key, $catalogues[$locale][$section] ?? [])) {
+ $leftovers[] = $locale.': '.$section.'.'.$key;
+ }
+ }
+ }
+
+ expect($leftovers)->toBe([]);
+});
+
+test('sends a credit note to the customer through the normal send endpoint', function () {
+ Mail::fake();
+
+ $invoice = creditableInvoice();
+
+ $creditNoteId = postJson("api/v1/invoices/{$invoice->id}/credit-note")
+ ->assertStatus(201)
+ ->json('data.id');
+
+ $data = [
+ 'from' => 'john@example.com',
+ 'to' => 'doe@example.com',
+ 'subject' => 'Your credit note',
+ 'body' => 'Please find your credit note attached.',
+ ];
+
+ // There is no separate credit-note send endpoint: a credit note goes out
+ // through the invoice send channel, which picks the mailable by type.
+ postJson("api/v1/invoices/{$creditNoteId}/send", $data)
+ ->assertOk()
+ ->assertJson(['success' => true]);
+
+ Mail::assertSent(SendCreditNoteMail::class);
+ Mail::assertNotSent(SendInvoiceMail::class);
+
+ // Sending promotes the draft credit note the same way it promotes an
+ // invoice.
+ $creditNote = Invoice::find($creditNoteId);
+ expect($creditNote->status)->toBe(Invoice::STATUS_SENT);
+ expect((bool) $creditNote->sent)->toBeTrue();
+});
+
+test('sending a regular invoice still uses the invoice mailable', function () {
+ Mail::fake();
+
+ $invoice = Invoice::factory()->hasItems(1)->create();
+
+ postJson("api/v1/invoices/{$invoice->id}/send", [
+ 'from' => 'john@example.com',
+ 'to' => 'doe@example.com',
+ 'subject' => 'Your invoice',
+ 'body' => 'Please find your invoice attached.',
+ ])->assertOk();
+
+ Mail::assertSent(SendInvoiceMail::class);
+ Mail::assertNotSent(SendCreditNoteMail::class);
+});
+
+test('previews the credit note email template, not the invoice one', function () {
+ $invoice = creditableInvoice();
+
+ $creditNoteId = postJson("api/v1/invoices/{$invoice->id}/credit-note")
+ ->assertStatus(201)
+ ->json('data.id');
+
+ // The two templates render near-identical markup, so the assertion hooks
+ // the view that actually gets composed rather than its output.
+ $rendered = [];
+ View::composer(['emails.send.credit-note', 'emails.send.invoice'], function ($view) use (&$rendered) {
+ $rendered[] = $view->name();
+ });
+
+ getJson("api/v1/invoices/{$creditNoteId}/send/preview?".http_build_query([
+ 'subject' => 'Your credit note',
+ 'body' => 'Please find your credit note attached.',
+ 'from' => 'john@example.com',
+ 'to' => 'doe@example.com',
+ ]))->assertOk();
+
+ expect($rendered)->toContain('emails.send.credit-note');
+ expect($rendered)->not->toContain('emails.send.invoice');
+});
+
+test('a credit note cannot be edited', function () {
+ $invoice = creditableInvoice();
+
+ $creditNoteId = postJson("api/v1/invoices/{$invoice->id}/credit-note")
+ ->assertStatus(201)
+ ->json('data.id');
+
+ $payload = Invoice::factory()->raw([
+ 'taxes' => [Tax::factory()->raw()],
+ 'items' => [InvoiceItem::factory()->raw()],
+ ]);
+
+ // A reversal document is immutable: editing it would recompute its totals
+ // positive through the ordinary invoice payload.
+ putJson("api/v1/invoices/{$creditNoteId}", $payload)->assertStatus(403);
+});
+
+test('a client cannot mint a credit note through the invoice create endpoint', function () {
+ $payload = Invoice::factory()->raw([
+ 'type' => Invoice::TYPE_CREDIT_NOTE,
+ 'related_invoice_id' => 1,
+ 'taxes' => [Tax::factory()->raw()],
+ 'items' => [InvoiceItem::factory()->raw()],
+ ]);
+
+ $response = postJson('api/v1/invoices', $payload)->assertOk();
+
+ // Credit notes are minted only by CreditNoteService::create(); the request
+ // payload must not be able to declare one.
+ $created = Invoice::find($response->json('data.id'));
+
+ expect($created->type)->toBe(Invoice::TYPE_INVOICE);
+ expect($created->related_invoice_id)->toBeNull();
+});
+
+test('cannot credit a draft invoice', function () {
+ $invoice = creditableInvoice([['price' => 10000, 'quantity' => 1]], [
+ 'status' => Invoice::STATUS_DRAFT,
+ 'sent' => false,
+ ]);
+
+ // A draft was never issued, so there is nothing to reverse.
+ postJson("api/v1/invoices/{$invoice->id}/credit-note")
+ ->assertStatus(422);
+
+ expect($invoice->creditNotes()->count())->toBe(0);
+});
+
+test('a credit note cannot be cloned or converted to an estimate', function () {
+ $invoice = creditableInvoice();
+
+ $creditNoteId = postJson("api/v1/invoices/{$invoice->id}/credit-note")
+ ->assertStatus(201)
+ ->json('data.id');
+
+ // Both copy the amounts unnegated, so either would mint a positive
+ // document out of a reversal.
+ postJson("api/v1/invoices/{$creditNoteId}/clone")->assertStatus(422);
+ postJson("api/v1/invoices/{$creditNoteId}/convert-to-estimate")->assertStatus(422);
+});
+
+test('a credit note is never marked overdue by the status command', function () {
+ $invoice = creditableInvoice();
+
+ $creditNoteId = postJson("api/v1/invoices/{$invoice->id}/credit-note")
+ ->assertStatus(201)
+ ->json('data.id');
+
+ // Force the credit note into the shape the command looks for: sent, not
+ // completed, with a due date in the past.
+ Invoice::where('id', $creditNoteId)->update([
+ 'status' => Invoice::STATUS_SENT,
+ 'due_date' => now()->subMonth()->format('Y-m-d'),
+ ]);
+
+ Artisan::call('check:invoices:status');
+
+ expect((bool) Invoice::find($creditNoteId)->overdue)->toBeFalse();
+});
+
+test('a real invoice is still marked overdue by the status command', function () {
+ $invoice = Invoice::factory()->hasItems(1)->create([
+ 'status' => Invoice::STATUS_SENT,
+ 'due_date' => now()->subMonth()->format('Y-m-d'),
+ 'overdue' => false,
+ ]);
+
+ Artisan::call('check:invoices:status');
+
+ expect((bool) $invoice->fresh()->overdue)->toBeTrue();
+});
+
+describe('credit note numbering', function () {
+ test('numbers credit notes in their own sequence, independent of invoices', function () {
+ $first = creditableInvoice();
+ $second = creditableInvoice();
+
+ expect($first->invoice_number)->toBe('INV-000001');
+ expect($first->sequence_number)->toBe(1);
+ expect($second->invoice_number)->toBe('INV-000002');
+ expect($second->sequence_number)->toBe(2);
+
+ $firstCreditNote = Invoice::find(
+ postJson("api/v1/invoices/{$first->id}/credit-note")
+ ->assertStatus(201)
+ ->json('data.id')
+ );
+
+ $secondCreditNote = Invoice::find(
+ postJson("api/v1/invoices/{$second->id}/credit-note")
+ ->assertStatus(201)
+ ->json('data.id')
+ );
+
+ // Credit notes live in the invoices table but count from 1 on their own
+ // format, so the two document series never interleave.
+ expect($firstCreditNote->invoice_number)->toBe('CN-000001');
+ expect($firstCreditNote->sequence_number)->toBe(1);
+ expect($secondCreditNote->invoice_number)->toBe('CN-000002');
+ expect($secondCreditNote->sequence_number)->toBe(2);
+
+ // And the invoice sequence is untouched by the two credit notes: the
+ // next invoice is 3, not 5.
+ $third = creditableInvoice();
+
+ expect($third->invoice_number)->toBe('INV-000003');
+ expect($third->sequence_number)->toBe(3);
+ });
+
+ test('generates the credit note number from the credit_note_number_format setting', function () {
+ $companyId = User::find(1)->companies()->first()->id;
+
+ CompanySetting::setSettings([
+ 'credit_note_number_format' => '{{SERIES:STORNO}}{{DELIMITER:/}}{{SEQUENCE:4}}',
+ ], $companyId);
+
+ $invoice = creditableInvoice();
+
+ $creditNote = Invoice::find(
+ postJson("api/v1/invoices/{$invoice->id}/credit-note")
+ ->assertStatus(201)
+ ->json('data.id')
+ );
+
+ expect($creditNote->invoice_number)->toBe('STORNO/0001');
+ });
+
+ test('returns the next credit note number from the next-number endpoint', function () {
+ getJson('api/v1/next-number?key=credit_note')
+ ->assertStatus(200)
+ ->assertJson([
+ 'success' => true,
+ 'nextNumber' => 'CN-000001',
+ ]);
+
+ $invoice = creditableInvoice();
+
+ postJson("api/v1/invoices/{$invoice->id}/credit-note")->assertStatus(201);
+
+ // The preview advances with the credit note sequence, not the invoice one.
+ getJson('api/v1/next-number?key=credit_note')
+ ->assertStatus(200)
+ ->assertJson([
+ 'nextNumber' => 'CN-000002',
+ ]);
+ });
+});
diff --git a/tests/Feature/Company/CompanyMailConfigurationControllerTest.php b/tests/Feature/Company/CompanyMailConfigurationControllerTest.php
new file mode 100644
index 00000000..3bd12677
--- /dev/null
+++ b/tests/Feature/Company/CompanyMailConfigurationControllerTest.php
@@ -0,0 +1,110 @@
+ true, '--class' => 'DatabaseSeeder']);
+ Artisan::call('db:seed', ['--force' => true, '--class' => 'DemoSeeder']);
+
+ $user = User::query()->find(1);
+ $this->companyId = $user->companies()->first()->id;
+
+ $this->withHeaders([
+ 'company' => $this->companyId,
+ ]);
+
+ Sanctum::actingAs($user, ['*']);
+});
+
+test('get company mail configuration falls back to global config defaults', function () {
+ Setting::setSettings([
+ 'mail_driver' => 'mail',
+ 'from_name' => 'Global Mailer',
+ 'from_mail' => 'global@example.com',
+ ]);
+
+ app(MailConfigurationService::class)->applyGlobalConfig();
+
+ getJson('/api/v1/company/mail/company-config')
+ ->assertOk()
+ ->assertJson([
+ 'use_custom_mail_config' => 'NO',
+ 'mail_driver' => 'mail',
+ 'from_name' => 'Global Mailer',
+ 'from_mail' => 'global@example.com',
+ ]);
+});
+
+test('save company mail configuration persists postmark settings', function () {
+ postJson('/api/v1/company/mail/company-config', [
+ 'use_custom_mail_config' => 'YES',
+ 'mail_driver' => 'postmark',
+ 'mail_postmark_token' => 'company-postmark-token',
+ 'mail_postmark_message_stream_id' => 'broadcasts',
+ 'from_name' => 'Company Mailer',
+ 'from_mail' => 'company@example.com',
+ ])
+ ->assertOk()
+ ->assertJson([
+ 'success' => true,
+ ]);
+
+ $this->assertDatabaseHas('company_settings', [
+ 'company_id' => $this->companyId,
+ 'option' => 'use_custom_mail_config',
+ 'value' => 'YES',
+ ]);
+
+ $this->assertDatabaseHas('company_settings', [
+ 'company_id' => $this->companyId,
+ 'option' => 'company_mail_postmark_token',
+ 'value' => 'company-postmark-token',
+ ]);
+});
+
+test('disabling company mail configuration only flips the custom-config toggle', function () {
+ CompanySetting::setSettings([
+ 'use_custom_mail_config' => 'YES',
+ 'company_mail_driver' => 'postmark',
+ 'company_mail_postmark_token' => 'existing-company-token',
+ ], $this->companyId);
+
+ postJson('/api/v1/company/mail/company-config', [
+ 'use_custom_mail_config' => 'NO',
+ ])
+ ->assertOk()
+ ->assertJson([
+ 'success' => true,
+ ]);
+
+ expect(CompanySetting::getSetting('use_custom_mail_config', $this->companyId))->toBe('NO');
+ expect(CompanySetting::getSetting('company_mail_postmark_token', $this->companyId))
+ ->toBe('existing-company-token');
+});
+
+test('company mail runtime apply maps postmark settings into laravel config', function () {
+ CompanySetting::setSettings([
+ 'use_custom_mail_config' => 'YES',
+ 'company_mail_driver' => 'postmark',
+ 'company_mail_postmark_token' => 'runtime-postmark-token',
+ 'company_mail_postmark_message_stream_id' => 'outbound',
+ 'company_from_name' => 'Runtime Mailer',
+ 'company_from_mail' => 'runtime@example.com',
+ ], $this->companyId);
+
+ app(MailConfigurationService::class)->applyCompanyConfig($this->companyId);
+
+ expect(config('mail.default'))->toBe('postmark');
+ expect(config('services.postmark.token'))->toBe('runtime-postmark-token');
+ expect(config('mail.mailers.postmark.message_stream_id'))->toBe('outbound');
+ expect(config('mail.from.name'))->toBe('Runtime Mailer');
+ expect(config('mail.from.address'))->toBe('runtime@example.com');
+});
diff --git a/tests/Feature/Customer/AuthTest.php b/tests/Feature/Customer/AuthTest.php
new file mode 100644
index 00000000..ff1eb485
--- /dev/null
+++ b/tests/Feature/Customer/AuthTest.php
@@ -0,0 +1,83 @@
+ true, '--class' => 'DatabaseSeeder']);
+ Artisan::call('db:seed', ['--force' => true, '--class' => 'DemoSeeder']);
+});
+
+test('customer portal guest entrypoints return the spa shell', function () {
+ $customer = Customer::factory()->create();
+ $companySlug = $customer->company->slug;
+
+ $this->followingRedirects()->get("/{$companySlug}/customer")
+ ->assertOk()
+ ->assertSee('window.InvoiceShelf.start()', false);
+
+ $this->followingRedirects()->get("/{$companySlug}/customer/login")
+ ->assertOk()
+ ->assertSee('window.InvoiceShelf.start()', false);
+
+ $this->followingRedirects()->get("/{$companySlug}/customer/forgot-password")
+ ->assertOk()
+ ->assertSee('window.InvoiceShelf.start()', false);
+
+ $this->followingRedirects()->get("/{$companySlug}/customer/reset/password/example-token")
+ ->assertOk()
+ ->assertSee('window.InvoiceShelf.start()', false);
+});
+
+test('customer can login to the customer portal', function () {
+ $customer = Customer::factory()->create([
+ 'password' => 'secret123',
+ 'enable_portal' => true,
+ ]);
+
+ $response = postJson("/{$customer->company->slug}/customer/login", [
+ 'email' => $customer->email,
+ 'password' => 'secret123',
+ 'device_name' => 'customer-portal-web',
+ ]);
+
+ $response
+ ->assertOk()
+ ->assertJson([
+ 'success' => true,
+ ]);
+
+ $this->assertAuthenticatedAs($customer, 'customer');
+});
+
+test('customer portal login rejects invalid credentials', function () {
+ $customer = Customer::factory()->create([
+ 'password' => 'secret123',
+ 'enable_portal' => true,
+ ]);
+
+ postJson("/{$customer->company->slug}/customer/login", [
+ 'email' => $customer->email,
+ 'password' => 'wrong-password',
+ 'device_name' => 'customer-portal-web',
+ ])
+ ->assertStatus(422)
+ ->assertJsonValidationErrors(['email']);
+});
+
+test('customer portal login rejects portal-disabled customers', function () {
+ $customer = Customer::factory()->create([
+ 'password' => 'secret123',
+ 'enable_portal' => false,
+ ]);
+
+ postJson("/{$customer->company->slug}/customer/login", [
+ 'email' => $customer->email,
+ 'password' => 'secret123',
+ 'device_name' => 'customer-portal-web',
+ ])
+ ->assertStatus(422)
+ ->assertJsonValidationErrors(['email']);
+});
diff --git a/tests/Feature/CustomerStatementTest.php b/tests/Feature/CustomerStatementTest.php
new file mode 100644
index 00000000..d6d23222
--- /dev/null
+++ b/tests/Feature/CustomerStatementTest.php
@@ -0,0 +1,239 @@
+ true, '--class' => 'DatabaseSeeder']);
+ Artisan::call('db:seed', ['--force' => true, '--class' => 'DemoSeeder']);
+
+ $this->user = User::findOrFail(1);
+ $this->company = $this->user->companies()->firstOrFail();
+ $this->withHeaders(['company' => $this->company->id]);
+ Sanctum::actingAs($this->user, ['*']);
+});
+
+function statementCustomer(): Customer
+{
+ return Customer::factory()->create([
+ 'company_id' => test()->company->id,
+ ]);
+}
+
+function statementInvoice(Customer $customer, string $date, array $attributes = []): Invoice
+{
+ return Invoice::factory()->create(array_merge([
+ 'company_id' => $customer->company_id,
+ 'customer_id' => $customer->id,
+ 'invoice_date' => $date,
+ 'due_date' => $date,
+ 'type' => Invoice::TYPE_INVOICE,
+ 'status' => Invoice::STATUS_SENT,
+ 'total' => 1000,
+ 'base_total' => 1000,
+ 'due_amount' => 1000,
+ 'base_due_amount' => 1000,
+ ], $attributes));
+}
+
+function statementPayment(Customer $customer, string $date, int $amount = 1000): Payment
+{
+ return Payment::factory()->create([
+ 'company_id' => $customer->company_id,
+ 'customer_id' => $customer->id,
+ 'payment_date' => $date,
+ 'amount' => $amount,
+ 'base_amount' => $amount,
+ ]);
+}
+
+test('activity statements calculate opening and closing balances and include draft credit notes', function () {
+ $customer = statementCustomer();
+ statementInvoice($customer, '2026-01-20', ['total' => 500, 'base_total' => 500]);
+ $invoice = statementInvoice($customer, '2026-02-10', ['total' => 1000, 'base_total' => 1000]);
+ $creditNote = statementInvoice($customer, '2026-02-10', [
+ 'type' => Invoice::TYPE_CREDIT_NOTE,
+ 'status' => Invoice::STATUS_DRAFT,
+ 'total' => -200,
+ 'base_total' => -200,
+ 'due_amount' => 0,
+ 'base_due_amount' => 0,
+ ]);
+ $payment = statementPayment($customer, '2026-02-10', 300);
+ statementInvoice($customer, '2026-02-11', [
+ 'status' => Invoice::STATUS_DRAFT,
+ 'total' => 999,
+ 'base_total' => 999,
+ ]);
+
+ $response = getJson("/api/v1/customers/{$customer->id}/statement?from_date=2026-02-01&to_date=2026-02-28");
+
+ $response->assertOk()
+ ->assertJsonPath('data.opening_balance', 500)
+ ->assertJsonPath('data.closing_balance', 1000)
+ ->assertJsonPath('data.entries.0.id', $invoice->id)
+ ->assertJsonPath('data.entries.0.entry_type', 'invoice')
+ ->assertJsonPath('data.entries.1.id', $creditNote->id)
+ ->assertJsonPath('data.entries.1.entry_type', 'credit_note')
+ ->assertJsonPath('data.entries.2.id', $payment->id)
+ ->assertJsonPath('data.entries.2.entry_type', 'payment')
+ ->assertJsonCount(3, 'data.entries');
+});
+
+test('outstanding statements respect allocation timing for historical as-of dates', function () {
+ $customer = statementCustomer();
+ $invoice = statementInvoice($customer, '2026-01-10');
+ $payment = statementPayment($customer, '2026-01-15');
+ $allocation = PaymentAllocation::create([
+ 'payment_id' => $payment->id,
+ 'invoice_id' => $invoice->id,
+ 'amount' => 1000,
+ 'base_amount' => 1000,
+ ]);
+ $allocation->forceFill(['created_at' => Carbon::parse('2026-02-05 09:00:00')])->save();
+
+ getJson("/api/v1/customers/{$customer->id}/statement?type=outstanding&as_of=2026-01-31")
+ ->assertOk()
+ ->assertJsonPath('data.invoice_due_amount', 1000)
+ ->assertJsonPath('data.available_credit', 1000)
+ ->assertJsonPath('data.account_balance', 0)
+ ->assertJsonCount(1, 'data.invoices')
+ ->assertJsonCount(1, 'data.credits');
+
+ getJson("/api/v1/customers/{$customer->id}/statement?type=outstanding&as_of=2026-02-28")
+ ->assertOk()
+ ->assertJsonPath('data.invoice_due_amount', 0)
+ ->assertJsonPath('data.available_credit', 0)
+ ->assertJsonCount(0, 'data.invoices')
+ ->assertJsonCount(0, 'data.credits');
+});
+
+test('customer account aggregates preserve due amount compatibility and show available credit', function () {
+ $customer = statementCustomer();
+ statementInvoice($customer, '2026-01-10', ['due_amount' => 600, 'base_due_amount' => 600]);
+ $payment = statementPayment($customer, '2026-01-15', 500);
+ PaymentAllocation::create([
+ 'payment_id' => $payment->id,
+ 'invoice_id' => statementInvoice($customer, '2026-01-11')->id,
+ 'amount' => 200,
+ 'base_amount' => 200,
+ ]);
+
+ getJson("/api/v1/customers/{$customer->id}")
+ ->assertOk()
+ ->assertJsonPath('data.due_amount', 1600)
+ ->assertJsonPath('data.invoice_due_amount', 1600)
+ ->assertJsonPath('data.available_credit', 300)
+ ->assertJsonPath('data.account_balance', 1300);
+});
+
+test('statements require both customer and financial-report abilities', function () {
+ $customer = statementCustomer();
+ $user = User::factory()->create();
+ $user->companies()->attach($this->company->id);
+
+ BouncerFacade::scope()->to($this->company->id);
+ BouncerFacade::allow($user)->to('view-customer', Customer::class);
+ Sanctum::actingAs($user, ['*']);
+
+ getJson("/api/v1/customers/{$customer->id}/statement")
+ ->assertForbidden();
+});
+
+test('statements cannot be read across companies', function () {
+ $otherCompany = Company::factory()->create();
+ $customer = Customer::factory()->create(['company_id' => $otherCompany->id]);
+
+ getJson("/api/v1/customers/{$customer->id}/statement")
+ ->assertForbidden();
+});
+
+test('sending a statement attaches the live PDF and logs it against the customer', function () {
+ Mail::fake();
+ config([
+ 'mail.from.address' => 'configured@example.test',
+ 'mail.from.name' => 'Configured Sender',
+ ]);
+ $customer = statementCustomer();
+ statementInvoice($customer, '2026-01-10');
+
+ postJson("/api/v1/customers/{$customer->id}/statement/send", [
+ 'subject' => 'January statement',
+ 'body' => 'Your statement is attached.',
+ 'to' => $customer->email,
+ ])->assertOk();
+
+ $sent = null;
+ Mail::assertSent(SendCustomerStatementMail::class, function (SendCustomerStatementMail $mail) use ($customer, &$sent) {
+ $sent = $mail;
+
+ return $mail->data['customer']->is($customer)
+ && $mail->data['from'] === 'configured@example.test'
+ && $mail->data['from_name'] === 'Configured Sender'
+ && str_ends_with($mail->data['filename'], '.pdf')
+ && $mail->data['pdf']->output() !== '';
+ });
+
+ $sent->build();
+
+ expect(EmailLog::query()
+ ->where('mailable_type', $customer->getMorphClass())
+ ->where('mailable_id', $customer->id)
+ ->where('from', 'configured@example.test')
+ ->exists())->toBeTrue();
+});
+
+test('statement email ignores a submitted sender address', function () {
+ Mail::fake();
+ config([
+ 'mail.from.address' => 'configured@example.test',
+ 'mail.from.name' => 'Configured Sender',
+ ]);
+ $customer = statementCustomer();
+
+ postJson("/api/v1/customers/{$customer->id}/statement/send", [
+ 'subject' => 'Statement',
+ 'body' => 'Your statement is attached.',
+ 'from' => 'spoofed@example.test',
+ 'to' => $customer->email,
+ ])->assertOk();
+
+ Mail::assertSent(SendCustomerStatementMail::class, fn (SendCustomerStatementMail $mail) => $mail->data['from'] === 'configured@example.test'
+ && $mail->data['from_name'] === 'Configured Sender');
+});
+
+test('the authenticated report route streams the customer statement PDF', function () {
+ $customer = statementCustomer();
+ statementInvoice($customer, '2026-01-10');
+
+ get("/reports/customers/{$customer->id}/statement?from_date=2026-01-01&to_date=2026-01-31")
+ ->assertOk()
+ ->assertHeader('content-type', 'application/pdf');
+});
+
+test('outstanding statement PDF preview includes account totals', function () {
+ $customer = statementCustomer();
+ statementInvoice($customer, '2026-01-10');
+ statementPayment($customer, '2026-01-15', 250);
+
+ get("/reports/customers/{$customer->id}/statement?type=outstanding&as_of=2026-01-31&preview=1")
+ ->assertOk()
+ ->assertSee('Gross invoice due')
+ ->assertSee('Available credit')
+ ->assertSee('Net account balance');
+});
diff --git a/tests/Feature/Marketplace/MarketplacePairingTest.php b/tests/Feature/Marketplace/MarketplacePairingTest.php
new file mode 100644
index 00000000..f03a6c7e
--- /dev/null
+++ b/tests/Feature/Marketplace/MarketplacePairingTest.php
@@ -0,0 +1,97 @@
+ true, '--class' => 'DatabaseSeeder']);
+ Artisan::call('db:seed', ['--force' => true, '--class' => 'DemoSeeder']);
+ Sanctum::actingAs(User::findOrFail(1), ['*']);
+ config()->set('invoiceshelf.base_url', 'https://marketplace.test');
+});
+
+it('starts pairing with installation compatibility metadata', function () {
+ Http::fake([
+ 'https://marketplace.test/api/marketplace/v1/device/code' => Http::response([
+ 'success' => true, 'device_code' => 'device-code', 'user_code' => 'ABCD1234',
+ 'verification_uri' => 'https://marketplace.test/pair', 'expires_in' => 600, 'interval' => 5,
+ ], 201),
+ ]);
+
+ postJson('/api/v1/modules/pairing/start')
+ ->assertCreated()
+ ->assertJsonPath('user_code', 'ABCD1234');
+
+ Http::assertSent(function ($request): bool {
+ $data = $request->data();
+
+ return $request->url() === 'https://marketplace.test/api/marketplace/v1/device/code'
+ && filled($data['installation_name'] ?? null)
+ && isset($data['module_api_version'], $data['php_version'], $data['extensions'])
+ && collect($data['extensions'])->every(
+ fn ($extension): bool => is_string($extension)
+ && preg_match('/^ext-[a-z0-9][a-z0-9_-]*$/', $extension) === 1,
+ );
+ });
+});
+
+it('stores only the encrypted opaque installation token after pairing', function () {
+ Http::fake([
+ 'https://marketplace.test/api/marketplace/v1/device/code' => Http::response([
+ 'success' => true, 'device_code' => 'device-code', 'user_code' => 'ABCD1234',
+ 'verification_uri' => 'https://marketplace.test/pair', 'expires_in' => 600, 'interval' => 5,
+ ], 201),
+ 'https://marketplace.test/api/marketplace/v1/device/token' => Http::response([
+ 'success' => true, 'installation_token' => 'opaque-installation-token', 'installation' => ['id' => 17, 'name' => 'Local'],
+ ]),
+ ]);
+
+ postJson('/api/v1/modules/pairing/start')->assertCreated();
+ postJson('/api/v1/modules/pairing/poll')->assertOk()->assertJsonPath('status', 'paired');
+
+ $credential = MarketplaceCredential::query()->sole();
+ expect($credential->credential)->not->toContain('opaque-installation-token')
+ ->and(Crypt::decryptString($credential->credential))->toBe('opaque-installation-token')
+ ->and($credential->device_id)->toBe('17');
+});
+
+it('reports pending device approval without storing a credential', function () {
+ Http::fake([
+ 'https://marketplace.test/api/marketplace/v1/device/code' => Http::response([
+ 'success' => true, 'device_code' => 'device-code', 'user_code' => 'ABCD1234',
+ 'verification_uri' => 'https://marketplace.test/pair', 'expires_in' => 600, 'interval' => 5,
+ ], 201),
+ 'https://marketplace.test/api/marketplace/v1/device/token' => Http::response([
+ 'success' => false, 'error' => 'authorization_pending', 'interval' => 5,
+ ], 428),
+ ]);
+
+ postJson('/api/v1/modules/pairing/start')->assertCreated();
+ postJson('/api/v1/modules/pairing/poll')->assertOk()->assertJsonPath('status', 'pending');
+
+ expect(MarketplaceCredential::query()->doesntExist())->toBeTrue();
+});
+
+it('revokes the remote installation when disconnecting locally', function () {
+ MarketplaceCredential::query()->create([
+ 'credential' => Crypt::encryptString('opaque-installation-token'),
+ 'paired_at' => now(),
+ ]);
+ Http::fake([
+ 'https://marketplace.test/api/marketplace/v1/device' => Http::response(['success' => true]),
+ ]);
+
+ deleteJson('/api/v1/modules/pairing')->assertOk()->assertJsonPath('success', true);
+
+ expect(MarketplaceCredential::query()->exists())->toBeFalse();
+ Http::assertSent(fn ($request) => $request->method() === 'DELETE'
+ && $request->url() === 'https://marketplace.test/api/marketplace/v1/device'
+ && $request->hasHeader('Authorization', 'Bearer opaque-installation-token'));
+});
diff --git a/tests/Feature/PaymentAllocationTest.php b/tests/Feature/PaymentAllocationTest.php
new file mode 100644
index 00000000..77ae4c8b
--- /dev/null
+++ b/tests/Feature/PaymentAllocationTest.php
@@ -0,0 +1,364 @@
+ true, '--class' => 'DatabaseSeeder']);
+ Artisan::call('db:seed', ['--force' => true, '--class' => 'DemoSeeder']);
+});
+
+function allocatableInvoice(int $amount = 10000): Invoice
+{
+ return Invoice::factory()->create([
+ 'type' => Invoice::TYPE_INVOICE,
+ 'status' => Invoice::STATUS_SENT,
+ 'sent' => true,
+ 'viewed' => false,
+ 'paid_status' => Invoice::STATUS_UNPAID,
+ 'sub_total' => $amount,
+ 'total' => $amount,
+ 'due_amount' => $amount,
+ 'exchange_rate' => 1,
+ 'base_sub_total' => $amount,
+ 'base_total' => $amount,
+ 'base_due_amount' => $amount,
+ ]);
+}
+
+function allocationPayment(Invoice $invoice, int $amount): Payment
+{
+ return Payment::factory()->create([
+ ...$invoice->only(['company_id', 'customer_id', 'currency_id']),
+ 'amount' => $amount,
+ 'base_amount' => $amount,
+ 'exchange_rate' => 1,
+ ]);
+}
+
+test('a payment can be allocated across multiple invoices and retain credit', function () {
+ $first = allocatableInvoice(300);
+ $second = allocatableInvoice(400);
+ $third = allocatableInvoice(200);
+
+ foreach ([$second, $third] as $invoice) {
+ $invoice->update([
+ 'company_id' => $first->company_id,
+ 'customer_id' => $first->customer_id,
+ 'currency_id' => $first->currency_id,
+ ]);
+ }
+
+ $payment = allocationPayment($first, 1000);
+
+ app(PaymentAllocationService::class)->replace($payment, [
+ ['invoice_id' => $first->id, 'amount' => 300],
+ ['invoice_id' => $second->id, 'amount' => 400],
+ ['invoice_id' => $third->id, 'amount' => 200],
+ ]);
+
+ expect(PaymentAllocation::where('payment_id', $payment->id)->sum('amount'))->toBe(900)
+ ->and($first->fresh()->due_amount)->toBe(0)
+ ->and($second->fresh()->due_amount)->toBe(0)
+ ->and($third->fresh()->due_amount)->toBe(0)
+ ->and($payment->fresh()->amount - PaymentAllocation::where('payment_id', $payment->id)->sum('amount'))->toBe(100);
+});
+
+test('replacing allocations recalculates both old and new invoice balances', function () {
+ $first = allocatableInvoice(500);
+ $second = allocatableInvoice(500);
+ $second->update([
+ 'company_id' => $first->company_id,
+ 'customer_id' => $first->customer_id,
+ 'currency_id' => $first->currency_id,
+ ]);
+ $payment = allocationPayment($first, 500);
+ $service = app(PaymentAllocationService::class);
+
+ $service->replace($payment, [['invoice_id' => $first->id, 'amount' => 500]]);
+ $service->replace($payment, [['invoice_id' => $second->id, 'amount' => 500]]);
+
+ expect($first->fresh()->due_amount)->toBe(500)
+ ->and($first->fresh()->paid_status)->toBe(Invoice::STATUS_UNPAID)
+ ->and($second->fresh()->due_amount)->toBe(0)
+ ->and($second->fresh()->paid_status)->toBe(Invoice::STATUS_PAID);
+});
+
+test('allocation over an invoice balance is rejected without changing allocations', function () {
+ $invoice = allocatableInvoice(100);
+ $payment = allocationPayment($invoice, 101);
+
+ expect(fn () => app(PaymentAllocationService::class)->replace($payment, [
+ ['invoice_id' => $invoice->id, 'amount' => 101],
+ ]))->toThrow(ValidationException::class);
+
+ expect(PaymentAllocation::where('payment_id', $payment->id)->exists())->toBeFalse()
+ ->and($invoice->fresh()->due_amount)->toBe(100);
+});
+
+test('allocations reject mismatched currencies, customers, drafts, and credit notes', function () {
+ $invoice = allocatableInvoice(100);
+ $payment = allocationPayment($invoice, 100);
+ $otherCustomer = Customer::factory()->create([
+ 'company_id' => $invoice->company_id,
+ 'currency_id' => $invoice->currency_id,
+ ]);
+ $otherCurrency = Currency::query()->whereKeyNot($invoice->currency_id)->firstOrFail();
+
+ $invalidInvoices = collect([
+ allocatableInvoice(100)->forceFill([
+ 'company_id' => $invoice->company_id,
+ 'customer_id' => $otherCustomer->id,
+ 'currency_id' => $invoice->currency_id,
+ ]),
+ allocatableInvoice(100)->forceFill([
+ 'company_id' => $invoice->company_id,
+ 'customer_id' => $invoice->customer_id,
+ 'currency_id' => $otherCurrency->id,
+ ]),
+ allocatableInvoice(100)->forceFill([
+ ...$invoice->only(['company_id', 'customer_id', 'currency_id']),
+ 'status' => Invoice::STATUS_DRAFT,
+ ]),
+ allocatableInvoice(100)->forceFill([
+ ...$invoice->only(['company_id', 'customer_id', 'currency_id']),
+ 'type' => Invoice::TYPE_CREDIT_NOTE,
+ ]),
+ ])->each->save();
+
+ foreach ($invalidInvoices as $invalidInvoice) {
+ expect(fn () => app(PaymentAllocationService::class)->replace($payment, [[
+ 'invoice_id' => $invalidInvoice->id,
+ 'amount' => 100,
+ ]]))->toThrow(ValidationException::class);
+ }
+
+ expect($payment->allocations()->exists())->toBeFalse();
+});
+
+test('duplicate targets and totals above the payment amount are rejected', function () {
+ $first = allocatableInvoice(100);
+ $second = allocatableInvoice(100);
+ $second->update([
+ 'company_id' => $first->company_id,
+ 'customer_id' => $first->customer_id,
+ 'currency_id' => $first->currency_id,
+ ]);
+ $payment = allocationPayment($first, 100);
+ $service = app(PaymentAllocationService::class);
+
+ expect(fn () => $service->replace($payment, [
+ ['invoice_id' => $first->id, 'amount' => 50],
+ ['invoice_id' => $first->id, 'amount' => 50],
+ ]))->toThrow(ValidationException::class)
+ ->and(fn () => $service->replace($payment, [
+ ['invoice_id' => $first->id, 'amount' => 60],
+ ['invoice_id' => $second->id, 'amount' => 50],
+ ]))->toThrow(ValidationException::class);
+});
+
+test('payment PDF generation is deferred until a successful allocation transaction commits', function () {
+ Queue::fake();
+ $invoice = allocatableInvoice(100);
+
+ DB::transaction(function () use ($invoice): void {
+ $payment = allocationPayment($invoice, 100);
+ app(PaymentAllocationService::class)->replace($payment, [
+ ['invoice_id' => $invoice->id, 'amount' => 100],
+ ]);
+
+ Queue::assertNothingPushed();
+ });
+
+ Queue::assertPushed(GeneratePaymentPdfJob::class);
+
+ Queue::fake();
+
+ expect(fn () => DB::transaction(function () use ($invoice): void {
+ $payment = allocationPayment($invoice, 100);
+
+ app(PaymentAllocationService::class)->replace($payment, [
+ ['invoice_id' => $invoice->id, 'amount' => 101],
+ ]);
+ }))->toThrow(ValidationException::class);
+
+ Queue::assertNothingPushed();
+});
+
+test('customer credit can be applied atomically to multiple invoices', function () {
+ $first = allocatableInvoice(100);
+ $second = allocatableInvoice(200);
+ $second->update([
+ 'company_id' => $first->company_id,
+ 'customer_id' => $first->customer_id,
+ 'currency_id' => $first->currency_id,
+ ]);
+ $payment = allocationPayment($first, 300);
+
+ app(PaymentAllocationService::class)->applyCustomerCredits(
+ $first->company_id,
+ $first->customer_id,
+ [
+ ['payment_id' => $payment->id, 'invoice_id' => $first->id, 'amount' => 100],
+ ['payment_id' => $payment->id, 'invoice_id' => $second->id, 'amount' => 200],
+ ],
+ );
+
+ expect(PaymentAllocation::where('payment_id', $payment->id)->sum('amount'))->toBe(300)
+ ->and($first->fresh()->due_amount)->toBe(0)
+ ->and($second->fresh()->due_amount)->toBe(0);
+});
+
+test('creating payments does not authorize reallocating existing customer credit', function () {
+ $invoice = allocatableInvoice(100);
+ $payment = allocationPayment($invoice, 100);
+ $user = User::factory()->create();
+ $user->companies()->attach($invoice->company_id);
+
+ BouncerFacade::scope()->to($invoice->company_id);
+ BouncerFacade::allow($user)->to('view-customer', Customer::class);
+ BouncerFacade::allow($user)->to('create-payment', Payment::class);
+ Sanctum::actingAs($user, ['*']);
+ $this->withHeaders(['company' => $invoice->company_id]);
+
+ $this->postJson("/api/v1/customers/{$invoice->customer_id}/credit-allocations", [
+ 'allocations' => [[
+ 'payment_id' => $payment->id,
+ 'invoice_id' => $invoice->id,
+ 'amount' => 100,
+ ]],
+ ])->assertForbidden();
+});
+
+test('the migration restores an invalid legacy link to unapplied credit and recalculates its invoice', function () {
+ $invoice = allocatableInvoice(100);
+ $invoice->update([
+ 'due_amount' => 0,
+ 'base_due_amount' => 0,
+ 'status' => Invoice::STATUS_COMPLETED,
+ 'paid_status' => Invoice::STATUS_PAID,
+ ]);
+ $otherCustomer = Customer::factory()->create([
+ 'company_id' => $invoice->company_id,
+ 'currency_id' => $invoice->currency_id,
+ ]);
+ $payment = Payment::factory()->create([
+ 'company_id' => $invoice->company_id,
+ 'customer_id' => $otherCustomer->id,
+ 'currency_id' => $invoice->currency_id,
+ 'amount' => 100,
+ 'base_amount' => 100,
+ 'exchange_rate' => 1,
+ ]);
+
+ Schema::table('payments', fn ($table) => $table->unsignedInteger('invoice_id')->nullable()->index());
+ Payment::query()->whereKey($payment->id)->update(['invoice_id' => $invoice->id]);
+
+ (require database_path('migrations/2026_08_02_230400_replace_payment_invoice_with_allocations.php'))->up();
+
+ expect(Schema::hasColumn('payments', 'invoice_id'))->toBeFalse()
+ ->and(PaymentAllocation::where('payment_id', $payment->id)->exists())->toBeFalse()
+ ->and($invoice->fresh()->due_amount)->toBe(100)
+ ->and($invoice->fresh()->status)->toBe(Invoice::STATUS_SENT)
+ ->and($invoice->fresh()->paid_status)->toBe(Invoice::STATUS_UNPAID);
+});
+
+test('the migration leaves an invalid legacy credit note target unchanged', function () {
+ $creditNote = allocatableInvoice(100);
+ $creditNote->update([
+ 'type' => Invoice::TYPE_CREDIT_NOTE,
+ 'status' => Invoice::STATUS_SENT,
+ 'paid_status' => Invoice::STATUS_UNPAID,
+ 'due_amount' => 100,
+ 'base_due_amount' => 100,
+ ]);
+ $payment = allocationPayment($creditNote, 100);
+
+ Schema::table('payments', fn ($table) => $table->unsignedInteger('invoice_id')->nullable()->index());
+ Payment::query()->whereKey($payment->id)->update(['invoice_id' => $creditNote->id]);
+
+ (require database_path('migrations/2026_08_02_230400_replace_payment_invoice_with_allocations.php'))->up();
+
+ expect(Schema::hasColumn('payments', 'invoice_id'))->toBeFalse()
+ ->and(PaymentAllocation::where('payment_id', $payment->id)->exists())->toBeFalse()
+ ->and($creditNote->fresh()->status)->toBe(Invoice::STATUS_SENT)
+ ->and($creditNote->fresh()->paid_status)->toBe(Invoice::STATUS_UNPAID)
+ ->and($creditNote->fresh()->due_amount)->toBe(100);
+});
+
+test('the migration allocates only the payable portion of an overpaid legacy payment', function () {
+ $invoice = allocatableInvoice(100);
+ $payment = allocationPayment($invoice, 150);
+
+ Schema::table('payments', fn ($table) => $table->unsignedInteger('invoice_id')->nullable()->index());
+ Payment::query()->whereKey($payment->id)->update(['invoice_id' => $invoice->id]);
+
+ (require database_path('migrations/2026_08_02_230400_replace_payment_invoice_with_allocations.php'))->up();
+
+ expect(PaymentAllocation::where('payment_id', $payment->id)->sum('amount'))->toBe(100)
+ ->and($invoice->fresh()->due_amount)->toBe(0)
+ ->and($invoice->fresh()->status)->toBe(Invoice::STATUS_COMPLETED)
+ ->and($payment->amount - PaymentAllocation::where('payment_id', $payment->id)->sum('amount'))->toBe(50);
+});
+
+test('the migration refuses rollback when a payment retains unapplied credit', function () {
+ $invoice = allocatableInvoice(100);
+ $payment = allocationPayment($invoice, 150);
+ PaymentAllocation::create([
+ 'payment_id' => $payment->id,
+ 'invoice_id' => $invoice->id,
+ 'amount' => 100,
+ 'base_amount' => 100,
+ ]);
+
+ $migration = require database_path('migrations/2026_08_02_230400_replace_payment_invoice_with_allocations.php');
+
+ expect(fn () => $migration->down())->toThrow(RuntimeException::class, 'Cannot roll back payment allocations with unapplied customer credit.');
+});
+
+test('a partial migration run with no legacy column still verifies existing allocations', function () {
+ $invoice = Invoice::factory()->create([
+ 'type' => Invoice::TYPE_INVOICE,
+ 'status' => Invoice::STATUS_DRAFT,
+ ]);
+ $payment = allocationPayment($invoice, 100);
+ PaymentAllocation::create([
+ 'payment_id' => $payment->id,
+ 'invoice_id' => $invoice->id,
+ 'amount' => 100,
+ 'base_amount' => 100,
+ ]);
+
+ $migration = require database_path('migrations/2026_08_02_230400_replace_payment_invoice_with_allocations.php');
+
+ expect(Schema::hasColumn('payments', 'invoice_id'))->toBeFalse()
+ ->and(fn () => $migration->up())->toThrow(RuntimeException::class, 'allocation target is not payable');
+});
+
+test('a partial migration run rejects allocations whose payment no longer exists', function () {
+ $invoice = allocatableInvoice(100);
+ PaymentAllocation::create([
+ 'payment_id' => 999999,
+ 'invoice_id' => $invoice->id,
+ 'amount' => 100,
+ 'base_amount' => 100,
+ ]);
+
+ $migration = require database_path('migrations/2026_08_02_230400_replace_payment_invoice_with_allocations.php');
+
+ expect(Schema::hasColumn('payments', 'invoice_id'))->toBeFalse()
+ ->and(fn () => $migration->up())->toThrow(RuntimeException::class, 'allocation payment is missing');
+});
diff --git a/tests/Feature/Pdf/TaxSummaryReportTest.php b/tests/Feature/Pdf/TaxSummaryReportTest.php
new file mode 100644
index 00000000..e8f491a3
--- /dev/null
+++ b/tests/Feature/Pdf/TaxSummaryReportTest.php
@@ -0,0 +1,190 @@
+ true, '--class' => 'DatabaseSeeder']);
+ Artisan::call('db:seed', ['--force' => true, '--class' => 'DemoSeeder']);
+
+ $user = User::findOrFail(1);
+ $this->company = $user->companies()->firstOrFail();
+
+ $this->withHeaders(['company' => $this->company->id]);
+ Sanctum::actingAs($user, ['*']);
+});
+
+function taxSummaryPreview(string $companyHash): TestResponse
+{
+ return get("/reports/tax-summary/{$companyHash}?from_date=2026-01-01&to_date=2026-01-31&preview=true");
+}
+
+function reportTax(TaxType $taxType, int $companyId, array $attributes = []): Tax
+{
+ return Tax::factory()->create(array_merge([
+ 'tax_type_id' => $taxType->id,
+ 'company_id' => $companyId,
+ 'base_amount' => 0,
+ ], $attributes));
+}
+
+function reportInvoice(int $companyId, string $date, string $paidStatus): Invoice
+{
+ return Invoice::factory()->create([
+ 'company_id' => $companyId,
+ 'invoice_date' => $date,
+ 'paid_status' => $paidStatus,
+ ]);
+}
+
+function reportExpense(int $companyId, string $date): Expense
+{
+ return Expense::factory()->create([
+ 'company_id' => $companyId,
+ 'expense_date' => $date,
+ ]);
+}
+
+test('groups paid sales taxes and dated expense taxes separately for a company', function () {
+ $outputTaxType = TaxType::factory()->create([
+ 'company_id' => $this->company->id,
+ 'name' => 'Output VAT',
+ ]);
+ $inputTaxType = TaxType::factory()->create([
+ 'company_id' => $this->company->id,
+ 'name' => 'Input VAT',
+ 'transaction_type' => TaxType::TRANSACTION_TYPE_PURCHASES,
+ ]);
+
+ $paidInvoice = reportInvoice($this->company->id, '2026-01-15', Invoice::STATUS_PAID);
+ reportTax($outputTaxType, $this->company->id, [
+ 'invoice_id' => $paidInvoice->id,
+ 'base_amount' => 300,
+ ]);
+
+ $paidInvoiceItem = InvoiceItem::factory()->create([
+ 'company_id' => $this->company->id,
+ 'invoice_id' => $paidInvoice->id,
+ ]);
+ reportTax($outputTaxType, $this->company->id, [
+ 'invoice_item_id' => $paidInvoiceItem->id,
+ 'base_amount' => 200,
+ ]);
+
+ $unpaidInvoice = reportInvoice($this->company->id, '2026-01-15', Invoice::STATUS_UNPAID);
+ reportTax($outputTaxType, $this->company->id, [
+ 'invoice_id' => $unpaidInvoice->id,
+ 'base_amount' => 500,
+ ]);
+ $outOfRangeInvoice = reportInvoice($this->company->id, '2026-02-01', Invoice::STATUS_PAID);
+ reportTax($outputTaxType, $this->company->id, [
+ 'invoice_id' => $outOfRangeInvoice->id,
+ 'base_amount' => 600,
+ ]);
+
+ $expense = reportExpense($this->company->id, '2026-01-20');
+ reportTax($inputTaxType, $this->company->id, ['expense_id' => $expense->id, 'base_amount' => 150]);
+ reportTax($inputTaxType, $this->company->id, ['expense_id' => $expense->id, 'base_amount' => 50]);
+ $outOfRangeExpense = reportExpense($this->company->id, '2026-02-01');
+ reportTax($inputTaxType, $this->company->id, ['expense_id' => $outOfRangeExpense->id, 'base_amount' => 125]);
+
+ $otherCompany = Company::factory()->create();
+ $otherTaxType = TaxType::factory()->create(['company_id' => $otherCompany->id]);
+ $otherInvoice = reportInvoice($otherCompany->id, '2026-01-15', Invoice::STATUS_PAID);
+ $otherInvoiceItem = InvoiceItem::factory()->create([
+ 'company_id' => $otherCompany->id,
+ 'invoice_id' => $otherInvoice->id,
+ ]);
+ reportTax($otherTaxType, $otherCompany->id, [
+ 'invoice_item_id' => $otherInvoiceItem->id,
+ 'base_amount' => 999,
+ ]);
+
+ $response = taxSummaryPreview($this->company->unique_hash);
+
+ $response->assertOk()
+ ->assertViewHas('taxTypes')
+ ->assertViewHas('totalTaxAmount', 500)
+ ->assertViewHas('expenseTaxTypes')
+ ->assertViewHas('totalExpenseTaxAmount', 200)
+ ->assertViewHas('netTaxAmount', 300);
+
+ expect($response->viewData('taxTypes')->mapWithKeys(
+ fn (Tax $tax) => [$tax->tax_type_id => (int) $tax->total_tax_amount]
+ )->all())->toBe([$outputTaxType->id => 500])
+ ->and($response->viewData('expenseTaxTypes')->mapWithKeys(
+ fn (Tax $tax) => [$tax->tax_type_id => (int) $tax->total_tax_amount]
+ )->all())->toBe([$inputTaxType->id => 200]);
+});
+
+test('keeps output tax values available to custom tax summary templates', function () {
+ $outputTaxType = TaxType::factory()->create(['company_id' => $this->company->id]);
+ $inputTaxType = TaxType::factory()->create([
+ 'company_id' => $this->company->id,
+ 'transaction_type' => TaxType::TRANSACTION_TYPE_PURCHASES,
+ ]);
+ $invoice = reportInvoice($this->company->id, '2026-01-15', Invoice::STATUS_PAID);
+ $expense = reportExpense($this->company->id, '2026-01-15');
+
+ reportTax($outputTaxType, $this->company->id, ['invoice_id' => $invoice->id, 'base_amount' => 500]);
+ reportTax($inputTaxType, $this->company->id, ['expense_id' => $expense->id, 'base_amount' => 200]);
+
+ $response = taxSummaryPreview($this->company->unique_hash);
+
+ expect($response->viewData('taxTypes'))->toHaveCount(1)
+ ->and($response->viewData('totalTaxAmount'))->toBe(500)
+ ->and($response->viewData('expenseTaxTypes'))->toHaveCount(1)
+ ->and($response->viewData('totalExpenseTaxAmount'))->toBe(200)
+ ->and($response->viewData('netTaxAmount'))->toBe(300);
+});
+
+test('calculates the correct signed net tax state', function (int $output, int $input, int $net, string $label) {
+ $outputTaxType = TaxType::factory()->create(['company_id' => $this->company->id]);
+ $inputTaxType = TaxType::factory()->create([
+ 'company_id' => $this->company->id,
+ 'transaction_type' => TaxType::TRANSACTION_TYPE_PURCHASES,
+ ]);
+
+ if ($output > 0) {
+ $invoice = reportInvoice($this->company->id, '2026-01-15', Invoice::STATUS_PAID);
+ reportTax($outputTaxType, $this->company->id, ['invoice_id' => $invoice->id, 'base_amount' => $output]);
+ }
+
+ if ($input > 0) {
+ $expense = reportExpense($this->company->id, '2026-01-15');
+ reportTax($inputTaxType, $this->company->id, ['expense_id' => $expense->id, 'base_amount' => $input]);
+ }
+
+ $response = taxSummaryPreview($this->company->unique_hash);
+
+ $response->assertOk()
+ ->assertViewHas('netTaxAmount', $net)
+ ->assertSee(__($label));
+})->with([
+ 'payable' => [500, 200, 300, 'pdf_tax_payable_label'],
+ 'refundable' => [200, 500, -300, 'pdf_tax_refundable_label'],
+ 'balanced' => [500, 500, 0, 'pdf_tax_balance_label'],
+]);
+
+test('reports a zero balance when the selected range has no taxes', function () {
+ $response = taxSummaryPreview($this->company->unique_hash);
+
+ $response->assertOk()
+ ->assertViewHas('totalTaxAmount', 0)
+ ->assertViewHas('totalExpenseTaxAmount', 0)
+ ->assertViewHas('netTaxAmount', 0)
+ ->assertSee(__('pdf_tax_balance_label'));
+
+ expect($response->viewData('taxTypes'))->toBeEmpty()
+ ->and($response->viewData('expenseTaxTypes'))->toBeEmpty();
+});
diff --git a/tests/Feature/RealisticDemoSeederTest.php b/tests/Feature/RealisticDemoSeederTest.php
new file mode 100644
index 00000000..389879b2
--- /dev/null
+++ b/tests/Feature/RealisticDemoSeederTest.php
@@ -0,0 +1,60 @@
+ true, '--class' => 'DatabaseSeeder']);
+ Artisan::call('db:seed', ['--force' => true, '--class' => 'DemoSeeder']);
+ Queue::fake();
+});
+
+test('realistic demo payments are allocated and leave invoice balances consistent', function () {
+ Artisan::call('db:seed', ['--class' => 'RealisticDemoSeeder', '--force' => true]);
+
+ $company = User::where('email', 'demo@invoiceshelf.com')->firstOrFail()->companies()->firstOrFail();
+ $payments = Payment::query()->where('company_id', $company->id)->with('allocations')->get();
+ $allocatedInvoices = Invoice::query()
+ ->where('company_id', $company->id)
+ ->whereHas('allocations')
+ ->with('allocations')
+ ->get();
+
+ expect($payments)->not->toBeEmpty()
+ ->and($payments->every(fn (Payment $payment) => $payment->allocations->isNotEmpty()))->toBeTrue()
+ ->and(PaymentAllocation::query()->whereIn('payment_id', $payments->modelKeys())->count())->toBe($payments->count());
+
+ foreach ($allocatedInvoices as $invoice) {
+ expect((int) $invoice->due_amount)
+ ->toBe(max(0, (int) $invoice->total - (int) $invoice->allocations->sum('amount')));
+ }
+});
+
+test('realistic demo provides current-month account activity for every customer', function () {
+ Artisan::call('db:seed', ['--class' => 'RealisticDemoSeeder', '--force' => true]);
+
+ $company = User::where('email', 'demo@invoiceshelf.com')->firstOrFail()->companies()->firstOrFail();
+ $from = Carbon::now()->startOfMonth();
+ $to = Carbon::now();
+ $statementQuery = app(CustomerStatementQuery::class);
+
+ Customer::query()
+ ->where('company_id', $company->id)
+ ->each(function (Customer $customer) use ($statementQuery, $from, $to): void {
+ $statement = $statementQuery->statement(
+ $customer,
+ CustomerStatementQuery::TYPE_ACTIVITY,
+ $from,
+ $to,
+ );
+
+ expect($statement['entries']->total())->toBeGreaterThan(0);
+ });
+});