'DatabaseSeeder', '--force' => true]); Artisan::call('db:seed', ['--class' => 'DemoSeeder', '--force' => true]); $user = User::find(1); $this->withHeaders([ 'company' => $user->companies()->first()->id, ]); 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([ 'invoice_id' => $invoice->id, 'customer_id' => $invoice->customer_id, 'amount' => $amount, ]); $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', ]); }); });