From fe8f88bf77988eb90c30d64d911dcfd155030292 Mon Sep 17 00:00:00 2001
From: Max Moon
Date: Tue, 8 Sep 2026 13:17:02 +0100
Subject: [PATCH] feat(invoices): allow published edits with version history
---
CONTRIBUTING.md | 4 +-
README.md | 2 +-
src/app/(app)/invoices/[id]/edit/page.tsx | 10 +-
.../[id]/history/[revisionId]/page.tsx | 47 +++
src/app/(app)/invoices/[id]/page.tsx | 55 +++
src/app/api/invoices/[id]/pdf/route.ts | 27 +-
src/features/customers/queries.ts | 2 +-
src/features/dashboard/queries.ts | 5 +-
src/features/finance/ledger.ts | 10 +-
src/features/invoices/actions.ts | 40 +-
src/features/invoices/aggregate.ts | 7 +-
.../invoices/country-snapshot.test.ts | 10 +-
src/features/invoices/detail-query.ts | 11 +-
.../invoices/edits.integration.test.ts | 351 ++++++++++++++++++
src/features/invoices/edits.ts | 100 +++++
src/features/invoices/invoice-editor.tsx | 25 ++
.../invoices/issue-invoice-dialog.tsx | 2 +-
src/features/invoices/list-queries.ts | 6 +-
src/features/invoices/revision-query.ts | 14 +
src/features/invoices/types.ts | 2 +
src/features/payments/actions.ts | 13 +-
src/features/payments/forms.test.ts | 1 +
src/features/payments/forms.ts | 6 +
.../payments/invoice-allocation.test.ts | 55 +++
src/features/payments/invoice-allocation.ts | 99 +++++
src/features/payments/payment-dialog.tsx | 1 +
src/features/payments/queries.ts | 6 +-
src/lib/db/invoice-edit-migration.ts | 20 +
src/lib/db/migrations.contract.test.ts | 1 +
src/lib/db/migrations.ts | 4 +
src/lib/db/payment-currency-migration.ts | 44 +++
src/lib/db/types.ts | 19 +
32 files changed, 956 insertions(+), 43 deletions(-)
create mode 100644 src/app/(app)/invoices/[id]/history/[revisionId]/page.tsx
create mode 100644 src/features/invoices/edits.integration.test.ts
create mode 100644 src/features/invoices/edits.ts
create mode 100644 src/features/invoices/revision-query.ts
create mode 100644 src/features/payments/invoice-allocation.test.ts
create mode 100644 src/features/payments/invoice-allocation.ts
create mode 100644 src/lib/db/invoice-edit-migration.ts
create mode 100644 src/lib/db/payment-currency-migration.ts
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 (
+
+ );
+}
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 (
) : null}
+ {publishedEdit && customerId === initialValues.customerId ? (
+
+
+
+
+ ) : null}
@@ -456,6 +471,16 @@ export function InvoiceEditor({
+ {publishedEdit ? (
+
+
+
+
+ ) : null}
+