From 4caadfb201481cb6a53b55c3121fcd7fbbe7e214 Mon Sep 17 00:00:00 2001 From: evgauer Date: Sun, 7 Jun 2026 13:52:49 -0400 Subject: [PATCH 1/5] Add region/locale fields to practices + locale utilities MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Foundation for region-aware behavior (Phase 2). Adds country, currency, taxRatePercent, and vatNumber to the practices table (US/usd/8% defaults so existing practices are unchanged), plus a pure locale module: formatCurrency, formatDate (UTC, locale-ordered), regulatoryFramework(country), and regionDefaults(country) for onboarding/settings. 7 tests. Note: additive schema — run pnpm db:push on deploy. Co-Authored-By: Claude Opus 4.8 --- apps/web/lib/locale/__tests__/format.test.ts | 61 ++++++++++++++++++ apps/web/lib/locale/format.ts | 67 ++++++++++++++++++++ packages/db/schema/practices.ts | 9 +++ 3 files changed, 137 insertions(+) create mode 100644 apps/web/lib/locale/__tests__/format.test.ts create mode 100644 apps/web/lib/locale/format.ts diff --git a/apps/web/lib/locale/__tests__/format.test.ts b/apps/web/lib/locale/__tests__/format.test.ts new file mode 100644 index 0000000..c714bbe --- /dev/null +++ b/apps/web/lib/locale/__tests__/format.test.ts @@ -0,0 +1,61 @@ +import { describe, it, expect } from "vitest"; +import { + formatCurrency, + formatDate, + regulatoryFramework, + regionDefaults, + localeForCountry, +} from "../format"; + +describe("formatCurrency", () => { + 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("€"); + }); +}); + +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..59b19ca --- /dev/null +++ b/apps/web/lib/locale/format.ts @@ -0,0 +1,67 @@ +/** + * 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, + currency: string = "usd", + country?: string | null +): string { + return new Intl.NumberFormat(localeForCountry(country), { + style: "currency", + currency: currency.toUpperCase(), + }).format(amount); +} + +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/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", { From d4f5a34ffaa22c136fcde3101f1f89d544df4268 Mon Sep 17 00:00:00 2001 From: evgauer Date: Sun, 7 Jun 2026 13:58:24 -0400 Subject: [PATCH 2/5] De-hardcode currency + tax rate from practice region settings Invoice tax was a fixed 8% and Stripe checkout was fixed to USD; both now read the practice's region config: - billing.createInvoice and templates.addItemsFromTemplate compute tax from practice.taxRatePercent (fallback 8%) - new billing.getTaxConfig query feeds the invoice-form total preview so it matches the server's authoritative calculation - portal checkout charges in practice.currency; createCheckoutSession takes a currency param (defaults usd) - settings.updatePractice accepts country/currency/taxRatePercent/vatNumber and applies regionDefaults() when the country changes US practices are unchanged (defaults usd/8%). Foundation for UK/EU pricing. Co-Authored-By: Claude Opus 4.8 --- apps/web/app/(dashboard)/billing/new/page.tsx | 9 ++++-- apps/web/app/api/portal/checkout/route.ts | 11 ++++++- apps/web/lib/stripe.ts | 3 +- apps/web/server/routers/billing.ts | 29 ++++++++++++++++++- apps/web/server/routers/settings.ts | 25 +++++++++++++++- apps/web/server/routers/templates.ts | 10 ++++++- 6 files changed, 79 insertions(+), 8 deletions(-) diff --git a/apps/web/app/(dashboard)/billing/new/page.tsx b/apps/web/app/(dashboard)/billing/new/page.tsx index c583c3e..35a257e 100644 --- a/apps/web/app/(dashboard)/billing/new/page.tsx +++ b/apps/web/app/(dashboard)/billing/new/page.tsx @@ -62,6 +62,7 @@ export default function NewInvoicePage() { ); const servicesQuery = trpc.billing.listServices.useQuery(); + const taxConfigQuery = trpc.billing.getTaxConfig.useQuery(); // Mutation const utils = trpc.useUtils(); @@ -76,19 +77,21 @@ export default function NewInvoicePage() { }, }); - // Calculations + // Calculations — preview only; the server recomputes tax authoritatively + // from the practice's configured (region-aware) rate. + const taxRate = parseFloat(taxConfigQuery.data?.taxRatePercent ?? "8.00") / 100; const { subtotal, tax, total } = useMemo(() => { const sub = items.reduce( (sum, item) => sum + item.quantity * parseFloat(item.unitPrice || "0"), 0 ); - const t = Math.round(sub * 0.08 * 100) / 100; + const t = Math.round(sub * taxRate * 100) / 100; return { subtotal: sub, tax: t, total: Math.round((sub + t) * 100) / 100, }; - }, [items]); + }, [items, taxRate]); function handleServiceSelect(serviceId: string) { setSelectedServiceId(serviceId); diff --git a/apps/web/app/api/portal/checkout/route.ts b/apps/web/app/api/portal/checkout/route.ts index b0455ea..e40dcce 100644 --- a/apps/web/app/api/portal/checkout/route.ts +++ b/apps/web/app/api/portal/checkout/route.ts @@ -1,7 +1,7 @@ import { NextRequest, NextResponse } from "next/server"; import { eq, and, isNull } from "drizzle-orm"; import { db } from "@openpims/db/client"; -import { clients, invoices, patients } from "@openpims/db"; +import { clients, invoices, patients, practices } from "@openpims/db"; import { createCheckoutSession } from "@/lib/stripe"; export async function POST(req: NextRequest) { @@ -38,6 +38,7 @@ export async function POST(req: NextRequest) { status: invoices.status, clientId: invoices.clientId, patientId: invoices.patientId, + practiceId: invoices.practiceId, }) .from(invoices) .where( @@ -89,6 +90,13 @@ export async function POST(req: NextRequest) { } } + // Charge in the practice's configured currency (region-aware). + const [practice] = await db + .select({ currency: practices.currency }) + .from(practices) + .where(eq(practices.id, invoice.practiceId)) + .limit(1); + const origin = req.nextUrl.origin; const result = await createCheckoutSession({ invoiceId: invoice.id, @@ -96,6 +104,7 @@ export async function POST(req: NextRequest) { clientEmail: client.email ?? "", clientName: `${client.firstName} ${client.lastName}`, description, + currency: practice?.currency ?? "usd", successUrl: `${origin}/portal/${token}?payment=success`, cancelUrl: `${origin}/portal/${token}?payment=cancelled`, }); 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/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 From 0e788adcd89b1a4fd1725cd881326cc6abb8ed91 Mon Sep 17 00:00:00 2001 From: evgauer Date: Sun, 7 Jun 2026 14:00:42 -0400 Subject: [PATCH 3/5] Add Region & Tax section to practice settings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Admins can now set their practice's country, currency, tax/VAT rate, and VAT number from Settings → Practice Info. Picking a country prefills the usual currency/tax/timezone defaults (overridable). This is the UI surface for the region fields wired through billing + checkout, making Phase 2 usable end to end. Adds UK/EU/CA/AU timezones to the picker. Co-Authored-By: Claude Opus 4.8 --- apps/web/app/(dashboard)/settings/page.tsx | 98 ++++++++++++++++++++++ 1 file changed, 98 insertions(+) diff --git a/apps/web/app/(dashboard)/settings/page.tsx b/apps/web/app/(dashboard)/settings/page.tsx index 0cadc0c..1f023a8 100644 --- a/apps/web/app/(dashboard)/settings/page.tsx +++ b/apps/web/app/(dashboard)/settings/page.tsx @@ -26,6 +26,7 @@ import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { cn } from "@/lib/utils"; import { toast } from "sonner"; +import { regionDefaults } from "@/lib/locale/format"; // ── Types ─────────────────────────────────────────────────── type Tab = "practice" | "staff" | "appointmentTypes" | "rooms" | "data" | "templates"; @@ -47,8 +48,23 @@ const TIMEZONES = [ "America/Phoenix", "America/Anchorage", "Pacific/Honolulu", + "America/Toronto", + "Europe/London", + "Europe/Dublin", + "Australia/Sydney", ]; +// Supported regions (ISO 3166-1 alpha-2). US-first; others are UK-ready. +const COUNTRIES: { code: string; label: string }[] = [ + { code: "US", label: "United States" }, + { code: "GB", label: "United Kingdom" }, + { code: "IE", label: "Ireland" }, + { code: "CA", label: "Canada" }, + { code: "AU", label: "Australia" }, +]; + +const CURRENCIES = ["usd", "gbp", "eur", "cad", "aud"]; + const PRESET_COLORS = [ "#0d9488", "#dc2626", @@ -153,6 +169,10 @@ function PracticeInfoTab() { email: string; website: string; timezone: string; + country: string; + currency: string; + taxRatePercent: string; + vatNumber: string; } | null>(null); // Initialize form when data loads @@ -163,6 +183,10 @@ function PracticeInfoTab() { email: practice?.email ?? "", website: practice?.website ?? "", timezone: practice?.timezone ?? "America/New_York", + country: practice?.country ?? "US", + currency: practice?.currency ?? "usd", + taxRatePercent: practice?.taxRatePercent ?? "8.00", + vatNumber: practice?.vatNumber ?? "", }; if (isLoading) { @@ -177,6 +201,19 @@ function PracticeInfoTab() { setForm({ ...current, [field]: value }); }; + // Changing country prefills sensible currency/tax/timezone defaults for that + // region; the admin can still override any of them before saving. + const handleCountryChange = (country: string) => { + const d = regionDefaults(country); + setForm({ + ...current, + country, + currency: d.currency, + taxRatePercent: d.taxRatePercent, + timezone: d.timezone, + }); + }; + return (
@@ -233,6 +270,67 @@ function PracticeInfoTab() {
+ + {/* ── Region & Tax ── */} +
+

