From 666cb8ae87599da90e57c791e18503e354e5d6cc Mon Sep 17 00:00:00 2001 From: Darko Gjorgjijoski Date: Sat, 22 Aug 2026 12:09:51 +0200 Subject: [PATCH] chore(tests): remove the triage test files retaining legacy attribution --- 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 deletions(-) delete mode 100644 tests/Feature/Admin/AdminSettingsTest.php delete mode 100644 tests/Feature/Admin/CreditNoteSchemaTest.php delete mode 100644 tests/Feature/Admin/CreditNoteTest.php delete mode 100644 tests/Feature/Company/CompanyMailConfigurationControllerTest.php delete mode 100644 tests/Feature/Customer/AuthTest.php delete mode 100644 tests/Feature/CustomerStatementTest.php delete mode 100644 tests/Feature/Marketplace/MarketplacePairingTest.php delete mode 100644 tests/Feature/PaymentAllocationTest.php delete mode 100644 tests/Feature/Pdf/TaxSummaryReportTest.php delete mode 100644 tests/Feature/RealisticDemoSeederTest.php diff --git a/tests/Feature/Admin/AdminSettingsTest.php b/tests/Feature/Admin/AdminSettingsTest.php deleted file mode 100644 index 274de0aa..00000000 --- a/tests/Feature/Admin/AdminSettingsTest.php +++ /dev/null @@ -1,342 +0,0 @@ - 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 deleted file mode 100644 index 52a1725b..00000000 --- a/tests/Feature/Admin/CreditNoteSchemaTest.php +++ /dev/null @@ -1,95 +0,0 @@ - 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 deleted file mode 100644 index f75a89cf..00000000 --- a/tests/Feature/Admin/CreditNoteTest.php +++ /dev/null @@ -1,1499 +0,0 @@ - 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 deleted file mode 100644 index 3bd12677..00000000 --- a/tests/Feature/Company/CompanyMailConfigurationControllerTest.php +++ /dev/null @@ -1,110 +0,0 @@ - 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 deleted file mode 100644 index ff1eb485..00000000 --- a/tests/Feature/Customer/AuthTest.php +++ /dev/null @@ -1,83 +0,0 @@ - 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 deleted file mode 100644 index d6d23222..00000000 --- a/tests/Feature/CustomerStatementTest.php +++ /dev/null @@ -1,239 +0,0 @@ - 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 deleted file mode 100644 index f03a6c7e..00000000 --- a/tests/Feature/Marketplace/MarketplacePairingTest.php +++ /dev/null @@ -1,97 +0,0 @@ - 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 deleted file mode 100644 index 77ae4c8b..00000000 --- a/tests/Feature/PaymentAllocationTest.php +++ /dev/null @@ -1,364 +0,0 @@ - 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 deleted file mode 100644 index e8f491a3..00000000 --- a/tests/Feature/Pdf/TaxSummaryReportTest.php +++ /dev/null @@ -1,190 +0,0 @@ - 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 deleted file mode 100644 index 389879b2..00000000 --- a/tests/Feature/RealisticDemoSeederTest.php +++ /dev/null @@ -1,60 +0,0 @@ - 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); - }); -});