-
{formatCurrency(inv.total)}
-
{formatDate(inv.createdAt)}
+
{formatCurrency(inv.total, inv.currency, inv.country)}
+
{formatDate(inv.createdAt, inv.country)}
- Paid: {formatCurrency(inv.paidAmount)}
+ Paid: {formatCurrency(inv.paidAmount, inv.currency, inv.country)}
{balance > 0 && (
- Balance: {formatCurrency(balance)}
+ Balance: {formatCurrency(balance, inv.currency, inv.country)}
)}
{inv.dueDate && (
-
Due: {formatDate(inv.dueDate)}
+
Due: {formatDate(inv.dueDate, inv.country)}
)}
{balance > 0 && inv.status !== "void" && (
@@ -167,16 +171,16 @@ export default function InvoicesPage() {
parseFloat(String(inv.total)) - parseFloat(String(inv.paidAmount));
return (
- | {formatDate(inv.createdAt)} |
+ {formatDate(inv.createdAt, inv.country)} |
{inv.patientName || "-"} |
- {formatCurrency(inv.total)}
+ {formatCurrency(inv.total, inv.currency, inv.country)}
|
- {formatCurrency(inv.paidAmount)}
+ {formatCurrency(inv.paidAmount, inv.currency, inv.country)}
|
0 ? "text-red-600" : "text-green-600"}`}>
- {formatCurrency(balance)}
+ {formatCurrency(balance, inv.currency, inv.country)}
|
{
+ it("formats USD with a dollar sign", () => {
+ const s = formatCurrency(65, "usd");
+ expect(s).toContain("$");
+ expect(s).toContain("65");
+ });
+ it("formats GBP with a pound sign in en-GB", () => {
+ const s = formatCurrency(65, "gbp", "GB");
+ expect(s).toContain("£");
+ expect(s).toContain("65");
+ });
+ it("formats EUR", () => {
+ expect(formatCurrency(40, "eur", "IE")).toContain("€");
+ });
+ it("accepts string amounts from the DB and coerces them", () => {
+ const s = formatCurrency("65.00", "gbp", "GB");
+ expect(s).toContain("£");
+ expect(s).toContain("65");
+ });
+ it("treats null/undefined/non-numeric as zero", () => {
+ expect(formatCurrency(null)).toContain("0");
+ expect(formatCurrency(undefined)).toContain("0");
+ expect(formatCurrency("not-a-number")).toContain("0");
+ });
+});
+
+describe("formatDate", () => {
+ it("uses month/day order for the US and day/month for the UK", () => {
+ const us = formatDate("2026-06-07", "US");
+ const gb = formatDate("2026-06-07", "GB");
+ expect(us).toBe("06/07/2026");
+ expect(gb).toBe("07/06/2026");
+ });
+});
+
+describe("regulatoryFramework", () => {
+ it("returns uk_vmd for GB, us_dea otherwise", () => {
+ expect(regulatoryFramework("GB")).toBe("uk_vmd");
+ expect(regulatoryFramework("US")).toBe("us_dea");
+ expect(regulatoryFramework(null)).toBe("us_dea");
+ });
+});
+
+describe("regionDefaults", () => {
+ it("returns USD/8% for US (and unknown), GBP/20% for GB", () => {
+ expect(regionDefaults("US")).toMatchObject({ currency: "usd", taxRatePercent: "8.00" });
+ expect(regionDefaults("ZZ")).toMatchObject({ currency: "usd" });
+ expect(regionDefaults("GB")).toMatchObject({
+ currency: "gbp",
+ taxRatePercent: "20.00",
+ timezone: "Europe/London",
+ });
+ });
+});
+
+describe("localeForCountry", () => {
+ it("maps known countries and falls back to en-US", () => {
+ expect(localeForCountry("GB")).toBe("en-GB");
+ expect(localeForCountry("xx")).toBe("en-US");
+ expect(localeForCountry(null)).toBe("en-US");
+ });
+});
diff --git a/apps/web/lib/locale/format.ts b/apps/web/lib/locale/format.ts
new file mode 100644
index 0000000..d375e64
--- /dev/null
+++ b/apps/web/lib/locale/format.ts
@@ -0,0 +1,68 @@
+/**
+ * Region/locale helpers. Pure — gates currency + date formatting and supplies
+ * sensible regional defaults so the app isn't hardcoded to US/USD/8% tax.
+ * `country` is ISO 3166-1 alpha-2 (e.g. "US", "GB"); `currency` ISO 4217.
+ */
+
+const COUNTRY_LOCALE: Record = {
+ US: "en-US",
+ GB: "en-GB",
+ IE: "en-IE",
+ CA: "en-CA",
+ AU: "en-AU",
+};
+
+export function localeForCountry(country?: string | null): string {
+ return COUNTRY_LOCALE[(country ?? "US").toUpperCase()] ?? "en-US";
+}
+
+export function formatCurrency(
+ amount: number | string | null | undefined,
+ currency: string = "usd",
+ country?: string | null
+): string {
+ const n = typeof amount === "string" ? parseFloat(amount) : amount ?? 0;
+ return new Intl.NumberFormat(localeForCountry(country), {
+ style: "currency",
+ currency: currency.toUpperCase(),
+ }).format(Number.isFinite(n) ? (n as number) : 0);
+}
+
+export function formatDate(date: Date | string, country?: string | null): string {
+ const d = typeof date === "string" ? new Date(date) : date;
+ return new Intl.DateTimeFormat(localeForCountry(country), {
+ year: "numeric",
+ month: "2-digit",
+ day: "2-digit",
+ // Date-only values must not shift by the runtime's timezone.
+ timeZone: "UTC",
+ }).format(d);
+}
+
+/** Which controlled-drug / prescribing framework applies (used later, P1). */
+export function regulatoryFramework(country?: string | null): "uk_vmd" | "us_dea" {
+ return (country ?? "US").toUpperCase() === "GB" ? "uk_vmd" : "us_dea";
+}
+
+export interface RegionDefaults {
+ currency: string;
+ /** Standard sales-tax / VAT rate as a percent string (e.g. "20.00"). */
+ taxRatePercent: string;
+ timezone: string;
+}
+
+/** Defaults applied when a practice picks a country (onboarding / settings). */
+export function regionDefaults(country?: string | null): RegionDefaults {
+ switch ((country ?? "US").toUpperCase()) {
+ case "GB":
+ return { currency: "gbp", taxRatePercent: "20.00", timezone: "Europe/London" };
+ case "IE":
+ return { currency: "eur", taxRatePercent: "23.00", timezone: "Europe/Dublin" };
+ case "CA":
+ return { currency: "cad", taxRatePercent: "5.00", timezone: "America/Toronto" };
+ case "AU":
+ return { currency: "aud", taxRatePercent: "10.00", timezone: "Australia/Sydney" };
+ default:
+ return { currency: "usd", taxRatePercent: "8.00", timezone: "America/New_York" };
+ }
+}
diff --git a/apps/web/lib/locale/useCurrency.ts b/apps/web/lib/locale/useCurrency.ts
new file mode 100644
index 0000000..ef43aad
--- /dev/null
+++ b/apps/web/lib/locale/useCurrency.ts
@@ -0,0 +1,20 @@
+"use client";
+
+import { trpc } from "@/lib/trpc";
+import { formatCurrency } from "./format";
+
+/**
+ * Returns a currency formatter bound to the current practice's region
+ * (currency + country), so amounts render with the right symbol and locale
+ * instead of being hardcoded to USD. React Query dedupes the underlying
+ * getTaxConfig request across every component that calls this.
+ */
+export function useCurrencyFormatter() {
+ const { data } = trpc.billing.getTaxConfig.useQuery(undefined, {
+ staleTime: 5 * 60 * 1000,
+ });
+ const currency = data?.currency ?? "usd";
+ const country = data?.country ?? "US";
+ return (value: number | string | null | undefined) =>
+ formatCurrency(value, currency, country);
+}
diff --git a/apps/web/lib/pdf.ts b/apps/web/lib/pdf.ts
index 2801c9c..cfe8cea 100644
--- a/apps/web/lib/pdf.ts
+++ b/apps/web/lib/pdf.ts
@@ -77,6 +77,8 @@ export interface InvoiceData {
tax: string;
total: string;
paidAmount: string;
+ /** Pre-formatted balance due (region-aware currency). Falls back to total − paid. */
+ balanceDue?: string;
}
export function generateInvoicePdf(data: InvoiceData): jsPDF {
@@ -248,15 +250,17 @@ export function generateInvoicePdf(data: InvoiceData): jsPDF {
doc.text(data.paidAmount, totalsValX, y, { align: "right" });
y += 6;
- // Balance due
+ // Balance due — prefer the caller's region-formatted value; otherwise derive
+ // it from total − paid (legacy callers without a currency context).
const balanceParts = [data.total, data.paidAmount].map((v) =>
parseFloat(v.replace(/[^0-9.-]/g, ""))
);
- const balance = (balanceParts[0]! - balanceParts[1]!).toFixed(2);
+ const balance =
+ data.balanceDue ?? `$${(balanceParts[0]! - balanceParts[1]!).toFixed(2)}`;
doc.setFont(FONT, "bold");
setColor(doc, COLOR_TEAL);
doc.text("Balance Due:", totalsX, y);
- doc.text(`$${balance}`, totalsValX, y, { align: "right" });
+ doc.text(balance, totalsValX, y, { align: "right" });
// --- Footer ----------------------------------------------------------------
const pageHeight = doc.internal.pageSize.getHeight();
diff --git a/apps/web/lib/stripe.ts b/apps/web/lib/stripe.ts
index f8755d2..ee6d0f3 100644
--- a/apps/web/lib/stripe.ts
+++ b/apps/web/lib/stripe.ts
@@ -12,6 +12,7 @@ export async function createCheckoutSession(data: {
description: string;
successUrl: string;
cancelUrl: string;
+ currency?: string; // ISO 4217 (lowercase), per the practice's region. Defaults to USD.
}): Promise<{ url: string | null } | null> {
if (!stripe) {
console.log("[Stripe] No API key configured, skipping checkout session", data);
@@ -23,7 +24,7 @@ export async function createCheckoutSession(data: {
customer_email: data.clientEmail,
line_items: [{
price_data: {
- currency: "usd",
+ currency: (data.currency ?? "usd").toLowerCase(),
product_data: { name: data.description },
unit_amount: data.amount,
},
diff --git a/apps/web/server/routers/billing.ts b/apps/web/server/routers/billing.ts
index 86d30e1..18c0403 100644
--- a/apps/web/server/routers/billing.ts
+++ b/apps/web/server/routers/billing.ts
@@ -11,9 +11,29 @@ import {
patients,
payments,
users,
+ practices,
} from "@openpims/db";
export const billingRouter = createRouter({
+ // Region-aware billing config for the practice (tax rate + currency).
+ // Available to any authenticated user so invoice forms can preview totals.
+ getTaxConfig: protectedProcedure.query(async ({ ctx }) => {
+ const [practice] = await ctx.db
+ .select({
+ taxRatePercent: practices.taxRatePercent,
+ currency: practices.currency,
+ country: practices.country,
+ })
+ .from(practices)
+ .where(eq(practices.id, ctx.practiceId))
+ .limit(1);
+ return {
+ taxRatePercent: practice?.taxRatePercent ?? "8.00",
+ currency: practice?.currency ?? "usd",
+ country: practice?.country ?? "US",
+ };
+ }),
+
listInvoices: protectedProcedure
.input(
z.object({
@@ -199,7 +219,14 @@ export const billingRouter = createRouter({
const subtotal = input.items.reduce((sum, item) => {
return sum + item.quantity * parseFloat(item.unitPrice);
}, 0);
- const tax = Math.round(subtotal * 0.08 * 100) / 100;
+ // Tax rate is configured per practice (region-aware), not hardcoded.
+ const [practice] = await ctx.db
+ .select({ taxRatePercent: practices.taxRatePercent })
+ .from(practices)
+ .where(eq(practices.id, ctx.practiceId))
+ .limit(1);
+ const taxRate = parseFloat(practice?.taxRatePercent ?? "8.00") / 100;
+ const tax = Math.round(subtotal * taxRate * 100) / 100;
const total = Math.round((subtotal + tax) * 100) / 100;
const [invoice] = await ctx.db
diff --git a/apps/web/server/routers/notifications.ts b/apps/web/server/routers/notifications.ts
index bcee8b6..cf1d2e7 100644
--- a/apps/web/server/routers/notifications.ts
+++ b/apps/web/server/routers/notifications.ts
@@ -10,12 +10,14 @@ import {
communications,
invoices,
vaccinationRecords,
+ practices,
} from "@openpims/db";
import {
sendAppointmentReminder,
sendInvoiceEmail,
sendVaccinationReminder,
} from "@/lib/email";
+import { formatCurrency } from "@/lib/locale/format";
function formatDate(d: Date | string): string {
return new Date(d).toLocaleDateString("en-US", {
@@ -121,10 +123,22 @@ export const notificationsRouter = createRouter({
throw new TRPCError({ code: "BAD_REQUEST", message: "Client does not have an email address on file" });
}
+ // Format the total in the practice's region currency.
+ const [practice] = await ctx.db
+ .select({ currency: practices.currency, country: practices.country })
+ .from(practices)
+ .where(eq(practices.id, ctx.practiceId))
+ .limit(1);
+ const totalFormatted = formatCurrency(
+ invoice.total ?? 0,
+ practice?.currency ?? "usd",
+ practice?.country ?? "US"
+ );
+
await sendInvoiceEmail({
to: invoice.clientEmail,
clientName: `${invoice.clientFirstName} ${invoice.clientLastName}`,
- invoiceTotal: `$${Number(invoice.total ?? 0).toFixed(2)}`,
+ invoiceTotal: totalFormatted,
dueDate: invoice.dueDate ?? undefined,
practiceName: "",
});
@@ -135,7 +149,7 @@ export const notificationsRouter = createRouter({
channel: "email",
direction: "outbound",
subject: "Invoice",
- content: `Invoice sent — total: $${Number(invoice.total ?? 0).toFixed(2)}`,
+ content: `Invoice sent — total: ${totalFormatted}`,
status: "sent",
});
diff --git a/apps/web/server/routers/portal.ts b/apps/web/server/routers/portal.ts
index cdb4d24..1abf54b 100644
--- a/apps/web/server/routers/portal.ts
+++ b/apps/web/server/routers/portal.ts
@@ -13,6 +13,7 @@ import {
appointmentTypes,
invoices,
communications,
+ practices,
} from "@openpims/db";
import { users } from "@openpims/db";
import { rateLimit } from "@/lib/rate-limit";
@@ -198,6 +199,15 @@ export const portalRouter = createRouter({
.query(async ({ ctx, input }) => {
const client = await getClientByToken(ctx.db, input.token);
+ // Practice currency so the portal renders amounts in the right currency.
+ const [practice] = await ctx.db
+ .select({ currency: practices.currency, country: practices.country })
+ .from(practices)
+ .where(eq(practices.id, client.practiceId))
+ .limit(1);
+ const currency = practice?.currency ?? "usd";
+ const country = practice?.country ?? "US";
+
const rows = await ctx.db
.select({
id: invoices.id,
@@ -215,7 +225,7 @@ export const portalRouter = createRouter({
.where(and(eq(invoices.clientId, client.id), isNull(invoices.deletedAt)))
.orderBy(desc(invoices.createdAt));
- return rows;
+ return rows.map((r) => ({ ...r, currency, country }));
}),
/** Appointment types a client can choose from when booking. */
diff --git a/apps/web/server/routers/settings.ts b/apps/web/server/routers/settings.ts
index 07234e4..4536d44 100644
--- a/apps/web/server/routers/settings.ts
+++ b/apps/web/server/routers/settings.ts
@@ -8,6 +8,7 @@ import {
appointmentTypes,
rooms,
} from "@openpims/db";
+import { regionDefaults } from "@/lib/locale/format";
const adminProcedure = protectedProcedure.use(requireRole("admin"));
@@ -32,12 +33,34 @@ export const settingsRouter = createRouter({
email: z.string().email().optional(),
website: z.string().optional(),
timezone: z.string().optional(),
+ // Region/locale (Phase 2). country is ISO 3166-1 alpha-2; currency is
+ // ISO 4217 lowercase; taxRatePercent is a percent string e.g. "20.00".
+ country: z.string().length(2).optional(),
+ currency: z.string().min(3).max(3).optional(),
+ taxRatePercent: z
+ .string()
+ .regex(/^\d{1,3}(\.\d{1,2})?$/, "Tax rate must be a number like 20 or 20.00")
+ .optional(),
+ vatNumber: z.string().max(32).optional(),
})
)
.mutation(async ({ ctx, input }) => {
+ // When the country changes, fill in any region fields the caller didn't
+ // explicitly set (currency/tax) with that country's sensible defaults.
+ const patch: Record = { ...input };
+ if (input.country) {
+ const defaults = regionDefaults(input.country);
+ patch.country = input.country.toUpperCase();
+ if (input.currency === undefined) patch.currency = defaults.currency;
+ if (input.taxRatePercent === undefined)
+ patch.taxRatePercent = defaults.taxRatePercent;
+ }
+ if (typeof patch.currency === "string") {
+ patch.currency = (patch.currency as string).toLowerCase();
+ }
const [updated] = await ctx.db
.update(practices)
- .set(input)
+ .set(patch)
.where(eq(practices.id, ctx.practiceId))
.returning();
return updated!;
diff --git a/apps/web/server/routers/templates.ts b/apps/web/server/routers/templates.ts
index 8730f8e..cebe8a7 100644
--- a/apps/web/server/routers/templates.ts
+++ b/apps/web/server/routers/templates.ts
@@ -7,6 +7,7 @@ import {
treatmentTemplateItems,
invoices,
invoiceItems,
+ practices,
} from "@openpims/db";
export const templatesRouter = createRouter({
@@ -358,7 +359,14 @@ export const templatesRouter = createRouter({
const subtotal = allItems.reduce((sum, row) => {
return sum + row.quantity * parseFloat(row.unitPrice);
}, 0);
- const tax = Math.round(subtotal * 0.08 * 100) / 100;
+ // Tax rate is configured per practice (region-aware), not hardcoded.
+ const [practice] = await ctx.db
+ .select({ taxRatePercent: practices.taxRatePercent })
+ .from(practices)
+ .where(eq(practices.id, ctx.practiceId))
+ .limit(1);
+ const taxRate = parseFloat(practice?.taxRatePercent ?? "8.00") / 100;
+ const tax = Math.round(subtotal * taxRate * 100) / 100;
const total = Math.round((subtotal + tax) * 100) / 100;
const [updatedInvoice] = await ctx.db
diff --git a/packages/db/schema/practices.ts b/packages/db/schema/practices.ts
index ed468ab..a0d8ec4 100644
--- a/packages/db/schema/practices.ts
+++ b/packages/db/schema/practices.ts
@@ -5,6 +5,7 @@ import {
text,
jsonb,
boolean,
+ numeric,
timestamp,
} from "drizzle-orm/pg-core";
import { relations } from "drizzle-orm";
@@ -21,6 +22,14 @@ export const practices = pgTable("practices", {
logoUrl: varchar("logo_url", { length: 512 }),
settings: jsonb("settings").default({}),
subscriptionTier: varchar("subscription_tier", { length: 32 }).default("free"),
+ // Region/locale — gates currency, tax, formatting, and (later) regulatory
+ // behavior. Defaults keep existing US practices working unchanged.
+ country: varchar("country", { length: 2 }).notNull().default("US"), // ISO 3166-1 alpha-2
+ currency: varchar("currency", { length: 3 }).notNull().default("usd"), // ISO 4217, Stripe-style lowercase
+ taxRatePercent: numeric("tax_rate_percent", { precision: 5, scale: 2 })
+ .notNull()
+ .default("8.00"),
+ vatNumber: varchar("vat_number", { length: 32 }), // shown on invoices where applicable
});
export const locations = pgTable("locations", {
|