diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b1b5ddb..98bc2b2 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -27,7 +27,9 @@ bun run build For UI changes, check the affected flow in light and dark mode at desktop and narrow viewport widths. Include before/after screenshots when appearance changes. -For database changes, add a forward-only migration and verify both SQLite and PostgreSQL behaviour. The normal test run exercises SQLite. Set `TEST_POSTGRES_DATABASE_URL` to a dedicated PostgreSQL test database to run the same migration contract in an isolated temporary schema. Never rewrite a migration that may already have been deployed. +For database changes, add a forward-only migration and verify both SQLite and PostgreSQL behaviour. The normal test run exercises SQLite. Set `TEST_POSTGRES_DATABASE_URL` to a dedicated PostgreSQL test database to run the migration contract and invoice editing transactions in isolated temporary schemas. Never rewrite a migration that may already have been deployed. + +Use `bun run test --no-file-parallelism` when `TEST_POSTGRES_DATABASE_URL` is set. Kysely discovers migration tables across accessible schemas, so these temporary schema migrations must run one suite at a time. ## Pull requests diff --git a/README.md b/README.md index 337df01..9bc0f20 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ ## What Folio does -- Creates drafts and searchable A4 PDF invoices in multiple currencies. Issued invoices retain the customer and seller details used at the time. +- Creates drafts and searchable A4 PDF invoices in multiple currencies. Published invoices, including paid and void invoices, can be edited after acknowledging a warning. Previous versions and PDFs remain available in the edit history; edits update the existing invoice and reports. Recorded payments keep their original amounts and currencies. - Records full and partial payments, blocks overpayments, and tracks outstanding and overdue balances. - Stores customers, billing addresses and reusable invoice lines. - Shows customer address maps after consent, with options to allow once or remember the choice in this browser. diff --git a/src/app/(app)/invoices/[id]/edit/page.tsx b/src/app/(app)/invoices/[id]/edit/page.tsx index d11ab0f..c212d55 100644 --- a/src/app/(app)/invoices/[id]/edit/page.tsx +++ b/src/app/(app)/invoices/[id]/edit/page.tsx @@ -24,7 +24,7 @@ function inputDecimal(value: number, scale: number, digits: number) { export default async function EditInvoicePage({ params }: EditInvoicePageProps) { const [business, { id }] = await Promise.all([requireBusiness(), params]); const detail = await getInvoiceDetail(business.id, id); - if (!detail || detail.invoice.lifecycle !== "draft") notFound(); + if (!detail) notFound(); const options = await getInvoiceEditorOptions( business.id, { includeCustomerId: detail.invoice.customer_id }, @@ -36,11 +36,13 @@ export default async function EditInvoicePage({ params }: EditInvoicePageProps) ); diff --git a/src/app/(app)/invoices/[id]/history/[revisionId]/page.tsx b/src/app/(app)/invoices/[id]/history/[revisionId]/page.tsx new file mode 100644 index 0000000..4be6d16 --- /dev/null +++ b/src/app/(app)/invoices/[id]/history/[revisionId]/page.tsx @@ -0,0 +1,47 @@ +import type { Metadata } from "next"; +import { notFound } from "next/navigation"; + +import { PageHeader } from "@/components/folio/page-header"; +import { Button } from "@/components/ui/button"; +import { buildInvoiceDocumentData } from "@/features/invoices/document-mapper"; +import { InvoiceDocument } from "@/features/invoices/invoice-document"; +import { getInvoiceRevision } from "@/features/invoices/revision-query"; +import { requireBusiness } from "@/lib/session"; + +export const metadata: Metadata = { title: "Invoice history" }; + +export default async function InvoiceRevisionPage({ params }: { + params: Promise<{ id: string; revisionId: string }>; +}) { + const [business, { id, revisionId }] = await Promise.all([requireBusiness(), params]); + const revision = await getInvoiceRevision(business.id, id, revisionId); + if (!revision) notFound(); + const savedAt = new Intl.DateTimeFormat("en-GB", { + dateStyle: "medium", timeStyle: "short", timeZone: business.timezone, + }).format(new Date(revision.created_at)); + + return ( +
+ +
+

This is a historical copy. Open the current invoice to make changes.