Region & Tax

+

+ Controls invoice currency, tax rate, and date formatting. Choosing a + country prefills the usual defaults — adjust as needed. +

+
+
+
+ + +
+
+ + +
+
)} diff --git a/apps/web/app/(dashboard)/billing/page.tsx b/apps/web/app/(dashboard)/billing/page.tsx index be307fe..d125dea 100644 --- a/apps/web/app/(dashboard)/billing/page.tsx +++ b/apps/web/app/(dashboard)/billing/page.tsx @@ -17,6 +17,7 @@ import { } from "lucide-react"; import { toast } from "sonner"; import { trpc } from "@/lib/trpc"; +import { useCurrencyFormatter } from "@/lib/locale/useCurrency"; import { generateInvoicePdf } from "@/lib/pdf"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; @@ -50,11 +51,6 @@ const PAYMENT_METHODS = [ { label: "Other", value: "other" }, ] as const; -function formatCurrency(value: string | number | null | undefined): string { - const num = Number(value ?? 0); - return `$${num.toFixed(2)}`; -} - function getDisplayStatus(invoice: { status: string; paidAmount: string | null; @@ -303,6 +299,7 @@ function InvoiceRow({ onConvertEstimate: (e: React.MouseEvent, id: string) => void; isMutating: boolean; }) { + const formatCurrency = useCurrencyFormatter(); const detail = trpc.billing.getInvoice.useQuery( { id: invoice.id }, { enabled: isExpanded } @@ -455,6 +452,10 @@ function InvoiceRow({ tax: formatCurrency(d.tax), total: formatCurrency(d.total), paidAmount: formatCurrency(d.paidAmount), + balanceDue: formatCurrency( + parseFloat(String(d.total)) - + parseFloat(String(d.paidAmount)) + ), }).save(`estimate-${clientName || "unknown"}.pdf`); }} > @@ -615,6 +616,10 @@ function InvoiceRow({ tax: formatCurrency(d.tax), total: formatCurrency(d.total), paidAmount: formatCurrency(d.paidAmount), + balanceDue: formatCurrency( + parseFloat(String(d.total)) - + parseFloat(String(d.paidAmount)) + ), }).save(`invoice-${clientName || "unknown"}.pdf`); }} > @@ -689,6 +694,7 @@ function PaymentSection({ invoicePaidAmount: string | null; invoiceStatus: string; }) { + const formatCurrency = useCurrencyFormatter(); const [showPaymentForm, setShowPaymentForm] = useState(false); const [paymentAmount, setPaymentAmount] = useState(""); const [paymentMethod, setPaymentMethod] = useState("cash"); diff --git a/apps/web/app/(dashboard)/inventory/page.tsx b/apps/web/app/(dashboard)/inventory/page.tsx index c6b355f..c9bcafc 100644 --- a/apps/web/app/(dashboard)/inventory/page.tsx +++ b/apps/web/app/(dashboard)/inventory/page.tsx @@ -12,6 +12,7 @@ import { Check, } from "lucide-react"; import { trpc } from "@/lib/trpc"; +import { useCurrencyFormatter } from "@/lib/locale/useCurrency"; import { Input } from "@/components/ui/input"; import { Button } from "@/components/ui/button"; import { cn } from "@/lib/utils"; @@ -26,11 +27,6 @@ const CATEGORIES = [ { label: "Supply", value: "supply" }, ] as const; -function formatCurrency(value: string | number | null | undefined): string { - const num = Number(value ?? 0); - return `$${num.toFixed(2)}`; -} - function isExpiringSoon(expirationDate: string | null | undefined): boolean { if (!expirationDate) return false; const expDate = new Date(expirationDate); @@ -490,6 +486,7 @@ function AddSupplierForm({ onClose }: { onClose: () => void }) { // --- Main Page --- export default function InventoryPage() { + const formatCurrency = useCurrencyFormatter(); const [tab, setTab] = useState<"products" | "suppliers">("products"); const [search, setSearch] = useState(""); const [category, setCategory] = useState(""); diff --git a/apps/web/app/(dashboard)/page.tsx b/apps/web/app/(dashboard)/page.tsx index 5c5d674..be9c9ae 100644 --- a/apps/web/app/(dashboard)/page.tsx +++ b/apps/web/app/(dashboard)/page.tsx @@ -3,6 +3,7 @@ import { Calendar, PawPrint, DollarSign, FileText, Clock } from "lucide-react"; import { cn } from "@/lib/utils"; import { trpc } from "@/lib/trpc"; +import { formatCurrency, localeForCountry } from "@/lib/locale/format"; import { BarChart, Bar, @@ -19,13 +20,6 @@ import { Line, } from "recharts"; -function formatCurrency(amount: number) { - return new Intl.NumberFormat("en-US", { - style: "currency", - currency: "USD", - }).format(amount); -} - function formatTime(date: Date | string) { return new Date(date).toLocaleTimeString("en-US", { hour: "numeric", @@ -39,28 +33,28 @@ const kpiConfig = [ label: "Today's Appointments", description: "Scheduled for today", icon: Calendar, - format: (v: number) => String(v), + isCurrency: false, }, { key: "patientsSeen" as const, label: "Patients Seen Today", description: "Checked out today", icon: PawPrint, - format: (v: number) => String(v), + isCurrency: false, }, { key: "revenueMtd" as const, label: "Revenue (MTD)", description: "Paid invoices this month", icon: DollarSign, - format: (v: number) => formatCurrency(v), + isCurrency: true, }, { key: "pendingInvoices" as const, label: "Pending Invoices", description: "Sent or overdue", icon: FileText, - format: (v: number) => String(v), + isCurrency: false, }, ]; @@ -147,6 +141,12 @@ function PieLabel({ export default function DashboardPage() { const stats = trpc.dashboard.getStats.useQuery(); const charts = trpc.dashboard.getCharts.useQuery(); + const taxConfig = trpc.billing.getTaxConfig.useQuery(undefined, { + staleTime: 5 * 60 * 1000, + }); + const currency = taxConfig.data?.currency ?? "usd"; + const country = taxConfig.data?.country ?? "US"; + const fmtMoney = (v: number) => formatCurrency(v, currency, country); const today = new Date(); const todayStr = today.toISOString().slice(0, 10); @@ -195,7 +195,7 @@ export default function DashboardPage() { {kpi.label}

- {kpi.format(value)} + {kpi.isCurrency ? fmtMoney(value) : String(value)}

@@ -385,9 +385,9 @@ export default function DashboardPage() { className="text-xs fill-muted-foreground" tick={{ fontSize: 12 }} tickFormatter={(value: number) => - new Intl.NumberFormat("en-US", { + new Intl.NumberFormat(localeForCountry(country), { style: "currency", - currency: "USD", + currency: currency.toUpperCase(), minimumFractionDigits: 0, maximumFractionDigits: 0, }).format(value) @@ -400,7 +400,7 @@ export default function DashboardPage() { borderRadius: "0.5rem", fontSize: "0.875rem", }} - formatter={(value: number) => [formatCurrency(value), "Revenue"]} + formatter={(value: number) => [fmtMoney(value), "Revenue"]} /> ; @@ -252,6 +245,7 @@ function AppointmentsTab() { } function ServicesTab() { + const formatCurrency = useCurrencyFormatter(); const { data, isLoading } = trpc.reports.topServices.useQuery(); if (isLoading || !data) return ; diff --git a/apps/web/app/(dashboard)/settings/page.tsx b/apps/web/app/(dashboard)/settings/page.tsx index 1f023a8..64cb477 100644 --- a/apps/web/app/(dashboard)/settings/page.tsx +++ b/apps/web/app/(dashboard)/settings/page.tsx @@ -27,6 +27,7 @@ import { Input } from "@/components/ui/input"; import { cn } from "@/lib/utils"; import { toast } from "sonner"; import { regionDefaults } from "@/lib/locale/format"; +import { useCurrencyFormatter } from "@/lib/locale/useCurrency"; // ── Types ─────────────────────────────────────────────────── type Tab = "practice" | "staff" | "appointmentTypes" | "rooms" | "data" | "templates"; @@ -1611,6 +1612,7 @@ interface TemplateItem { } function TemplatesTab() { + const formatCurrency = useCurrencyFormatter(); const utils = trpc.useUtils(); const { data: templateList, isLoading } = trpc.templates.list.useQuery(); const createMutation = trpc.templates.create.useMutation({ @@ -1759,7 +1761,7 @@ function TemplatesTab() { {item.defaultQuantity} - ${Number(item.defaultUnitPrice).toFixed(2)} + {formatCurrency(item.defaultUnitPrice)} ))} diff --git a/apps/web/app/portal/[token]/invoices/page.tsx b/apps/web/app/portal/[token]/invoices/page.tsx index ce167f7..81dd2f2 100644 --- a/apps/web/app/portal/[token]/invoices/page.tsx +++ b/apps/web/app/portal/[token]/invoices/page.tsx @@ -4,6 +4,10 @@ import { useState } from "react"; import { useParams } from "next/navigation"; import Link from "next/link"; import { trpc } from "@/lib/trpc"; +import { + formatCurrency as formatCurrencyBase, + localeForCountry, +} from "@/lib/locale/format"; const statusStyles: Record = { draft: "bg-gray-100 text-gray-600", @@ -13,21 +17,21 @@ const statusStyles: Record = { void: "bg-gray-100 text-gray-400", }; -function formatDate(d: string | Date | null): string { +function formatDate(d: string | Date | null, country?: string | null): string { if (!d) return "N/A"; - return new Date(d).toLocaleDateString("en-US", { + return new Date(d).toLocaleDateString(localeForCountry(country), { month: "short", day: "numeric", year: "numeric", }); } -function formatCurrency(amount: string | number | null): string { - const n = typeof amount === "string" ? parseFloat(amount) : amount ?? 0; - return new Intl.NumberFormat("en-US", { - style: "currency", - currency: "USD", - }).format(n); +function formatCurrency( + amount: string | number | null, + currency: string = "usd", + country?: string | null +): string { + return formatCurrencyBase(amount, currency, country); } function PayButton({ token, invoiceId }: { token: string; invoiceId: string }) { @@ -109,8 +113,8 @@ export default function InvoicesPage() {
-

{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 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", () => { diff --git a/apps/web/lib/locale/format.ts b/apps/web/lib/locale/format.ts index 59b19ca..d375e64 100644 --- a/apps/web/lib/locale/format.ts +++ b/apps/web/lib/locale/format.ts @@ -17,14 +17,15 @@ export function localeForCountry(country?: string | null): string { } export function formatCurrency( - amount: number, + 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(amount); + }).format(Number.isFinite(n) ? (n as number) : 0); } export function formatDate(date: Date | string, country?: string | null): string { 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/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. */ From a97b558d631d367129306ba856eb5a94af806993 Mon Sep 17 00:00:00 2001 From: evgauer Date: Sun, 7 Jun 2026 14:14:00 -0400 Subject: [PATCH 5/5] Format invoice-email total in the practice's region currency MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit sendInvoiceEmail hardcoded a $ on the invoice total in both the client email and the logged communication. Read the practice currency/country and format via the shared helper so emailed totals match the rest of the app (e.g. £ for GB practices). Co-Authored-By: Claude Opus 4.8 --- apps/web/server/routers/notifications.ts | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) 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", });