feat(deals): make default deal currency configurable per account (#218)

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) <noreply@anthropic.com>
This commit is contained in:
Arnas Donauskas 2026-06-08 13:59:48 +03:00
parent 11196b4728
commit 3abecb433b
No known key found for this signature in database
13 changed files with 340 additions and 71 deletions

View File

@ -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<MetricsBundle | null>(null)
const [metricsLoading, setMetricsLoading] = useState(true)
@ -155,7 +158,7 @@ export default function DashboardPage() {
/>
<MetricCard
title="Open Deals Value"
value={formatCurrency(metrics.openDealsValue)}
value={formatCurrency(metrics.openDealsValue, defaultCurrency)}
icon={DollarSign}
subtitle={`${metrics.openDealsCount} open deal${metrics.openDealsCount === 1 ? '' : 's'}`}
/>
@ -196,7 +199,11 @@ export default function DashboardPage() {
/>
</div>
<div className="h-full lg:col-span-2">
<PipelineDonut data={pipeline} loading={pipelineLoading} />
<PipelineDonut
data={pipeline}
loading={pipelineLoading}
currency={defaultCurrency}
/>
</div>
</div>
@ -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 ? '+' : ''

View File

@ -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() {
<Tag className="size-4" />
Tags
</TabsTrigger>
<TabsTrigger
value="deals"
className="data-active:bg-slate-800 data-active:text-primary text-slate-400"
>
<Coins className="size-4" />
Deals
</TabsTrigger>
<TabsTrigger
value="appearance"
className="data-active:bg-slate-800 data-active:text-primary text-slate-400"
@ -124,6 +134,10 @@ export default function SettingsPage() {
<TagManager />
</TabsContent>
<TabsContent value="deals">
<DealsSettings />
</TabsContent>
<TabsContent value="appearance">
<AppearancePanel />
</TabsContent>

View File

@ -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<Contact | null>(null);
const [loading, setLoading] = useState(false);
@ -656,11 +657,10 @@ export function ContactDetailView({
<div className="mt-1.5 flex items-center justify-between text-xs text-slate-400">
<span className="flex items-center gap-1">
<DollarSign className="size-3" />
{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,
)}
</span>
{deal.status && deal.status !== 'open' && (
<span

View File

@ -2,15 +2,18 @@
import { GitBranch } from 'lucide-react'
import type { PipelineDonutData } from '@/lib/dashboard/types'
import { formatCurrencyShort } from '@/lib/currency'
import { EmptyState } from './empty-state'
import { Skeleton } from './skeleton'
interface PipelineDonutProps {
data: PipelineDonutData | null
loading: boolean
/** Account default currency for the totals. */
currency: string
}
export function PipelineDonut({ data, loading }: PipelineDonutProps) {
export function PipelineDonut({ data, loading, currency }: PipelineDonutProps) {
return (
<section className="flex h-full flex-col rounded-xl border border-slate-800 bg-slate-900">
<header className="border-b border-slate-800 px-5 py-4">
@ -31,7 +34,7 @@ export function PipelineDonut({ data, loading }: PipelineDonutProps) {
/>
) : (
<>
<Donut data={data} />
<Donut data={data} currency={currency} />
<ul className="mt-5 space-y-2">
{data.stages.map((s) => (
<li key={s.id} className="flex items-center gap-3 text-xs">
@ -45,7 +48,7 @@ export function PipelineDonut({ data, loading }: PipelineDonutProps) {
{s.dealCount} deal{s.dealCount === 1 ? '' : 's'}
</span>
<span className="w-20 text-right text-slate-300 tabular-nums">
{formatCurrencyShort(s.totalValue)}
{formatCurrencyShort(s.totalValue, currency)}
</span>
</li>
))}
@ -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)}
</text>
</svg>
</div>
@ -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)}`
}

View File

@ -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",

View File

@ -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"
>
<option value="USD">USD</option>
<option value="EUR">EUR</option>
<option value="GBP">GBP</option>
{CURRENCIES.map((c) => (
<option key={c.code} value={c.code}>
{c.code}
</option>
))}
</select>
</div>
</div>

View File

@ -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) {
<Metric
icon={<DollarSign className="h-4 w-4 text-primary" />}
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."
/>
<Metric
icon={<Target className="h-4 w-4 text-blue-400" />}
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."
/>
<Metric
icon={<TrendingUp className="h-4 w-4 text-purple-400" />}
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."
/>
<Metric

View File

@ -18,6 +18,8 @@ import type { Deal, PipelineStage } from "@/types";
import { DealCard } from "./deal-card";
import { Button } from "@/components/ui/button";
import { Plus } from "lucide-react";
import { useAuth } from "@/hooks/use-auth";
import { formatCurrency } from "@/lib/currency";
interface PipelineBoardProps {
stages: PipelineStage[];
@ -27,15 +29,6 @@ interface PipelineBoardProps {
onEditDeal: (deal: Deal) => 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<string | null>(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}
</span>
</div>
<p className="text-xs text-slate-400">{formatCurrency(totalValue)}</p>
<p className="text-xs text-slate-400">
{formatCurrency(totalValue, currency)}
</p>
<div
ref={setNodeRef}

View File

@ -0,0 +1,125 @@
"use client";
import { useEffect, useState } from "react";
import { toast } from "sonner";
import { Coins, Loader2 } from "lucide-react";
import { createClient } from "@/lib/supabase/client";
import { useAuth } from "@/hooks/use-auth";
import { CURRENCIES } from "@/lib/currency";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import {
Card,
CardContent,
CardHeader,
CardTitle,
CardDescription,
} from "@/components/ui/card";
/**
* Deals settings — account-wide default currency.
*
* One currency per account (issue #218): the chosen code seeds new
* deals and formats every aggregated total. Existing deals keep their
* own saved currency. Writes go straight to `accounts.default_currency`;
* the `accounts_update` RLS policy (017) already restricts that to
* admins+, so non-admins see a disabled, read-only control.
*/
export function DealsSettings() {
const supabase = createClient();
const {
accountId,
defaultCurrency,
canEditSettings,
profileLoading,
refreshProfile,
} = useAuth();
const [selected, setSelected] = useState(defaultCurrency);
const [saving, setSaving] = useState(false);
// Keep the select in sync once the profile (and its account default)
// resolves, and after a save round-trips through refreshProfile.
useEffect(() => {
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 (
<section className="mt-4 space-y-4">
<Card className="bg-slate-900 border-slate-700 ring-0 ring-transparent">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-white">
<Coins className="size-4 text-primary" />
Default currency
</CardTitle>
<CardDescription className="text-slate-400">
New deals default to this currency, and pipeline and
dashboard totals are shown in it. Existing deals keep the
currency they were saved with.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-2 sm:max-w-xs">
<Label className="text-slate-300">Currency</Label>
<select
value={selected}
onChange={(e) => setSelected(e.target.value)}
disabled={!canEditSettings || profileLoading}
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 focus:ring-1 focus:ring-primary disabled:cursor-not-allowed disabled:opacity-60"
>
{CURRENCIES.map((c) => (
<option key={c.code} value={c.code}>
{c.code} — {c.label}
</option>
))}
</select>
{!canEditSettings && (
<p className="text-xs text-slate-500">
Only account admins can change the default currency.
</p>
)}
</div>
{canEditSettings && (
<Button
onClick={handleSave}
disabled={saving || !dirty}
className="bg-primary text-primary-foreground hover:bg-primary/90"
>
{saving ? (
<>
<Loader2 className="size-4 animate-spin" />
Saving...
</>
) : (
"Save"
)}
</Button>
)}
</CardContent>
</Card>
</section>
);
}

View File

@ -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,

View File

@ -465,6 +465,16 @@ async function runStep(step: AutomationStep, args: ExecuteArgs): Promise<string>
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<string>
contact_id: args.contactId,
title: interpolate(cfg.title, args),
value: cfg.value ?? 0,
currency: acct?.default_currency ?? 'USD',
status: 'open',
})
return 'deal created'

80
src/lib/currency.ts Normal file
View File

@ -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)}`;
}

View File

@ -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}$');