diff --git a/README.md b/README.md index 890f13c..ae1e4f9 100644 --- a/README.md +++ b/README.md @@ -27,4 +27,8 @@ pnpm typecheck # tsc --noEmit ## Invoice lifecycle -`draft` → (`issue`) → `open` → (`pay`) → `paid`; `draft`/`open` → (`void`) → `void`. +`draft` → (`issue`) → `open` → (`pay`) → `paid`; `draft`/`open`/`paid` → (`void`) → `void`. + +Credit notes (`POST`/`GET /invoices/:id/credit-notes`, numbered `CN-0001`, ...) can be issued against +`open` or `paid` invoices, up to the invoice total minus earlier credit notes. Open invoices' credits +reduce the outstanding report. Voiding a paid invoice issues a credit note for whatever is left. diff --git a/src/domain/credit-note.ts b/src/domain/credit-note.ts new file mode 100644 index 0000000..9a48b40 --- /dev/null +++ b/src/domain/credit-note.ts @@ -0,0 +1,43 @@ +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, ... */ + number: string; + invoiceId: string; + currency: Currency; + /** cents, always positive */ + amount: Cents; + createdAt: string; +}; + +export function parseCreditNoteAmount(body: unknown): Cents { + if (typeof body !== "object" || body === null) { + throw new ValidationError("body must be an object"); + } + return assertCents((body as Record).amount, "amount", { min: 1 }); +} + +/** What is still creditable 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)); +} + +export function createCreditNote( + invoice: Invoice, + existing: CreditNote[], + amount: Cents, + ids: { id: string; number: string }, + now: Date, +): CreditNote { + if (invoice.status !== "open" && invoice.status !== "paid") { + throw new ConflictError(`invoice ${invoice.id} is ${invoice.status}, only open or paid invoices can be credited`); + } + const remaining = remainingCredit(invoice, existing); + if (amount > remaining) { + throw new ConflictError(`credit note of ${amount} exceeds the ${remaining} still creditable on invoice ${invoice.id}`); + } + return { ...ids, invoiceId: invoice.id, currency: invoice.currency, amount, createdAt: now.toISOString() }; +} diff --git a/src/domain/invoice.ts b/src/domain/invoice.ts index 5b69fc9..1a3967b 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() }; } +/** Paid invoices can be voided too, but the caller must credit what is left on them first. */ export function voidInvoice(invoice: Invoice, now: Date): Invoice { - if (invoice.status === "paid" || invoice.status === "void") { + if (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..38d7ffa 100644 --- a/src/http/context.ts +++ b/src/http/context.ts @@ -1,5 +1,6 @@ import type { Store } from "../store/json-store"; import { customerRepo } from "../store/customer-repo"; +import { creditNoteRepo } from "../store/credit-note-repo"; import { invoiceRepo } from "../store/invoice-repo"; export type AppContext = { @@ -7,8 +8,9 @@ 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..17b3910 100644 --- a/src/http/handlers/invoices.ts +++ b/src/http/handlers/invoices.ts @@ -1,3 +1,4 @@ +import { type CreditNote, createCreditNote, parseCreditNoteAmount, 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,33 @@ export function invoiceRoutes(router: Router, ctx: AppContext): void { return { status: 200, body: invoiceView(ctx.invoices.save(paid)) }; }); + /** Voiding a paid invoice credits whatever is left on it, so the books stay balanced. */ router.on("POST", "/invoices/:id/void", ({ params }) => { - const voided = voidInvoice(ctx.invoices.get(params.id), ctx.now()); + const current = ctx.invoices.get(params.id); + const now = ctx.now(); + const voided = voidInvoice(current, now); + if (current.status === "paid") { + const left = remainingCredit(current, ctx.creditNotes.listForInvoice(current.id)); + if (left > 0) issueCreditNote(current, left); + } return { status: 200, body: invoiceView(ctx.invoices.save(voided)) }; }); + + router.on("POST", "/invoices/:id/credit-notes", ({ params, body }) => { + const invoice = ctx.invoices.get(params.id); + const amount = parseCreditNoteAmount(body); + return { status: 201, body: issueCreditNote(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) }; + }); + + function issueCreditNote(invoice: Invoice, amount: number): CreditNote { + const existing = ctx.creditNotes.listForInvoice(invoice.id); + const id = ctx.store.nextId("creditNote"); + const note = createCreditNote(invoice, existing, amount, { id: `cn_${id}`, number: `CN-${String(id).padStart(4, "0")}` }, ctx.now()); + return ctx.creditNotes.insert(note); + } } diff --git a/src/http/handlers/reports.ts b/src/http/handlers/reports.ts index 19262c9..c84089a 100644 --- a/src/http/handlers/reports.ts +++ b/src/http/handlers/reports.ts @@ -1,4 +1,5 @@ -import { computeTotals, isOverdue } from "../../domain/invoice"; +import { remainingCredit } from "../../domain/credit-note"; +import { isOverdue } from "../../domain/invoice"; import type { Currency } from "../../domain/money"; import type { AppContext } from "../context"; import type { Router } from "../router"; @@ -11,8 +12,8 @@ export function reportRoutes(router: Router, ctx: AppContext): void { let count = 0; let overdue = 0; for (const invoice of ctx.invoices.list({ status: "open" })) { - const { total } = computeTotals(invoice); - totals[invoice.currency] = (totals[invoice.currency] ?? 0) + total; + const owed = remainingCredit(invoice, ctx.creditNotes.listForInvoice(invoice.id)); + totals[invoice.currency] = (totals[invoice.currency] ?? 0) + owed; count += 1; if (isOverdue(invoice, now)) overdue += 1; } 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 b404c51..99205b7 100644 --- a/src/store/json-store.ts +++ b/src/store/json-store.ts @@ -13,9 +13,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..91d4f99 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 (a `creditNotes` list and a `creditNote` counter). */ +const v1ToV2: Migration = (data) => ({ + ...data, + schemaVersion: 2, + counters: { creditNote: 0, ...(data.counters as Record | undefined) }, + creditNotes: (data.creditNotes as unknown[] | undefined) ?? [], +}); + /** 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/credit-notes.test.ts b/test/credit-notes.test.ts new file mode 100644 index 0000000..f841a3c --- /dev/null +++ b/test/credit-notes.test.ts @@ -0,0 +1,94 @@ +import { describe, expect, it } from "vitest"; +import { openStore } from "../src/store/json-store"; +import { migrate } from "../src/store/migrations"; +import { seedCustomer, seedOpenInvoice, testApp } from "./helpers"; + +async function setup(opts: { unitPrice?: number; discount?: number } = {}) { + const app = testApp(); + const customerId = await seedCustomer(app); + const id = await seedOpenInvoice(app, { customerId, unitPrice: opts.unitPrice ?? 10_000, discount: opts.discount }); + return { app, id }; +} + +describe("credit notes", () => { + it("numbers credit notes sequentially and lists them per invoice", async () => { + const { app, id } = await setup(); + const first = await app.handle("POST", `/invoices/${id}/credit-notes`, { amount: 2_500 }); + const second = await app.handle("POST", `/invoices/${id}/credit-notes`, { amount: 1_000 }); + expect(first.status).toBe(201); + expect(first.body).toMatchObject({ number: "CN-0001", invoiceId: id, amount: 2_500, currency: "EUR" }); + expect(second.body).toMatchObject({ number: "CN-0002" }); + const list = await app.handle("GET", `/invoices/${id}/credit-notes`); + expect((list.body as { number: string }[]).map((n) => n.number)).toEqual(["CN-0001", "CN-0002"]); + }); + + it("rejects bad amounts (400) and amounts above what is left (409)", async () => { + const { app, id } = await setup({ discount: 1_000 }); + for (const amount of [0, -5, 1.5, "10", undefined]) { + expect((await app.handle("POST", `/invoices/${id}/credit-notes`, { amount })).status).toBe(400); + } + await app.handle("POST", `/invoices/${id}/credit-notes`, { amount: 8_000 }); + expect((await app.handle("POST", `/invoices/${id}/credit-notes`, { amount: 1_001 })).status).toBe(409); + expect((await app.handle("POST", `/invoices/${id}/credit-notes`, { amount: 1_000 })).status).toBe(201); + }); + + it("refuses drafts and unknown invoices", async () => { + const app = testApp(); + const customerId = await seedCustomer(app); + const draft = await app.handle("POST", "/invoices", { + customerId, + currency: "EUR", + lines: [{ description: "x", quantity: 1, unitPrice: 100 }], + }); + const id = (draft.body as { id: string }).id; + expect((await app.handle("POST", `/invoices/${id}/credit-notes`, { amount: 10 })).status).toBe(409); + expect((await app.handle("GET", "/invoices/inv_999/credit-notes")).status).toBe(404); + }); + + it("reduces the outstanding report for open invoices", async () => { + const { app, id } = await setup(); + await app.handle("POST", `/invoices/${id}/credit-notes`, { amount: 4_000 }); + const report = await app.handle("GET", "/reports/outstanding"); + expect(report.body).toMatchObject({ totals: { EUR: 6_000 }, count: 1 }); + }); + + it("voids a paid invoice by crediting what is left", async () => { + const { app, id } = await setup(); + await app.handle("POST", `/invoices/${id}/pay`); + await app.handle("POST", `/invoices/${id}/credit-notes`, { amount: 3_000 }); + const res = await app.handle("POST", `/invoices/${id}/void`); + expect(res.status).toBe(200); + expect(res.body).toMatchObject({ status: "void" }); + const notes = (await app.handle("GET", `/invoices/${id}/credit-notes`)).body as { amount: number }[]; + expect(notes.map((n) => n.amount)).toEqual([3_000, 7_000]); + }); + + it("voids a fully credited paid invoice without another note", async () => { + const { app, id } = await setup(); + await app.handle("POST", `/invoices/${id}/pay`); + await app.handle("POST", `/invoices/${id}/credit-notes`, { amount: 10_000 }); + expect((await app.handle("POST", `/invoices/${id}/void`)).status).toBe(200); + const notes = (await app.handle("GET", `/invoices/${id}/credit-notes`)).body as unknown[]; + expect(notes).toHaveLength(1); + }); + + it("refuses credit notes on void invoices and double voids", async () => { + const { app, id } = await setup(); + await app.handle("POST", `/invoices/${id}/pay`); + await app.handle("POST", `/invoices/${id}/void`); + expect((await app.handle("POST", `/invoices/${id}/credit-notes`, { amount: 1 })).status).toBe(409); + expect((await app.handle("POST", `/invoices/${id}/void`)).status).toBe(409); + }); + + it("migrates a v1 file by adding the credit note list and counter", () => { + const data = migrate({ + schemaVersion: 1, + counters: { customer: 1, invoice: 1, invoiceNumber: 1 }, + customers: [], + invoices: [], + }); + expect(data.creditNotes).toEqual([]); + expect(data.counters).toEqual({ customer: 1, invoice: 1, invoiceNumber: 1, creditNote: 0 }); + expect(openStore().nextId("creditNote")).toBe(1); + }); +}); diff --git a/test/invoice.test.ts b/test/invoice.test.ts index 85202f2..4ad9677 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, open and paid invoices, never void ones", () => { expect(voidInvoice(draft(), NOW).status).toBe("void"); - expect(() => voidInvoice(draft({ status: "paid" }), NOW)).toThrow(ConflictError); + expect(voidInvoice(draft({ status: "paid" }), NOW).status).toBe("void"); + expect(() => voidInvoice(draft({ status: "void" }), NOW)).toThrow(ConflictError); }); it("knows when an open invoice is overdue", () => {