diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md
index d801a92..4fe1fd7 100644
--- a/.github/pull_request_template.md
+++ b/.github/pull_request_template.md
@@ -4,10 +4,4 @@ Describe the user-facing problem and the change that solves it.
## Verification
-- [ ] `bun run typecheck`
-- [ ] `bun run lint`
-- [ ] `bun run test`
-- [ ] `bun run build`
-- [ ] Relevant light and dark UI states checked, if applicable
-- [ ] Documentation and `.env.example` updated, if applicable
-- [ ] No secrets, local databases or real customer data included
+Summarise the relevant checks and their results. Include any failures or checks that could not be run.
diff --git a/README.md b/README.md
index 2df5a0e..337df01 100644
--- a/README.md
+++ b/README.md
@@ -25,10 +25,13 @@
- Creates drafts and searchable A4 PDF invoices in multiple currencies. Issued invoices retain the customer and seller details used at the time.
- 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.
- Issues invoices from recurring schedules that can be paused and resumed.
- Records expenses and receipt images, then reports sales, receipts, expenses, cash net income and collections.
- Exports report data as CSV.
+Customer address maps use a Google Maps iframe and need no API key. Folio makes no map requests until the viewer chooses **Allow once** or **Always allow**. Loading a map shares the billing address and the viewer's IP address with Google, which may also use cookies. **Allow once** lasts for the current page visit; **Always allow** is saved in this browser for all customer maps on this Folio installation. Customers without address details beyond a country do not show a map.
+
## Run with Docker Compose
Create `compose.yaml`:
diff --git a/src/app/(app)/customers/[id]/page.tsx b/src/app/(app)/customers/[id]/page.tsx
index 1ed4d15..e985d9a 100644
--- a/src/app/(app)/customers/[id]/page.tsx
+++ b/src/app/(app)/customers/[id]/page.tsx
@@ -7,6 +7,7 @@ import { EntityHeader } from "@/components/folio/entity-header";
import { StatusBadge } from "@/components/folio/status-badge";
import { Button } from "@/components/ui/button";
import { CustomerAvatar } from "@/features/customers/customer-avatar";
+import { CustomerAddressMap } from "@/features/customers/customer-address-map";
import {
archiveCustomer,
restoreCustomer,
@@ -17,6 +18,7 @@ import {
} from "@/features/invoices/calculations";
import { invoiceStatusTone } from "@/features/invoices/presentation";
import type { Customer } from "@/lib/db/types";
+import { countryName } from "@/lib/countries";
import { formatDate, formatMoney } from "@/lib/format";
type CustomerPageProps = {
@@ -30,7 +32,7 @@ function addressLines(customer: Customer) {
customer.address_line_1,
customer.address_line_2,
[customer.city, customer.region, customer.postal_code].filter(Boolean).join(", "),
- customer.country_code,
+ countryName(customer.country_code),
].filter(Boolean) as string[];
}
@@ -42,6 +44,17 @@ export default async function CustomerPage({ params }: CustomerPageProps) {
const { customer, invoices, summary } = workspace;
const address = addressLines(customer);
+ const mapLocation = [
+ customer.address_line_1,
+ customer.address_line_2,
+ customer.city,
+ customer.region,
+ customer.postal_code,
+ ].map((part) => part?.trim()).filter(Boolean).join(", ");
+ const mapAddress = [
+ mapLocation,
+ countryName(customer.country_code),
+ ].filter(Boolean).join(", ");
return (
@@ -225,6 +238,9 @@ export default async function CustomerPage({ params }: CustomerPageProps) {
) : (
No billing address added.
)}
+ {mapLocation ? (
+
+ ) : null}
diff --git a/src/app/(app)/customers/page.tsx b/src/app/(app)/customers/page.tsx
index 4ed81ca..22e4576 100644
--- a/src/app/(app)/customers/page.tsx
+++ b/src/app/(app)/customers/page.tsx
@@ -24,6 +24,7 @@ import { HorizontalSlidingTabBar } from "@/components/folio/sliding-tab-bar";
import { StatusBadge } from "@/components/folio/status-badge";
import { Input } from "@/components/ui/input";
import { formatDate, formatMoney } from "@/lib/format";
+import { countryName } from "@/lib/countries";
import {
buildListHref,
DEFAULT_LIST_PAGE_LIMIT,
@@ -257,7 +258,7 @@ export default async function CustomersPage({ searchParams }: CustomersPageProps
{customer.name}
- {customer.country_code ?? "No country added"}
+ {countryName(customer.country_code) ?? "No country added"}
diff --git a/src/app/(app)/settings/page.tsx b/src/app/(app)/settings/page.tsx
index fad47d7..93419cb 100644
--- a/src/app/(app)/settings/page.tsx
+++ b/src/app/(app)/settings/page.tsx
@@ -109,7 +109,7 @@ export default async function SettingsPage() {
id="countryCode"
name="countryCode"
options={COUNTRIES.map((country) => ({
- description: country.code,
+ keywords: country.code,
label: country.name,
value: country.code,
}))}
diff --git a/src/components/auth/business-setup/address-step.tsx b/src/components/auth/business-setup/address-step.tsx
index a57d035..08d2788 100644
--- a/src/components/auth/business-setup/address-step.tsx
+++ b/src/components/auth/business-setup/address-step.tsx
@@ -7,7 +7,7 @@ import { COUNTRIES } from "@/lib/countries"
import { cn } from "@/lib/utils"
const countryOptions = COUNTRIES.map((country) => ({
- description: country.code,
+ keywords: country.code,
label: country.name,
value: country.code,
}))
diff --git a/src/components/folio/app-shell.tsx b/src/components/folio/app-shell.tsx
index ef56b1e..654e066 100644
--- a/src/components/folio/app-shell.tsx
+++ b/src/components/folio/app-shell.tsx
@@ -624,7 +624,7 @@ function SidebarContent({
mobile?: boolean;
}) {
return (
-
+
{mobile ? (
diff --git a/src/features/customers/customer-address-map.tsx b/src/features/customers/customer-address-map.tsx
new file mode 100644
index 0000000..c689526
--- /dev/null
+++ b/src/features/customers/customer-address-map.tsx
@@ -0,0 +1,100 @@
+"use client";
+
+import { useState, useSyncExternalStore } from "react";
+
+import { Button } from "@/components/ui/button";
+import { MapPin } from "@/components/ui/icons";
+
+const CONSENT_KEY = "folio:google-maps-consent";
+const CONSENT_EVENT = "folio:google-maps-consent-change";
+
+function hasSavedConsent() {
+ try {
+ return window.localStorage.getItem(CONSENT_KEY) === "allowed";
+ } catch {
+ return false;
+ }
+}
+
+function subscribeToConsent(onChange: () => void) {
+ function onStorage(event: StorageEvent) {
+ if (event.key === CONSENT_KEY || event.key === null) onChange();
+ }
+
+ window.addEventListener("storage", onStorage);
+ window.addEventListener(CONSENT_EVENT, onChange);
+ return () => {
+ window.removeEventListener("storage", onStorage);
+ window.removeEventListener(CONSENT_EVENT, onChange);
+ };
+}
+
+export function CustomerAddressMap({ address }: { address: string }) {
+ const alwaysAllow = useSyncExternalStore(
+ subscribeToConsent,
+ hasSavedConsent,
+ () => false,
+ );
+ const [allowedOnce, setAllowedOnce] = useState(false);
+ const [message, setMessage] = useState(null);
+ const allowed = allowedOnce || alwaysAllow;
+
+ function allow(always: boolean) {
+ setMessage(null);
+ if (!always) {
+ setAllowedOnce(true);
+ return;
+ }
+
+ try {
+ window.localStorage.setItem(CONSENT_KEY, "allowed");
+ window.dispatchEvent(new Event(CONSENT_EVENT));
+ setAllowedOnce(false);
+ } catch {
+ setAllowedOnce(true);
+ setMessage("Your browser could not save this preference. The map is allowed for this visit only.");
+ }
+ }
+
+ return (
+
+ {allowed ? (
+
+ ) : (
+
+
+
+ View address on a map
+
+
+ Loading this map shares the customer's billing address and your IP
+ address with Google. Google may also use cookies. Nothing is sent to
+ Google Maps until you allow it.
+
+
+ “Always allow” applies to all customer maps in this browser.
+
+
+ allow(false)} type="button" variant="outline">
+ Allow once
+
+ allow(true)} type="button" variant="outline">
+ Always allow
+
+
+
+ )}
+ {message ? (
+
+ {message}
+
+ ) : null}
+
+ );
+}
diff --git a/src/features/customers/customer-form.tsx b/src/features/customers/customer-form.tsx
index 72ada98..605a6f1 100644
--- a/src/features/customers/customer-form.tsx
+++ b/src/features/customers/customer-form.tsx
@@ -345,7 +345,7 @@ export function CustomerForm({ action, cancelHref, customer }: CustomerFormProps
options={[
{ label: "No country selected", value: "" },
...COUNTRIES.map((country) => ({
- description: country.code,
+ keywords: country.code,
label: country.name,
value: country.code,
})),
diff --git a/src/features/invoices/aggregate.ts b/src/features/invoices/aggregate.ts
index 557eabb..b11706e 100644
--- a/src/features/invoices/aggregate.ts
+++ b/src/features/invoices/aggregate.ts
@@ -102,8 +102,8 @@ export function prepareInvoiceAggregate(
input.customer.city,
input.customer.region,
input.customer.postal_code,
- input.customer.country_code,
]),
+ customer_country_code: input.customer.country_code,
notes: input.notes,
payment_instructions: input.paymentInstructions,
subtotal_cents: totals.subtotalCents,
@@ -165,12 +165,14 @@ export async function replaceDraftInvoiceAggregate(
seller_phone: invoice.seller_phone,
seller_tax_id: invoice.seller_tax_id,
seller_address: invoice.seller_address,
+ seller_country_code: invoice.seller_country_code,
customer_name: invoice.customer_name,
customer_billing_name: invoice.customer_billing_name,
customer_email: invoice.customer_email,
customer_phone: invoice.customer_phone,
customer_tax_id: invoice.customer_tax_id,
customer_address: invoice.customer_address,
+ customer_country_code: invoice.customer_country_code,
notes: invoice.notes,
payment_instructions: invoice.payment_instructions,
invoice_footer: invoice.invoice_footer,
diff --git a/src/features/invoices/country-snapshot.test.ts b/src/features/invoices/country-snapshot.test.ts
new file mode 100644
index 0000000..20fe1a7
--- /dev/null
+++ b/src/features/invoices/country-snapshot.test.ts
@@ -0,0 +1,198 @@
+import { randomBytes, randomInt, randomUUID } from "node:crypto";
+
+import { sql } from "kysely";
+import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
+
+import type { Business, Customer } from "@/lib/db/types";
+
+import {
+ insertInvoiceAggregate,
+ prepareInvoiceAggregate,
+ replaceDraftInvoiceAggregate,
+} from "./aggregate";
+
+const date = new Date(Date.UTC(2024, randomInt(0, 12), randomInt(1, 28)))
+ .toISOString().slice(0, 10);
+const timestamp = `${date}T12:00:00.000Z`;
+const business: Business = {
+ id: randomUUID(),
+ owner_user_id: randomUUID(),
+ name: `Fictional Seller ${randomUUID()}`,
+ legal_name: null,
+ email: `${randomUUID()}@example.invalid`,
+ phone: null,
+ tax_id: null,
+ address_line_1: `${randomInt(1, 999)} Fictional-${randomUUID()} Way`,
+ address_line_2: null,
+ city: `Imaginary-${randomUUID()}`,
+ region: "CA",
+ postal_code: null,
+ country_code: "CA",
+ currency: "USD",
+ timezone: "UTC",
+ invoice_prefix: "SYN",
+ next_invoice_number: 1,
+ default_payment_terms_days: 0,
+ payment_instructions: null,
+ invoice_footer: null,
+ 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: null,
+ phone: null,
+ tax_id: null,
+ address_line_1: `${randomInt(1, 999)} Fictional-${randomUUID()} Way`,
+ address_line_2: null,
+ city: `Imaginary-${randomUUID()}`,
+ region: "CA",
+ postal_code: null,
+ country_code: "CA",
+ notes: null,
+ archived_at: null,
+ created_at: timestamp,
+ updated_at: timestamp,
+};
+
+let database: typeof import("@/lib/db");
+let documents: typeof import("./document-query");
+
+beforeAll(async () => {
+ vi.stubEnv("DATABASE_URL", ":memory:");
+ vi.stubEnv("DATABASE_DIALECT", "sqlite");
+ vi.stubEnv("BETTER_AUTH_SECRET", randomBytes(32).toString("hex"));
+ vi.stubEnv("FOLIO_PUBLIC_URL", "http://localhost:3000");
+ vi.resetModules();
+ database = await import("@/lib/db");
+ await database.migrateDatabase();
+ documents = await import("./document-query");
+ await database.db.insertInto("auth_user").values({
+ id: business.owner_user_id,
+ name: `Fictional Owner ${randomUUID()}`,
+ email: `${randomUUID()}@example.invalid`,
+ email_verified: 1,
+ image: null,
+ created_at: timestamp,
+ updated_at: timestamp,
+ }).execute();
+ await database.db.insertInto("businesses").values(business).execute();
+ await database.db.insertInto("customers").values(customer).execute();
+});
+
+afterAll(async () => {
+ await database?.closeDatabase();
+ vi.unstubAllEnvs();
+ vi.resetModules();
+});
+
+function aggregateFor(countryCode: string | null, id: string = randomUUID()) {
+ return prepareInvoiceAggregate({
+ id,
+ business: { ...business, country_code: countryCode },
+ customer: { ...customer, country_code: countryCode },
+ recurringInvoiceId: null,
+ recurrenceIndex: null,
+ invoiceNumber: null,
+ lifecycle: "draft",
+ issueDate: date,
+ dueDate: null,
+ currency: "USD",
+ exchangeRate: { base: "USD", quote: "USD", rateMicros: 1_000_000, date, source: "synthetic" },
+ notes: null,
+ paymentInstructions: null,
+ lines: [{
+ itemId: null,
+ description: `Fictional Service ${randomUUID()}`,
+ unit: "",
+ quantityThousandths: 1_000,
+ unitPriceCents: randomInt(100, 10_000),
+ taxRateBps: 0,
+ }],
+ timestamp,
+ }, randomUUID);
+}
+
+describe("invoice country snapshots", () => {
+ it.each([
+ [null, null],
+ ["CA", "Canada"],
+ ["US", "United States"],
+ ["GB", "United Kingdom"],
+ ])("keeps state CA separate from country %s in stored and rendered invoices", async (code, name) => {
+ const aggregate = aggregateFor(code);
+ await insertInvoiceAggregate(database.db, aggregate);
+ const stored = await database.db.selectFrom("invoices").selectAll()
+ .where("id", "=", aggregate.invoice.id).executeTakeFirstOrThrow();
+ expect(stored.seller_country_code).toBe(code);
+ expect(stored.customer_country_code).toBe(code);
+ expect(stored.seller_address).toBe(`${business.address_line_1}\n${business.city}\nCA`);
+ expect(stored.customer_address).toBe(`${customer.address_line_1}\n${customer.city}\nCA`);
+
+ const record = await documents.getInvoiceDocumentRecord(business, stored.id);
+ expect(record?.data.seller.address).toEqual([
+ business.address_line_1, business.city, "CA", ...(name ? [name] : []),
+ ]);
+ expect(record?.data.customer.address).toEqual([
+ customer.address_line_1, customer.city, "CA", ...(name ? [name] : []),
+ ]);
+ });
+
+ it("replaces and clears draft country snapshots together with their addresses", async () => {
+ const aggregate = aggregateFor("CA");
+ 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)),
+ );
+ const record = await documents.getInvoiceDocumentRecord(business, aggregate.invoice.id);
+ expect(record?.data.seller.address.at(-1)).toBe(lastLine);
+ expect(record?.data.customer.address.at(-1)).toBe(lastLine);
+ expect(record?.data.seller.address).not.toContain("Canada");
+ expect(record?.data.customer.address).not.toContain("Canada");
+ }
+ });
+
+ it("preserves issued country snapshots when current party details change", async () => {
+ const aggregate = aggregateFor("CA");
+ aggregate.invoice.lifecycle = "issued";
+ aggregate.invoice.invoice_number = `SYN-${randomUUID()}`;
+ await insertInvoiceAggregate(database.db, aggregate);
+ await database.db.updateTable("businesses").set({ country_code: "US" })
+ .where("id", "=", business.id).execute();
+ await database.db.updateTable("customers").set({ country_code: "US" })
+ .where("id", "=", customer.id).execute();
+ const record = await documents.getInvoiceDocumentRecord(business, aggregate.invoice.id);
+ expect(record?.data.seller.address.at(-1)).toBe("Canada");
+ expect(record?.data.customer.address.at(-1)).toBe("Canada");
+ });
+
+ it("migrates legacy invoices without guessing countries or rewriting their addresses", async () => {
+ const aggregate = aggregateFor(null);
+ aggregate.invoice.seller_address = `${business.address_line_1}\nCA`;
+ aggregate.invoice.customer_address = `${customer.address_line_1}\nGB`;
+ 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'`
+ .execute(database.db);
+
+ await database.migrateDatabase();
+ await database.migrateDatabase();
+ const stored = await database.db.selectFrom("invoices").selectAll()
+ .where("id", "=", aggregate.invoice.id).executeTakeFirstOrThrow();
+ expect(stored.seller_country_code).toBeNull();
+ expect(stored.customer_country_code).toBeNull();
+ expect(stored.seller_address).toBe(aggregate.invoice.seller_address);
+ expect(stored.customer_address).toBe(aggregate.invoice.customer_address);
+ const record = await documents.getInvoiceDocumentRecord(business, stored.id);
+ expect(record?.data.seller.address).toEqual([business.address_line_1, "CA"]);
+ expect(record?.data.customer.address).toEqual([customer.address_line_1, "GB"]);
+ });
+});
diff --git a/src/features/invoices/document-mapper.ts b/src/features/invoices/document-mapper.ts
index 418305d..e054392 100644
--- a/src/features/invoices/document-mapper.ts
+++ b/src/features/invoices/document-mapper.ts
@@ -1,12 +1,19 @@
+import { countryName } from "@/lib/countries";
+
import { invoiceStatusLabel } from "./calculations";
import type { InvoiceDocumentData } from "./document-data";
import type { InvoiceDetail } from "./types";
-function splitAddress(address: string | null) {
- return (address ?? "")
+function splitAddress(address: string | null, countryCode: string | null) {
+ const lines = (address ?? "")
.split("\n")
.map((line) => line.trim())
.filter(Boolean);
+
+ // Legacy snapshots have no country metadata; preserve their address lines.
+ const country = countryName(countryCode);
+ if (country) lines.push(country);
+ return lines;
}
function paymentDetailText(value: string) {
@@ -56,14 +63,14 @@ export function buildInvoiceDocumentData(
currency: invoice.currency,
seller: {
name: invoice.seller_name,
- address: splitAddress(invoice.seller_address),
+ address: splitAddress(invoice.seller_address, invoice.seller_country_code),
...(invoice.seller_email ? { email: invoice.seller_email } : {}),
...(invoice.seller_phone ? { phone: invoice.seller_phone } : {}),
...(invoice.seller_tax_id ? { taxId: invoice.seller_tax_id } : {}),
},
customer: {
name: invoice.customer_billing_name ?? invoice.customer_name,
- address: splitAddress(invoice.customer_address),
+ address: splitAddress(invoice.customer_address, invoice.customer_country_code),
...(invoice.customer_email ? { email: invoice.customer_email } : {}),
...(invoice.customer_phone ? { phone: invoice.customer_phone } : {}),
...(invoice.customer_tax_id ? { taxId: invoice.customer_tax_id } : {}),
diff --git a/src/features/invoices/seller-snapshot.test.ts b/src/features/invoices/seller-snapshot.test.ts
index e02580f..41429d0 100644
--- a/src/features/invoices/seller-snapshot.test.ts
+++ b/src/features/invoices/seller-snapshot.test.ts
@@ -1,3 +1,4 @@
+import { randomInt, randomUUID } from "node:crypto";
import { describe, expect, it } from "vitest";
import type { Business } from "@/lib/db";
@@ -5,29 +6,30 @@ import type { Business } from "@/lib/db";
import { buildInvoiceSellerSnapshot } from "./seller-snapshot";
const business = {
- name: "Folio Studio",
- legal_name: "Folio Studio Ltd",
- email: "billing@folio.test",
- phone: "+44 20 7946 0000",
- tax_id: "GB123456789",
- address_line_1: "1 Market Street",
+ name: `Fictional Studio ${randomUUID()}`,
+ legal_name: `Fictional Company ${randomUUID()}`,
+ email: `${randomUUID()}@example.invalid`,
+ phone: `synthetic-${randomInt(100000, 999999)}`,
+ tax_id: `synthetic-${randomUUID()}`,
+ address_line_1: `${randomInt(1, 999)} Fictional-${randomUUID()} Way`,
address_line_2: null,
- city: "London",
+ city: `Imaginary-${randomUUID()}`,
region: null,
- postal_code: "EC1 1AA",
- country_code: "GB",
- invoice_footer: "Registered in England and Wales.",
+ postal_code: `SYN-${randomInt(1000, 9999)}`,
+ country_code: "CA",
+ invoice_footer: `Fictional footer ${randomUUID()}`,
} as Business;
describe("buildInvoiceSellerSnapshot", () => {
it("uses current legal and contact details for a draft invoice", () => {
expect(buildInvoiceSellerSnapshot(business)).toEqual({
- seller_name: "Folio Studio Ltd",
- seller_email: "billing@folio.test",
- seller_phone: "+44 20 7946 0000",
- seller_tax_id: "GB123456789",
- seller_address: "1 Market Street\nLondon\nEC1 1AA\nGB",
- invoice_footer: "Registered in England and Wales.",
+ seller_name: business.legal_name,
+ seller_email: business.email,
+ seller_phone: business.phone,
+ seller_tax_id: business.tax_id,
+ seller_address: `${business.address_line_1}\n${business.city}\n${business.postal_code}`,
+ seller_country_code: "CA",
+ invoice_footer: business.invoice_footer,
});
});
@@ -42,8 +44,9 @@ describe("buildInvoiceSellerSnapshot", () => {
country_code: null,
}),
).toMatchObject({
- seller_name: "Folio Studio",
+ seller_name: business.name,
seller_address: null,
+ seller_country_code: null,
});
});
});
diff --git a/src/features/invoices/seller-snapshot.ts b/src/features/invoices/seller-snapshot.ts
index 639d5f3..1eefc72 100644
--- a/src/features/invoices/seller-snapshot.ts
+++ b/src/features/invoices/seller-snapshot.ts
@@ -13,8 +13,8 @@ export function buildInvoiceSellerSnapshot(business: Business) {
business.city,
business.region,
business.postal_code,
- business.country_code,
]),
+ seller_country_code: business.country_code,
invoice_footer: business.invoice_footer,
};
}
diff --git a/src/lib/countries.ts b/src/lib/countries.ts
index 59e37f8..2603a76 100644
--- a/src/lib/countries.ts
+++ b/src/lib/countries.ts
@@ -9,7 +9,7 @@ export const COUNTRIES = [
{ code: "AO", name: "Angola" },
{ code: "AI", name: "Anguilla" },
{ code: "AQ", name: "Antarctica" },
- { code: "AG", name: "Antigua & Barbuda" },
+ { code: "AG", name: "Antigua and Barbuda" },
{ code: "AR", name: "Argentina" },
{ code: "AM", name: "Armenia" },
{ code: "AW", name: "Aruba" },
@@ -27,7 +27,7 @@ export const COUNTRIES = [
{ code: "BM", name: "Bermuda" },
{ code: "BT", name: "Bhutan" },
{ code: "BO", name: "Bolivia" },
- { code: "BA", name: "Bosnia & Herzegovina" },
+ { code: "BA", name: "Bosnia and Herzegovina" },
{ code: "BW", name: "Botswana" },
{ code: "BV", name: "Bouvet Island" },
{ code: "BR", name: "Brazil" },
@@ -98,9 +98,9 @@ export const COUNTRIES = [
{ code: "GW", name: "Guinea-Bissau" },
{ code: "GY", name: "Guyana" },
{ code: "HT", name: "Haiti" },
- { code: "HM", name: "Heard & McDonald Islands" },
+ { code: "HM", name: "Heard and McDonald Islands" },
{ code: "HN", name: "Honduras" },
- { code: "HK", name: "Hong Kong SAR China" },
+ { code: "HK", name: "Hong Kong Special Administrative Region of China" },
{ code: "HU", name: "Hungary" },
{ code: "IS", name: "Iceland" },
{ code: "IN", name: "India" },
@@ -129,7 +129,7 @@ export const COUNTRIES = [
{ code: "LI", name: "Liechtenstein" },
{ code: "LT", name: "Lithuania" },
{ code: "LU", name: "Luxembourg" },
- { code: "MO", name: "Macao SAR China" },
+ { code: "MO", name: "Macao Special Administrative Region of China" },
{ code: "MG", name: "Madagascar" },
{ code: "MW", name: "Malawi" },
{ code: "MY", name: "Malaysia" },
@@ -186,7 +186,7 @@ export const COUNTRIES = [
{ code: "RW", name: "Rwanda" },
{ code: "WS", name: "Samoa" },
{ code: "SM", name: "San Marino" },
- { code: "ST", name: "São Tomé & Príncipe" },
+ { code: "ST", name: "São Tomé and Príncipe" },
{ code: "SA", name: "Saudi Arabia" },
{ code: "SN", name: "Senegal" },
{ code: "RS", name: "Serbia" },
@@ -199,21 +199,21 @@ export const COUNTRIES = [
{ code: "SB", name: "Solomon Islands" },
{ code: "SO", name: "Somalia" },
{ code: "ZA", name: "South Africa" },
- { code: "GS", name: "South Georgia & South Sandwich Islands" },
+ { code: "GS", name: "South Georgia and South Sandwich Islands" },
{ code: "KR", name: "South Korea" },
{ code: "SS", name: "South Sudan" },
{ code: "ES", name: "Spain" },
{ code: "LK", name: "Sri Lanka" },
- { code: "BL", name: "St. Barthélemy" },
- { code: "SH", name: "St. Helena" },
- { code: "KN", name: "St. Kitts & Nevis" },
- { code: "LC", name: "St. Lucia" },
- { code: "MF", name: "St. Martin" },
- { code: "PM", name: "St. Pierre & Miquelon" },
- { code: "VC", name: "St. Vincent & Grenadines" },
+ { code: "BL", name: "Saint Barthélemy" },
+ { code: "SH", name: "Saint Helena" },
+ { code: "KN", name: "Saint Kitts and Nevis" },
+ { code: "LC", name: "Saint Lucia" },
+ { code: "MF", name: "Saint Martin" },
+ { code: "PM", name: "Saint Pierre and Miquelon" },
+ { code: "VC", name: "Saint Vincent and Grenadines" },
{ code: "SD", name: "Sudan" },
{ code: "SR", name: "Suriname" },
- { code: "SJ", name: "Svalbard & Jan Mayen" },
+ { code: "SJ", name: "Svalbard and Jan Mayen" },
{ code: "SE", name: "Sweden" },
{ code: "CH", name: "Switzerland" },
{ code: "SY", name: "Syria" },
@@ -225,14 +225,14 @@ export const COUNTRIES = [
{ code: "TG", name: "Togo" },
{ code: "TK", name: "Tokelau" },
{ code: "TO", name: "Tonga" },
- { code: "TT", name: "Trinidad & Tobago" },
+ { code: "TT", name: "Trinidad and Tobago" },
{ code: "TN", name: "Tunisia" },
{ code: "TR", name: "Türkiye" },
{ code: "TM", name: "Turkmenistan" },
- { code: "TC", name: "Turks & Caicos Islands" },
+ { code: "TC", name: "Turks and Caicos Islands" },
{ code: "TV", name: "Tuvalu" },
- { code: "UM", name: "U.S. Outlying Islands" },
- { code: "VI", name: "U.S. Virgin Islands" },
+ { code: "UM", name: "United States Outlying Islands" },
+ { code: "VI", name: "United States Virgin Islands" },
{ code: "UG", name: "Uganda" },
{ code: "UA", name: "Ukraine" },
{ code: "AE", name: "United Arab Emirates" },
@@ -244,7 +244,7 @@ export const COUNTRIES = [
{ code: "VA", name: "Vatican City" },
{ code: "VE", name: "Venezuela" },
{ code: "VN", name: "Vietnam" },
- { code: "WF", name: "Wallis & Futuna" },
+ { code: "WF", name: "Wallis and Futuna" },
{ code: "EH", name: "Western Sahara" },
{ code: "YE", name: "Yemen" },
{ code: "ZM", name: "Zambia" },
@@ -254,3 +254,9 @@ export const COUNTRIES = [
export const COUNTRY_CODES = new Set(
COUNTRIES.map((country) => country.code),
);
+
+export function countryName(value: string | null | undefined): string | null {
+ const code = value?.trim();
+ if (!code) return null;
+ return COUNTRIES.find((country) => country.code === code.toUpperCase())?.name ?? code;
+}
diff --git a/src/lib/db/migrations.contract.test.ts b/src/lib/db/migrations.contract.test.ts
index 239224c..5daead4 100644
--- a/src/lib/db/migrations.contract.test.ts
+++ b/src/lib/db/migrations.contract.test.ts
@@ -51,6 +51,8 @@ async function expectCurrentSchema(database: DatabaseModule): Promise {
"exchange_rate_date",
"exchange_rate_micros",
"exchange_rate_source",
+ "seller_country_code",
+ "customer_country_code",
]),
);
}
diff --git a/src/lib/db/migrations.ts b/src/lib/db/migrations.ts
index ddc5195..2e089a9 100644
--- a/src/lib/db/migrations.ts
+++ b/src/lib/db/migrations.ts
@@ -559,6 +559,46 @@ const domainMigrationProvider = {
.execute();
},
},
+ "202609070001_invoice_country_snapshot": {
+ async up(database) {
+ // Existing address strings cannot reliably identify a country.
+ await database.schema
+ .alterTable("invoices")
+ .addColumn("seller_country_code", "text")
+ .execute();
+ await database.schema
+ .alterTable("invoices")
+ .addColumn("customer_country_code", "text")
+ .execute();
+ },
+ async down(database) {
+ // Restore the legacy address format before removing the metadata.
+ await sql`
+ update invoices
+ set seller_address = case
+ when seller_address is null then seller_country_code
+ else seller_address || ${"\n"} || seller_country_code
+ end
+ where seller_country_code is not null
+ `.execute(database);
+ await sql`
+ update invoices
+ set customer_address = case
+ when customer_address is null then customer_country_code
+ else customer_address || ${"\n"} || customer_country_code
+ end
+ where customer_country_code is not null
+ `.execute(database);
+ await database.schema
+ .alterTable("invoices")
+ .dropColumn("customer_country_code")
+ .execute();
+ await database.schema
+ .alterTable("invoices")
+ .dropColumn("seller_country_code")
+ .execute();
+ },
+ },
};
},
};
diff --git a/src/lib/db/types.ts b/src/lib/db/types.ts
index f4b3f0d..ebef12b 100644
--- a/src/lib/db/types.ts
+++ b/src/lib/db/types.ts
@@ -235,6 +235,11 @@ export interface InvoicesTable {
>;
seller_tax_id: string | null;
seller_address: string | null;
+ seller_country_code: ColumnType<
+ string | null,
+ string | null | undefined,
+ string | null
+ >;
customer_name: string;
customer_billing_name: ColumnType<
string | null,
@@ -249,6 +254,11 @@ export interface InvoicesTable {
>;
customer_tax_id: string | null;
customer_address: string | null;
+ customer_country_code: ColumnType<
+ string | null,
+ string | null | undefined,
+ string | null
+ >;
notes: string | null;
payment_instructions: string | null;
invoice_footer: ColumnType<