From 3abecb433b5304bfb633dd2863aaefd8fb3ea8c3 Mon Sep 17 00:00:00 2001 From: Arnas Donauskas Date: Mon, 8 Jun 2026 13:59:48 +0300 Subject: [PATCH] feat(deals): make default deal currency configurable per account (#218) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit wacrm hardcoded USD across every deal-value formatter, the new-deal form, and automation-created deals, making deal tracking unhelpful for non-US self-hosted users. Add a per-account default currency. - Migration 021: accounts.default_currency TEXT NOT NULL DEFAULT 'USD' with a 3-letter-code CHECK. The existing admin-only accounts_update RLS policy already gates writes. - New src/lib/currency.ts: single source of truth (CURRENCIES list, formatCurrency, formatCurrencyShort), replacing ~6 duplicated USD-hardcoded helpers. - useAuth exposes defaultCurrency (joined from accounts), falling back to USD while loading / on pre-021 schemas. - Settings → Deals tab to pick the currency (admin-only, read-only for others to match RLS). - Deal form defaults new deals to the account currency and sources its dropdown from CURRENCIES; existing deals keep their saved currency. - Pipeline board/analytics, dashboard "Open Deals Value", and the pipeline donut format totals in the account currency. Individual deal cards still show each deal's own currency. - Automation create_deal sets the new deal's currency from the account default instead of the static DB default. One currency per account (no FX conversion) per the issue's recommended first pass. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/app/(dashboard)/dashboard/page.tsx | 20 ++- src/app/(dashboard)/settings/page.tsx | 14 ++ .../contacts/contact-detail-view.tsx | 12 +- src/components/dashboard/pipeline-donut.tsx | 19 ++- src/components/pipelines/deal-card.tsx | 10 +- src/components/pipelines/deal-form.tsx | 19 +-- .../pipelines/pipeline-analytics.tsx | 18 +-- src/components/pipelines/pipeline-board.tsx | 19 ++- src/components/settings/deals-settings.tsx | 125 ++++++++++++++++++ src/hooks/use-auth.tsx | 32 ++++- src/lib/automations/engine.ts | 11 ++ src/lib/currency.ts | 80 +++++++++++ .../021_account_default_currency.sql | 32 +++++ 13 files changed, 340 insertions(+), 71 deletions(-) create mode 100644 src/components/settings/deals-settings.tsx create mode 100644 src/lib/currency.ts create mode 100644 supabase/migrations/021_account_default_currency.sql diff --git a/src/app/(dashboard)/dashboard/page.tsx b/src/app/(dashboard)/dashboard/page.tsx index 10f3888..db567d3 100644 --- a/src/app/(dashboard)/dashboard/page.tsx +++ b/src/app/(dashboard)/dashboard/page.tsx @@ -2,6 +2,8 @@ import { useCallback, useEffect, useState } from 'react' import { createClient } from '@/lib/supabase/client' +import { useAuth } from '@/hooks/use-auth' +import { formatCurrency } from '@/lib/currency' import { MessageSquare, UserPlus, @@ -35,6 +37,7 @@ import { ActivityFeed } from '@/components/dashboard/activity-feed' type RangeDays = 7 | 30 | 90 export default function DashboardPage() { + const { defaultCurrency } = useAuth() const [metrics, setMetrics] = useState(null) const [metricsLoading, setMetricsLoading] = useState(true) @@ -155,7 +158,7 @@ export default function DashboardPage() { /> @@ -196,7 +199,11 @@ export default function DashboardPage() { />
- +
@@ -211,15 +218,6 @@ export default function DashboardPage() { // ------------------------------------------------------------ -function formatCurrency(v: number): string { - return new Intl.NumberFormat(undefined, { - style: 'currency', - currency: 'USD', - minimumFractionDigits: 0, - maximumFractionDigits: 0, - }).format(v) -} - function deltaLabel(delta: number, suffix: string): string { if (delta === 0) return `No change ${suffix}` const sign = delta > 0 ? '+' : '' diff --git a/src/app/(dashboard)/settings/page.tsx b/src/app/(dashboard)/settings/page.tsx index f8e3c3f..8b7f912 100644 --- a/src/app/(dashboard)/settings/page.tsx +++ b/src/app/(dashboard)/settings/page.tsx @@ -8,6 +8,7 @@ import { User, Palette, UsersRound, + Coins, } from 'lucide-react'; import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'; import { WhatsAppConfig } from '@/components/settings/whatsapp-config'; @@ -18,12 +19,14 @@ import { PasswordForm } from '@/components/settings/password-form'; import { SessionsCard } from '@/components/settings/sessions-card'; import { AppearancePanel } from '@/components/settings/appearance-panel'; import { MembersTab } from '@/components/settings/members-tab'; +import { DealsSettings } from '@/components/settings/deals-settings'; const TAB_VALUES = [ 'profile', 'whatsapp', 'templates', 'tags', + 'deals', 'appearance', 'members', ] as const; @@ -90,6 +93,13 @@ export default function SettingsPage() { Tags + + + Deals + + + + + diff --git a/src/components/contacts/contact-detail-view.tsx b/src/components/contacts/contact-detail-view.tsx index fb6d232..faad210 100644 --- a/src/components/contacts/contact-detail-view.tsx +++ b/src/components/contacts/contact-detail-view.tsx @@ -3,6 +3,7 @@ import { useState, useEffect, useCallback } from 'react'; import { createClient } from '@/lib/supabase/client'; import { useAuth } from '@/hooks/use-auth'; +import { formatCurrency } from '@/lib/currency'; import { toast } from 'sonner'; import type { Contact, Tag, ContactTag, ContactNote, CustomField, ContactCustomValue, Deal } from '@/types'; import { @@ -48,7 +49,7 @@ export function ContactDetailView({ onUpdated, }: ContactDetailViewProps) { const supabase = createClient(); - const { accountId } = useAuth(); + const { accountId, defaultCurrency } = useAuth(); const [contact, setContact] = useState(null); const [loading, setLoading] = useState(false); @@ -656,11 +657,10 @@ export function ContactDetailView({
- {new Intl.NumberFormat('en-US', { - style: 'currency', - currency: deal.currency || 'USD', - maximumFractionDigits: 0, - }).format(Number(deal.value || 0))} + {formatCurrency( + deal.value ?? 0, + deal.currency || defaultCurrency, + )} {deal.status && deal.status !== 'open' && (
@@ -31,7 +34,7 @@ export function PipelineDonut({ data, loading }: PipelineDonutProps) { /> ) : ( <> - +
    {data.stages.map((s) => (
  • @@ -45,7 +48,7 @@ export function PipelineDonut({ data, loading }: PipelineDonutProps) { {s.dealCount} deal{s.dealCount === 1 ? '' : 's'} - {formatCurrencyShort(s.totalValue)} + {formatCurrencyShort(s.totalValue, currency)}
  • ))} @@ -63,7 +66,7 @@ export function PipelineDonut({ data, loading }: PipelineDonutProps) { // between segments are implied by a thin slate-900 stroke between // them for a cleaner look. // ------------------------------------------------------------ -function Donut({ data }: { data: PipelineDonutData }) { +function Donut({ data, currency }: { data: PipelineDonutData; currency: string }) { const size = 200 const r = 80 const ringWidth = 18 @@ -121,7 +124,7 @@ function Donut({ data }: { data: PipelineDonutData }) { textAnchor="middle" className="fill-white text-[18px] font-semibold tabular-nums" > - {formatCurrencyShort(data.totalValue)} + {formatCurrencyShort(data.totalValue, currency)}
@@ -136,9 +139,3 @@ function arcPath(cx: number, cy: number, r: number, startRad: number, endRad: nu const largeArc = endRad - startRad > Math.PI ? 1 : 0 return `M ${x1} ${y1} A ${r} ${r} 0 ${largeArc} 1 ${x2} ${y2}` } - -function formatCurrencyShort(v: number): string { - if (v >= 1_000_000) return `$${(v / 1_000_000).toFixed(1)}M` - if (v >= 1_000) return `$${(v / 1_000).toFixed(1)}k` - return `$${v.toFixed(0)}` -} diff --git a/src/components/pipelines/deal-card.tsx b/src/components/pipelines/deal-card.tsx index ba1c693..d8ecbb7 100644 --- a/src/components/pipelines/deal-card.tsx +++ b/src/components/pipelines/deal-card.tsx @@ -2,6 +2,7 @@ import type { Deal, PipelineStage } from "@/types"; import { Calendar, Check, X } from "lucide-react"; +import { formatCurrency } from "@/lib/currency"; interface DealCardProps { deal: Deal; @@ -10,15 +11,6 @@ interface DealCardProps { isOverlay?: boolean; } -function formatCurrency(value: number, currency?: string) { - return new Intl.NumberFormat("en-US", { - style: "currency", - currency: currency || "USD", - minimumFractionDigits: 0, - maximumFractionDigits: 0, - }).format(Number(value || 0)); -} - function formatDate(dateStr: string) { return new Date(dateStr).toLocaleDateString("en-US", { month: "short", diff --git a/src/components/pipelines/deal-form.tsx b/src/components/pipelines/deal-form.tsx index 5fdb81f..f489f4d 100644 --- a/src/components/pipelines/deal-form.tsx +++ b/src/components/pipelines/deal-form.tsx @@ -4,6 +4,7 @@ import { useState, useEffect } from "react"; import Link from "next/link"; import { createClient } from "@/lib/supabase/client"; import { useAuth } from "@/hooks/use-auth"; +import { CURRENCIES } from "@/lib/currency"; import type { Contact, Conversation, @@ -52,11 +53,11 @@ export function DealForm({ onSaved, }: DealFormProps) { const supabase = createClient(); - const { accountId } = useAuth(); + const { accountId, defaultCurrency } = useAuth(); const [title, setTitle] = useState(""); const [value, setValue] = useState(""); - const [currency, setCurrency] = useState("USD"); + const [currency, setCurrency] = useState(defaultCurrency); const [contactId, setContactId] = useState(""); const [stageId, setStageId] = useState(""); const [assignedTo, setAssignedTo] = useState(""); @@ -83,7 +84,7 @@ export function DealForm({ if (deal) { setTitle(deal.title); setValue(String(deal.value ?? "")); - setCurrency(deal.currency || "USD"); + setCurrency(deal.currency || defaultCurrency); // contact_id is nullable when the contact has been deleted // (migration 004: ON DELETE SET NULL). "" means "no selection". setContactId(deal.contact_id ?? ""); @@ -94,14 +95,14 @@ export function DealForm({ } else { setTitle(""); setValue(""); - setCurrency("USD"); + setCurrency(defaultCurrency); setContactId(""); setStageId(defaultStageId || stages[0]?.id || ""); setAssignedTo(""); setExpectedCloseDate(""); setNotes(""); } - }, [open, deal, defaultStageId, stages]); + }, [open, deal, defaultStageId, stages, defaultCurrency]); /* eslint-enable react-hooks/set-state-in-effect */ // Load supporting data once the sheet is open @@ -313,9 +314,11 @@ export function DealForm({ onChange={(e) => setCurrency(e.target.value)} className="h-9 w-full rounded-lg border border-slate-700 bg-slate-800 px-2.5 text-sm text-white outline-none focus:border-primary" > - - - + {CURRENCIES.map((c) => ( + + ))} diff --git a/src/components/pipelines/pipeline-analytics.tsx b/src/components/pipelines/pipeline-analytics.tsx index 0408681..0c4c315 100644 --- a/src/components/pipelines/pipeline-analytics.tsx +++ b/src/components/pipelines/pipeline-analytics.tsx @@ -17,21 +17,14 @@ import { TooltipProvider, TooltipTrigger, } from "@/components/ui/tooltip"; +import { useAuth } from "@/hooks/use-auth"; +import { formatCurrency } from "@/lib/currency"; interface PipelineAnalyticsProps { stages: PipelineStage[]; deals: Deal[]; } -function formatCurrency(value: number) { - return new Intl.NumberFormat("en-US", { - style: "currency", - currency: "USD", - minimumFractionDigits: 0, - maximumFractionDigits: 0, - }).format(value); -} - /** * Weighted pipeline value: value × per-stage probability. * First stage ≈ 10%, stages interpolate up to 90% before the final stage, @@ -53,6 +46,7 @@ function computeStageProbability( } export function PipelineAnalytics({ stages, deals }: PipelineAnalyticsProps) { + const { defaultCurrency } = useAuth(); const sortedStages = useMemo( () => [...stages].sort((a, b) => a.position - b.position), [stages], @@ -109,19 +103,19 @@ export function PipelineAnalytics({ stages, deals }: PipelineAnalyticsProps) { } label="Pipeline Value" - value={formatCurrency(stats.totalValue)} + value={formatCurrency(stats.totalValue, defaultCurrency)} tooltip="Sum of the dollar values of all deals in this pipeline, excluding deals marked as Lost." /> } label="Avg Deal Size" - value={formatCurrency(stats.avgValue)} + value={formatCurrency(stats.avgValue, defaultCurrency)} tooltip="Pipeline Value divided by Total Deals — the average value of a single non-lost deal." /> } label="Weighted Value" - value={formatCurrency(stats.weightedValue)} + value={formatCurrency(stats.weightedValue, defaultCurrency)} tooltip="Expected revenue: each open deal's value × its stage probability. First stage ≈ 10%, stages progress up to 90%, Won = 100%. Lost deals are excluded." /> void; } -function formatCurrency(value: number) { - return new Intl.NumberFormat("en-US", { - style: "currency", - currency: "USD", - minimumFractionDigits: 0, - maximumFractionDigits: 0, - }).format(value); -} - export function PipelineBoard({ stages, deals, @@ -43,6 +36,7 @@ export function PipelineBoard({ onAddDeal, onEditDeal, }: PipelineBoardProps) { + const { defaultCurrency } = useAuth(); const [activeDealId, setActiveDealId] = useState(null); const sortedStages = useMemo( @@ -121,6 +115,7 @@ export function PipelineBoard({ stage={stage} deals={stageDeals} totalValue={totalValue} + currency={defaultCurrency} onAddDeal={onAddDeal} onEditDeal={onEditDeal} /> @@ -194,12 +189,14 @@ function StageColumn({ stage, deals, totalValue, + currency, onAddDeal, onEditDeal, }: { stage: PipelineStage; deals: Deal[]; totalValue: number; + currency: string; onAddDeal: (stageId: string) => void; onEditDeal: (deal: Deal) => void; }) { @@ -226,7 +223,9 @@ function StageColumn({ {deals.length} -

{formatCurrency(totalValue)}

+

+ {formatCurrency(totalValue, currency)} +

{ + setSelected(defaultCurrency); + }, [defaultCurrency]); + + const dirty = selected !== defaultCurrency; + + async function handleSave() { + if (!accountId || !dirty) return; + setSaving(true); + const { error } = await supabase + .from("accounts") + .update({ default_currency: selected }) + .eq("id", accountId); + if (error) { + toast.error("Failed to save default currency"); + setSaving(false); + return; + } + // Pull the new value back into the auth context so the deal form + // and every total pick it up without a full reload. + await refreshProfile(); + setSaving(false); + toast.success("Default currency updated"); + } + + return ( +
+ + + + + Default currency + + + New deals default to this currency, and pipeline and + dashboard totals are shown in it. Existing deals keep the + currency they were saved with. + + + +
+ + + {!canEditSettings && ( +

+ Only account admins can change the default currency. +

+ )} +
+ + {canEditSettings && ( + + )} +
+
+
+ ); +} diff --git a/src/hooks/use-auth.tsx b/src/hooks/use-auth.tsx index 4170512..a4b6a33 100644 --- a/src/hooks/use-auth.tsx +++ b/src/hooks/use-auth.tsx @@ -11,6 +11,7 @@ import { } from "react"; import { createClient } from "@/lib/supabase/client"; import type { User } from "@supabase/supabase-js"; +import { DEFAULT_CURRENCY } from "@/lib/currency"; import { canEditSettings as canEditSettingsFor, canManageMembers as canManageMembersFor, @@ -38,6 +39,9 @@ interface Profile { interface AccountSummary { id: string; name: string; + /** Default deal currency (ISO-4217). NOT NULL DEFAULT 'USD' in the + * DB (migration 021); narrowed to DEFAULT_CURRENCY when absent. */ + default_currency: string; } interface AuthContextValue { @@ -77,8 +81,12 @@ interface AuthContextValue { accountId: string | null; /** Role within that account. Null while loading. */ accountRole: AccountRole | null; - /** Lightweight account meta — id + name. Null while loading. */ + /** Lightweight account meta — id + name + default_currency. Null while loading. */ account: AccountSummary | null; + /** Account default deal currency. Falls back to DEFAULT_CURRENCY + * while loading or when no account is resolved, so callers can use + * it unconditionally. */ + defaultCurrency: string; /** True if `accountRole === 'owner'`. */ isOwner: boolean; /** True if `accountRole === 'admin'` (does NOT include owner — use canManageMembers for "admin or above"). */ @@ -128,7 +136,7 @@ export function AuthProvider({ children }: { children: ReactNode }) { // missing account collapses to null rather than a half- // populated row (shouldn't happen post-017 NOT NULL, but // belt-and-braces against forks running older schemas). - "id, full_name, email, avatar_url, role, beta_features, account_id, account_role, account:accounts!inner(id, name)", + "id, full_name, email, avatar_url, role, beta_features, account_id, account_role, account:accounts!inner(id, name, default_currency)", ) .eq("user_id", userId) .maybeSingle(); @@ -148,9 +156,23 @@ export function AuthProvider({ children }: { children: ReactNode }) { // as either an object or a single-element array depending on // the schema's inferred cardinality — normalise to the object // form before reading. - const accountRow = Array.isArray(data.account) + const accountRaw = Array.isArray(data.account) ? data.account[0] ?? null - : (data.account as { id: string; name: string } | null); + : (data.account as { + id: string; + name: string; + default_currency: string | null; + } | null); + // Narrow default_currency defensively: forks running pre-021 + // schemas won't have the column, so a missing/null value reads + // as the safe USD fallback rather than crashing the picker. + const accountRow: AccountSummary | null = accountRaw + ? { + id: accountRaw.id, + name: accountRaw.name, + default_currency: accountRaw.default_currency ?? DEFAULT_CURRENCY, + } + : null; // Narrow the DB enum into our AccountRole union. The DB // constraint should make this unconditional, but a future @@ -299,6 +321,7 @@ export function AuthProvider({ children }: { children: ReactNode }) { signOut, refreshProfile, account, + defaultCurrency: account?.default_currency ?? DEFAULT_CURRENCY, ...derived, }} > @@ -328,6 +351,7 @@ export function useAuth(): AuthContextValue { }, refreshProfile: async () => {}, account: null, + defaultCurrency: DEFAULT_CURRENCY, accountId: null, accountRole: null, isOwner: false, diff --git a/src/lib/automations/engine.ts b/src/lib/automations/engine.ts index e91b45d..6124fac 100644 --- a/src/lib/automations/engine.ts +++ b/src/lib/automations/engine.ts @@ -465,6 +465,16 @@ async function runStep(step: AutomationStep, args: ExecuteArgs): Promise case 'create_deal': { const cfg = step.step_config as CreateDealStepConfig if (!cfg.pipeline_id || !cfg.stage_id) throw new Error('create_deal needs pipeline + stage') + // Match the account's configured default currency rather than + // the static `deals.currency` DB default — keeps automation- + // created deals consistent with the one-currency-per-account + // rule (issue #218). Fall back to USD if the row is somehow + // missing the value (pre-021 forks). + const { data: acct } = await db + .from('accounts') + .select('default_currency') + .eq('id', args.automation.account_id) + .maybeSingle() await db.from('deals').insert({ // Tenancy + audit, same split as automation_logs above. account_id: args.automation.account_id, @@ -474,6 +484,7 @@ async function runStep(step: AutomationStep, args: ExecuteArgs): Promise contact_id: args.contactId, title: interpolate(cfg.title, args), value: cfg.value ?? 0, + currency: acct?.default_currency ?? 'USD', status: 'open', }) return 'deal created' diff --git a/src/lib/currency.ts b/src/lib/currency.ts new file mode 100644 index 0000000..46df731 --- /dev/null +++ b/src/lib/currency.ts @@ -0,0 +1,80 @@ +/** + * Currency — single source of truth for deal-value formatting and + * the currency picker options. + * + * Before this module, ~6 components each defined their own + * `Intl.NumberFormat(..., { currency: "USD" })` helper with USD + * baked in. The default currency is now configurable per account + * (accounts.default_currency, migration 021), so every formatter + * takes a currency and falls back to DEFAULT_CURRENCY only when + * nothing is known. + */ + +/** App-wide fallback when no account/deal currency is available. */ +export const DEFAULT_CURRENCY = "USD"; + +export interface CurrencyOption { + /** ISO-4217 code, e.g. "USD". Stored verbatim in the DB. */ + code: string; + /** Human label for the dropdown, e.g. "US Dollar". */ + label: string; + /** Symbol for compact display, e.g. "$". */ + symbol: string; +} + +/** + * The currencies offered in pickers. Codes must be valid ISO-4217 so + * `Intl.NumberFormat` renders the right symbol/grouping. Extend this + * list to offer more — nothing else needs to change. + */ +export const CURRENCIES: CurrencyOption[] = [ + { code: "USD", label: "US Dollar", symbol: "$" }, + { code: "EUR", label: "Euro", symbol: "€" }, + { code: "GBP", label: "British Pound", symbol: "£" }, + { code: "INR", label: "Indian Rupee", symbol: "₹" }, + { code: "AUD", label: "Australian Dollar", symbol: "A$" }, + { code: "CAD", label: "Canadian Dollar", symbol: "C$" }, + { code: "BRL", label: "Brazilian Real", symbol: "R$" }, + { code: "JPY", label: "Japanese Yen", symbol: "¥" }, + { code: "CNY", label: "Chinese Yuan", symbol: "¥" }, + { code: "AED", label: "UAE Dirham", symbol: "د.إ" }, + { code: "ZAR", label: "South African Rand", symbol: "R" }, + { code: "NGN", label: "Nigerian Naira", symbol: "₦" }, + { code: "SGD", label: "Singapore Dollar", symbol: "S$" }, + { code: "MXN", label: "Mexican Peso", symbol: "$" }, +]; + +/** + * Format a deal value as a currency string. Whole-number output + * (no minor units) — deal values are tracked to the dollar across + * the app. `currency` defaults to USD so callers with nothing better + * stay safe, but pass the account/deal currency wherever known. + */ +export function formatCurrency( + value: number, + currency: string = DEFAULT_CURRENCY, +): string { + return new Intl.NumberFormat(undefined, { + style: "currency", + currency: currency || DEFAULT_CURRENCY, + minimumFractionDigits: 0, + maximumFractionDigits: 0, + }).format(Number(value || 0)); +} + +/** + * Compact currency for tight spaces (donut center, legend rows): + * "$1.2M" / "€34.5k" / "₹900". Uses the currency's symbol from + * CURRENCIES, falling back to the code when we don't carry a symbol. + */ +export function formatCurrencyShort( + value: number, + currency: string = DEFAULT_CURRENCY, +): string { + const code = currency || DEFAULT_CURRENCY; + const symbol = CURRENCIES.find((c) => c.code === code)?.symbol ?? `${code} `; + const v = Number(value || 0); + if (v >= 1_000_000) return `${symbol}${(v / 1_000_000).toFixed(1)}M`; + if (v >= 1_000) return `${symbol}${(v / 1_000).toFixed(1)}k`; + return `${symbol}${v.toFixed(0)}`; +} diff --git a/supabase/migrations/021_account_default_currency.sql b/supabase/migrations/021_account_default_currency.sql new file mode 100644 index 0000000..025695b --- /dev/null +++ b/supabase/migrations/021_account_default_currency.sql @@ -0,0 +1,32 @@ +-- ============================================================ +-- 021_account_default_currency +-- +-- Make the default deal currency configurable per account. +-- +-- Before this, the app hardcoded USD everywhere — deal-value +-- formatters, the new-deal form, and automation-created deals all +-- assumed USD. wacrm is self-hostable and used globally, so a fixed +-- USD default made deal tracking unhelpful for non-US businesses +-- (issue #218). +-- +-- We add a single `default_currency` column to `accounts`. New deals +-- and all aggregated totals (pipeline/dashboard) format in this +-- currency; existing deals keep their own saved `deals.currency`. +-- We enforce one currency per account (no FX conversion) — the +-- issue's recommended first pass. +-- +-- RLS: no change needed. The existing `accounts_update` policy +-- (017) already restricts writes to admins+, which is exactly who +-- should change an account-wide setting. +-- ============================================================ + +ALTER TABLE accounts + ADD COLUMN IF NOT EXISTS default_currency TEXT NOT NULL DEFAULT 'USD'; + +-- Keep the value an ISO-4217-shaped 3-letter uppercase code without +-- pinning to a fixed enum — forks can use any currency Intl supports. +ALTER TABLE accounts + DROP CONSTRAINT IF EXISTS accounts_default_currency_format; +ALTER TABLE accounts + ADD CONSTRAINT accounts_default_currency_format + CHECK (default_currency ~ '^[A-Z]{3}$');