diff --git a/README.md b/README.md index 890f13c..3aa7a58 100644 --- a/README.md +++ b/README.md @@ -28,3 +28,5 @@ pnpm typecheck # tsc --noEmit ## Invoice lifecycle `draft` → (`issue`) → `open` → (`pay`) → `paid`; `draft`/`open` → (`void`) → `void`. +A `paid` invoice is voided by crediting whatever is left on it (credit notes `CN-0001`, ..., via +`POST /invoices/:id/credit-notes` `{ "amount": cents }`; list with `GET`), then it becomes `void`. diff --git a/src/domain/credit-note.ts b/src/domain/credit-note.ts new file mode 100644 index 0000000..e2047c3 --- /dev/null +++ b/src/domain/credit-note.ts @@ -0,0 +1,39 @@ +import { ConflictError, ValidationError } from "./errors"; +import { computeTotals, type Invoice } from "./invoice"; +import { assertCents, type Cents, type Currency, sum } from "./money"; + +export type CreditNote = { + id: string; + /** CN-0001, CN-0002, ... */ + number: string; + invoiceId: string; + currency: Currency; + /** cents, always > 0 */ + amount: Cents; + createdAt: string; +}; + +/** What can still be credited on an invoice: its total minus the credit notes already issued. */ +export function remainingCredit(invoice: Invoice, notes: CreditNote[]): Cents { + return computeTotals(invoice).total - sum(notes.map((n) => n.amount)); +} + +/** Checks a credit-note request against the invoice and returns the amount. Throws before any number is taken. */ +export function parseCreditAmount(body: unknown, invoice: Invoice, notes: CreditNote[]): Cents { + if (typeof body !== "object" || body === null) { + throw new ValidationError("body must be an object"); + } + const amount = assertCents((body as Record).amount, "amount", { min: 1 }); + if (invoice.status !== "paid") { + throw new ConflictError(`invoice ${invoice.id} is ${invoice.status}, only paid invoices can be credited`); + } + const remaining = remainingCredit(invoice, notes); + if (amount > remaining) { + throw new ConflictError(`amount ${amount} exceeds the ${remaining} left to credit on invoice ${invoice.id}`); + } + return amount; +} + +export function creditNumber(n: number): string { + return `CN-${String(n).padStart(4, "0")}`; +} diff --git a/src/domain/invoice.ts b/src/domain/invoice.ts index 5b69fc9..60be8ac 100644 --- a/src/domain/invoice.ts +++ b/src/domain/invoice.ts @@ -52,8 +52,9 @@ export function markPaid(invoice: Invoice, now: Date): Invoice { return { ...invoice, status: "paid", paidAt: now.toISOString() }; } -export function voidInvoice(invoice: Invoice, now: Date): Invoice { - if (invoice.status === "paid" || invoice.status === "void") { +/** A paid invoice can be voided only once it is fully credited (`fullyCredited`). */ +export function voidInvoice(invoice: Invoice, now: Date, opts: { fullyCredited?: boolean } = {}): Invoice { + if ((invoice.status === "paid" && !opts.fullyCredited) || invoice.status === "void") { throw new ConflictError(`invoice ${invoice.id} is ${invoice.status} and cannot be voided`); } return { ...invoice, status: "void", voidedAt: now.toISOString() }; diff --git a/src/http/context.ts b/src/http/context.ts index c0a89d3..4b26d92 100644 --- a/src/http/context.ts +++ b/src/http/context.ts @@ -1,4 +1,5 @@ import type { Store } from "../store/json-store"; +import { creditNoteRepo } from "../store/credit-note-repo"; import { customerRepo } from "../store/customer-repo"; import { invoiceRepo } from "../store/invoice-repo"; @@ -7,8 +8,15 @@ export type AppContext = { now: () => Date; customers: ReturnType; invoices: ReturnType; + creditNotes: ReturnType; }; export function createContext(store: Store, now: () => Date = () => new Date()): AppContext { - return { store, now, customers: customerRepo(store), invoices: invoiceRepo(store) }; + return { + store, + now, + customers: customerRepo(store), + invoices: invoiceRepo(store), + creditNotes: creditNoteRepo(store), + }; } diff --git a/src/http/handlers/invoices.ts b/src/http/handlers/invoices.ts index 09959e1..d393c34 100644 --- a/src/http/handlers/invoices.ts +++ b/src/http/handlers/invoices.ts @@ -1,3 +1,4 @@ +import { type CreditNote, creditNumber, parseCreditAmount, remainingCredit } from "../../domain/credit-note"; import { ValidationError } from "../../domain/errors"; import { type Invoice, type InvoiceStatus, issue, markPaid, voidInvoice } from "../../domain/invoice"; import { parseNewInvoice } from "../../domain/validation"; @@ -51,8 +52,39 @@ export function invoiceRoutes(router: Router, ctx: AppContext): void { return { status: 200, body: invoiceView(ctx.invoices.save(paid)) }; }); + router.on("POST", "/invoices/:id/credit-notes", ({ params, body }) => { + const invoice = ctx.invoices.get(params.id); + const amount = parseCreditAmount(body, invoice, ctx.creditNotes.listForInvoice(invoice.id)); + return { status: 201, body: ctx.creditNotes.insert(newCreditNote(ctx, invoice, amount)) }; + }); + + router.on("GET", "/invoices/:id/credit-notes", ({ params }) => { + const invoice = ctx.invoices.get(params.id); + return { status: 200, body: ctx.creditNotes.listForInvoice(invoice.id) }; + }); + router.on("POST", "/invoices/:id/void", ({ params }) => { - const voided = voidInvoice(ctx.invoices.get(params.id), ctx.now()); + const current = ctx.invoices.get(params.id); + if (current.status !== "paid") { + return { status: 200, body: invoiceView(ctx.invoices.save(voidInvoice(current, ctx.now()))) }; + } + // Paid invoice: credit whatever is left, then void it. + const left = remainingCredit(current, ctx.creditNotes.listForInvoice(current.id)); + if (left > 0) ctx.creditNotes.insert(newCreditNote(ctx, current, left)); + const voided = voidInvoice(current, ctx.now(), { fullyCredited: true }); return { status: 200, body: invoiceView(ctx.invoices.save(voided)) }; }); } + +/** Takes the next number: call only once the amount has been validated. */ +function newCreditNote(ctx: AppContext, invoice: Invoice, amount: number): CreditNote { + const n = ctx.store.nextId("creditNote"); + return { + id: `cn_${n}`, + number: creditNumber(n), + invoiceId: invoice.id, + currency: invoice.currency, + amount, + createdAt: ctx.now().toISOString(), + }; +} diff --git a/src/store/credit-note-repo.ts b/src/store/credit-note-repo.ts new file mode 100644 index 0000000..c922142 --- /dev/null +++ b/src/store/credit-note-repo.ts @@ -0,0 +1,14 @@ +import type { CreditNote } from "../domain/credit-note"; +import type { Store } from "./json-store"; + +export function creditNoteRepo(store: Store) { + return { + listForInvoice(invoiceId: string): CreditNote[] { + return store.read().creditNotes.filter((n) => n.invoiceId === invoiceId); + }, + insert(note: CreditNote): CreditNote { + store.update((d) => ({ ...d, creditNotes: [...d.creditNotes, note] })); + return note; + }, + }; +} diff --git a/src/store/json-store.ts b/src/store/json-store.ts index e1daef5..03df910 100644 --- a/src/store/json-store.ts +++ b/src/store/json-store.ts @@ -14,9 +14,10 @@ export type Store = { function empty(): StoreData { return { schemaVersion: CURRENT_SCHEMA_VERSION, - counters: { customer: 0, invoice: 0, invoiceNumber: 0 }, + counters: { customer: 0, invoice: 0, invoiceNumber: 0, creditNote: 0 }, customers: [], invoices: [], + creditNotes: [], }; } diff --git a/src/store/migrations.ts b/src/store/migrations.ts index c8b73a9..e8545db 100644 --- a/src/store/migrations.ts +++ b/src/store/migrations.ts @@ -1,6 +1,6 @@ import type { StoreData } from "./types"; -export const CURRENT_SCHEMA_VERSION = 1; +export const CURRENT_SCHEMA_VERSION = 2; type RawData = Record & { schemaVersion?: number }; type Migration = (data: RawData) => RawData; @@ -21,9 +21,18 @@ const v0ToV1: Migration = (data) => { }; }; +/** v1 -> v2: credit notes (`creditNotes` list and `counters.creditNote`). */ +const v1ToV2: Migration = (data) => ({ + ...data, + schemaVersion: 2, + counters: { ...(data.counters as object), creditNote: 0 }, + creditNotes: [], +}); + /** migrations[n] upgrades a file from version n to n + 1. */ const migrations: Record = { 0: v0ToV1, + 1: v1ToV2, }; export function migrate(raw: unknown): StoreData { @@ -43,8 +52,9 @@ export function migrate(raw: unknown): StoreData { } return { schemaVersion: CURRENT_SCHEMA_VERSION, - counters: (data.counters as StoreData["counters"]) ?? { customer: 0, invoice: 0, invoiceNumber: 0 }, + counters: (data.counters as StoreData["counters"]) ?? { customer: 0, invoice: 0, invoiceNumber: 0, creditNote: 0 }, customers: (data.customers as StoreData["customers"]) ?? [], invoices: (data.invoices as StoreData["invoices"]) ?? [], + creditNotes: (data.creditNotes as StoreData["creditNotes"]) ?? [], }; } diff --git a/src/store/types.ts b/src/store/types.ts index 222b191..a9d7277 100644 --- a/src/store/types.ts +++ b/src/store/types.ts @@ -1,7 +1,8 @@ +import type { CreditNote } from "../domain/credit-note"; import type { Customer } from "../domain/customer"; import type { Invoice } from "../domain/invoice"; -export type Counters = { customer: number; invoice: number; invoiceNumber: number }; +export type Counters = { customer: number; invoice: number; invoiceNumber: number; creditNote: number }; /** The shape of the JSON file at CURRENT_SCHEMA_VERSION. */ export type StoreData = { @@ -9,4 +10,5 @@ export type StoreData = { counters: Counters; customers: Customer[]; invoices: Invoice[]; + creditNotes: CreditNote[]; }; diff --git a/test/invoice.test.ts b/test/invoice.test.ts index 85202f2..97ef4d9 100644 --- a/test/invoice.test.ts +++ b/test/invoice.test.ts @@ -53,9 +53,10 @@ describe("invoice domain", () => { expect(() => markPaid(draft({ status: "void" }), NOW)).toThrow(ConflictError); }); - it("voids drafts and open invoices, never paid ones", () => { + it("voids drafts and open invoices, paid ones only when fully credited", () => { expect(voidInvoice(draft(), NOW).status).toBe("void"); expect(() => voidInvoice(draft({ status: "paid" }), NOW)).toThrow(ConflictError); + expect(voidInvoice(draft({ status: "paid" }), NOW, { fullyCredited: true }).status).toBe("void"); }); it("knows when an open invoice is overdue", () => { diff --git a/test/numbering.test.ts b/test/numbering.test.ts index a97d189..1fa4dd8 100644 --- a/test/numbering.test.ts +++ b/test/numbering.test.ts @@ -20,3 +20,34 @@ describe("credit note numbering", () => { expect(next.body).toMatchObject({ number: "CN-0002" }); }); }); + +describe("credit notes and voiding", () => { + it("lists credit notes and voids a paid invoice by crediting the rest", async () => { + const app = testApp(); + const customerId = await seedCustomer(app); + const id = await seedOpenInvoice(app, { customerId, unitPrice: 10_000 }); + await app.handle("POST", `/invoices/${id}/pay`); + await app.handle("POST", `/invoices/${id}/credit-notes`, { amount: 2_500 }); + + const voided = await app.handle("POST", `/invoices/${id}/void`); + expect(voided.status).toBe(200); + expect(voided.body).toMatchObject({ status: "void" }); + + const list = await app.handle("GET", `/invoices/${id}/credit-notes`); + expect((list.body as { number: string; amount: number }[]).map((n) => [n.number, n.amount])).toEqual([ + ["CN-0001", 2_500], + ["CN-0002", 7_500], + ]); + }); + + it("rejects bad amounts and unpaid invoices", async () => { + const app = testApp(); + const customerId = await seedCustomer(app); + const id = await seedOpenInvoice(app, { customerId }); + const url = `/invoices/${id}/credit-notes`; + expect((await app.handle("POST", url, { amount: 100 })).status).toBe(409); + await app.handle("POST", `/invoices/${id}/pay`); + expect((await app.handle("POST", url, { amount: 0 })).status).toBe(400); + expect((await app.handle("POST", url, {})).status).toBe(400); + }); +}); diff --git a/test/store.test.ts b/test/store.test.ts index a1bf345..82cc59f 100644 --- a/test/store.test.ts +++ b/test/store.test.ts @@ -31,7 +31,7 @@ describe("store", () => { writeFileSync( file, JSON.stringify({ - counters: { customer: 1, invoice: 1, invoiceNumber: 0 }, + counters: { customer: 1, invoice: 1, invoiceNumber: 0, creditNote: 0 }, customers: [{ id: "cus_1", name: "Acme", email: "a@acme.io", createdAt: "2025-01-01T00:00:00.000Z" }], invoices: [{ id: "inv_1", customer: { id: "cus_1", name: "Acme" }, status: "draft" }], }),