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