+ +
+
+
+ +
+
+
+ ); +} diff --git a/src/app/(app)/invoices/[id]/page.tsx b/src/app/(app)/invoices/[id]/page.tsx index 39b9dc3..2a4c761 100644 --- a/src/app/(app)/invoices/[id]/page.tsx +++ b/src/app/(app)/invoices/[id]/page.tsx @@ -46,6 +46,11 @@ export default async function InvoicePage({ params }: InvoicePageProps) { if (!detail) notFound(); const { invoice } = detail; + const historyDateFormatter = new Intl.DateTimeFormat("en-GB", { + dateStyle: "medium", + timeStyle: "short", + timeZone: business.timezone, + }); const documentData = buildInvoiceDocumentData(detail); const payableInvoices: PayableInvoice[] = invoice.lifecycle === "issued" && @@ -93,6 +98,12 @@ export default async function InvoicePage({ params }: InvoicePageProps) { ) : ( <> + {payableInvoices.length ? ( Invoice details + {detail.revisions?.length ? ( + + Edit history + + ) : null}
@@ -197,6 +217,14 @@ export default async function InvoicePage({ params }: InvoicePageProps) {
Paid
{formatMoney(detail.paidCents, invoice.currency)}
+ {detail.paidCents > invoice.total_cents ? ( +
+
Overpaid
+
+ {formatMoney(detail.paidCents - invoice.total_cents, invoice.currency)} +
+
+ ) : null}
Tax
{formatMoney(invoice.tax_cents, invoice.currency)}
@@ -298,6 +326,33 @@ export default async function InvoicePage({ params }: InvoicePageProps) { ) : null} + + {detail.revisions?.length ? ( +
+
+

Edit history

+

+ Open the version saved before each edit. +

+
+
+ {detail.revisions.map((revision) => ( + +

+ {revision.invoice_number} · {formatMoney(revision.total_cents, revision.currency)} +

+

+ Before edit by {revision.actor_name} · {historyDateFormatter.format(new Date(revision.created_at))} +

+ + ))} +
+
+ ) : null}
diff --git a/src/app/api/invoices/[id]/pdf/route.ts b/src/app/api/invoices/[id]/pdf/route.ts index 1d0b135..58c7586 100644 --- a/src/app/api/invoices/[id]/pdf/route.ts +++ b/src/app/api/invoices/[id]/pdf/route.ts @@ -2,6 +2,8 @@ import { createElement, type ReactElement } from "react"; import type { DocumentProps } from "@react-pdf/renderer"; import { getInvoiceDocumentRecord } from "@/features/invoices/document-query"; +import { getInvoiceRevision } from "@/features/invoices/revision-query"; +import { buildInvoiceDocumentData } from "@/features/invoices/document-mapper"; import { getCurrentBusiness } from "@/lib/session"; export const runtime = "nodejs"; @@ -11,16 +13,33 @@ type PdfRouteContext = { params: Promise<{ id: string }>; }; -export async function GET(_request: Request, { params }: PdfRouteContext) { +export async function GET(request: Request, { params }: PdfRouteContext) { const business = await getCurrentBusiness(); if (!business) { return Response.json({ error: "Authentication required" }, { status: 401 }); } const { id } = await params; - const record = await getInvoiceDocumentRecord(business, id); + const searchParams = new URL(request.url).searchParams; + if (searchParams.has("document")) { + return Response.json({ error: "Document not found" }, { status: 404 }); + } + const revisionId = searchParams.get("revision"); + let record; + if (revisionId) { + const revision = await getInvoiceRevision(business.id, id, revisionId); + record = revision ? { + lifecycle: revision.detail.invoice.lifecycle, + data: buildInvoiceDocumentData(revision.detail), + } : null; + } else { + record = await getInvoiceDocumentRecord(business, id); + } if (!record) { - return Response.json({ error: "Invoice not found" }, { status: 404 }); + return Response.json( + { error: "Invoice not found" }, + { status: 404 }, + ); } const { data } = record; @@ -30,7 +49,7 @@ export async function GET(_request: Request, { params }: PdfRouteContext) { ]); const document = createElement(InvoicePdfDocument, { data }) as ReactElement; const buffer = await renderToBuffer(document); - const filename = `${data.number.replace(/[^a-zA-Z0-9_-]/g, "-")}.pdf`; + const filename = `${data.number.replace(/[^a-zA-Z0-9_-]/g, "-")}${revisionId ? "-previous-version" : ""}.pdf`; return new Response(new Uint8Array(buffer), { headers: { diff --git a/src/features/customers/queries.ts b/src/features/customers/queries.ts index 3c4d195..de2944d 100644 --- a/src/features/customers/queries.ts +++ b/src/features/customers/queries.ts @@ -89,7 +89,7 @@ export async function listCustomers( const paymentTotals = db .selectFrom("payments") .select("invoice_id") - .select(({ fn }) => fn.sum("amount_cents").as("paid_cents")) + .select(sql`sum(coalesce(applied_amount_cents, amount_cents))`.as("paid_cents")) .where("business_id", "=", business.id) .groupBy("invoice_id") .as("payment_totals"); diff --git a/src/features/dashboard/queries.ts b/src/features/dashboard/queries.ts index ffc01eb..fb3010a 100644 --- a/src/features/dashboard/queries.ts +++ b/src/features/dashboard/queries.ts @@ -90,7 +90,8 @@ export async function getDashboardSummary( .execute(), db .selectFrom("payments") - .select(["invoice_id", "amount_cents"]) + .select("invoice_id") + .select(sql`coalesce(applied_amount_cents, amount_cents)`.as("amount_cents")) .where("business_id", "=", businessId) .execute(), db @@ -167,7 +168,7 @@ export async function getDashboardUpdates( const paymentTotals = db .selectFrom("payments") .select("invoice_id") - .select(({ fn }) => fn.sum("amount_cents").as("paid_cents")) + .select(sql`sum(coalesce(applied_amount_cents, amount_cents))`.as("paid_cents")) .where("business_id", "=", businessId) .groupBy("invoice_id") .as("payment_totals"); diff --git a/src/features/finance/ledger.ts b/src/features/finance/ledger.ts index 01acc71..f76e112 100644 --- a/src/features/finance/ledger.ts +++ b/src/features/finance/ledger.ts @@ -1,6 +1,6 @@ import "server-only"; -import type { Kysely } from "kysely"; +import { sql, type Kysely } from "kysely"; import { db, type Database } from "@/lib/db"; import { @@ -134,10 +134,10 @@ export async function loadFinancialEntriesWithDatabase( "payments.currency", "invoices.invoice_number", "invoices.customer_name", - "invoices.base_currency", - "invoices.exchange_rate_micros", - "invoices.exchange_rate_date", - "invoices.exchange_rate_source", + sql`case when payments.base_currency is null and payments.currency = invoices.currency then invoices.base_currency else payments.base_currency end`.as("base_currency"), + sql`case when payments.base_currency is null and payments.currency = invoices.currency then invoices.exchange_rate_micros else payments.exchange_rate_micros end`.as("exchange_rate_micros"), + sql`case when payments.base_currency is null and payments.currency = invoices.currency then invoices.exchange_rate_date else payments.exchange_rate_date end`.as("exchange_rate_date"), + sql`case when payments.base_currency is null and payments.currency = invoices.currency then invoices.exchange_rate_source else payments.exchange_rate_source end`.as("exchange_rate_source"), ]) .where("payments.business_id", "=", businessId) .where("payments.payment_date", ">=", range.from); diff --git a/src/features/invoices/actions.ts b/src/features/invoices/actions.ts index 5b9b919..1c0b981 100644 --- a/src/features/invoices/actions.ts +++ b/src/features/invoices/actions.ts @@ -7,7 +7,6 @@ import { InvoiceAggregateConflictError, insertInvoiceAggregate, prepareInvoiceAggregate, - replaceDraftInvoiceAggregate, type InvoiceExchangeSnapshot, } from "@/features/invoices/aggregate"; import { @@ -17,12 +16,14 @@ import { type SubmittedInvoice, } from "@/features/invoices/forms"; import { buildInvoiceSellerSnapshot } from "@/features/invoices/seller-snapshot"; +import { editInvoice } from "@/features/invoices/edits"; +import { PaymentAllocationError } from "@/features/payments/invoice-allocation"; import { db, newId, nowIso, type Business } from "@/lib/db"; import { allocateInvoiceNumber } from "@/lib/db/businesses"; import { getExchangeRate } from "@/lib/exchange-rates"; import { todayInTimeZone } from "@/lib/format"; import { addPaymentTerms, convertCents } from "@/lib/finance"; -import { requireBusiness } from "@/lib/session"; +import { requireBusiness, requireSession } from "@/lib/session"; function readId(formData: FormData, name: string) { const value = formData.get(name); @@ -97,6 +98,7 @@ async function exchangeSnapshot(business: Business, input: SubmittedInvoice) { function actionError(error: unknown): InvoiceActionState { if (error instanceof FormSubmissionError) return { error: error.message }; if (error instanceof InvoiceAggregateConflictError) return { error: error.message }; + if (error instanceof PaymentAllocationError) return { error: error.message }; throw error; } @@ -199,14 +201,34 @@ export async function updateInvoiceAction( formData: FormData, ): Promise { const business = await requireBusiness(); + const session = await requireSession(); let invoiceId: string; try { invoiceId = readId(formData, "invoiceId"); const input = parseInvoiceFormData(formData); + const original = await db.selectFrom("invoices").selectAll() + .where("id", "=", invoiceId).where("business_id", "=", business.id) + .executeTakeFirst(); + if (!original) throw new FormSubmissionError("This invoice is unavailable"); + const expectedUpdatedAt = readId(formData, "expectedUpdatedAt"); + const confirmed = formData.get("publishedEditConfirmed") === "on"; + if (original.lifecycle !== "draft" && !confirmed) { + throw new FormSubmissionError("Acknowledge the warning before saving changes to a published invoice"); + } + const unchangedRate = original.exchange_rate_source === "Manual" + ? input.exchangeRateMicros === original.exchange_rate_micros + : input.exchangeRateMicros === null; + const preserveRate = original.lifecycle !== "draft" && unchangedRate && original.currency === input.currency && + original.issue_date === input.issueDate && original.base_currency === business.currency && + original.exchange_rate_micros && original.exchange_rate_date && original.exchange_rate_source; const [customer, exchangeRate] = await Promise.all([ resolveDraftRelations(business, input), - exchangeSnapshot(business, input), + preserveRate ? Promise.resolve({ + base: original.currency, quote: business.currency, + rateMicros: original.exchange_rate_micros!, + date: original.exchange_rate_date!, source: original.exchange_rate_source!, + }) : exchangeSnapshot(business, input), ]); const timestamp = nowIso(); const aggregate = prepareDraftAggregate({ @@ -218,16 +240,18 @@ export async function updateInvoiceAction( timestamp, }); - await db.transaction().execute(async (transaction) => { - await replaceDraftInvoiceAggregate(transaction, aggregate); + aggregate.invoice.payment_instructions = input.paymentInstructions; + await editInvoice(db, business, aggregate, { + expectedUpdatedAt, + confirmed, + refreshCustomerDetails: formData.get("refreshCustomerDetails") === "on", + actorName: session.user.name, }); } catch (error) { return actionError(error); } - revalidatePath("/invoices"); - revalidatePath(`/invoices/${invoiceId}`); - revalidatePath("/"); + revalidatePath("/", "layout"); redirect(`/invoices/${invoiceId}`); } diff --git a/src/features/invoices/aggregate.ts b/src/features/invoices/aggregate.ts index b11706e..20ee11e 100644 --- a/src/features/invoices/aggregate.ts +++ b/src/features/invoices/aggregate.ts @@ -144,9 +144,10 @@ export async function insertInvoiceAggregate( await executor.insertInto("invoice_lines").values(aggregate.lines).execute(); } -export async function replaceDraftInvoiceAggregate( +export async function replaceInvoiceAggregate( executor: DatabaseExecutor, aggregate: PreparedInvoiceAggregate, + lifecycle: InvoiceLifecycle, ) { const invoice = aggregate.invoice; const result = await executor @@ -183,11 +184,11 @@ export async function replaceDraftInvoiceAggregate( }) .where("id", "=", invoice.id) .where("business_id", "=", invoice.business_id) - .where("lifecycle", "=", "draft") + .where("lifecycle", "=", lifecycle) .executeTakeFirst(); if (Number(result.numUpdatedRows) !== 1) { - throw new InvoiceAggregateConflictError("Only an existing draft can be edited"); + throw new InvoiceAggregateConflictError("This invoice changed. Refresh the page and try again."); } await executor diff --git a/src/features/invoices/country-snapshot.test.ts b/src/features/invoices/country-snapshot.test.ts index 20fe1a7..ce71587 100644 --- a/src/features/invoices/country-snapshot.test.ts +++ b/src/features/invoices/country-snapshot.test.ts @@ -8,7 +8,7 @@ import type { Business, Customer } from "@/lib/db/types"; import { insertInvoiceAggregate, prepareInvoiceAggregate, - replaceDraftInvoiceAggregate, + replaceInvoiceAggregate, } from "./aggregate"; const date = new Date(Date.UTC(2024, randomInt(0, 12), randomInt(1, 28))) @@ -149,7 +149,7 @@ describe("invoice country snapshots", () => { await insertInvoiceAggregate(database.db, aggregate); for (const [code, lastLine] of [["US", "United States"], [null, "CA"]]) { await database.db.transaction().execute((transaction) => - replaceDraftInvoiceAggregate(transaction, aggregateFor(code, aggregate.invoice.id)), + replaceInvoiceAggregate(transaction, aggregateFor(code, aggregate.invoice.id), "draft"), ); const record = await documents.getInvoiceDocumentRecord(business, aggregate.invoice.id); expect(record?.data.seller.address.at(-1)).toBe(lastLine); @@ -180,7 +180,11 @@ describe("invoice country snapshots", () => { await insertInvoiceAggregate(database.db, aggregate); await database.db.schema.alterTable("invoices").dropColumn("seller_country_code").execute(); await database.db.schema.alterTable("invoices").dropColumn("customer_country_code").execute(); - await sql`delete from folio_migrations where name = '202609070001_invoice_country_snapshot'` + await database.db.schema.dropTable("invoice_revisions").execute(); + for (const column of ["applied_amount_cents", "base_currency", "exchange_rate_micros", "exchange_rate_date", "exchange_rate_source"]) { + await database.db.schema.alterTable("payments").dropColumn(column).execute(); + } + await sql`delete from folio_migrations where name >= '202609070001_invoice_country_snapshot'` .execute(database.db); await database.migrateDatabase(); diff --git a/src/features/invoices/detail-query.ts b/src/features/invoices/detail-query.ts index da6aa29..3999461 100644 --- a/src/features/invoices/detail-query.ts +++ b/src/features/invoices/detail-query.ts @@ -20,7 +20,7 @@ export async function getInvoiceDetail( if (!invoice) return null; - const [lines, payments] = await Promise.all([ + const [lines, payments, revisions] = await Promise.all([ db .selectFrom("invoice_lines") .selectAll() @@ -36,9 +36,13 @@ export async function getInvoiceDetail( .orderBy("payment_date", "desc") .orderBy("created_at", "desc") .execute(), + db.selectFrom("invoice_revisions") + .select(["id", "actor_name", "invoice_number", "currency", "total_cents", "created_at"]) + .where("business_id", "=", businessId).where("invoice_id", "=", invoiceId) + .orderBy("created_at", "desc").execute(), ]); const paidCents = payments.reduce( - (total, payment) => total + payment.amount_cents, + (total, payment) => total + (payment.applied_amount_cents ?? payment.amount_cents), 0, ); @@ -46,8 +50,9 @@ export async function getInvoiceDetail( invoice, lines, payments, + revisions, paidCents, - balanceDueCents: invoiceBalance(invoice.total_cents, paidCents), + balanceDueCents: invoice.lifecycle === "void" ? 0 : invoiceBalance(invoice.total_cents, paidCents), status: deriveInvoiceStatus({ lifecycle: invoice.lifecycle, totalCents: invoice.total_cents, diff --git a/src/features/invoices/edits.integration.test.ts b/src/features/invoices/edits.integration.test.ts new file mode 100644 index 0000000..919b2af --- /dev/null +++ b/src/features/invoices/edits.integration.test.ts @@ -0,0 +1,351 @@ +import { randomBytes, randomInt, randomUUID } from "node:crypto"; + +import type { Kysely } from "kysely"; +import { Client } from "pg"; +import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; + +import type { Business, Customer, Database, InvoiceLifecycle } from "@/lib/db/types"; +import { todayInTimeZone } from "@/lib/format"; + +import type { PreparedInvoiceAggregate } from "./aggregate"; +import type { InvoiceDetail } from "./types"; + +let databaseModule: typeof import("@/lib/db"); +let aggregateModule: typeof import("./aggregate"); +let editModule: typeof import("./edits"); +let detailModule: typeof import("./detail-query"); +let database: Kysely; +const postgresUrl = process.env.TEST_POSTGRES_DATABASE_URL; +const postgresSchema = `edits_test_${randomUUID().replaceAll("-", "")}`; +let postgresAdmin: Client | undefined; +let postgresSchemaCreated = false; + +beforeAll(async () => { + if (postgresUrl) { + postgresAdmin = new Client({ connectionString: postgresUrl }); + await postgresAdmin.connect(); + await postgresAdmin.query(`CREATE SCHEMA "${postgresSchema}"`); + postgresSchemaCreated = true; + const isolatedUrl = new URL(postgresUrl); + isolatedUrl.searchParams.set("options", `-c search_path=${postgresSchema}`); + vi.stubEnv("DATABASE_URL", isolatedUrl.toString()); + vi.stubEnv("DATABASE_DIALECT", "postgres"); + } else { + vi.stubEnv("DATABASE_URL", ":memory:"); + vi.stubEnv("DATABASE_DIALECT", "sqlite"); + } + vi.stubEnv("APP_SECRET", randomBytes(32).toString("hex")); + vi.resetModules(); + databaseModule = await import("@/lib/db"); + await databaseModule.migrateDatabase(); + [aggregateModule, editModule, detailModule] = await Promise.all([ + import("./aggregate"), import("./edits"), import("./detail-query"), + ]); + database = databaseModule.db; +}); + +afterAll(async () => { + try { + await databaseModule?.closeDatabase(); + if (postgresSchemaCreated) await postgresAdmin?.query(`DROP SCHEMA "${postgresSchema}" CASCADE`); + } finally { + await postgresAdmin?.end(); + vi.unstubAllEnvs(); + vi.resetModules(); + } +}); + +async function createFixture(lifecycle: InvoiceLifecycle = "issued") { + const timestamp = new Date(Date.now() - randomInt(2, 30) * 86_400_000).toISOString(); + const ownerId = randomUUID(); + await database.insertInto("auth_user").values({ + id: ownerId, name: `Fictional owner ${randomUUID()}`, email: `${randomUUID()}@example.invalid`, + email_verified: postgresUrl ? true : 1, image: null, created_at: timestamp, updated_at: timestamp, + }).execute(); + const business: Business = { + id: randomUUID(), owner_user_id: ownerId, name: `Fictional business ${randomUUID()}`, + legal_name: null, email: `${randomUUID()}@example.invalid`, phone: null, + tax_id: `Synthetic tax ${randomUUID()}`, address_line_1: `Fictional address ${randomUUID()}`, + address_line_2: null, city: null, region: null, postal_code: null, country_code: "GB", + currency: "GBP", timezone: "Europe/London", invoice_prefix: `SYN-${randomUUID()}`, + next_invoice_number: randomInt(20, 900), default_payment_terms_days: randomInt(7, 31), + payment_instructions: `Synthetic instructions ${randomUUID()}`, + invoice_footer: `Synthetic footer ${randomUUID()}`, logo_url: null, + created_at: timestamp, updated_at: timestamp, + }; + const customer: Customer = { + id: randomUUID(), business_id: business.id, name: `Fictional customer ${randomUUID()}`, + avatar_data_url: null, default_currency: null, contact_name: null, + email: `${randomUUID()}@example.invalid`, phone: null, tax_id: `Synthetic tax ${randomUUID()}`, + address_line_1: `Fictional address ${randomUUID()}`, address_line_2: null, city: null, + region: null, postal_code: null, country_code: null, notes: null, archived_at: null, + created_at: timestamp, updated_at: timestamp, + }; + await database.insertInto("businesses").values(business).execute(); + await database.insertInto("customers").values(customer).execute(); + const today = todayInTimeZone(business.timezone); + const recurrenceId = randomUUID(); + await database.insertInto("recurring_invoices").values({ + id: recurrenceId, business_id: business.id, customer_id: customer.id, state: "active", + frequency: "month", interval_count: 1, start_date: today, end_date: null, + next_issue_date: today, next_occurrence_index: 2, payment_terms_days: 14, + currency: business.currency, notes: null, payment_instructions: null, + created_at: timestamp, updated_at: timestamp, + }).execute(); + const invoiceId = randomUUID(); + const prepare = (currency = business.currency, rateMicros = 1_000_000) => aggregateModule.prepareInvoiceAggregate({ + id: invoiceId, business, customer, recurringInvoiceId: recurrenceId, recurrenceIndex: 1, + invoiceNumber: `SYN-${randomUUID()}`, lifecycle, issueDate: today, dueDate: today, currency, + exchangeRate: { base: currency, quote: business.currency, rateMicros, date: today, source: `Synthetic rate ${randomUUID()}` }, + notes: `Synthetic notes ${randomUUID()}`, paymentInstructions: business.payment_instructions, + lines: [{ itemId: null, description: `Synthetic service ${randomUUID()}`, unit: "unit", + quantityThousandths: randomInt(1, 5) * 1_000, unitPriceCents: randomInt(1_001, 90_000), taxRateBps: randomInt(1, 20) * 100 }], + timestamp, + }, randomUUID); + await aggregateModule.insertInvoiceAggregate(database, prepare()); + const original = await database.selectFrom("invoices").selectAll() + .where("id", "=", invoiceId).executeTakeFirstOrThrow(); + const originalLines = await database.selectFrom("invoice_lines").selectAll() + .where("invoice_id", "=", invoiceId).orderBy("position").execute(); + const options = { + expectedUpdatedAt: original.updated_at, confirmed: true, refreshCustomerDetails: false, + actorName: `Fictional editor ${randomUUID()}`, + }; + return { business, customer, original, originalLines, prepare, today, options }; +} + +type Fixture = Awaited>; + +async function recordPayment(fixture: Fixture) { + await database.insertInto("payments").values({ + id: randomUUID(), business_id: fixture.business.id, invoice_id: fixture.original.id, + payment_date: fixture.today, amount_cents: fixture.original.total_cents, currency: fixture.original.currency, + method: "bank_transfer", reference: `Synthetic receipt ${randomUUID()}`, notes: null, + created_at: fixture.original.created_at, updated_at: fixture.original.updated_at, + }).execute(); + return database.selectFrom("payments").selectAll() + .where("invoice_id", "=", fixture.original.id).executeTakeFirstOrThrow(); +} + +async function state(fixture: Fixture) { + const { business, original } = fixture; + return { + detail: await detailModule.getInvoiceDetail(business.id, original.id, fixture.today), + revisions: await database.selectFrom("invoice_revisions").selectAll() + .where("invoice_id", "=", original.id).orderBy("created_at").execute(), + counter: await database.selectFrom("businesses").select("next_invoice_number") + .where("id", "=", business.id).executeTakeFirstOrThrow(), + }; +} + +describe("editing existing invoices", () => { + it("edits a paid invoice, retains its identity and saves the prior receipt before reallocating currencies", async () => { + const fixture = await createFixture(); + const { business, original, originalLines, options } = fixture; + const receipt = await recordPayment(fixture); + const changed = fixture.prepare("EUR", 800_000); + changed.invoice.lifecycle = "draft"; + changed.invoice.recurring_invoice_id = null; + changed.invoice.recurrence_index = null; + await editModule.editInvoice(database, business, changed, options); + const saved = await state(fixture); + expect(saved.detail!.invoice).toMatchObject({ + id: original.id, invoice_number: original.invoice_number, lifecycle: "issued", + recurring_invoice_id: original.recurring_invoice_id, recurrence_index: original.recurrence_index, + created_at: original.created_at, currency: "EUR", total_cents: changed.invoice.total_cents, + }); + expect(saved.detail!.lines).toEqual(changed.lines); + expect(saved.detail!.invoice.updated_at).not.toBe(original.updated_at); + expect(saved.counter.next_invoice_number).toBe(business.next_invoice_number); + expect(saved.revisions).toHaveLength(1); + expect(saved.revisions[0]).toMatchObject({ actor_name: options.actorName, invoice_number: original.invoice_number, total_cents: original.total_cents }); + const snapshot = JSON.parse(saved.revisions[0].snapshot) as InvoiceDetail; + expect(snapshot).toMatchObject({ invoice: original, lines: originalLines, payments: [receipt], paidCents: receipt.amount_cents, balanceDueCents: 0, status: "paid" }); + const appliedAmount = Math.round(receipt.amount_cents * 1_000_000 / 800_000); + expect(saved.detail!.paidCents).toBe(appliedAmount); + expect(saved.detail!.payments[0]).toEqual({ + ...receipt, applied_amount_cents: appliedAmount, base_currency: original.base_currency, + exchange_rate_micros: original.exchange_rate_micros, exchange_rate_date: original.exchange_rate_date, + exchange_rate_source: original.exchange_rate_source, + }); + + await editModule.editInvoice(database, business, fixture.prepare(), { + ...options, expectedUpdatedAt: saved.detail!.invoice.updated_at, + }); + const returned = await state(fixture); + expect(returned.detail!.payments[0]).toMatchObject({ id: receipt.id, currency: "GBP", amount_cents: receipt.amount_cents, applied_amount_cents: receipt.amount_cents }); + expect(returned.revisions).toHaveLength(2); + expect(returned.revisions[0]).toEqual(saved.revisions[0]); + expect(JSON.parse(returned.revisions[1].snapshot)).toMatchObject({ invoice: saved.detail!.invoice, payments: saved.detail!.payments }); + }); + + it("edits a void invoice while retaining its status and previous version", async () => { + const fixture = await createFixture("void"); + const before = await state(fixture); + const changed = fixture.prepare("EUR", 800_000); + await editModule.editInvoice(database, fixture.business, changed, { + ...fixture.options, expectedUpdatedAt: before.detail!.invoice.updated_at, + }); + const after = await state(fixture); + expect(after.detail!.invoice).toMatchObject({ lifecycle: "void", currency: "EUR", total_cents: changed.invoice.total_cents }); + expect(after.detail!.balanceDueCents).toBe(0); + expect(JSON.parse(after.revisions[0].snapshot)).toMatchObject({ invoice: before.detail!.invoice, status: "void" }); + }); + + it("preserves published seller and customer snapshots unless customer details are explicitly refreshed", async () => { + const fixture = await createFixture(); + const changed = fixture.prepare(); + changed.invoice.seller_name = `Fictional updated seller ${randomUUID()}`; + changed.invoice.seller_email = `${randomUUID()}@example.invalid`; + changed.invoice.seller_country_code = "DE"; + changed.invoice.invoice_footer = `Synthetic updated footer ${randomUUID()}`; + changed.invoice.customer_name = `Fictional updated customer ${randomUUID()}`; + changed.invoice.customer_email = `${randomUUID()}@example.invalid`; + changed.invoice.customer_billing_name = `Synthetic billing name ${randomUUID()}`; + changed.invoice.customer_country_code = "FR"; + await editModule.editInvoice(database, fixture.business, changed, fixture.options); + const retained = await state(fixture); + expect(retained.detail!.invoice).toMatchObject({ + seller_name: fixture.original.seller_name, seller_email: fixture.original.seller_email, + invoice_footer: fixture.original.invoice_footer, customer_name: fixture.original.customer_name, + customer_email: fixture.original.customer_email, + seller_country_code: fixture.original.seller_country_code, + customer_country_code: fixture.original.customer_country_code, + customer_billing_name: fixture.original.customer_billing_name, + }); + await editModule.editInvoice(database, fixture.business, changed, { + ...fixture.options, expectedUpdatedAt: retained.detail!.invoice.updated_at, refreshCustomerDetails: true, + }); + const refreshed = await state(fixture); + expect(refreshed.detail!.invoice).toMatchObject({ + seller_name: fixture.original.seller_name, invoice_footer: fixture.original.invoice_footer, + customer_name: changed.invoice.customer_name, customer_email: changed.invoice.customer_email, + seller_country_code: fixture.original.seller_country_code, + customer_country_code: changed.invoice.customer_country_code, + customer_billing_name: changed.invoice.customer_billing_name, + }); + expect(JSON.parse(refreshed.revisions[1].snapshot).invoice.customer_name).toBe(fixture.original.customer_name); + }); + + it("rejects missing acknowledgement, stale edits and cross-business access without partial changes", async () => { + const fixture = await createFixture(); + const other = await createFixture(); + const before = await state(fixture); + const otherBefore = await state(other); + await expect(editModule.editInvoice(database, fixture.business, fixture.prepare(), { + ...fixture.options, confirmed: false, + })).rejects.toThrow("Acknowledge the warning"); + expect(await state(fixture)).toEqual(before); + await expect(editModule.editInvoice(database, other.business, fixture.prepare(), fixture.options)) + .rejects.toThrow("changed or is unavailable"); + const forged = fixture.prepare(); + forged.invoice.business_id = other.business.id; + await expect(editModule.editInvoice(database, fixture.business, forged, fixture.options)) + .rejects.toThrow("changed or is unavailable"); + expect(await state(fixture)).toEqual(before); + expect(await state(other)).toEqual(otherBefore); + await editModule.editInvoice(database, fixture.business, fixture.prepare(), fixture.options); + const saved = await state(fixture); + await expect(editModule.editInvoice(database, fixture.business, fixture.prepare(), fixture.options)) + .rejects.toThrow("changed or is unavailable"); + expect(await state(fixture)).toEqual(saved); + }); + + it("allows changing and clearing a published invoice's manual reporting rate", async () => { + const fixture = await createFixture(); + await editModule.editInvoice(database, fixture.business, fixture.prepare("EUR", 800_000), fixture.options); + const redirectSignal = new Error(`Synthetic redirect ${randomUUID()}`); + vi.doMock("@/lib/session", () => ({ + requireBusiness: async () => fixture.business, + requireSession: async () => ({ user: { name: fixture.options.actorName } }), + })); + vi.doMock("next/cache", () => ({ revalidatePath: vi.fn() })); + vi.doMock("next/navigation", () => ({ redirect: () => { throw redirectSignal; } })); + vi.doMock("@/lib/exchange-rates", () => ({ getExchangeRate: async () => ({ + base: "EUR", quote: "GBP", rateMicros: 900_000, date: fixture.today, source: "Synthetic reference rate", + }) })); + try { + const { updateInvoiceAction } = await import("./actions"); + for (const [rate, expectedRate] of [["0.7", 700_000], ["", 900_000]] as const) { + const before = await state(fixture); + const form = new FormData(); + for (const [key, value] of Object.entries({ + invoiceId: fixture.original.id, customerId: fixture.customer.id, currency: "EUR", + issueDate: fixture.today, dueDate: fixture.today, exchangeRate: rate, + expectedUpdatedAt: before.detail!.invoice.updated_at, publishedEditConfirmed: "on", + lines: JSON.stringify([{ description: `Synthetic service ${randomUUID()}`, unit: "unit", + quantity: "1", unitPrice: String(randomInt(10, 100)), taxRate: "0" }]), + })) form.set(key, value); + await expect(updateInvoiceAction({}, form)).rejects.toBe(redirectSignal); + expect((await state(fixture)).detail!.invoice.exchange_rate_micros).toBe(expectedRate); + } + } finally { + vi.doUnmock("@/lib/session"); + vi.doUnmock("next/cache"); + vi.doUnmock("next/navigation"); + vi.doUnmock("@/lib/exchange-rates"); + } + }); + + it("rolls back the invoice, receipt allocation and revision when replacement lines fail", async () => { + const fixture = await createFixture(); + await recordPayment(fixture); + const before = await state(fixture); + const changed = fixture.prepare("EUR", 800_000); + changed.lines[0].item_id = randomUUID(); + await expect(editModule.editInvoice(database, fixture.business, changed, fixture.options)).rejects.toThrow(); + expect(await state(fixture)).toEqual(before); + }); + + it("rejects a payment submitted in the old invoice currency after an edit", async () => { + const fixture = await createFixture(); + const changed = fixture.prepare("EUR", 800_000); + await editModule.editInvoice(database, fixture.business, changed, fixture.options); + const before = await state(fixture); + vi.doMock("@/lib/session", () => ({ requireBusiness: async () => fixture.business })); + vi.doMock("next/cache", () => ({ revalidatePath: vi.fn() })); + try { + const { recordPaymentAction } = await import("@/features/payments/actions"); + const amountCents = randomInt(1, changed.invoice.total_cents); + const form = new FormData(); + form.set("invoiceId", fixture.original.id); + form.set("currency", fixture.original.currency); + form.set("paymentDate", fixture.today); + form.set("method", "bank_transfer"); + form.set("amount", (amountCents / 100).toFixed(2)); + expect(await recordPaymentAction({}, form)).toEqual({ + error: "The invoice currency has changed. Refresh the page before recording this payment.", + }); + expect(await state(fixture)).toEqual(before); + + form.set("currency", "EUR"); + expect(await recordPaymentAction({}, form)).toEqual({ success: "Payment recorded" }); + const saved = await state(fixture); + expect(saved.detail!.payments).toHaveLength(1); + expect(saved.detail!.payments[0]).toMatchObject({ + currency: "EUR", amount_cents: amountCents, applied_amount_cents: amountCents, + base_currency: "GBP", exchange_rate_micros: 800_000, + }); + await editModule.editInvoice(database, fixture.business, fixture.prepare("USD", 600_000), { + ...fixture.options, expectedUpdatedAt: saved.detail!.invoice.updated_at, + }); + const { listPaymentsWithDatabase } = await import("@/features/payments/queries"); + const payments = await listPaymentsWithDatabase(database, fixture.business.id, 1, "GBP", fixture.today); + expect(payments.summary).toMatchObject({ totalCents: Math.round(amountCents * 800_000 / 1_000_000), missingConversionCount: 0 }); + } finally { + vi.doUnmock("@/lib/session"); + vi.doUnmock("next/cache"); + } + }); + + it("keeps ordinary draft editing free of published-invoice acknowledgement and revision history", async () => { + const fixture = await createFixture("draft"); + const changed: PreparedInvoiceAggregate = fixture.prepare(); + changed.invoice.customer_name = `Fictional revised customer ${randomUUID()}`; + await editModule.editInvoice(database, fixture.business, changed, { ...fixture.options, confirmed: false }); + const saved = await state(fixture); + expect(saved.detail!.invoice).toMatchObject({ lifecycle: "draft", customer_name: changed.invoice.customer_name, total_cents: changed.invoice.total_cents }); + expect(saved.detail!.lines).toEqual(changed.lines); + expect(saved.revisions).toEqual([]); + }); +}); diff --git a/src/features/invoices/edits.ts b/src/features/invoices/edits.ts new file mode 100644 index 0000000..903f33f --- /dev/null +++ b/src/features/invoices/edits.ts @@ -0,0 +1,100 @@ +import { sql, type Kysely } from "kysely"; + +import { reallocateInvoicePayments } from "@/features/payments/invoice-allocation"; +import { newId, type Business, type Database } from "@/lib/db"; +import { todayInTimeZone } from "@/lib/format"; +import { deriveInvoiceStatus, invoiceBalance } from "@/lib/finance/money"; + +import { replaceInvoiceAggregate, type PreparedInvoiceAggregate } from "./aggregate"; +import { FormSubmissionError } from "./forms"; +import type { InvoiceDetail } from "./types"; + +export async function editInvoice( + database: Kysely, + business: Business, + aggregate: PreparedInvoiceAggregate, + options: { + expectedUpdatedAt: string; + confirmed: boolean; + refreshCustomerDetails: boolean; + actorName: string; + }, +) { + return database.transaction().execute(async (transaction) => { + const lock = await transaction.updateTable("invoices") + .set({ updated_at: sql`updated_at` }) + .where("id", "=", aggregate.invoice.id) + .where("business_id", "=", business.id) + .where("updated_at", "=", options.expectedUpdatedAt) + .executeTakeFirst(); + if (Number(lock.numUpdatedRows) !== 1 || aggregate.invoice.business_id !== business.id) { + throw new FormSubmissionError("This invoice changed or is unavailable. Refresh the page before editing it."); + } + const original = await transaction.selectFrom("invoices").selectAll() + .where("id", "=", aggregate.invoice.id).where("business_id", "=", business.id) + .executeTakeFirstOrThrow(); + const timestamp = new Date(Math.max(Date.now(), Date.parse(original.updated_at) + 1)).toISOString(); + const next = { ...aggregate.invoice, updated_at: timestamp }; + + if (original.lifecycle !== "draft") { + if (!options.confirmed) { + throw new FormSubmissionError("Acknowledge the warning before saving changes to a published invoice"); + } + if (!next.issue_date) { + throw new FormSubmissionError("An issued invoice must have an issue date"); + } + // Preserve document details unless the user changes or explicitly refreshes the customer. + Object.assign(next, { + seller_name: original.seller_name, + seller_email: original.seller_email, + seller_phone: original.seller_phone, + seller_tax_id: original.seller_tax_id, + seller_address: original.seller_address, + seller_country_code: original.seller_country_code, + invoice_footer: original.invoice_footer, + }); + if (next.customer_id === original.customer_id && !options.refreshCustomerDetails) { + Object.assign(next, { + customer_name: original.customer_name, + customer_billing_name: original.customer_billing_name, + customer_email: original.customer_email, + customer_phone: original.customer_phone, + customer_tax_id: original.customer_tax_id, + customer_address: original.customer_address, + customer_country_code: original.customer_country_code, + }); + } + const [lines, payments] = await Promise.all([ + transaction.selectFrom("invoice_lines").selectAll() + .where("business_id", "=", business.id).where("invoice_id", "=", original.id) + .orderBy("position").execute(), + transaction.selectFrom("payments").selectAll() + .where("business_id", "=", business.id).where("invoice_id", "=", original.id) + .orderBy("payment_date", "desc").execute(), + ]); + const paidCents = payments.reduce((total, payment) => total + (payment.applied_amount_cents ?? payment.amount_cents), 0); + const snapshot: InvoiceDetail = { + invoice: original, + lines, + payments, + paidCents, + balanceDueCents: original.lifecycle === "void" ? 0 : invoiceBalance(original.total_cents, paidCents), + status: deriveInvoiceStatus({ + lifecycle: original.lifecycle, + totalCents: original.total_cents, + paidCents, + dueDate: original.due_date, + today: todayInTimeZone(business.timezone), + }), + }; + await transaction.insertInto("invoice_revisions").values({ + id: newId(), business_id: business.id, invoice_id: original.id, + actor_name: options.actorName, invoice_number: original.invoice_number, + currency: original.currency, total_cents: original.total_cents, + snapshot: JSON.stringify(snapshot), created_at: timestamp, + }).execute(); + await reallocateInvoicePayments(transaction, original, next); + } + await replaceInvoiceAggregate(transaction, { invoice: next, lines: aggregate.lines }, original.lifecycle); + }); +} diff --git a/src/features/invoices/invoice-editor.tsx b/src/features/invoices/invoice-editor.tsx index b3e8b6c..5514c99 100644 --- a/src/features/invoices/invoice-editor.tsx +++ b/src/features/invoices/invoice-editor.tsx @@ -12,6 +12,7 @@ import { CardHeader, CardTitle, } from "@/components/ui/card"; +import { Checkbox } from "@/components/ui/checkbox"; import { DatePicker } from "@/components/ui/date-picker"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; @@ -38,9 +39,11 @@ type InvoiceEditorProps = { baseCurrency: string; cancelHref: string; customers: InvoiceEditorCustomer[]; + expectedUpdatedAt?: string; initialValues: InvoiceEditorInitialValues; invoiceId?: string; items: InvoiceEditorItem[]; + publishedEdit?: boolean; }; function SubmitButton({ editing }: { editing: boolean }) { @@ -58,9 +61,11 @@ export function InvoiceEditor({ baseCurrency, cancelHref, customers, + expectedUpdatedAt, initialValues, invoiceId, items, + publishedEdit, }: InvoiceEditorProps) { const [state, formAction] = useActionState(action, {}); const lineEditor = useInvoiceLines({ @@ -99,6 +104,7 @@ export function InvoiceEditor({ return (
{invoiceId ? : null} + {expectedUpdatedAt ? : null} {state.error ? ( @@ -153,6 +159,14 @@ export function InvoiceEditor({

) : null} + {publishedEdit && customerId === initialValues.customerId ? ( +
+ + +
+ ) : null}
@@ -456,6 +471,16 @@ export function InvoiceEditor({
+ {publishedEdit ? ( +
+ + +
+ ) : null} +