Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
10 changes: 7 additions & 3 deletions src/app/(app)/invoices/[id]/edit/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
Expand All @@ -36,18 +36,21 @@ export default async function EditInvoicePage({ params }: EditInvoicePageProps)
<PageHeader
breadcrumbs={[
{ label: "Invoices", href: "/invoices" },
{ label: "Draft invoice", href: `/invoices/${id}` },
{ label: detail.invoice.invoice_number ?? "Draft invoice", href: `/invoices/${id}` },
{ label: "Edit" },
]}
title="Edit invoice"
description="Changes update this draft’s customer snapshot and totals."
description={detail.invoice.lifecycle === "draft"
? "Changes update this draft’s customer snapshot and totals."
: "Update this invoice and keep its previous version in edit history."}
/>

<InvoiceEditor
action={updateInvoiceAction}
baseCurrency={business.currency}
cancelHref={`/invoices/${id}`}
customers={options.customers}
expectedUpdatedAt={detail.invoice.updated_at}
initialValues={{
customerId: detail.invoice.customer_id ?? "",
currency: detail.invoice.currency,
Expand Down Expand Up @@ -75,6 +78,7 @@ export default async function EditInvoicePage({ params }: EditInvoicePageProps)
}}
invoiceId={id}
items={options.items}
publishedEdit={detail.invoice.lifecycle !== "draft"}
/>
</div>
);
Expand Down
47 changes: 47 additions & 0 deletions src/app/(app)/invoices/[id]/history/[revisionId]/page.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="mx-auto w-full max-w-6xl space-y-6">
<PageHeader
breadcrumbs={[
{ label: "Invoices", href: "/invoices" },
{ label: revision.invoice_number ?? "Invoice", href: `/invoices/${id}` },
{ label: "Previous version" },
]}
title="Previous invoice version"
description={`Saved before ${revision.actor_name} edited this invoice on ${savedAt}.`}
/>
<div className="flex flex-wrap items-center justify-between gap-3 rounded-2xl border bg-card p-4 text-[13px]">
<p className="text-muted-foreground">This is a historical copy. Open the current invoice to make changes.</p>
<Button asChild variant="outline">
<a href={`/api/invoices/${id}/pdf?revision=${revision.id}`}>Download previous PDF</a>
</Button>
</div>
<div className="overflow-x-auto rounded-2xl bg-muted p-4 sm:p-6">
<div className="mx-auto w-[700px]">
<InvoiceDocument data={buildInvoiceDocumentData(revision.detail)} />
</div>
</div>
</div>
);
}
55 changes: 55 additions & 0 deletions src/app/(app)/invoices/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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" &&
Expand Down Expand Up @@ -93,6 +98,12 @@ export default async function InvoicePage({ params }: InvoicePageProps) {
</>
) : (
<>
<Button asChild className="text-[13px]" variant="outline">
<Link href={`/invoices/${invoice.id}/edit`}>
<Pencil aria-hidden="true" className="size-[14px]" />
Edit
</Link>
</Button>
{payableInvoices.length ? (
<PaymentDialog
action={recordPaymentAction}
Expand Down Expand Up @@ -153,6 +164,15 @@ export default async function InvoicePage({ params }: InvoicePageProps) {
>
Invoice details
</a>
{detail.revisions?.length ? (
<a
className="relative z-10 shrink-0 border-b-2 border-transparent px-0.5 pb-2.5 text-[13px] font-medium text-subtle-foreground motion-safe:transition-[color,transform] motion-safe:duration-150 hover:text-muted-foreground motion-safe:active:scale-[0.98]"
data-sliding-tab
href="#history"
>
Edit history
</a>
) : null}
</HorizontalSlidingTabBar>

<div className="grid gap-5 min-[1440px]:grid-cols-[minmax(0,1fr)_20rem]">
Expand Down Expand Up @@ -197,6 +217,14 @@ export default async function InvoicePage({ params }: InvoicePageProps) {
<dt className="text-subtle-foreground">Paid</dt>
<dd className="font-medium text-foreground">{formatMoney(detail.paidCents, invoice.currency)}</dd>
</div>
{detail.paidCents > invoice.total_cents ? (
<div className="flex items-center justify-between gap-4">
<dt className="text-warning">Overpaid</dt>
<dd className="font-medium text-warning">
{formatMoney(detail.paidCents - invoice.total_cents, invoice.currency)}
</dd>
</div>
) : null}
<div className="flex items-center justify-between gap-4">
<dt className="text-subtle-foreground">Tax</dt>
<dd className="font-medium text-foreground">{formatMoney(invoice.tax_cents, invoice.currency)}</dd>
Expand Down Expand Up @@ -298,6 +326,33 @@ export default async function InvoicePage({ params }: InvoicePageProps) {
) : null}
</dl>
</section>

{detail.revisions?.length ? (
<section className="scroll-mt-24 overflow-hidden rounded-[16px] bg-card" id="history">
<div className="border-b p-4">
<h2 className="text-[14px] font-medium text-foreground">Edit history</h2>
<p className="mt-0.5 text-[12px] leading-5 text-subtle-foreground">
Open the version saved before each edit.
</p>
</div>
<div className="divide-y">
{detail.revisions.map((revision) => (
<Link
className="block p-4 transition-colors hover:bg-muted/50"
href={`/invoices/${invoice.id}/history/${revision.id}`}
key={revision.id}
>
<p className="text-[13px] font-medium text-foreground">
{revision.invoice_number} · {formatMoney(revision.total_cents, revision.currency)}
</p>
<p className="mt-1 text-[12px] leading-5 text-subtle-foreground">
Before edit by {revision.actor_name} · {historyDateFormatter.format(new Date(revision.created_at))}
</p>
</Link>
))}
</div>
</section>
) : null}
</aside>
</div>
</div>
Expand Down
27 changes: 23 additions & 4 deletions src/app/api/invoices/[id]/pdf/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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;

Expand All @@ -30,7 +49,7 @@ export async function GET(_request: Request, { params }: PdfRouteContext) {
]);
const document = createElement(InvoicePdfDocument, { data }) as ReactElement<DocumentProps>;
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: {
Expand Down
2 changes: 1 addition & 1 deletion src/features/customers/queries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@ export async function listCustomers(
const paymentTotals = db
.selectFrom("payments")
.select("invoice_id")
.select(({ fn }) => fn.sum<number>("amount_cents").as("paid_cents"))
.select(sql<number>`sum(coalesce(applied_amount_cents, amount_cents))`.as("paid_cents"))
.where("business_id", "=", business.id)
.groupBy("invoice_id")
.as("payment_totals");
Expand Down
5 changes: 3 additions & 2 deletions src/features/dashboard/queries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,8 @@ export async function getDashboardSummary(
.execute(),
db
.selectFrom("payments")
.select(["invoice_id", "amount_cents"])
.select("invoice_id")
.select(sql<number>`coalesce(applied_amount_cents, amount_cents)`.as("amount_cents"))
.where("business_id", "=", businessId)
.execute(),
db
Expand Down Expand Up @@ -167,7 +168,7 @@ export async function getDashboardUpdates(
const paymentTotals = db
.selectFrom("payments")
.select("invoice_id")
.select(({ fn }) => fn.sum<number>("amount_cents").as("paid_cents"))
.select(sql<number>`sum(coalesce(applied_amount_cents, amount_cents))`.as("paid_cents"))
.where("business_id", "=", businessId)
.groupBy("invoice_id")
.as("payment_totals");
Expand Down
10 changes: 5 additions & 5 deletions src/features/finance/ledger.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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<string | null>`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<number | null>`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<string | null>`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<string | null>`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);
Expand Down
40 changes: 32 additions & 8 deletions src/features/invoices/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ import {
InvoiceAggregateConflictError,
insertInvoiceAggregate,
prepareInvoiceAggregate,
replaceDraftInvoiceAggregate,
type InvoiceExchangeSnapshot,
} from "@/features/invoices/aggregate";
import {
Expand All @@ -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);
Expand Down Expand Up @@ -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;
}

Expand Down Expand Up @@ -199,14 +201,34 @@ export async function updateInvoiceAction(
formData: FormData,
): Promise<InvoiceActionState> {
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({
Expand All @@ -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}`);
}

Expand Down
Loading