From 9a83b7364f19f4226a914d2282e9c753ecfcc392 Mon Sep 17 00:00:00 2001 From: Darko Gjorgjijoski Date: Sat, 22 Aug 2026 12:09:51 +0200 Subject: [PATCH] feat(tests): fresh triage test files --- tests/Feature/Admin/AdminSettingsTest.php | 342 ++++ tests/Feature/Admin/CreditNoteSchemaTest.php | 95 ++ tests/Feature/Admin/CreditNoteTest.php | 1499 +++++++++++++++++ ...CompanyMailConfigurationControllerTest.php | 110 ++ tests/Feature/Customer/AuthTest.php | 83 + tests/Feature/CustomerStatementTest.php | 239 +++ .../Marketplace/MarketplacePairingTest.php | 97 ++ tests/Feature/PaymentAllocationTest.php | 364 ++++ tests/Feature/Pdf/TaxSummaryReportTest.php | 190 +++ tests/Feature/RealisticDemoSeederTest.php | 60 + 10 files changed, 3079 insertions(+) create mode 100644 tests/Feature/Admin/AdminSettingsTest.php create mode 100644 tests/Feature/Admin/CreditNoteSchemaTest.php create mode 100644 tests/Feature/Admin/CreditNoteTest.php create mode 100644 tests/Feature/Company/CompanyMailConfigurationControllerTest.php create mode 100644 tests/Feature/Customer/AuthTest.php create mode 100644 tests/Feature/CustomerStatementTest.php create mode 100644 tests/Feature/Marketplace/MarketplacePairingTest.php create mode 100644 tests/Feature/PaymentAllocationTest.php create mode 100644 tests/Feature/Pdf/TaxSummaryReportTest.php create mode 100644 tests/Feature/RealisticDemoSeederTest.php 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); + }); +});