Просмотр исходного кода

Add all circulating currencies and centralize currency handling (#441)

* added currenices

* search
Bernt Christian Egeland 16 часов назад
Родитель
Сommit
9451f39454
69 измененных файлов с 836 добавлено и 1012 удалено
  1. 0 58
      messages/de/settings.json
  2. 0 58
      messages/en/settings.json
  3. 0 58
      messages/es/settings.json
  4. 0 58
      messages/fr/settings.json
  5. 0 58
      messages/it/settings.json
  6. 0 58
      messages/lt/settings.json
  7. 0 58
      messages/nb/settings.json
  8. 0 58
      messages/nl/settings.json
  9. 0 58
      messages/pl/settings.json
  10. 0 58
      messages/pt-BR/settings.json
  11. 0 58
      messages/pt-PT/settings.json
  12. 0 58
      messages/ru/settings.json
  13. 0 58
      messages/tr/settings.json
  14. 82 0
      src/__tests__/lib/currencies.test.ts
  15. 182 0
      src/__tests__/lib/payment-vendor-amounts.test.ts
  16. 2 1
      src/app/(authenticated)/billing/billing-client.tsx
  17. 2 1
      src/app/(authenticated)/billing/page.tsx
  18. 2 1
      src/app/(authenticated)/billing/recurring/page.tsx
  19. 2 1
      src/app/(authenticated)/calendar/page.tsx
  20. 2 1
      src/app/(authenticated)/dashboard-client.tsx
  21. 2 1
      src/app/(authenticated)/inventory/[id]/page.tsx
  22. 2 1
      src/app/(authenticated)/inventory/inventory-client.tsx
  23. 2 1
      src/app/(authenticated)/inventory/page.tsx
  24. 2 1
      src/app/(authenticated)/labor-presets/labor-presets-client.tsx
  25. 2 1
      src/app/(authenticated)/labor-presets/page.tsx
  26. 2 1
      src/app/(authenticated)/page.tsx
  27. 2 1
      src/app/(authenticated)/quotes/[id]/page.tsx
  28. 2 1
      src/app/(authenticated)/quotes/page.tsx
  29. 2 1
      src/app/(authenticated)/quotes/quotes-client.tsx
  30. 2 1
      src/app/(authenticated)/reports/page.tsx
  31. 18 69
      src/app/(authenticated)/settings/localization/localization-settings.tsx
  32. 2 1
      src/app/(authenticated)/tire-hotel/[id]/page.tsx
  33. 2 1
      src/app/(authenticated)/vehicles/[id]/page.tsx
  34. 2 1
      src/app/(authenticated)/vehicles/[id]/service-records-table.tsx
  35. 2 1
      src/app/(authenticated)/vehicles/[id]/vehicle-detail-client.tsx
  36. 2 1
      src/app/(authenticated)/work-orders/page.tsx
  37. 2 1
      src/app/(authenticated)/work-orders/work-orders-client.tsx
  38. 4 2
      src/app/(public)/portal/[orgId]/invoices/page.tsx
  39. 4 2
      src/app/(public)/portal/[orgId]/quotes/page.tsx
  40. 3 1
      src/app/(public)/portal/[orgId]/vehicles/[vehicleId]/page.tsx
  41. 5 2
      src/app/(public)/share/invoice/[orgId]/[token]/page.tsx
  42. 5 3
      src/app/(public)/share/quote/[orgId]/[token]/page.tsx
  43. 2 1
      src/app/api/protected/ai/chat/route.ts
  44. 3 4
      src/app/api/protected/vehicles/[id]/service-history-pdf/route.ts
  45. 2 5
      src/app/api/public/share/invoice/[orgId]/[token]/checkout/route.ts
  46. 3 1
      src/app/api/public/share/invoice/[orgId]/[token]/verify/route.ts
  47. 2 1
      src/app/api/webhooks/stripe/route.ts
  48. 10 10
      src/components/currency-settings-context.tsx
  49. 5 3
      src/features/email/Actions/emailActions.ts
  50. 2 1
      src/features/email/Lib/emailContext.ts
  51. 3 2
      src/features/invoice-designer/Pdf/buildInvoicePrint.ts
  52. 3 2
      src/features/invoice-designer/Pdf/buildQuotePrint.ts
  53. 3 2
      src/features/invoice-designer/Pdf/buildWorkOrderPrint.ts
  54. 5 4
      src/features/invoices/Lib/assembleInvoicePrint.ts
  55. 2 1
      src/features/labor-presets/Components/PresetPartsEditor.tsx
  56. 2 1
      src/features/quotes/Components/QuotePDF.tsx
  57. 2 1
      src/features/quotes/Components/QuotePageClient.tsx
  58. 3 4
      src/features/quotes/Pdf/buildQuotePdfBuffer.ts
  59. 3 2
      src/features/settings/Components/InvoiceLayoutPreviewRenderer.tsx
  60. 6 1
      src/features/subscription/Components/subscription-settings.tsx
  61. 3 2
      src/features/vehicles/Components/service-history-pdf/ServiceHistoryPDF.tsx
  62. 2 1
      src/features/vehicles/Components/service-page/ServiceRecordPage.tsx
  63. 5 3
      src/lib/cron/report-schedules.ts
  64. 284 0
      src/lib/currencies.ts
  65. 17 103
      src/lib/format.ts
  66. 2 1
      src/lib/payment-providers/paypal.ts
  67. 3 2
      src/lib/payment-providers/stripe.ts
  68. 83 0
      src/lib/payment-providers/vendor-amounts.ts
  69. 31 0
      src/lib/workshop-currency.ts

+ 0 - 58
messages/de/settings.json

@@ -1078,64 +1078,6 @@
     "applyTaxNoRecords": "Keine Einträge benötigen den Standardsteuersatz.",
     "saveSettings": "Einstellungen speichern",
     "saved": "Einstellungen gespeichert",
-    "currencies": {
-      "USD": "US-Dollar",
-      "EUR": "Euro",
-      "GBP": "Britisches Pfund",
-      "NOK": "Norwegische Krone",
-      "SEK": "Schwedische Krone",
-      "DKK": "Danische Krone",
-      "CHF": "Schweizer Franken",
-      "CAD": "Kanadischer Dollar",
-      "AUD": "Australischer Dollar",
-      "NZD": "Neuseelandischer Dollar",
-      "JPY": "Japanischer Yen",
-      "CNY": "Chinesischer Yuan",
-      "INR": "Indische Rupie",
-      "BRL": "Brasilianischer Real",
-      "MXN": "Mexikanischer Peso",
-      "PLN": "Polnischer Zloty",
-      "CZK": "Tschechische Krone",
-      "HUF": "Ungarischer Forint",
-      "TRY": "Turkische Lira",
-      "ZAR": "Sudafrikanischer Rand",
-      "KRW": "Sudkoreanischer Won",
-      "SGD": "Singapur-Dollar",
-      "HKD": "Hongkong-Dollar",
-      "THB": "Thailandischer Baht",
-      "ISK": "Islandische Krone",
-      "RON": "Rumanischer Leu",
-      "ILS": "Israelischer Schekel",
-      "PHP": "Philippinischer Peso",
-      "IDR": "Indonesische Rupiah",
-      "MYR": "Malaysischer Ringgit",
-      "LKR": "Sri-Lanka-Rupie",
-      "AED": "VAE-Dirham",
-      "SAR": "Saudi-Riyal",
-      "RUB": "Russischer Rubel",
-      "TWD": "Taiwan-Dollar",
-      "VND": "Vietnamesischer Dong",
-      "PKR": "Pakistanische Rupie",
-      "BDT": "Bangladesch-Taka",
-      "NPR": "Nepalesische Rupie",
-      "BGN": "Bulgarischer Lew",
-      "UAH": "Ukrainische Hrywnja",
-      "CLP": "Chilenischer Peso",
-      "COP": "Kolumbianischer Peso",
-      "ARS": "Argentinischer Peso",
-      "PEN": "Peruanischer Sol",
-      "EGP": "Ägyptisches Pfund",
-      "NGN": "Nigerianischer Naira",
-      "KES": "Kenia-Schilling",
-      "MAD": "Marokkanischer Dirham",
-      "DZD": "Algerischer Dinar",
-      "GHS": "Ghanaischer Cedi",
-      "QAR": "Katar-Riyal",
-      "KWD": "Kuwait-Dinar",
-      "BHD": "Bahrain-Dinar",
-      "OMR": "Omanischer Rial",
-      "JOD": "Jordanischer Dinar"
-    },
     "formatTitle": "Währungsformat",
     "formatSymbolName": "Symbol",
     "formatCodeName": "Code",

+ 0 - 58
messages/en/settings.json

@@ -1078,64 +1078,6 @@
     "applyTaxNoRecords": "No records need the default tax rate applied.",
     "saveSettings": "Save Settings",
     "saved": "Settings saved",
-    "currencies": {
-      "USD": "US Dollar",
-      "EUR": "Euro",
-      "GBP": "British Pound",
-      "NOK": "Norwegian Krone",
-      "SEK": "Swedish Krona",
-      "DKK": "Danish Krone",
-      "CHF": "Swiss Franc",
-      "CAD": "Canadian Dollar",
-      "AUD": "Australian Dollar",
-      "NZD": "New Zealand Dollar",
-      "JPY": "Japanese Yen",
-      "CNY": "Chinese Yuan",
-      "INR": "Indian Rupee",
-      "BRL": "Brazilian Real",
-      "MXN": "Mexican Peso",
-      "PLN": "Polish Zloty",
-      "CZK": "Czech Koruna",
-      "HUF": "Hungarian Forint",
-      "TRY": "Turkish Lira",
-      "ZAR": "South African Rand",
-      "KRW": "South Korean Won",
-      "SGD": "Singapore Dollar",
-      "HKD": "Hong Kong Dollar",
-      "THB": "Thai Baht",
-      "ISK": "Icelandic Krona",
-      "RON": "Romanian Leu",
-      "ILS": "Israeli Shekel",
-      "PHP": "Philippine Peso",
-      "IDR": "Indonesian Rupiah",
-      "MYR": "Malaysian Ringgit",
-      "LKR": "Sri Lankan Rupee",
-      "AED": "UAE Dirham",
-      "SAR": "Saudi Riyal",
-      "RUB": "Russian Ruble",
-      "TWD": "Taiwan Dollar",
-      "VND": "Vietnamese Dong",
-      "PKR": "Pakistani Rupee",
-      "BDT": "Bangladeshi Taka",
-      "NPR": "Nepalese Rupee",
-      "BGN": "Bulgarian Lev",
-      "UAH": "Ukrainian Hryvnia",
-      "CLP": "Chilean Peso",
-      "COP": "Colombian Peso",
-      "ARS": "Argentine Peso",
-      "PEN": "Peruvian Sol",
-      "EGP": "Egyptian Pound",
-      "NGN": "Nigerian Naira",
-      "KES": "Kenyan Shilling",
-      "MAD": "Moroccan Dirham",
-      "DZD": "Algerian Dinar",
-      "GHS": "Ghanaian Cedi",
-      "QAR": "Qatari Riyal",
-      "KWD": "Kuwaiti Dinar",
-      "BHD": "Bahraini Dinar",
-      "OMR": "Omani Rial",
-      "JOD": "Jordanian Dinar"
-    },
     "formatTitle": "Currency Format",
     "formatSymbolName": "Symbol",
     "formatCodeName": "Code",

+ 0 - 58
messages/es/settings.json

@@ -1078,64 +1078,6 @@
     "applyTaxNoRecords": "Ningún registro necesita la tasa de impuesto predeterminada.",
     "saveSettings": "Guardar configuración",
     "saved": "Configuración guardada",
-    "currencies": {
-      "USD": "Dolar estadounidense",
-      "EUR": "Euro",
-      "GBP": "Libra esterlina",
-      "NOK": "Corona noruega",
-      "SEK": "Corona sueca",
-      "DKK": "Corona danesa",
-      "CHF": "Franco suizo",
-      "CAD": "Dolar canadiense",
-      "AUD": "Dolar australiano",
-      "NZD": "Dolar neozelandes",
-      "JPY": "Yen japones",
-      "CNY": "Yuan chino",
-      "INR": "Rupia india",
-      "BRL": "Real brasileno",
-      "MXN": "Peso mexicano",
-      "PLN": "Zloty polaco",
-      "CZK": "Corona checa",
-      "HUF": "Forinto hungaro",
-      "TRY": "Lira turca",
-      "ZAR": "Rand sudafricano",
-      "KRW": "Won surcoreano",
-      "SGD": "Dolar de Singapur",
-      "HKD": "Dolar de Hong Kong",
-      "THB": "Baht tailandes",
-      "ISK": "Corona islandesa",
-      "RON": "Leu rumano",
-      "ILS": "Shekel israeli",
-      "PHP": "Peso filipino",
-      "IDR": "Rupia indonesia",
-      "MYR": "Ringgit malayo",
-      "LKR": "Rupia de Sri Lanka",
-      "AED": "Dirham de los EAU",
-      "SAR": "Riyal saudi",
-      "RUB": "Rublo ruso",
-      "TWD": "Dolar taiwanes",
-      "VND": "Dong vietnamita",
-      "PKR": "Rupia pakistani",
-      "BDT": "Taka de Bangladesh",
-      "NPR": "Rupia nepali",
-      "BGN": "Lev bulgaro",
-      "UAH": "Grivna ucraniana",
-      "CLP": "Peso chileno",
-      "COP": "Peso colombiano",
-      "ARS": "Peso argentino",
-      "PEN": "Sol peruano",
-      "EGP": "Libra egipcia",
-      "NGN": "Naira nigeriano",
-      "KES": "Chelin keniano",
-      "MAD": "Dirham marroqui",
-      "DZD": "Dinar argelino",
-      "GHS": "Cedi ghanes",
-      "QAR": "Riyal qatari",
-      "KWD": "Dinar kuwaiti",
-      "BHD": "Dinar bahreini",
-      "OMR": "Rial omani",
-      "JOD": "Dinar jordano"
-    },
     "formatTitle": "Formato de moneda",
     "formatSymbolName": "Simbolo",
     "formatCodeName": "Código",

+ 0 - 58
messages/fr/settings.json

@@ -1078,64 +1078,6 @@
     "applyTaxNoRecords": "Aucun enregistrement ne necessite le taux de taxe par défaut.",
     "saveSettings": "Enregistrer les paramètres",
     "saved": "Paramètres enregistrés",
-    "currencies": {
-      "USD": "Dollar americain",
-      "EUR": "Euro",
-      "GBP": "Livre sterling",
-      "NOK": "Couronne norvegienne",
-      "SEK": "Couronne suedoise",
-      "DKK": "Couronne danoise",
-      "CHF": "Franc suisse",
-      "CAD": "Dollar canadien",
-      "AUD": "Dollar australien",
-      "NZD": "Dollar neo-zelandais",
-      "JPY": "Yen japonais",
-      "CNY": "Yuan chinois",
-      "INR": "Roupie indienne",
-      "BRL": "Real bresilien",
-      "MXN": "Peso mexicain",
-      "PLN": "Zloty polonais",
-      "CZK": "Couronne tcheque",
-      "HUF": "Forint hongrois",
-      "TRY": "Livre turque",
-      "ZAR": "Rand sud-africain",
-      "KRW": "Won sud-coreen",
-      "SGD": "Dollar de Singapour",
-      "HKD": "Dollar de Hong Kong",
-      "THB": "Baht thailandais",
-      "ISK": "Couronne islandaise",
-      "RON": "Leu roumain",
-      "ILS": "Shekel israelien",
-      "PHP": "Peso philippin",
-      "IDR": "Roupie indonesienne",
-      "MYR": "Ringgit malaisien",
-      "LKR": "Roupie srilankaise",
-      "AED": "Dirham des EAU",
-      "SAR": "Riyal saoudien",
-      "RUB": "Rouble russe",
-      "TWD": "Dollar taiwanais",
-      "VND": "Dong vietnamien",
-      "PKR": "Roupie pakistanaise",
-      "BDT": "Taka bangladais",
-      "NPR": "Roupie nepalaise",
-      "BGN": "Lev bulgare",
-      "UAH": "Hryvnia ukrainienne",
-      "CLP": "Peso chilien",
-      "COP": "Peso colombien",
-      "ARS": "Peso argentin",
-      "PEN": "Sol peruvien",
-      "EGP": "Livre egyptienne",
-      "NGN": "Naira nigerian",
-      "KES": "Shilling kenyan",
-      "MAD": "Dirham marocain",
-      "DZD": "Dinar algérien",
-      "GHS": "Cedi ghaneen",
-      "QAR": "Riyal qatari",
-      "KWD": "Dinar koweitien",
-      "BHD": "Dinar bahreini",
-      "OMR": "Rial omanais",
-      "JOD": "Dinar jordanien"
-    },
     "formatTitle": "Format de la monnaie",
     "formatSymbolName": "Symbole",
     "formatCodeName": "Code",

+ 0 - 58
messages/it/settings.json

@@ -1078,64 +1078,6 @@
     "applyTaxNoRecords": "Nessun record necessita dell'aliquota fiscale predefinita.",
     "saveSettings": "Salva impostazioni",
     "saved": "Impostazioni salvate",
-    "currencies": {
-      "USD": "Dollaro USA",
-      "EUR": "Euro",
-      "GBP": "Sterlina britannica",
-      "NOK": "Corona norvegese",
-      "SEK": "Corona svedese",
-      "DKK": "Corona danese",
-      "CHF": "Franco svizzero",
-      "CAD": "Dollaro canadese",
-      "AUD": "Dollaro australiano",
-      "NZD": "Dollaro neozelandese",
-      "JPY": "Yen giapponese",
-      "CNY": "Yuan cinese",
-      "INR": "Rupia indiana",
-      "BRL": "Real brasiliano",
-      "MXN": "Peso messicano",
-      "PLN": "Zloty polacco",
-      "CZK": "Corona ceca",
-      "HUF": "Fiorino ungherese",
-      "TRY": "Lira turca",
-      "ZAR": "Rand sudafricano",
-      "KRW": "Won sudcoreano",
-      "SGD": "Dollaro di Singapore",
-      "HKD": "Dollaro di Hong Kong",
-      "THB": "Baht tailandese",
-      "ISK": "Corona islandese",
-      "RON": "Leu romeno",
-      "ILS": "Shekel israeliano",
-      "PHP": "Peso filippino",
-      "IDR": "Rupia indonesiana",
-      "MYR": "Ringgit malese",
-      "LKR": "Rupia dello Sri Lanka",
-      "AED": "Dirham degli EAU",
-      "SAR": "Riyal saudita",
-      "RUB": "Rublo russo",
-      "TWD": "Dollaro taiwanese",
-      "VND": "Dong vietnamita",
-      "PKR": "Rupia pakistana",
-      "BDT": "Taka del Bangladesh",
-      "NPR": "Rupia nepalese",
-      "BGN": "Lev bulgaro",
-      "UAH": "Grivnia ucraina",
-      "CLP": "Peso cileno",
-      "COP": "Peso colombiano",
-      "ARS": "Peso argentino",
-      "PEN": "Sol peruviano",
-      "EGP": "Sterlina egiziana",
-      "NGN": "Naira nigeriana",
-      "KES": "Scellino keniota",
-      "MAD": "Dirham marocchino",
-      "DZD": "Dinaro algerino",
-      "GHS": "Cedi ghanese",
-      "QAR": "Riyal del Qatar",
-      "KWD": "Dinaro kuwaitiano",
-      "BHD": "Dinaro del Bahrein",
-      "OMR": "Rial omanita",
-      "JOD": "Dinaro giordano"
-    },
     "formatTitle": "Formato valuta",
     "formatSymbolName": "Simbolo",
     "formatCodeName": "Codice",

+ 0 - 58
messages/lt/settings.json

@@ -1078,64 +1078,6 @@
     "applyTaxNoRecords": "Jokie įrašai nereikalauja numatytojo mokesčių tarifo.",
     "saveSettings": "Išsaugoti nustatymus",
     "saved": "Nustatymai išsaugoti",
-    "currencies": {
-      "USD": "JAV doleris",
-      "EUR": "Euras",
-      "GBP": "Svaras sterlingų",
-      "NOK": "Norvegijos krona",
-      "SEK": "Švedijos krona",
-      "DKK": "Danijos krona",
-      "CHF": "Šveicarijos frankas",
-      "CAD": "Kanados doleris",
-      "AUD": "Australijos doleris",
-      "NZD": "Naujosios Zelandijos doleris",
-      "JPY": "Japonijos jena",
-      "CNY": "Kinijos juanis",
-      "INR": "Indijos rupija",
-      "BRL": "Brazilijos realas",
-      "MXN": "Meksikos pesas",
-      "PLN": "Lenkijos zlotas",
-      "CZK": "Čekijos krona",
-      "HUF": "Vengrijos forintas",
-      "TRY": "Turkijos lira",
-      "ZAR": "Pietų Afrikos randas",
-      "KRW": "Pietų Korėjos vonas",
-      "SGD": "Singapūro doleris",
-      "HKD": "Honkongo doleris",
-      "THB": "Tailando batas",
-      "ISK": "Islandijos krona",
-      "RON": "Rumunijos lėja",
-      "ILS": "Izraelio šekelis",
-      "PHP": "Filipinų pesas",
-      "IDR": "Indonezijos rupija",
-      "MYR": "Malaizijos ringitas",
-      "LKR": "Šri Lankos rupija",
-      "AED": "JAE dirhamas",
-      "SAR": "Saudo Arabijos rijalas",
-      "RUB": "Rusijos rublis",
-      "TWD": "Taivano doleris",
-      "VND": "Vietnamo donas",
-      "PKR": "Pakistano rupija",
-      "BDT": "Bangladešo taka",
-      "NPR": "Nepalo rupija",
-      "BGN": "Bulgarijos levas",
-      "UAH": "Ukrainos grivina",
-      "CLP": "Čilės pesas",
-      "COP": "Kolumbijos pesas",
-      "ARS": "Argentinos pesas",
-      "PEN": "Peru solis",
-      "EGP": "Egipto svaras",
-      "NGN": "Nigerijos naira",
-      "KES": "Kenijos šilingas",
-      "MAD": "Maroko dirhamas",
-      "DZD": "Alžyro dinaras",
-      "GHS": "Ganos sedis",
-      "QAR": "Kataro rialas",
-      "KWD": "Kuveito dinaras",
-      "BHD": "Bahreino dinaras",
-      "OMR": "Omano rialas",
-      "JOD": "Jordanijos dinaras"
-    },
     "formatTitle": "Valiutos formatas",
     "formatSymbolName": "Simbolis",
     "formatCodeName": "Kodas",

+ 0 - 58
messages/nb/settings.json

@@ -1078,64 +1078,6 @@
     "applyTaxNoRecords": "Ingen poster trenger standard avgiftssats.",
     "saveSettings": "Lagre innstillinger",
     "saved": "Innstillinger lagret",
-    "currencies": {
-      "USD": "Amerikanske dollar",
-      "EUR": "Euro",
-      "GBP": "Britiske pund",
-      "NOK": "Norske kroner",
-      "SEK": "Svenske kroner",
-      "DKK": "Danske kroner",
-      "CHF": "Sveitsiske franc",
-      "CAD": "Kanadiske dollar",
-      "AUD": "Australske dollar",
-      "NZD": "Newzealandske dollar",
-      "JPY": "Japanske yen",
-      "CNY": "Kinesiske yuan",
-      "INR": "Indiske rupier",
-      "BRL": "Brasilianske real",
-      "MXN": "Meksikanske pesos",
-      "PLN": "Polske zloty",
-      "CZK": "Tsjekkiske koruna",
-      "HUF": "Ungarske forint",
-      "TRY": "Tyrkiske lira",
-      "ZAR": "Sorafrikanske rand",
-      "KRW": "Sorkoreanske won",
-      "SGD": "Singaporske dollar",
-      "HKD": "Hongkong-dollar",
-      "THB": "Thailandske baht",
-      "ISK": "Islandske kroner",
-      "RON": "Rumenske leu",
-      "ILS": "Israelske shekel",
-      "PHP": "Filippinske pesos",
-      "IDR": "Indonesiske rupiah",
-      "MYR": "Malaysiske ringgit",
-      "LKR": "Srilankiske rupier",
-      "AED": "Emiratiske dirham",
-      "SAR": "Saudiarabiske riyal",
-      "RUB": "Russisk rubel",
-      "TWD": "Taiwanske dollar",
-      "VND": "Vietnamesisk dong",
-      "PKR": "Pakistanske rupier",
-      "BDT": "Bangladeshisk taka",
-      "NPR": "Nepalesiske rupier",
-      "BGN": "Bulgarsk lev",
-      "UAH": "Ukrainsk hryvnia",
-      "CLP": "Chilenske pesos",
-      "COP": "Colombianske pesos",
-      "ARS": "Argentinske pesos",
-      "PEN": "Peruansk sol",
-      "EGP": "Egyptisk pund",
-      "NGN": "Nigerianske naira",
-      "KES": "Kenyansk shilling",
-      "MAD": "Marokkansk dirham",
-      "DZD": "Algerisk dinar",
-      "GHS": "Ghanesisk cedi",
-      "QAR": "Qatarske riyal",
-      "KWD": "Kuwaitiske dinar",
-      "BHD": "Bahrainske dinar",
-      "OMR": "Omanske rial",
-      "JOD": "Jordanske dinar"
-    },
     "formatTitle": "Valutaformat",
     "formatSymbolName": "Symbol",
     "formatCodeName": "Kode",

+ 0 - 58
messages/nl/settings.json

@@ -1078,64 +1078,6 @@
     "applyTaxNoRecords": "Geen records hebben het standaard belastingtarief nodig.",
     "saveSettings": "Instellingen opslaan",
     "saved": "Instellingen opgeslagen",
-    "currencies": {
-      "USD": "Amerikaanse dollar",
-      "EUR": "Euro",
-      "GBP": "Brits pond",
-      "NOK": "Noorse kroon",
-      "SEK": "Zweedse kroon",
-      "DKK": "Deense kroon",
-      "CHF": "Zwitserse frank",
-      "CAD": "Canadese dollar",
-      "AUD": "Australische dollar",
-      "NZD": "Nieuw-Zeelandse dollar",
-      "JPY": "Japanse yen",
-      "CNY": "Chinese yuan",
-      "INR": "Indiase roepie",
-      "BRL": "Braziliaanse real",
-      "MXN": "Mexicaanse peso",
-      "PLN": "Poolse zloty",
-      "CZK": "Tsjechische kroon",
-      "HUF": "Hongaarse forint",
-      "TRY": "Turkse lira",
-      "ZAR": "Zuid-Afrikaanse rand",
-      "KRW": "Zuid-Koreaanse won",
-      "SGD": "Singaporese dollar",
-      "HKD": "Hongkongse dollar",
-      "THB": "Thaise baht",
-      "ISK": "IJslandse kroon",
-      "RON": "Roemeense leu",
-      "ILS": "Israëlische sjekel",
-      "PHP": "Filipijnse peso",
-      "IDR": "Indonesische roepia",
-      "MYR": "Maleisische ringgit",
-      "LKR": "Srilankaanse roepie",
-      "AED": "VAE-dirham",
-      "SAR": "Saoedische riyal",
-      "RUB": "Russische roebel",
-      "TWD": "Taiwanese dollar",
-      "VND": "Vietnamese dong",
-      "PKR": "Pakistaanse roepie",
-      "BDT": "Bengaalse taka",
-      "NPR": "Nepalese roepie",
-      "BGN": "Bulgaarse lev",
-      "UAH": "Oekraïense hryvnia",
-      "CLP": "Chileense peso",
-      "COP": "Colombiaanse peso",
-      "ARS": "Argentijnse peso",
-      "PEN": "Peruaanse sol",
-      "EGP": "Egyptisch pond",
-      "NGN": "Nigeriaanse naira",
-      "KES": "Keniaanse shilling",
-      "MAD": "Marokkaanse dirham",
-      "DZD": "Algerijnse dinar",
-      "GHS": "Ghanese cedi",
-      "QAR": "Qatarese rial",
-      "KWD": "Koeweitse dinar",
-      "BHD": "Bahreinse dinar",
-      "OMR": "Omaanse rial",
-      "JOD": "Jordaanse dinar"
-    },
     "formatTitle": "Valutaformaat",
     "formatSymbolName": "Symbool",
     "formatCodeName": "Code",

+ 0 - 58
messages/pl/settings.json

@@ -1078,64 +1078,6 @@
     "applyTaxNoRecords": "Żadne rekordy nie wymagają domyślnej stawki podatku.",
     "saveSettings": "Zapisz ustawienia",
     "saved": "Ustawienia zapisane",
-    "currencies": {
-      "USD": "Dolar amerykanski",
-      "EUR": "Euro",
-      "GBP": "Funt brytyjski",
-      "NOK": "Korona norweska",
-      "SEK": "Korona szwedzka",
-      "DKK": "Korona dunska",
-      "CHF": "Frank szwajcarski",
-      "CAD": "Dolar kanadyjski",
-      "AUD": "Dolar australijski",
-      "NZD": "Dolar nowozelandzki",
-      "JPY": "Jen japonski",
-      "CNY": "Juan chinski",
-      "INR": "Rupia indyjska",
-      "BRL": "Real brazylijski",
-      "MXN": "Peso meksykanskie",
-      "PLN": "Zloty polski",
-      "CZK": "Korona czeska",
-      "HUF": "Forint wegierski",
-      "TRY": "Lira turecka",
-      "ZAR": "Rand poludniowoafrykanski",
-      "KRW": "Won poludniowokoreanskii",
-      "SGD": "Dolar singapurski",
-      "HKD": "Dolar hongkonski",
-      "THB": "Baht tajski",
-      "ISK": "Korona islandzka",
-      "RON": "Lej rumunski",
-      "ILS": "Szekel izraelski",
-      "PHP": "Peso filipinskie",
-      "IDR": "Rupia indonezyjska",
-      "MYR": "Ringgit malezyjski",
-      "LKR": "Rupia lankijska",
-      "AED": "Dirham ZEA",
-      "SAR": "Rial saudyjski",
-      "RUB": "Rubel rosyjski",
-      "TWD": "Dolar tajwanski",
-      "VND": "Dong wietnamski",
-      "PKR": "Rupia pakistanska",
-      "BDT": "Taka bangladeska",
-      "NPR": "Rupia nepalska",
-      "BGN": "Lew bulgarski",
-      "UAH": "Hrywna ukrainska",
-      "CLP": "Peso chilijskie",
-      "COP": "Peso kolumbijskie",
-      "ARS": "Peso argentynskie",
-      "PEN": "Sol peruwianski",
-      "EGP": "Funt egipski",
-      "NGN": "Naira nigeryjska",
-      "KES": "Szyling kenijski",
-      "MAD": "Dirham marokanski",
-      "DZD": "Dinar algierski",
-      "GHS": "Cedi ghanskie",
-      "QAR": "Rial katarski",
-      "KWD": "Dinar kuwejcki",
-      "BHD": "Dinar bahrajnski",
-      "OMR": "Rial omanski",
-      "JOD": "Dinar jordanski"
-    },
     "formatTitle": "Format waluty",
     "formatSymbolName": "Symbol",
     "formatCodeName": "Kod",

+ 0 - 58
messages/pt-BR/settings.json

@@ -1078,64 +1078,6 @@
     "applyTaxNoRecords": "Nenhum registro precisa da taxa de imposto padrão.",
     "saveSettings": "Salvar configurações",
     "saved": "Configurações salvas",
-    "currencies": {
-      "USD": "Dólar americano",
-      "EUR": "Euro",
-      "GBP": "Libra esterlina",
-      "NOK": "Coroa norueguesa",
-      "SEK": "Coroa sueca",
-      "DKK": "Coroa dinamarquesa",
-      "CHF": "Franco suíço",
-      "CAD": "Dólar canadense",
-      "AUD": "Dólar australiano",
-      "NZD": "Dólar neozelandês",
-      "JPY": "Iene japonês",
-      "CNY": "Yuan chinês",
-      "INR": "Rupia indiana",
-      "BRL": "Real brasileiro",
-      "MXN": "Peso mexicano",
-      "PLN": "Zloti polonês",
-      "CZK": "Coroa tcheca",
-      "HUF": "Florim húngaro",
-      "TRY": "Lira turca",
-      "ZAR": "Rand sul-africano",
-      "KRW": "Won sul-coreano",
-      "SGD": "Dólar de Singapura",
-      "HKD": "Dólar de Hong Kong",
-      "THB": "Baht tailandês",
-      "ISK": "Coroa islandesa",
-      "RON": "Leu romeno",
-      "ILS": "Shekel israelense",
-      "PHP": "Peso filipino",
-      "IDR": "Rupia indonésia",
-      "MYR": "Ringgit malaio",
-      "LKR": "Rupia do Sri Lanka",
-      "AED": "Dirham dos EAU",
-      "SAR": "Rial saudita",
-      "RUB": "Rublo russo",
-      "TWD": "Dólar taiwanês",
-      "VND": "Dong vietnamita",
-      "PKR": "Rupia paquistanesa",
-      "BDT": "Taka bengalesa",
-      "NPR": "Rupia nepalesa",
-      "BGN": "Lev búlgaro",
-      "UAH": "Hryvnia ucraniana",
-      "CLP": "Peso chileno",
-      "COP": "Peso colombiano",
-      "ARS": "Peso argentino",
-      "PEN": "Sol peruano",
-      "EGP": "Libra egípcia",
-      "NGN": "Naira nigeriana",
-      "KES": "Xelim queniano",
-      "MAD": "Dirham marroquino",
-      "DZD": "Dinar argelino",
-      "GHS": "Cedi ganês",
-      "QAR": "Rial catariano",
-      "KWD": "Dinar kuwaitiano",
-      "BHD": "Dinar do Bahrein",
-      "OMR": "Rial omanense",
-      "JOD": "Dinar jordaniano"
-    },
     "formatTitle": "Formato da moeda",
     "formatSymbolName": "Símbolo",
     "formatCodeName": "Código",

+ 0 - 58
messages/pt-PT/settings.json

@@ -1078,64 +1078,6 @@
     "applyTaxNoRecords": "Nenhum registo precisa da taxa de imposto predefinida.",
     "saveSettings": "Guardar definições",
     "saved": "Definições guardadas",
-    "currencies": {
-      "USD": "Dólar americano",
-      "EUR": "Euro",
-      "GBP": "Libra esterlina",
-      "NOK": "Coroa norueguesa",
-      "SEK": "Coroa sueca",
-      "DKK": "Coroa dinamarquesa",
-      "CHF": "Franco suíço",
-      "CAD": "Dólar canadiano",
-      "AUD": "Dólar australiano",
-      "NZD": "Dólar neozelandês",
-      "JPY": "Iene japonês",
-      "CNY": "Yuan chinês",
-      "INR": "Rupia indiana",
-      "BRL": "Real brasileiro",
-      "MXN": "Peso mexicano",
-      "PLN": "Zlóti polaco",
-      "CZK": "Coroa checa",
-      "HUF": "Florim húngaro",
-      "TRY": "Lira turca",
-      "ZAR": "Rand sul-africano",
-      "KRW": "Won sul-coreano",
-      "SGD": "Dólar de Singapura",
-      "HKD": "Dólar de Hong Kong",
-      "THB": "Baht tailandês",
-      "ISK": "Coroa islandesa",
-      "RON": "Leu romeno",
-      "ILS": "Shekel israelita",
-      "PHP": "Peso filipino",
-      "IDR": "Rupia indonésia",
-      "MYR": "Ringgit malaio",
-      "LKR": "Rupia do Sri Lanka",
-      "AED": "Dirham dos EAU",
-      "SAR": "Rial saudita",
-      "RUB": "Rublo russo",
-      "TWD": "Dólar de Taiwan",
-      "VND": "Dong vietnamita",
-      "PKR": "Rupia paquistanesa",
-      "BDT": "Taka do Bangladesh",
-      "NPR": "Rupia nepalesa",
-      "BGN": "Lev búlgaro",
-      "UAH": "Hryvnia ucraniana",
-      "CLP": "Peso chileno",
-      "COP": "Peso colombiano",
-      "ARS": "Peso argentino",
-      "PEN": "Sol peruano",
-      "EGP": "Libra egípcia",
-      "NGN": "Naira nigeriana",
-      "KES": "Xelim queniano",
-      "MAD": "Dirham marroquino",
-      "DZD": "Dinar argelino",
-      "GHS": "Cedi ganês",
-      "QAR": "Rial do Catar",
-      "KWD": "Dinar do Kuwait",
-      "BHD": "Dinar do Bahrein",
-      "OMR": "Rial omanense",
-      "JOD": "Dinar jordaniano"
-    },
     "formatTitle": "Formato da moeda",
     "formatSymbolName": "Símbolo",
     "formatCodeName": "Código",

+ 0 - 58
messages/ru/settings.json

@@ -1078,64 +1078,6 @@
     "applyTaxNoRecords": "Нет записей, требующих налоговой ставки по умолчанию.",
     "saveSettings": "Сохранить настройки",
     "saved": "Настройки сохранены",
-    "currencies": {
-      "USD": "Доллар США",
-      "EUR": "Евро",
-      "GBP": "Британский фунт",
-      "NOK": "Норвежская крона",
-      "SEK": "Шведская крона",
-      "DKK": "Датская крона",
-      "CHF": "Швейцарский франк",
-      "CAD": "Канадский доллар",
-      "AUD": "Австралийский доллар",
-      "NZD": "Новозеландский доллар",
-      "JPY": "Японская иена",
-      "CNY": "Китайский юань",
-      "INR": "Индийская рупия",
-      "BRL": "Бразильский реал",
-      "MXN": "Мексиканское песо",
-      "PLN": "Польский злотый",
-      "CZK": "Чешская крона",
-      "HUF": "Венгерский форинт",
-      "TRY": "Турецкая лира",
-      "ZAR": "Южноафриканский рэнд",
-      "KRW": "Южнокорейская вона",
-      "SGD": "Сингапурский доллар",
-      "HKD": "Гонконгский доллар",
-      "THB": "Тайский бат",
-      "ISK": "Исландская крона",
-      "RON": "Румынский лей",
-      "ILS": "Израильский шекель",
-      "PHP": "Филиппинское песо",
-      "IDR": "Индонезийская рупия",
-      "MYR": "Малайзийский ринггит",
-      "LKR": "Шри-ланкийская рупия",
-      "AED": "Дирхам ОАЭ",
-      "SAR": "Саудовский риял",
-      "RUB": "Российский рубль",
-      "TWD": "Тайваньский доллар",
-      "VND": "Вьетнамский донг",
-      "PKR": "Пакистанская рупия",
-      "BDT": "Бангладешская така",
-      "NPR": "Непальская рупия",
-      "BGN": "Болгарский лев",
-      "UAH": "Украинская гривна",
-      "CLP": "Чилийское песо",
-      "COP": "Колумбийское песо",
-      "ARS": "Аргентинское песо",
-      "PEN": "Перуанский соль",
-      "EGP": "Египетский фунт",
-      "NGN": "Нигерийская найра",
-      "KES": "Кенийский шиллинг",
-      "MAD": "Марокканский дирхам",
-      "DZD": "Алжирский динар",
-      "GHS": "Ганский седи",
-      "QAR": "Катарский риал",
-      "KWD": "Кувейтский динар",
-      "BHD": "Бахрейнский динар",
-      "OMR": "Оманский риал",
-      "JOD": "Иорданский динар"
-    },
     "formatTitle": "Формат валюты",
     "formatSymbolName": "Символ",
     "formatCodeName": "Код",

+ 0 - 58
messages/tr/settings.json

@@ -1078,64 +1078,6 @@
     "applyTaxNoRecords": "Hiçbir kayıt varsayılan vergi oranına ihtiyaç duymuyor.",
     "saveSettings": "Ayarları Kaydet",
     "saved": "Ayarlar kaydedildi",
-    "currencies": {
-      "USD": "ABD Doları",
-      "EUR": "Euro",
-      "GBP": "İngiliz Sterlini",
-      "NOK": "Norveç Kronu",
-      "SEK": "İsveç Kronu",
-      "DKK": "Danimarka Kronu",
-      "CHF": "İsviçre Frangı",
-      "CAD": "Kanada Doları",
-      "AUD": "Avustralya Doları",
-      "NZD": "Yeni Zelanda Doları",
-      "JPY": "Japon Yeni",
-      "CNY": "Çin Yuanı",
-      "INR": "Hint Rupisi",
-      "BRL": "Brezilya Reali",
-      "MXN": "Meksika Pesosu",
-      "PLN": "Polonya Zlotisi",
-      "CZK": "Çek Korunası",
-      "HUF": "Macar Forinti",
-      "TRY": "Türk Lirası",
-      "ZAR": "Güney Afrika Randı",
-      "KRW": "Güney Kore Wonu",
-      "SGD": "Singapur Doları",
-      "HKD": "Hong Kong Doları",
-      "THB": "Tayland Bahtı",
-      "ISK": "İzlanda Kronu",
-      "RON": "Rumen Leyi",
-      "ILS": "İsrail Şekeli",
-      "PHP": "Filipin Pesosu",
-      "IDR": "Endonezya Rupisi",
-      "MYR": "Malezya Ringgiti",
-      "LKR": "Sri Lanka Rupisi",
-      "AED": "BAE Dirhemi",
-      "SAR": "Suudi Riyali",
-      "RUB": "Rus Rublesi",
-      "TWD": "Tayvan Doları",
-      "VND": "Vietnam Dongu",
-      "PKR": "Pakistan Rupisi",
-      "BDT": "Bangladeş Takası",
-      "NPR": "Nepal Rupisi",
-      "BGN": "Bulgar Levası",
-      "UAH": "Ukrayna Grivnası",
-      "CLP": "Şili Pesosu",
-      "COP": "Kolombiya Pesosu",
-      "ARS": "Arjantin Pesosu",
-      "PEN": "Peru Solu",
-      "EGP": "Mısır Lirası",
-      "NGN": "Nijerya Nairası",
-      "KES": "Kenya Şilini",
-      "MAD": "Fas Dirhemi",
-      "DZD": "Cezayir Dinarı",
-      "GHS": "Gana Sedisi",
-      "QAR": "Katar Riyali",
-      "KWD": "Kuveyt Dinarı",
-      "BHD": "Bahreyn Dinarı",
-      "OMR": "Umman Riyali",
-      "JOD": "Ürdün Dinarı"
-    },
     "formatTitle": "Para Birimi Formatı",
     "formatSymbolName": "Sembol",
     "formatCodeName": "Kod",

+ 82 - 0
src/__tests__/lib/currencies.test.ts

@@ -0,0 +1,82 @@
+import { describe, expect, it } from 'vitest'
+import { locales } from '@/i18n/config'
+import {
+  CURRENCIES,
+  currencyName,
+  currencySearchScore,
+  resolveCurrencySettings,
+  SELECTABLE_CURRENCIES,
+} from '@/lib/currencies'
+import { formatCurrency, getCurrencySymbol } from '@/lib/format'
+
+// The PDFs print in Helvetica, which only carries WinAnsi (Latin-1 plus a few
+// extras such as the euro sign). A symbol outside it comes out as "?". The
+// narrow no-break space some locales group digits with is allowed through:
+// it is not a symbol, and the formatter has always produced it.
+const WIN_ANSI = /^[ -~ -ÿ€’ ]*$/
+
+describe('currency registry', () => {
+  it('lists every code once', () => {
+    const codes = CURRENCIES.map((c) => c.code)
+    expect(new Set(codes).size).toBe(codes.length)
+    for (const code of codes) expect(code).toMatch(/^[A-Z]{3}$/)
+  })
+
+  it('offers the Nicaraguan córdoba', () => {
+    expect(SELECTABLE_CURRENCIES.map((c) => c.code)).toContain('NIO')
+    expect(formatCurrency(20412.9, 'NIO')).toBe('C$20,412.90')
+    expect(getCurrencySymbol('NIO')).toBe('C$')
+  })
+
+  it('prints every currency in characters the PDF font has', () => {
+    for (const { code } of CURRENCIES) {
+      for (const format of ['symbol', 'code'] as const) {
+        expect(formatCurrency(1234.5, code, format), `${code} ${format}`).toMatch(WIN_ANSI)
+        expect(getCurrencySymbol(code, format), `${code} ${format}`).toMatch(WIN_ANSI)
+      }
+    }
+  })
+
+  it('names every currency in every language the app ships', () => {
+    for (const locale of locales) {
+      for (const { code } of CURRENCIES) {
+        const name = currencyName(code, locale)
+        expect(name, `${code} in ${locale}`).not.toBe(code)
+        expect(name.length, `${code} in ${locale}`).toBeGreaterThan(3)
+      }
+    }
+  })
+
+  it('falls back to the code for a currency the runtime cannot name', () => {
+    expect(currencyName('QQQ', 'en')).toBe('QQQ')
+  })
+
+  it('reads the workshop currency out of a settings record or map', () => {
+    expect(resolveCurrencySettings({})).toEqual({ currencyCode: 'USD', currencyFormat: 'symbol' })
+    expect(
+      resolveCurrencySettings({
+        'workshop.currencyCode': 'NIO',
+        'workshop.currencyFormat': 'code',
+      })
+    ).toEqual({ currencyCode: 'NIO', currencyFormat: 'code' })
+    expect(resolveCurrencySettings(new Map([['workshop.currencyCode', 'NOK']]))).toEqual({
+      currencyCode: 'NOK',
+      currencyFormat: 'symbol',
+    })
+  })
+
+  it('finds a currency by its code alone', () => {
+    const found = (search: string) =>
+      SELECTABLE_CURRENCIES.map((c) => `${c.code} ${currencyName(c.code, 'en')}`)
+        .filter((label) => currencySearchScore(label, search) > 0)
+        .map((label) => label.slice(0, 3))
+    expect(found('NIO')).toEqual(['NIO'])
+    expect(found('nio')).toEqual(['NIO'])
+    expect(found('GTQ')).toEqual(['GTQ'])
+    expect(found('Nicara')).toEqual(['NIO'])
+    expect(found('cordoba')).toEqual(['NIO'])
+    expect(found('krone')).toEqual(expect.arrayContaining(['NOK', 'DKK']))
+    expect(found('zzz')).toEqual([])
+    expect(found('').length).toBe(SELECTABLE_CURRENCIES.length)
+  })
+})

+ 182 - 0
src/__tests__/lib/payment-vendor-amounts.test.ts

@@ -0,0 +1,182 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import {
+  fromStripeAmount,
+  toPayPalValue,
+  toStripeAmount,
+} from '@/lib/payment-providers/vendor-amounts'
+
+/**
+ * What a customer is charged for an invoice, per currency.
+ *
+ * Stripe takes an integer in the currency's smallest unit, and that unit is
+ * not a hundredth everywhere. Scaling a yen invoice by 100 charges the
+ * customer a hundred times what the invoice says while the app records the
+ * invoice amount as paid, so both directions are pinned here.
+ */
+
+const create = vi.fn()
+const retrieve = vi.fn()
+
+vi.mock('@/lib/payment-providers/vendor-hosts', () => ({
+  stripeClient: () => ({ checkout: { sessions: { create, retrieve } } }),
+  paypalApiBase: () => 'https://paypal.test',
+}))
+
+const { StripeProvider } = await import('@/lib/payment-providers/stripe')
+const { PayPalProvider } = await import('@/lib/payment-providers/paypal')
+
+describe('amounts sent to Stripe', () => {
+  it('sends two-decimal currencies in hundredths', () => {
+    expect(toStripeAmount(1250.5, 'USD')).toBe(125050)
+    expect(toStripeAmount(19.99, 'NOK')).toBe(1999)
+    expect(toStripeAmount(20412.9, 'NIO')).toBe(2041290)
+  })
+
+  it('sends zero-decimal currencies as they are', () => {
+    expect(toStripeAmount(5000, 'JPY')).toBe(5000)
+    expect(toStripeAmount(5000, 'jpy')).toBe(5000)
+    for (const code of ['KRW', 'VND', 'CLP', 'PYG', 'XOF', 'XAF', 'XPF', 'RWF']) {
+      expect(toStripeAmount(120000, code), code).toBe(120000)
+    }
+  })
+
+  it('rounds a zero-decimal amount to whole units', () => {
+    expect(toStripeAmount(1234.5, 'JPY')).toBe(1235)
+    expect(toStripeAmount(1234.4, 'KRW')).toBe(1234)
+  })
+
+  it('sends kronur and Ugandan shillings as whole units in hundredths', () => {
+    expect(toStripeAmount(5, 'ISK')).toBe(500)
+    expect(toStripeAmount(12990.4, 'ISK')).toBe(1299000)
+    expect(toStripeAmount(5, 'UGX')).toBe(500)
+  })
+
+  it('sends three-decimal currencies in thousandths ending in zero', () => {
+    expect(toStripeAmount(5.12, 'KWD')).toBe(5120)
+    expect(toStripeAmount(100, 'BHD')).toBe(100000)
+    expect(toStripeAmount(19.99, 'JOD') % 10).toBe(0)
+  })
+
+  it('reads back exactly what it sent', () => {
+    for (const [amount, code] of [
+      [1250.5, 'USD'],
+      [5000, 'JPY'],
+      [120000, 'XOF'],
+      [12990, 'ISK'],
+      [5, 'UGX'],
+      [5.12, 'KWD'],
+      [42.75, 'HUF'],
+    ] as const) {
+      expect(fromStripeAmount(toStripeAmount(amount, code), code.toLowerCase()), code).toBe(amount)
+    }
+  })
+
+  it('reads a session without a currency as hundredths', () => {
+    expect(fromStripeAmount(125050, null)).toBe(1250.5)
+  })
+})
+
+describe('the Stripe checkout', () => {
+  const request = {
+    invoiceNumber: 'INV-1',
+    description: 'Service',
+    successUrl: 'https://app.test/ok',
+    cancelUrl: 'https://app.test/cancel',
+    serviceRecordId: 'rec_1',
+    orgId: 'org_1',
+  }
+
+  beforeEach(() => {
+    create.mockReset().mockResolvedValue({ id: 'cs_1', url: 'https://stripe.test/pay' })
+    retrieve.mockReset()
+  })
+
+  it('charges a yen invoice its own amount, not a hundred times it', async () => {
+    await new StripeProvider('sk_test_x').createCheckout({
+      ...request,
+      amount: 5000,
+      currency: 'JPY',
+    })
+    const priceData = create.mock.calls[0][0].line_items[0].price_data
+    expect(priceData).toMatchObject({ currency: 'jpy', unit_amount: 5000 })
+  })
+
+  it('still charges a dollar invoice in cents', async () => {
+    await new StripeProvider('sk_test_x').createCheckout({
+      ...request,
+      amount: 50,
+      currency: 'USD',
+    })
+    expect(create.mock.calls[0][0].line_items[0].price_data.unit_amount).toBe(5000)
+  })
+
+  it('records what a yen session was paid, in yen', async () => {
+    retrieve.mockResolvedValue({
+      payment_status: 'paid',
+      amount_total: 5000,
+      currency: 'jpy',
+      metadata: { serviceRecordId: 'rec_1', orgId: 'org_1' },
+    })
+    const result = await new StripeProvider('sk_test_x').verifyPayment('cs_1')
+    expect(result).toMatchObject({ paid: true, amount: 5000 })
+  })
+
+  it('records what a dollar session was paid, in dollars', async () => {
+    retrieve.mockResolvedValue({
+      payment_status: 'paid',
+      amount_total: 5000,
+      currency: 'usd',
+      metadata: {},
+    })
+    const result = await new StripeProvider('sk_test_x').verifyPayment('cs_1')
+    expect(result?.amount).toBe(50)
+  })
+})
+
+describe('amounts sent to PayPal', () => {
+  it('writes two decimals for most currencies', () => {
+    expect(toPayPalValue(1250.5, 'USD')).toBe('1250.50')
+    expect(toPayPalValue(19.99, 'NOK')).toBe('19.99')
+  })
+
+  it('writes whole units for the currencies PayPal takes no decimals in', () => {
+    expect(toPayPalValue(5000, 'JPY')).toBe('5000')
+    expect(toPayPalValue(42750.4, 'HUF')).toBe('42750')
+    expect(toPayPalValue(800.5, 'TWD')).toBe('801')
+  })
+
+  it('puts the whole-unit value on a yen order', async () => {
+    const fetchMock = vi.fn(async (url: string | URL, _init?: RequestInit) =>
+      String(url).includes('/oauth2/token')
+        ? new Response(JSON.stringify({ access_token: 'token' }))
+        : new Response(
+            JSON.stringify({
+              id: 'order_1',
+              links: [{ rel: 'payer-action', href: 'https://paypal.test/pay' }],
+            })
+          )
+    )
+    vi.stubGlobal('fetch', fetchMock)
+    try {
+      await new PayPalProvider({
+        clientId: 'id',
+        clientSecret: 'secret',
+        useSandbox: true,
+      }).createCheckout({
+        invoiceNumber: 'INV-1',
+        description: 'Service',
+        successUrl: 'https://app.test/ok',
+        cancelUrl: 'https://app.test/cancel',
+        serviceRecordId: 'rec_1',
+        orgId: 'org_1',
+        amount: 5000,
+        currency: 'JPY',
+      })
+      const order = fetchMock.mock.calls.find(([url]) => String(url).includes('/checkout/orders'))
+      const body = JSON.parse(String(order?.[1]?.body))
+      expect(body.purchase_units[0].amount).toEqual({ currency_code: 'JPY', value: '5000' })
+    } finally {
+      vi.unstubAllGlobals()
+    }
+  })
+})

+ 2 - 1
src/app/(authenticated)/billing/billing-client.tsx

@@ -1,5 +1,6 @@
 'use client'
 
+import { DEFAULT_CURRENCY_CODE } from '@/lib/currencies'
 import { useTableKeyboardNav } from '@/hooks/use-table-keyboard-nav'
 import { interactiveRow } from '@/lib/interactive-row'
 import { useDebouncedSearch } from '@/hooks/use-debounced-search'
@@ -106,7 +107,7 @@ const STATUS_TABS = [
 
 export default function BillingClient({
   data,
-  currencyCode = 'USD',
+  currencyCode = DEFAULT_CURRENCY_CODE,
   search,
   statusFilter,
   deliveryFilter,

+ 2 - 1
src/app/(authenticated)/billing/page.tsx

@@ -1,3 +1,4 @@
+import { resolveCurrencyCode } from '@/lib/currencies'
 import { resolveListSort } from '@/lib/list-sort-preference.server'
 import { getBillingHistory } from '@/features/billing/Actions/billingActions'
 import { getDisplaySettings } from '@/features/settings/Actions/settingsActions'
@@ -54,7 +55,7 @@ export default async function BillingPage({
   ])
 
   const settings = settingsResult.success && settingsResult.data ? settingsResult.data : {}
-  const currencyCode = settings[SETTING_KEYS.CURRENCY_CODE] || 'USD'
+  const currencyCode = resolveCurrencyCode(settings[SETTING_KEYS.CURRENCY_CODE])
 
   if (!result.success || !result.data) {
     return (

+ 2 - 1
src/app/(authenticated)/billing/recurring/page.tsx

@@ -1,3 +1,4 @@
+import { resolveCurrencyCode } from '@/lib/currencies'
 import { getRecurringInvoices } from '@/features/billing/Actions/recurringInvoiceActions'
 import { getDisplaySettings } from '@/features/settings/Actions/settingsActions'
 import { SETTING_KEYS } from '@/features/settings/Schema/settingsSchema'
@@ -32,7 +33,7 @@ export default async function RecurringInvoicesPage() {
   ])
 
   const settings = settingsResult.success && settingsResult.data ? settingsResult.data : {}
-  const currencyCode = settings[SETTING_KEYS.CURRENCY_CODE] || 'USD'
+  const currencyCode = resolveCurrencyCode(settings[SETTING_KEYS.CURRENCY_CODE])
   const invoices = result.success && result.data ? result.data : []
 
   return (

+ 2 - 1
src/app/(authenticated)/calendar/page.tsx

@@ -1,3 +1,4 @@
+import { resolveCurrencyCode } from '@/lib/currencies'
 import { getCalendarEvents } from '@/features/calendar/Actions/calendarActions'
 import { getVehicles } from '@/features/vehicles/Actions/vehicleActions'
 import { getCustomersList } from '@/features/customers/Actions/customerActions'
@@ -39,7 +40,7 @@ export default async function CalendarPage({
     SETTING_KEYS.WORKBOARD_WEEK_START_DAY,
   ])
   const settings = settingsResult.success && settingsResult.data ? settingsResult.data : {}
-  const currencyCode = settings[SETTING_KEYS.CURRENCY_CODE] || 'USD'
+  const currencyCode = resolveCurrencyCode(settings[SETTING_KEYS.CURRENCY_CODE])
   // The same week start the layout hands the client, so the grid the server
   // fetched for is the grid the client draws.
   const weekStartRaw = parseInt(settings[SETTING_KEYS.WORKBOARD_WEEK_START_DAY] || '1', 10)

+ 2 - 1
src/app/(authenticated)/dashboard-client.tsx

@@ -1,5 +1,6 @@
 'use client'
 
+import { DEFAULT_CURRENCY_CODE } from '@/lib/currencies'
 import { useTableKeyboardNav } from '@/hooks/use-table-keyboard-nav'
 import { interactiveRow } from '@/lib/interactive-row'
 import { useState, useTransition } from 'react'
@@ -279,7 +280,7 @@ const observationSeverityColors: Record<string, string> = {
 
 export function DashboardClient({
   stats,
-  currencyCode = 'USD',
+  currencyCode = DEFAULT_CURRENCY_CODE,
   upcomingReminders = [],
   vehiclesDueForService = [],
   dismissedMaintenanceVehicles = [],

+ 2 - 1
src/app/(authenticated)/inventory/[id]/page.tsx

@@ -1,3 +1,4 @@
+import { resolveCurrencyCode } from '@/lib/currencies'
 import { notFound } from 'next/navigation'
 import {
   getInventoryPart,
@@ -85,7 +86,7 @@ export default async function InventoryPartDetailPage({
         pageSize={movements.pageSize}
         totalPages={movements.totalPages}
         reason={sp.reason ?? ''}
-        currencyCode={settings[SETTING_KEYS.CURRENCY_CODE] || 'USD'}
+        currencyCode={resolveCurrencyCode(settings[SETTING_KEYS.CURRENCY_CODE])}
         markupMultiplier={Number(settings[SETTING_KEYS.INVENTORY_MARKUP_MULTIPLIER]) || 1}
         categories={categoriesResult.data ?? []}
         lowStockDefault={Number(settings[SETTING_KEYS.LOW_STOCK_DEFAULT_THRESHOLD]) || 0}

+ 2 - 1
src/app/(authenticated)/inventory/inventory-client.tsx

@@ -1,5 +1,6 @@
 'use client'
 
+import { DEFAULT_CURRENCY_CODE } from '@/lib/currencies'
 import { useRememberedSort } from '@/hooks/use-remembered-sort'
 import { useTableKeyboardNav } from '@/hooks/use-table-keyboard-nav'
 import { formatQuantity } from '@/lib/format-quantity'
@@ -131,7 +132,7 @@ export function InventoryClient({
   search,
   category,
   categories,
-  currencyCode = 'USD',
+  currencyCode = DEFAULT_CURRENCY_CODE,
   markupMultiplier: initialMarkup = 1.0,
   defaultUnit = '',
   unitSystem = 'imperial',

+ 2 - 1
src/app/(authenticated)/inventory/page.tsx

@@ -1,3 +1,4 @@
+import { resolveCurrencyCode } from '@/lib/currencies'
 import { resolveListSort } from '@/lib/list-sort-preference.server'
 import {
   getInventoryPartsPaginated,
@@ -62,7 +63,7 @@ export default async function InventoryPage({
 
   const categories = categoriesResult.success && categoriesResult.data ? categoriesResult.data : []
   const settings = settingsResult.success && settingsResult.data ? settingsResult.data : {}
-  const currencyCode = settings[SETTING_KEYS.CURRENCY_CODE] || 'USD'
+  const currencyCode = resolveCurrencyCode(settings[SETTING_KEYS.CURRENCY_CODE])
   const markupMultiplier = Number(settings[SETTING_KEYS.INVENTORY_MARKUP_MULTIPLIER]) || 1.0
 
   return (

+ 2 - 1
src/app/(authenticated)/labor-presets/labor-presets-client.tsx

@@ -1,5 +1,6 @@
 'use client'
 
+import { DEFAULT_CURRENCY_CODE } from '@/lib/currencies'
 import { useRememberedSort } from '@/hooks/use-remembered-sort'
 import { useTableKeyboardNav } from '@/hooks/use-table-keyboard-nav'
 import { interactiveRow } from '@/lib/interactive-row'
@@ -72,7 +73,7 @@ export function LaborPresetsClient({
   search,
   sortBy,
   sortOrder,
-  currencyCode = 'USD',
+  currencyCode = DEFAULT_CURRENCY_CODE,
   defaultLaborRate = 0,
   inventoryParts = [],
 }: {

+ 2 - 1
src/app/(authenticated)/labor-presets/page.tsx

@@ -1,3 +1,4 @@
+import { resolveCurrencyCode } from '@/lib/currencies'
 import { resolveListSort } from '@/lib/list-sort-preference.server'
 import { getInventoryPartsList } from '@/features/inventory/Actions/inventoryActions'
 import { getLaborPresetsPaginated } from '@/features/labor-presets/Actions/laborPresetActions'
@@ -61,7 +62,7 @@ export default async function LaborPresetsPage({
           quantity: p.quantity,
         }))
       : []
-  const currencyCode = settings[SETTING_KEYS.CURRENCY_CODE] || 'USD'
+  const currencyCode = resolveCurrencyCode(settings[SETTING_KEYS.CURRENCY_CODE])
   const defaultLaborRate = Number(settings[SETTING_KEYS.DEFAULT_LABOR_RATE]) || 0
 
   return (

+ 2 - 1
src/app/(authenticated)/page.tsx

@@ -1,3 +1,4 @@
+import { resolveCurrencyCode } from '@/lib/currencies'
 import { getTranslations } from 'next-intl/server'
 import {
   getDashboardStats,
@@ -152,7 +153,7 @@ export default async function DashboardPage() {
   }
 
   const settings = settingsResult.success && settingsResult.data ? settingsResult.data : {}
-  const currencyCode = settings[SETTING_KEYS.CURRENCY_CODE] || 'USD'
+  const currencyCode = resolveCurrencyCode(settings[SETTING_KEYS.CURRENCY_CODE])
   const unitSystem = (settings[SETTING_KEYS.UNIT_SYSTEM] || 'imperial') as 'metric' | 'imperial'
   const smsThreads = smsResult && smsResult.success && smsResult.data ? smsResult.data.threads : []
   const notifications =

+ 2 - 1
src/app/(authenticated)/quotes/[id]/page.tsx

@@ -1,3 +1,4 @@
+import { resolveCurrencyCode } from '@/lib/currencies'
 import { getQuote } from '@/features/quotes/Actions/quoteActions'
 import { getDisplaySettings } from '@/features/settings/Actions/settingsActions'
 import { SETTING_KEYS } from '@/features/settings/Schema/settingsSchema'
@@ -52,7 +53,7 @@ export default async function QuoteDetailPage({ params }: { params: Promise<{ id
   }
 
   const settings = settingsResult.success && settingsResult.data ? settingsResult.data : {}
-  const currencyCode = settings[SETTING_KEYS.CURRENCY_CODE] || 'USD'
+  const currencyCode = resolveCurrencyCode(settings[SETTING_KEYS.CURRENCY_CODE])
   const taxEnabled = settings[SETTING_KEYS.TAX_ENABLED] !== 'false'
   const defaultTaxRate = taxEnabled ? Number(settings[SETTING_KEYS.DEFAULT_TAX_RATE]) || 0 : 0
   const defaultLaborRate = Number(settings[SETTING_KEYS.DEFAULT_LABOR_RATE]) || 0

+ 2 - 1
src/app/(authenticated)/quotes/page.tsx

@@ -1,3 +1,4 @@
+import { resolveCurrencyCode } from '@/lib/currencies'
 import { resolveListSort } from '@/lib/list-sort-preference.server'
 import { getQuotesPaginated } from '@/features/quotes/Actions/quoteActions'
 import { getDisplaySettings } from '@/features/settings/Actions/settingsActions'
@@ -47,7 +48,7 @@ export default async function QuotesPage({
   }
 
   const settings = settingsResult.success && settingsResult.data ? settingsResult.data : {}
-  const currencyCode = settings[SETTING_KEYS.CURRENCY_CODE] || 'USD'
+  const currencyCode = resolveCurrencyCode(settings[SETTING_KEYS.CURRENCY_CODE])
 
   return (
     <>

+ 2 - 1
src/app/(authenticated)/quotes/quotes-client.tsx

@@ -1,5 +1,6 @@
 'use client'
 
+import { DEFAULT_CURRENCY_CODE } from '@/lib/currencies'
 import { useRememberedSort } from '@/hooks/use-remembered-sort'
 import { interactiveRow } from '@/lib/interactive-row'
 import { useTableKeyboardNav } from '@/hooks/use-table-keyboard-nav'
@@ -101,7 +102,7 @@ const statusColors: Record<string, string> = {
 
 export function QuotesClient({
   data,
-  currencyCode = 'USD',
+  currencyCode = DEFAULT_CURRENCY_CODE,
   search,
   statusFilter,
   sortBy = '',

+ 2 - 1
src/app/(authenticated)/reports/page.tsx

@@ -1,3 +1,4 @@
+import { resolveCurrencyCode } from '@/lib/currencies'
 import { getDisplaySettings } from '@/features/settings/Actions/settingsActions'
 import { SETTING_KEYS } from '@/features/settings/Schema/settingsSchema'
 import { PageHeader } from '@/components/page-header'
@@ -51,7 +52,7 @@ export default async function ReportsPage() {
     SETTING_KEYS.INVOICE_PRIMARY_COLOR,
   ])
   const settings = settingsResult.success && settingsResult.data ? settingsResult.data : {}
-  const currencyCode = settings[SETTING_KEYS.CURRENCY_CODE] || 'USD'
+  const currencyCode = resolveCurrencyCode(settings[SETTING_KEYS.CURRENCY_CODE])
   const primaryColor = settings[SETTING_KEYS.INVOICE_PRIMARY_COLOR] || '#d97706'
   const organizationName = data.organizations.find((o) => o.id === data.organizationId)?.name ?? ''
 

+ 18 - 69
src/app/(authenticated)/settings/localization/localization-settings.tsx

@@ -32,69 +32,17 @@ import {
 import { Calendar, Check, ChevronDown, Clock, Coins, Globe, Loader2, Save } from 'lucide-react'
 import { cn } from '@/lib/utils'
 import { formatCurrency, formatDate, formatDateTime, DEFAULT_DATE_FORMAT } from '@/lib/format'
+import {
+  SELECTABLE_CURRENCIES,
+  currencyName,
+  currencySearchScore,
+  resolveCurrencyFormat,
+  resolveCurrencySettings,
+} from '@/lib/currencies'
 import { setLocale } from '@/i18n/actions'
 import { locales, localeNames } from '@/i18n/config'
 import { ReadOnlyBanner, SaveButton, ReadOnlyWrapper } from '../read-only-guard'
 
-const CURRENCIES = [
-  { code: 'USD', key: 'USD' },
-  { code: 'EUR', key: 'EUR' },
-  { code: 'GBP', key: 'GBP' },
-  { code: 'NOK', key: 'NOK' },
-  { code: 'SEK', key: 'SEK' },
-  { code: 'DKK', key: 'DKK' },
-  { code: 'CHF', key: 'CHF' },
-  { code: 'CAD', key: 'CAD' },
-  { code: 'AUD', key: 'AUD' },
-  { code: 'NZD', key: 'NZD' },
-  { code: 'JPY', key: 'JPY' },
-  { code: 'CNY', key: 'CNY' },
-  { code: 'INR', key: 'INR' },
-  { code: 'BRL', key: 'BRL' },
-  { code: 'MXN', key: 'MXN' },
-  { code: 'PLN', key: 'PLN' },
-  { code: 'CZK', key: 'CZK' },
-  { code: 'HUF', key: 'HUF' },
-  { code: 'TRY', key: 'TRY' },
-  { code: 'ZAR', key: 'ZAR' },
-  { code: 'KRW', key: 'KRW' },
-  { code: 'SGD', key: 'SGD' },
-  { code: 'HKD', key: 'HKD' },
-  { code: 'THB', key: 'THB' },
-  { code: 'ISK', key: 'ISK' },
-  { code: 'RON', key: 'RON' },
-  { code: 'ILS', key: 'ILS' },
-  { code: 'PHP', key: 'PHP' },
-  { code: 'IDR', key: 'IDR' },
-  { code: 'MYR', key: 'MYR' },
-  { code: 'LKR', key: 'LKR' },
-  { code: 'TWD', key: 'TWD' },
-  { code: 'VND', key: 'VND' },
-  { code: 'PKR', key: 'PKR' },
-  { code: 'BDT', key: 'BDT' },
-  { code: 'NPR', key: 'NPR' },
-  { code: 'BGN', key: 'BGN' },
-  { code: 'UAH', key: 'UAH' },
-  { code: 'CLP', key: 'CLP' },
-  { code: 'COP', key: 'COP' },
-  { code: 'ARS', key: 'ARS' },
-  { code: 'PEN', key: 'PEN' },
-  { code: 'EGP', key: 'EGP' },
-  { code: 'NGN', key: 'NGN' },
-  { code: 'KES', key: 'KES' },
-  { code: 'MAD', key: 'MAD' },
-  { code: 'DZD', key: 'DZD' },
-  { code: 'GHS', key: 'GHS' },
-  { code: 'AED', key: 'AED' },
-  { code: 'SAR', key: 'SAR' },
-  { code: 'QAR', key: 'QAR' },
-  { code: 'KWD', key: 'KWD' },
-  { code: 'BHD', key: 'BHD' },
-  { code: 'OMR', key: 'OMR' },
-  { code: 'JOD', key: 'JOD' },
-  { code: 'RUB', key: 'RUB' },
-]
-
 const DATE_FORMAT_OPTIONS = [
   { value: 'MMM d, yyyy', label: 'MMM D, YYYY', example: 'Feb 15, 2026' },
   { value: 'dd/MM/yyyy', label: 'DD/MM/YYYY', example: '15/02/2026' },
@@ -171,10 +119,9 @@ export function LocalizationSettings({ settings }: { settings: Record<string, st
   const [timezoneOpen, setTimezoneOpen] = useState(false)
 
   // Currency
-  const [currencyCode, setCurrencyCode] = useState(settings[SETTING_KEYS.CURRENCY_CODE] || 'USD')
-  const [currencyFormat, setCurrencyFormat] = useState<'symbol' | 'code'>(
-    settings[SETTING_KEYS.CURRENCY_FORMAT] === 'code' ? 'code' : 'symbol'
-  )
+  const stored = resolveCurrencySettings(settings)
+  const [currencyCode, setCurrencyCode] = useState(stored.currencyCode)
+  const [currencyFormat, setCurrencyFormat] = useState(stored.currencyFormat)
 
   // Date & Time
   const [dateFormat, setDateFormat] = useState(
@@ -344,20 +291,22 @@ export function LocalizationSettings({ settings }: { settings: Record<string, st
                     aria-expanded={currencyOpen}
                     className="w-64 justify-between font-normal"
                   >
-                    {currencyCode} &mdash; {t('currency.currencies.' + currencyCode)}
+                    <span className="truncate">
+                      {currencyCode} &mdash; {currencyName(currencyCode, currentLocale)}
+                    </span>
                     <ChevronDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
                   </Button>
                 </PopoverTrigger>
                 <PopoverContent className="w-64 p-0" align="start">
-                  <Command>
+                  <Command filter={currencySearchScore}>
                     <CommandInput placeholder={t('currency.searchCurrency')} />
                     <CommandList>
                       <CommandEmpty>{t('currency.noCurrencyFound')}</CommandEmpty>
                       <CommandGroup>
-                        {CURRENCIES.map((c) => (
+                        {SELECTABLE_CURRENCIES.map((c) => (
                           <CommandItem
                             key={c.code}
-                            value={`${c.code} ${t('currency.currencies.' + c.code)}`}
+                            value={`${c.code} ${currencyName(c.code, currentLocale)}`}
                             onSelect={() => {
                               setCurrencyCode(c.code)
                               setCurrencyOpen(false)
@@ -369,7 +318,7 @@ export function LocalizationSettings({ settings }: { settings: Record<string, st
                                 currencyCode === c.code ? 'opacity-100' : 'opacity-0'
                               )}
                             />
-                            {c.code} &mdash; {t('currency.currencies.' + c.code)}
+                            {c.code} &mdash; {currencyName(c.code, currentLocale)}
                           </CommandItem>
                         ))}
                       </CommandGroup>
@@ -383,7 +332,7 @@ export function LocalizationSettings({ settings }: { settings: Record<string, st
               <Label>{t('currency.formatTitle')}</Label>
               <Select
                 value={currencyFormat}
-                onValueChange={(v) => setCurrencyFormat(v === 'code' ? 'code' : 'symbol')}
+                onValueChange={(v) => setCurrencyFormat(resolveCurrencyFormat(v))}
               >
                 <SelectTrigger className="w-full">
                   <SelectValue />

+ 2 - 1
src/app/(authenticated)/tire-hotel/[id]/page.tsx

@@ -1,3 +1,4 @@
+import { resolveCurrencyCode } from '@/lib/currencies'
 import { notFound } from 'next/navigation'
 import { db } from '@/lib/db'
 import { getCachedMembership, getCachedSession } from '@/lib/cached-session'
@@ -65,7 +66,7 @@ export default async function TireSetPage({ params }: { params: Promise<{ id: st
   const imperial = settings[SETTING_KEYS.UNIT_SYSTEM] === 'imperial'
   const billing = {
     seasonalPrice: Number(settings[SETTING_KEYS.TIRE_HOTEL_DEFAULT_SEASONAL_PRICE]) || 0,
-    currency: settings[SETTING_KEYS.CURRENCY_CODE] || 'USD',
+    currency: resolveCurrencyCode(settings[SETTING_KEYS.CURRENCY_CODE]),
   }
 
   return (

+ 2 - 1
src/app/(authenticated)/vehicles/[id]/page.tsx

@@ -1,3 +1,4 @@
+import { resolveCurrencyCode } from '@/lib/currencies'
 import { getTranslations } from 'next-intl/server'
 import { getVehicle } from '@/features/vehicles/Actions/vehicleActions'
 import { getServiceRecordsPaginated } from '@/features/vehicles/Actions/serviceActions'
@@ -114,7 +115,7 @@ export default async function VehicleDetailPage({
       : { records: [], total: 0, page: 1, pageSize: 10, totalPages: 0 }
 
   const currencySettings = settingsResult.success && settingsResult.data ? settingsResult.data : {}
-  const currencyCode = currencySettings[SETTING_KEYS.CURRENCY_CODE] || 'USD'
+  const currencyCode = resolveCurrencyCode(currencySettings[SETTING_KEYS.CURRENCY_CODE])
   const unitSystem = (currencySettings[SETTING_KEYS.UNIT_SYSTEM] || 'imperial') as
     | 'metric'
     | 'imperial'

+ 2 - 1
src/app/(authenticated)/vehicles/[id]/service-records-table.tsx

@@ -1,5 +1,6 @@
 'use client'
 
+import { DEFAULT_CURRENCY_CODE } from '@/lib/currencies'
 import { useTableKeyboardNav } from '@/hooks/use-table-keyboard-nav'
 import { interactiveRow } from '@/lib/interactive-row'
 import { useDebouncedSearch } from '@/hooks/use-debounced-search'
@@ -102,7 +103,7 @@ export function ServiceRecordsTable({
   totalPages,
   search,
   type,
-  currencyCode = 'USD',
+  currencyCode = DEFAULT_CURRENCY_CODE,
   vehicleMileage,
 }: ServiceRecordsTableProps) {
   const formatCurrency = useFormatCurrency()

+ 2 - 1
src/app/(authenticated)/vehicles/[id]/vehicle-detail-client.tsx

@@ -1,5 +1,6 @@
 'use client'
 
+import { DEFAULT_CURRENCY_CODE } from '@/lib/currencies'
 import { useTableKeyboardNav } from '@/hooks/use-table-keyboard-nav'
 import { interactiveRow } from '@/lib/interactive-row'
 import { useState, useTransition, useCallback } from 'react'
@@ -292,7 +293,7 @@ export function VehicleDetailClient({
   paginatedNotes,
   serviceSearch,
   serviceRecordType,
-  currencyCode = 'USD',
+  currencyCode = DEFAULT_CURRENCY_CODE,
   unitSystem = 'imperial',
   predictionData,
   inspections,

+ 2 - 1
src/app/(authenticated)/work-orders/page.tsx

@@ -1,3 +1,4 @@
+import { resolveCurrencyCode } from '@/lib/currencies'
 import { resolveListSort } from '@/lib/list-sort-preference.server'
 import { resolveListColumns } from '@/lib/list-columns.server'
 import { WORK_ORDER_COLUMNS } from '@/lib/list-columns'
@@ -65,7 +66,7 @@ export default async function WorkOrdersPage({
   }
 
   const settings = settingsResult.success && settingsResult.data ? settingsResult.data : {}
-  const currencyCode = settings[SETTING_KEYS.CURRENCY_CODE] || 'USD'
+  const currencyCode = resolveCurrencyCode(settings[SETTING_KEYS.CURRENCY_CODE])
   const vehicles =
     vehiclesResult.success && vehiclesResult.data
       ? vehiclesResult.data.map((v) => ({

+ 2 - 1
src/app/(authenticated)/work-orders/work-orders-client.tsx

@@ -1,5 +1,6 @@
 'use client'
 
+import { DEFAULT_CURRENCY_CODE } from '@/lib/currencies'
 import { useRememberedSort } from '@/hooks/use-remembered-sort'
 import { interactiveRow } from '@/lib/interactive-row'
 import { useTableKeyboardNav } from '@/hooks/use-table-keyboard-nav'
@@ -172,7 +173,7 @@ export function WorkOrdersClient({
   data,
   vehicles = [],
   customers = [],
-  currencyCode = 'USD',
+  currencyCode = DEFAULT_CURRENCY_CODE,
   search,
   statusFilter,
   dueFilter = '',

+ 4 - 2
src/app/(public)/portal/[orgId]/invoices/page.tsx

@@ -14,6 +14,7 @@ import Link from 'next/link'
 import { getTranslations } from 'next-intl/server'
 import { resolvePortalOrg } from '@/lib/portal-slug'
 import { workshopTimeZone } from '@/lib/workshop-timezone'
+import { workshopMoneyFormatter } from '@/lib/workshop-currency'
 
 export default async function PortalInvoicesPage({
   params,
@@ -24,6 +25,7 @@ export default async function PortalInvoicesPage({
   // Dates on the portal are the workshop's calendar days, not the server's.
   const org = await resolvePortalOrg(orgId)
   const timeZone = org ? await workshopTimeZone(org.id) : 'UTC'
+  const money = await workshopMoneyFormatter(org?.id)
   const t = await getTranslations('portal.invoices')
   const result = await getPortalInvoices()
 
@@ -70,7 +72,7 @@ export default async function PortalInvoicesPage({
                       <span className="min-w-0 flex-1 truncate font-medium">
                         {inv.invoiceNumber ? `#${inv.invoiceNumber}` : inv.title}
                       </span>
-                      <span className="shrink-0 font-semibold">${inv.totalAmount.toFixed(2)}</span>
+                      <span className="shrink-0 font-semibold">{money(inv.totalAmount)}</span>
                     </div>
                     <div className="mt-2 flex flex-wrap items-center gap-x-3 gap-y-1.5 text-xs text-muted-foreground">
                       <Badge
@@ -151,7 +153,7 @@ export default async function PortalInvoicesPage({
                             { timeZone }
                           )}
                         </TableCell>
-                        <TableCell>${inv.totalAmount.toFixed(2)}</TableCell>
+                        <TableCell>{money(inv.totalAmount)}</TableCell>
                         <TableCell>
                           <Badge
                             variant={

+ 4 - 2
src/app/(public)/portal/[orgId]/quotes/page.tsx

@@ -15,12 +15,14 @@ import { getTranslations } from 'next-intl/server'
 import { quoteStatusLabel } from '@/features/quotes/Lib/quoteStatus'
 import { resolvePortalOrg } from '@/lib/portal-slug'
 import { workshopTimeZone } from '@/lib/workshop-timezone'
+import { workshopMoneyFormatter } from '@/lib/workshop-currency'
 
 export default async function PortalQuotesPage({ params }: { params: Promise<{ orgId: string }> }) {
   const { orgId } = await params
   // Dates on the portal are the workshop's calendar days, not the server's.
   const org = await resolvePortalOrg(orgId)
   const timeZone = org ? await workshopTimeZone(org.id) : 'UTC'
+  const money = await workshopMoneyFormatter(org?.id)
   const t = await getTranslations('portal.quotes')
   const tQuoteStatus = await getTranslations('quotes.statusLabels')
   const result = await getPortalQuotes()
@@ -58,7 +60,7 @@ export default async function PortalQuotesPage({ params }: { params: Promise<{ o
                     <span className="min-w-0 flex-1 truncate font-medium">
                       {q.quoteNumber ? `#${q.quoteNumber}` : q.title}
                     </span>
-                    <span className="shrink-0 font-semibold">${q.totalAmount.toFixed(2)}</span>
+                    <span className="shrink-0 font-semibold">{money(q.totalAmount)}</span>
                   </div>
                   <div className="mt-2 flex flex-wrap items-center gap-x-3 gap-y-1.5 text-xs text-muted-foreground">
                     <Badge
@@ -131,7 +133,7 @@ export default async function PortalQuotesPage({ params }: { params: Promise<{ o
                           {quoteStatusLabel(q.status, tQuoteStatus)}
                         </Badge>
                       </TableCell>
-                      <TableCell>${q.totalAmount.toFixed(2)}</TableCell>
+                      <TableCell>{money(q.totalAmount)}</TableCell>
                       <TableCell>
                         {q.validUntil
                           ? new Date(q.validUntil).toLocaleDateString(undefined, { timeZone })

+ 3 - 1
src/app/(public)/portal/[orgId]/vehicles/[vehicleId]/page.tsx

@@ -8,6 +8,7 @@ import Link from 'next/link'
 import { getTranslations } from 'next-intl/server'
 import { resolvePortalOrg } from '@/lib/portal-slug'
 import { workshopTimeZone } from '@/lib/workshop-timezone'
+import { workshopMoneyFormatter } from '@/lib/workshop-currency'
 import { db } from '@/lib/db'
 import { getWarrantyStatus, type WarrantyStatus } from '@/lib/warranty'
 import { ReportSoldButton } from '@/features/portal/Components/ReportSoldButton'
@@ -29,6 +30,7 @@ export default async function PortalVehicleDetailPage({
   // Dates on the portal are the workshop's calendar days, not the server's.
   const org = await resolvePortalOrg(orgId)
   const timeZone = org ? await workshopTimeZone(org.id) : 'UTC'
+  const money = await workshopMoneyFormatter(org?.id)
   const t = await getTranslations('portal.vehicles')
   const tInvoices = await getTranslations('portal.invoices')
   const tWarranty = await getTranslations('vehicles.services.warranty.status')
@@ -151,7 +153,7 @@ export default async function PortalVehicleDetailPage({
                             </Badge>
                           ) : null
                         })()}
-                        <span className="text-sm font-medium">${sr.totalAmount.toFixed(2)}</span>
+                        <span className="text-sm font-medium">{money(sr.totalAmount)}</span>
                         {sr.publicToken && (
                           <Link
                             href={`/share/invoice/${orgId}/${sr.publicToken}`}

+ 5 - 2
src/app/(public)/share/invoice/[orgId]/[token]/page.tsx

@@ -1,3 +1,4 @@
+import { resolveCurrencyCode, resolveCurrencyFormat } from '@/lib/currencies'
 import { db } from '@/lib/db'
 import { notFound } from 'next/navigation'
 import { InvoiceView } from './invoice-view'
@@ -64,8 +65,10 @@ export default async function PublicInvoicePage({
     email: assembly.workshop.email,
   }
 
-  const currencyCode = assembly.invoiceSettings.currencyCode || 'USD'
-  const currencyFormat: 'symbol' | 'code' = assembly.invoiceSettings.currencyFormat || 'symbol'
+  const currencyCode = resolveCurrencyCode(assembly.invoiceSettings.currencyCode)
+  const currencyFormat: 'symbol' | 'code' = resolveCurrencyFormat(
+    assembly.invoiceSettings.currencyFormat
+  )
 
   const invoiceSettings = {
     bankAccount: assembly.invoiceSettings.bankAccount || '',

+ 5 - 3
src/app/(public)/share/quote/[orgId]/[token]/page.tsx

@@ -1,3 +1,4 @@
+import { resolveCurrencyCode, resolveCurrencyFormat } from '@/lib/currencies'
 import { documentLogoPath } from '@/features/invoice-designer/Lib/documentLogo'
 import { db } from '@/lib/db'
 import { notFound } from 'next/navigation'
@@ -158,9 +159,10 @@ export default async function PublicQuotePage({
     slogan: settingsMap['workshop.slogan'] || undefined,
   }
 
-  const currencyCode = settingsMap['workshop.currencyCode'] || 'USD'
-  const currencyFormat: 'symbol' | 'code' =
-    settingsMap['workshop.currencyFormat'] === 'code' ? 'code' : 'symbol'
+  const currencyCode = resolveCurrencyCode(settingsMap['workshop.currencyCode'])
+  const currencyFormat: 'symbol' | 'code' = resolveCurrencyFormat(
+    settingsMap['workshop.currencyFormat']
+  )
 
   // Rewrite logo URL for public access
   const rawLogoUrl = documentLogoPath(settingsMap, 'quote')

+ 2 - 1
src/app/api/protected/ai/chat/route.ts

@@ -1,3 +1,4 @@
+import { resolveCurrencyCode } from '@/lib/currencies'
 import { type NextRequest, NextResponse } from 'next/server'
 import { getLocale } from 'next-intl/server'
 import type OpenAI from 'openai'
@@ -109,7 +110,7 @@ export async function POST(request: NextRequest) {
   const setting = (key: string) => settings.find((s) => s.key === key)?.value
   const options = {
     showMoney,
-    currencyCode: setting(SETTING_KEYS.CURRENCY_CODE) || 'USD',
+    currencyCode: resolveCurrencyCode(setting(SETTING_KEYS.CURRENCY_CODE)),
     unitSystem: (setting(SETTING_KEYS.UNIT_SYSTEM) === 'metric' ? 'metric' : 'imperial') as
       | 'metric'
       | 'imperial',

+ 3 - 4
src/app/api/protected/vehicles/[id]/service-history-pdf/route.ts

@@ -1,3 +1,4 @@
+import { resolveCurrencyCode, resolveCurrencyFormat } from '@/lib/currencies'
 import { NextResponse } from 'next/server'
 import { renderToBuffer } from '@react-pdf/renderer'
 import '@/features/vehicles/Components/invoice-pdf/fonts'
@@ -134,10 +135,8 @@ export async function GET(_request: Request, { params }: { params: Promise<{ id:
     }
 
     const invoiceSettings = {
-      currencyCode: settingsMap['workshop.currencyCode'] || 'USD',
-      currencyFormat: (settingsMap['workshop.currencyFormat'] === 'code' ? 'code' : 'symbol') as
-        | 'symbol'
-        | 'code',
+      currencyCode: resolveCurrencyCode(settingsMap['workshop.currencyCode']),
+      currencyFormat: resolveCurrencyFormat(settingsMap['workshop.currencyFormat']),
       unitSystem: settingsMap['workshop.unitSystem'] || 'imperial',
       dateFormat: settingsMap['workshop.dateFormat'] || undefined,
       timezone: settingsMap['workshop.timezone'] || undefined,

+ 2 - 5
src/app/api/public/share/invoice/[orgId]/[token]/checkout/route.ts

@@ -1,4 +1,5 @@
 import { NextResponse } from 'next/server'
+import { workshopCurrencySettings } from '@/lib/workshop-currency'
 import { z } from 'zod'
 import { db } from '@/lib/db'
 import {
@@ -107,11 +108,7 @@ export async function POST(
       )
     }
 
-    const currencySetting = await db.appSetting.findUnique({
-      where: { organizationId_key: { organizationId: orgId, key: 'workshop.currencyCode' } },
-      select: { value: true },
-    })
-    const currencyCode = currencySetting?.value || 'USD'
+    const { currencyCode } = await workshopCurrencySettings(orgId)
     const invoiceNumber = record.invoiceNumber || `INV-${record.id.slice(-8).toUpperCase()}`
 
     const appUrl = process.env.NEXT_PUBLIC_APP_URL || ''

+ 3 - 1
src/app/api/public/share/invoice/[orgId]/[token]/verify/route.ts

@@ -8,6 +8,7 @@ import { rateLimit } from '@/lib/rate-limit'
 import { notify } from '@/lib/notify'
 import { serviceRecordHref } from '@/lib/service-record'
 import { resolvePortalOrg } from '@/lib/portal-slug'
+import { workshopMoneyFormatter } from '@/lib/workshop-currency'
 
 const verifySchema = z.object({
   provider: z.enum(PAYMENT_CONNECTOR_IDS as [string, ...string[]]),
@@ -88,11 +89,12 @@ export async function POST(
     })
 
     if (created) {
+      const money = await workshopMoneyFormatter(orgId)
       notify({
         organizationId: orgId,
         type: 'invoice_payment',
         title: 'Invoice Payment Received',
-        message: `${(record.customer ?? record.vehicle?.customer)?.name || 'A customer'} paid ${result.amount.toFixed(2)} for invoice ${record.invoiceNumber || record.title}`,
+        message: `${(record.customer ?? record.vehicle?.customer)?.name || 'A customer'} paid ${money(result.amount)} for invoice ${record.invoiceNumber || record.title}`,
         entityType: 'invoice',
         entityId: record.id,
         entityUrl: serviceRecordHref(record),

+ 2 - 1
src/app/api/webhooks/stripe/route.ts

@@ -3,6 +3,7 @@ import Stripe from 'stripe'
 import { db } from '@/lib/db'
 import { paymentProviderFor } from '@/features/integrations/Lib/payments'
 import { paymentMatchesRecord } from '@/lib/payment-providers/attribution'
+import { fromStripeAmount } from '@/lib/payment-providers/vendor-amounts'
 import { stripeClient } from '@/lib/payment-providers/vendor-hosts'
 import { recordVendorPayment } from '@/lib/payment-providers/record-payment'
 
@@ -105,7 +106,7 @@ export async function POST(request: Request) {
       // Once, however many reports arrive together: Stripe retries, and the
       // customer coming back to the invoice reports the same session.
       await recordVendorPayment({
-        amount: (session.amount_total ?? 0) / 100,
+        amount: fromStripeAmount(session.amount_total ?? 0, session.currency),
         method: 'stripe',
         provider: 'stripe',
         externalId: session.id,

+ 10 - 10
src/components/currency-settings-context.tsx

@@ -1,20 +1,20 @@
 'use client'
 
 import { createContext, useCallback, useContext } from 'react'
+import {
+  type CurrencySettings,
+  DEFAULT_CURRENCY_CODE,
+  DEFAULT_CURRENCY_FORMAT,
+  resolveCurrencyCode,
+  resolveCurrencyFormat,
+} from '@/lib/currencies'
 import {
   formatCurrency as formatCurrencyRaw,
   getCurrencySymbol as getCurrencySymbolRaw,
-  DEFAULT_CURRENCY_FORMAT,
-  type CurrencyFormat,
 } from '@/lib/format'
 
-interface CurrencySettings {
-  currencyCode: string
-  currencyFormat: CurrencyFormat
-}
-
 const defaultSettings: CurrencySettings = {
-  currencyCode: 'USD',
+  currencyCode: DEFAULT_CURRENCY_CODE,
   currencyFormat: DEFAULT_CURRENCY_FORMAT,
 }
 
@@ -30,8 +30,8 @@ export function CurrencySettingsProvider({
   children: React.ReactNode
 }) {
   const value: CurrencySettings = {
-    currencyCode: currencyCode || 'USD',
-    currencyFormat: currencyFormat === 'code' ? 'code' : 'symbol',
+    currencyCode: resolveCurrencyCode(currencyCode),
+    currencyFormat: resolveCurrencyFormat(currencyFormat),
   }
   return (
     <CurrencySettingsContext.Provider value={value}>{children}</CurrencySettingsContext.Provider>

+ 5 - 3
src/features/email/Actions/emailActions.ts

@@ -1,5 +1,6 @@
 'use server'
 
+import { resolveCurrencyCode, resolveCurrencyFormat } from '@/lib/currencies'
 import { db } from '@/lib/db'
 import { withAuth } from '@/lib/with-auth'
 import { PermissionAction, PermissionSubject } from '@/lib/permissions'
@@ -126,9 +127,10 @@ export async function sendQuoteEmail(input: {
       const locale = await resolveCustomerLocale(organizationId, null)
       // The mail's own summary of the document quotes a figure, so it needs
       // the same currency the sheet prints in.
-      const currencyCode = settings['workshop.currencyCode'] || 'USD'
-      const currencyFormat: 'symbol' | 'code' =
-        settings['workshop.currencyFormat'] === 'code' ? 'code' : 'symbol'
+      const currencyCode = resolveCurrencyCode(settings['workshop.currencyCode'])
+      const currencyFormat: 'symbol' | 'code' = resolveCurrencyFormat(
+        settings['workshop.currencyFormat']
+      )
 
       let pdfBuffer: Buffer | null = null
       if (attachPdf) {

+ 2 - 1
src/features/email/Lib/emailContext.ts

@@ -1,3 +1,4 @@
+import { resolveCurrencyCode } from '@/lib/currencies'
 import { formatCurrency, type CurrencyFormat } from '@/lib/format'
 import type { EmailKind } from './emailKinds'
 import { kindSpec } from './emailKinds'
@@ -104,7 +105,7 @@ function asDate(value: Date | string | null | undefined): Date | null {
 }
 
 function money(doc: DocumentContext): (amount: number) => string {
-  const currency = doc.currencyCode || 'USD'
+  const currency = resolveCurrencyCode(doc.currencyCode)
   return (amount) => formatCurrency(amount, currency, doc.currencyFormat)
 }
 

+ 3 - 2
src/features/invoice-designer/Pdf/buildInvoicePrint.ts

@@ -1,3 +1,4 @@
+import { resolveCurrencyCode, resolveCurrencyFormat } from '@/lib/currencies'
 import { DEFAULT_DATE_FORMAT, formatCurrency, formatDateForPdf } from '@/lib/format'
 import { documentLaborLines, isShopFeeLine } from '@/features/settings/Lib/shopFee'
 import { formatQuantity } from '@/lib/format-quantity'
@@ -139,8 +140,8 @@ export function buildInvoicePrintSpec(input: InvoicePrintInput): DocumentSpec {
   const layout = resolveLayout(input)
   const doc = layout.document ?? {}
 
-  const cc = invoiceSettings?.currencyCode || 'USD'
-  const cf: 'symbol' | 'code' = invoiceSettings?.currencyFormat === 'code' ? 'code' : 'symbol'
+  const cc = resolveCurrencyCode(invoiceSettings?.currencyCode)
+  const cf: 'symbol' | 'code' = resolveCurrencyFormat(invoiceSettings?.currencyFormat)
   const money = (value: number) => formatCurrency(value, cc, cf)
   const taxRate = data.taxRate
   const taxInclusive = data.taxInclusive ?? false

+ 3 - 2
src/features/invoice-designer/Pdf/buildQuotePrint.ts

@@ -1,3 +1,4 @@
+import { resolveCurrencyCode, resolveCurrencyFormat } from '@/lib/currencies'
 import { DEFAULT_DATE_FORMAT, formatCurrency, formatDateForPdf } from '@/lib/format'
 import { documentLaborLines, isShopFeeLine } from '@/features/settings/Lib/shopFee'
 import { formatQuantity } from '@/lib/format-quantity'
@@ -162,8 +163,8 @@ export function buildQuotePrintSpec(input: QuotePrintInput): DocumentSpec {
   }
 
   const doc = layout.document ?? {}
-  const cc = input.currencyCode || 'USD'
-  const cf: 'symbol' | 'code' = input.currencyFormat === 'code' ? 'code' : 'symbol'
+  const cc = resolveCurrencyCode(input.currencyCode)
+  const cf: 'symbol' | 'code' = resolveCurrencyFormat(input.currencyFormat)
   const money = (value: number) => formatCurrency(value, cc, cf)
   const taxRate = data.taxRate
   const taxInclusive = data.taxInclusive ?? false

+ 3 - 2
src/features/invoice-designer/Pdf/buildWorkOrderPrint.ts

@@ -1,3 +1,4 @@
+import { resolveCurrencyCode, resolveCurrencyFormat } from '@/lib/currencies'
 import {
   DEFAULT_DATE_FORMAT,
   DEFAULT_TIME_FORMAT,
@@ -151,8 +152,8 @@ export function buildWorkOrderPrintSpec(input: WorkOrderPrintInput): DocumentSpe
   const layout = resolveLayout(input)
   const doc = layout.document ?? {}
 
-  const cc = invoiceSettings?.currencyCode || 'USD'
-  const cf: 'symbol' | 'code' = invoiceSettings?.currencyFormat === 'code' ? 'code' : 'symbol'
+  const cc = resolveCurrencyCode(invoiceSettings?.currencyCode)
+  const cf: 'symbol' | 'code' = resolveCurrencyFormat(invoiceSettings?.currencyFormat)
   const money = (value: number) => formatCurrency(value, cc, cf)
   const taxRate = data.taxRate
   const taxInclusive = data.taxInclusive ?? false

+ 5 - 4
src/features/invoices/Lib/assembleInvoicePrint.ts

@@ -19,6 +19,7 @@
  * the reader's translations.
  */
 
+import { resolveCurrencyCode, resolveCurrencyFormat } from '@/lib/currencies'
 import { readFile } from 'fs/promises'
 import { db } from '@/lib/db'
 import { SETTING_KEYS } from '@/features/settings/Schema/settingsSchema'
@@ -285,8 +286,8 @@ export function liveInvoiceSettings(settingsMap: Record<string, string>): Invoic
     showOrgNumber: settingsMap['invoice.showOrgNumber'] !== 'false',
     lineItemsInclTax: settingsMap['invoice.lineItemsInclTax'] === 'true',
     dueDays: Number(settingsMap['invoice.dueDays']) || 0,
-    currencyCode: settingsMap['workshop.currencyCode'] || 'USD',
-    currencyFormat: settingsMap['workshop.currencyFormat'] === 'code' ? 'code' : 'symbol',
+    currencyCode: resolveCurrencyCode(settingsMap['workshop.currencyCode']),
+    currencyFormat: resolveCurrencyFormat(settingsMap['workshop.currencyFormat']),
     unitSystem: settingsMap['workshop.unitSystem'] || 'imperial',
     dateFormat: settingsMap['workshop.dateFormat'] || undefined,
     timeFormat: settingsMap['workshop.timeFormat'] || undefined,
@@ -468,8 +469,8 @@ function assembleFrozen(
 
   const invoiceSettings: InvoiceSettingsProps = {
     ...frozen.invoiceSettings,
-    currencyCode: frozen.invoiceSettings.currencyCode || 'USD',
-    currencyFormat: frozen.invoiceSettings.currencyFormat || 'symbol',
+    currencyCode: resolveCurrencyCode(frozen.invoiceSettings.currencyCode),
+    currencyFormat: resolveCurrencyFormat(frozen.invoiceSettings.currencyFormat),
   }
   const serviceType = frozen.serviceType === 'marine' ? 'marine' : 'automotive'
   const taxLabel = frozen.taxLabel?.trim() || undefined

+ 2 - 1
src/features/labor-presets/Components/PresetPartsEditor.tsx

@@ -1,5 +1,6 @@
 'use client'
 
+import { DEFAULT_CURRENCY_CODE } from '@/lib/currencies'
 import { useState } from 'react'
 import { useTranslations } from 'next-intl'
 import { Button } from '@/components/ui/button'
@@ -34,7 +35,7 @@ export function PresetPartsEditor({
   parts,
   onPartsChange,
   inventoryParts,
-  currencyCode = 'USD',
+  currencyCode = DEFAULT_CURRENCY_CODE,
 }: PresetPartsEditorProps) {
   const t = useTranslations('laborPresets')
   const [pickerOpen, setPickerOpen] = useState(false)

+ 2 - 1
src/features/quotes/Components/QuotePDF.tsx

@@ -1,3 +1,4 @@
+import { DEFAULT_CURRENCY_CODE } from '@/lib/currencies'
 import { Document, Image, Page, Text, View } from '@react-pdf/renderer'
 import {
   buildQuotePrintSpec,
@@ -28,7 +29,7 @@ interface OtherAttachmentPDF {
 export function QuotePDF({
   data,
   workshop,
-  currencyCode = 'USD',
+  currencyCode = DEFAULT_CURRENCY_CODE,
   currencyFormat = 'symbol',
   logoDataUri,
   signer,

+ 2 - 1
src/features/quotes/Components/QuotePageClient.tsx

@@ -1,5 +1,6 @@
 'use client'
 
+import { DEFAULT_CURRENCY_CODE } from '@/lib/currencies'
 import type { ShopFeeConfig } from '@/features/settings/Lib/shopFee'
 import { ShopFeeProvider } from '@/features/settings/Components/ShopFeeContext'
 import { DocumentLockBanner } from '@/components/document-lock-banner'
@@ -73,7 +74,7 @@ export function QuotePageClient({
   organizationId,
   lockState,
   canUnlock,
-  currencyCode = 'USD',
+  currencyCode = DEFAULT_CURRENCY_CODE,
   defaultTaxRate = 0,
   taxEnabled = true,
   defaultLaborRate = 0,

+ 3 - 4
src/features/quotes/Pdf/buildQuotePdfBuffer.ts

@@ -15,6 +15,7 @@
  * appended whole where they are PDFs.
  */
 
+import { resolveCurrencyCode, resolveCurrencyFormat } from '@/lib/currencies'
 import { readFile } from 'node:fs/promises'
 import { renderToBuffer } from '@react-pdf/renderer'
 import '@/features/vehicles/Components/invoice-pdf/fonts'
@@ -180,10 +181,8 @@ export async function buildQuotePdfBuffer(
       email: settingsMap['workshop.email'] || '',
       slogan: settingsMap['workshop.slogan'] || undefined,
     },
-    currencyCode: settingsMap['workshop.currencyCode'] || 'USD',
-    currencyFormat: (settingsMap['workshop.currencyFormat'] === 'code' ? 'code' : 'symbol') as
-      | 'symbol'
-      | 'code',
+    currencyCode: resolveCurrencyCode(settingsMap['workshop.currencyCode']),
+    currencyFormat: resolveCurrencyFormat(settingsMap['workshop.currencyFormat']),
     logoDataUri,
     signer,
     // The mark comes off for the plans that paid to remove it.

+ 3 - 2
src/features/settings/Components/InvoiceLayoutPreviewRenderer.tsx

@@ -1,5 +1,6 @@
 'use client'
 
+import { DEFAULT_CURRENCY_CODE, DEFAULT_CURRENCY_FORMAT } from '@/lib/currencies'
 import { useMemo, useState, useEffect } from 'react'
 import { useMessages, useTranslations } from 'next-intl'
 import { PDFViewer } from '@react-pdf/renderer'
@@ -210,8 +211,8 @@ const DUMMY_INVOICE_SETTINGS = {
   showBankAccount: true,
   showOrgNumber: true,
   dueDays: 14,
-  currencyCode: 'USD',
-  currencyFormat: 'symbol' as const,
+  currencyCode: DEFAULT_CURRENCY_CODE,
+  currencyFormat: DEFAULT_CURRENCY_FORMAT,
   unitSystem: 'imperial',
 }
 

+ 6 - 1
src/features/subscription/Components/subscription-settings.tsx

@@ -1,5 +1,6 @@
 'use client'
 
+import { formatCurrency } from '@/lib/format'
 import { useEffect, useState } from 'react'
 import { toast } from 'sonner'
 import { useLocale, useTranslations } from 'next-intl'
@@ -588,7 +589,11 @@ export function SubscriptionSettings({
                   <p>
                     {t('subscription.upgradeAmountDue')}:{' '}
                     <span className="font-semibold">
-                      ${upgradePreview.amountDue.toFixed(2)} {upgradePreview.currency.toUpperCase()}
+                      {formatCurrency(
+                        upgradePreview.amountDue,
+                        upgradePreview.currency.toUpperCase(),
+                        'code'
+                      )}
                     </span>
                   </p>
                 </div>

+ 3 - 2
src/features/vehicles/Components/service-history-pdf/ServiceHistoryPDF.tsx

@@ -1,3 +1,4 @@
+import { resolveCurrencyCode, resolveCurrencyFormat } from '@/lib/currencies'
 import { Document, Page, Text, View, Image } from '@react-pdf/renderer'
 import {
   formatDateForPdf,
@@ -78,8 +79,8 @@ export function ServiceHistoryPDF({
   const showCompanyName = template?.showCompanyName !== false
   const styles = createStyles(primaryColor, fontFamily)
   const fontBold = getFontBold(fontFamily)
-  const cc = invoiceSettings?.currencyCode || 'USD'
-  const cf: 'symbol' | 'code' = invoiceSettings?.currencyFormat === 'code' ? 'code' : 'symbol'
+  const cc = resolveCurrencyCode(invoiceSettings?.currencyCode)
+  const cf: 'symbol' | 'code' = resolveCurrencyFormat(invoiceSettings?.currencyFormat)
   const df = invoiceSettings?.dateFormat || DEFAULT_DATE_FORMAT
   const tz = invoiceSettings?.timezone || undefined
   const unitLabel = invoiceSettings?.unitSystem === 'metric' ? labels.km || 'km' : labels.mi || 'mi'

+ 2 - 1
src/features/vehicles/Components/service-page/ServiceRecordPage.tsx

@@ -1,3 +1,4 @@
+import { resolveCurrencyCode } from '@/lib/currencies'
 import { parseTaxComponents } from '@/lib/tax-components'
 import { getServiceRecord } from '@/features/vehicles/Actions/serviceActions'
 import { getServiceVideoCall } from '@/features/integrations/Actions/integrationActions'
@@ -126,7 +127,7 @@ export async function ServiceRecordPage({
   const record = result.data
   const vehicleId = record.vehicleId
   const settings = settingsResult.success && settingsResult.data ? settingsResult.data : {}
-  const currencyCode = settings[SETTING_KEYS.CURRENCY_CODE] || 'USD'
+  const currencyCode = resolveCurrencyCode(settings[SETTING_KEYS.CURRENCY_CODE])
   const unitSystem = (settings[SETTING_KEYS.UNIT_SYSTEM] || 'imperial') as 'metric' | 'imperial'
   const taxEnabled = settings[SETTING_KEYS.TAX_ENABLED] !== 'false'
   const defaultTaxRate = taxEnabled ? Number(settings[SETTING_KEYS.DEFAULT_TAX_RATE]) || 0 : 0

+ 5 - 3
src/lib/cron/report-schedules.ts

@@ -1,3 +1,4 @@
+import { resolveCurrencyCode, resolveCurrencyFormat } from '@/lib/currencies'
 import { CronJob } from 'cron'
 import { db } from '@/lib/db'
 import { renderToBuffer } from '@react-pdf/renderer'
@@ -687,9 +688,10 @@ export async function processOneSchedule(
   })
   const settingsMap: Record<string, string> = {}
   for (const s of settings) settingsMap[s.key] = s.value
-  const currencyCode = settingsMap[SETTING_KEYS.CURRENCY_CODE] || 'USD'
-  const currencyFormat: 'symbol' | 'code' =
-    settingsMap[SETTING_KEYS.CURRENCY_FORMAT] === 'code' ? 'code' : 'symbol'
+  const currencyCode = resolveCurrencyCode(settingsMap[SETTING_KEYS.CURRENCY_CODE])
+  const currencyFormat: 'symbol' | 'code' = resolveCurrencyFormat(
+    settingsMap[SETTING_KEYS.CURRENCY_FORMAT]
+  )
   const primaryColor = settingsMap[SETTING_KEYS.INVOICE_PRIMARY_COLOR] || '#d97706'
 
   // Fetch report data for selected sections

+ 284 - 0
src/lib/currencies.ts

@@ -0,0 +1,284 @@
+import { SETTING_KEYS } from '@/features/settings/Schema/settingsSchema'
+
+/**
+ * Every currency the app knows, and the one place to add another.
+ *
+ * A new currency is one line here: the Localization dropdown, the formatter
+ * and the documents all read this list, and its name comes from the runtime's
+ * own locale data, so there is nothing to translate.
+ */
+export interface CurrencyDefinition {
+  /** ISO 4217 code. */
+  code: string
+  /** The locale whose grouping, decimals and symbol placement amounts follow. */
+  locale: string
+  /**
+   * Print the ISO code where the symbol would go. Set for currencies whose
+   * narrow symbol falls outside the WinAnsi encoding used by the default
+   * Helvetica font in @react-pdf/renderer (it renders as "?" in PDFs).
+   */
+  printCode?: true
+  /**
+   * Replaces CLDR's narrow symbol where that one is ambiguous or locally
+   * non-idiomatic on business documents. Applied only in 'symbol' mode.
+   */
+  symbol?: string
+  /** No longer offered in the dropdown, still formatted for workshops that have it stored. */
+  retired?: true
+}
+
+/** In the order the Localization dropdown lists them: the common ones first. */
+export const CURRENCIES: readonly CurrencyDefinition[] = [
+  { code: 'USD', locale: 'en-US' },
+  { code: 'EUR', locale: 'de-DE' },
+  { code: 'GBP', locale: 'en-GB' },
+  { code: 'NOK', locale: 'nb-NO' },
+  { code: 'SEK', locale: 'sv-SE' },
+  { code: 'DKK', locale: 'da-DK' },
+  { code: 'CHF', locale: 'de-CH' },
+  { code: 'CAD', locale: 'en-CA' },
+  { code: 'AUD', locale: 'en-AU' },
+  { code: 'NZD', locale: 'en-NZ' },
+  { code: 'JPY', locale: 'ja-JP', printCode: true },
+  // RMB disambiguates from JPY ¥ and matches Chinese invoice convention.
+  { code: 'CNY', locale: 'zh-CN', symbol: 'RMB' },
+  { code: 'INR', locale: 'en-IN', printCode: true },
+  { code: 'BRL', locale: 'pt-BR' },
+  { code: 'MXN', locale: 'es-MX' },
+  { code: 'PLN', locale: 'pl-PL', printCode: true },
+  { code: 'CZK', locale: 'cs-CZ', printCode: true },
+  { code: 'HUF', locale: 'hu-HU' },
+  { code: 'TRY', locale: 'tr-TR', printCode: true },
+  { code: 'ZAR', locale: 'en-ZA' },
+  { code: 'KRW', locale: 'ko-KR', printCode: true },
+  { code: 'SGD', locale: 'en-SG' },
+  { code: 'HKD', locale: 'zh-HK' },
+  { code: 'THB', locale: 'th-TH', printCode: true },
+  { code: 'ISK', locale: 'is-IS' },
+  { code: 'RON', locale: 'ro-RO' },
+  { code: 'ILS', locale: 'en', printCode: true },
+  { code: 'PHP', locale: 'en-PH', printCode: true },
+  { code: 'IDR', locale: 'id-ID' },
+  { code: 'MYR', locale: 'ms-MY' },
+  { code: 'LKR', locale: 'en-LK' },
+  { code: 'TWD', locale: 'zh-TW' },
+  { code: 'VND', locale: 'vi-VN', printCode: true },
+  { code: 'PKR', locale: 'en-PK' },
+  { code: 'BDT', locale: 'en-BD', printCode: true },
+  { code: 'NPR', locale: 'en-NP' },
+  { code: 'BGN', locale: 'bg-BG', printCode: true },
+  { code: 'UAH', locale: 'uk-UA', printCode: true },
+  { code: 'CLP', locale: 'es-CL' },
+  { code: 'COP', locale: 'es-CO' },
+  { code: 'ARS', locale: 'es-AR' },
+  { code: 'PEN', locale: 'es-PE' },
+  { code: 'NIO', locale: 'es-NI' },
+  { code: 'EGP', locale: 'en-EG' },
+  { code: 'NGN', locale: 'en-NG', printCode: true },
+  { code: 'KES', locale: 'en-KE' },
+  { code: 'MAD', locale: 'en-MA' },
+  // French-Algerian formatting gives the idiomatic Latin "DA" symbol; the
+  // Arabic locale's د.ج would fall outside the PDF font's encoding.
+  { code: 'DZD', locale: 'fr-DZ' },
+  { code: 'GHS', locale: 'en-GH', printCode: true },
+  { code: 'AED', locale: 'en' },
+  { code: 'SAR', locale: 'en' },
+  { code: 'QAR', locale: 'en' },
+  { code: 'KWD', locale: 'en' },
+  { code: 'BHD', locale: 'en' },
+  { code: 'OMR', locale: 'en' },
+  { code: 'JOD', locale: 'en' },
+  { code: 'RUB', locale: 'ru-RU', printCode: true },
+  // Every other currency in circulation, alphabetically. Where the country's
+  // own locale writes digits or the symbol outside Latin script, amounts follow
+  // English formatting instead, so they print in the PDF font.
+  { code: 'AFN', locale: 'en', printCode: true },
+  { code: 'ALL', locale: 'sq-AL' },
+  { code: 'AMD', locale: 'hy-AM', printCode: true },
+  { code: 'AOA', locale: 'pt-AO' },
+  { code: 'AWG', locale: 'nl-AW' },
+  { code: 'AZN', locale: 'az-AZ', printCode: true },
+  { code: 'BAM', locale: 'bs-BA' },
+  { code: 'BBD', locale: 'en-BB' },
+  { code: 'BIF', locale: 'fr-BI' },
+  { code: 'BMD', locale: 'en-BM' },
+  { code: 'BND', locale: 'ms-BN' },
+  { code: 'BOB', locale: 'es-BO' },
+  { code: 'BSD', locale: 'en-BS' },
+  { code: 'BTN', locale: 'en' },
+  { code: 'BWP', locale: 'en-BW' },
+  { code: 'BYN', locale: 'ru-BY' },
+  { code: 'BZD', locale: 'en-BZ' },
+  { code: 'CDF', locale: 'fr-CD' },
+  { code: 'CRC', locale: 'es-CR', printCode: true },
+  { code: 'CUP', locale: 'es-CU' },
+  { code: 'CVE', locale: 'pt-CV', printCode: true },
+  { code: 'DJF', locale: 'fr-DJ' },
+  { code: 'DOP', locale: 'es-DO' },
+  { code: 'ERN', locale: 'en-ER' },
+  { code: 'ETB', locale: 'en' },
+  { code: 'FJD', locale: 'en-FJ' },
+  { code: 'FKP', locale: 'en-FK' },
+  { code: 'GEL', locale: 'ka-GE', printCode: true },
+  { code: 'GIP', locale: 'en-GI' },
+  { code: 'GMD', locale: 'en-GM' },
+  { code: 'GNF', locale: 'fr-GN' },
+  { code: 'GTQ', locale: 'es-GT' },
+  { code: 'GYD', locale: 'en-GY' },
+  { code: 'HNL', locale: 'es-HN' },
+  { code: 'HTG', locale: 'fr-HT' },
+  { code: 'IQD', locale: 'en' },
+  { code: 'IRR', locale: 'en' },
+  { code: 'JMD', locale: 'en-JM' },
+  { code: 'KGS', locale: 'ru-KG', printCode: true },
+  { code: 'KHR', locale: 'en', printCode: true },
+  { code: 'KMF', locale: 'fr-KM' },
+  { code: 'KPW', locale: 'en', printCode: true },
+  { code: 'KYD', locale: 'en-KY' },
+  { code: 'KZT', locale: 'ru-KZ', printCode: true },
+  { code: 'LAK', locale: 'en', printCode: true },
+  { code: 'LBP', locale: 'en' },
+  { code: 'LRD', locale: 'en-LR' },
+  { code: 'LSL', locale: 'en-LS' },
+  { code: 'LYD', locale: 'en' },
+  { code: 'MDL', locale: 'ro-MD' },
+  { code: 'MGA', locale: 'fr-MG' },
+  { code: 'MKD', locale: 'mk-MK', printCode: true },
+  { code: 'MMK', locale: 'en' },
+  { code: 'MNT', locale: 'mn-MN', printCode: true },
+  { code: 'MOP', locale: 'zh-MO' },
+  { code: 'MRU', locale: 'fr-MR' },
+  { code: 'MUR', locale: 'en-MU' },
+  { code: 'MVR', locale: 'en' },
+  { code: 'MWK', locale: 'en-MW' },
+  { code: 'MZN', locale: 'pt-MZ' },
+  { code: 'NAD', locale: 'en-NA' },
+  { code: 'PAB', locale: 'es-PA' },
+  { code: 'PGK', locale: 'en-PG' },
+  { code: 'PYG', locale: 'es-PY', printCode: true },
+  { code: 'RSD', locale: 'sr-Latn-RS' },
+  { code: 'RWF', locale: 'en-RW' },
+  { code: 'SBD', locale: 'en-SB' },
+  { code: 'SCR', locale: 'en-SC' },
+  { code: 'SDG', locale: 'en-SD' },
+  { code: 'SHP', locale: 'en-SH' },
+  { code: 'SLE', locale: 'en-SL' },
+  { code: 'SOS', locale: 'so-SO' },
+  { code: 'SRD', locale: 'nl-SR' },
+  { code: 'SSP', locale: 'en-SS' },
+  { code: 'STN', locale: 'pt-ST' },
+  { code: 'SVC', locale: 'es-SV' },
+  { code: 'SYP', locale: 'en' },
+  { code: 'SZL', locale: 'en-SZ' },
+  { code: 'TJS', locale: 'ru' },
+  { code: 'TMT', locale: 'tk-TM' },
+  { code: 'TND', locale: 'fr-TN' },
+  { code: 'TOP', locale: 'en-TO' },
+  { code: 'TTD', locale: 'en-TT' },
+  { code: 'TZS', locale: 'en-TZ' },
+  { code: 'UGX', locale: 'en-UG' },
+  { code: 'UYU', locale: 'es-UY' },
+  { code: 'UZS', locale: 'uz-UZ', printCode: true },
+  { code: 'VES', locale: 'es-VE' },
+  { code: 'VUV', locale: 'en-VU' },
+  { code: 'WST', locale: 'en-WS' },
+  { code: 'XAF', locale: 'fr-CM' },
+  { code: 'XCD', locale: 'en-AG' },
+  { code: 'XCG', locale: 'nl-CW' },
+  { code: 'XOF', locale: 'fr-SN' },
+  { code: 'XPF', locale: 'fr-PF' },
+  { code: 'YER', locale: 'en' },
+  { code: 'ZMW', locale: 'en-ZM' },
+  { code: 'ZWG', locale: 'en-ZW' },
+  { code: 'HRK', locale: 'hr-HR', retired: true },
+]
+
+const BY_CODE = new Map(CURRENCIES.map((c) => [c.code, c]))
+
+/** The currencies a workshop can pick. */
+export const SELECTABLE_CURRENCIES = CURRENCIES.filter((c) => !c.retired)
+
+/** What a workshop that never chose a currency is shown. */
+export const DEFAULT_CURRENCY_CODE = 'USD'
+
+export type CurrencyFormat = 'symbol' | 'code'
+export const DEFAULT_CURRENCY_FORMAT: CurrencyFormat = 'symbol'
+
+export interface CurrencySettings {
+  currencyCode: string
+  currencyFormat: CurrencyFormat
+}
+
+export function currencyDefinition(code: string): CurrencyDefinition | undefined {
+  return BY_CODE.get(code)
+}
+
+/** A stored or passed currency code, or the default when there is none. */
+export function resolveCurrencyCode(value: string | null | undefined): string {
+  return value || DEFAULT_CURRENCY_CODE
+}
+
+/** A stored or passed format preference, held to the two the app knows. */
+export function resolveCurrencyFormat(value: string | null | undefined): CurrencyFormat {
+  return value === 'code' ? 'code' : DEFAULT_CURRENCY_FORMAT
+}
+
+type SettingsLookup =
+  | Record<string, string | null | undefined>
+  | Map<string, string | null | undefined>
+
+/** The workshop's currency and format out of a settings map, however it was read. */
+export function resolveCurrencySettings(settings: SettingsLookup): CurrencySettings {
+  const read = (key: string) => (settings instanceof Map ? settings.get(key) : settings[key])
+  return {
+    currencyCode: resolveCurrencyCode(read(SETTING_KEYS.CURRENCY_CODE)),
+    currencyFormat: resolveCurrencyFormat(read(SETTING_KEYS.CURRENCY_FORMAT)),
+  }
+}
+
+/**
+ * The currency's name in the reader's language, e.g. "Norwegian Krone" or
+ * "Norske kroner". A currency newer than the runtime's data for that language
+ * gets its English name, and the bare code only where there is none at all.
+ */
+export function currencyName(code: string, locale: string): string {
+  for (const language of [locale, 'en']) {
+    try {
+      const name = new Intl.DisplayNames([language], { type: 'currency', fallback: 'none' }).of(
+        code
+      )
+      // Several locales write currency names in lower case mid-sentence; here
+      // the name stands alone in a list.
+      if (name) return name.charAt(0).toLocaleUpperCase(language) + name.slice(1)
+    } catch {
+      // An unknown locale or a malformed code: try the next, then the code.
+    }
+  }
+  return code
+}
+
+/** Lower case with the accents off, so "cordoba" finds "Córdoba". */
+function fold(text: string): string {
+  return text
+    .normalize('NFD')
+    .replace(/\p{Diacritic}/gu, '')
+    .toLowerCase()
+}
+
+/**
+ * How well a currency's "CODE Name" label answers what was typed into the
+ * currency search, 0 for not at all.
+ *
+ * A code is matched from its first letter and a name on any run of letters in
+ * it. The picker's stock fuzzy match read "NIO" as any label with an n, an i
+ * and an o somewhere in it, and listed thirty currencies for it.
+ */
+export function currencySearchScore(label: string, search: string): number {
+  const query = fold(search.trim())
+  if (!query) return 1
+  const text = fold(label)
+  if (text.startsWith(query)) return 1
+  const at = text.indexOf(query)
+  if (at < 0) return 0
+  return text[at - 1] === ' ' ? 0.8 : 0.5
+}

+ 17 - 103
src/lib/format.ts

@@ -1,99 +1,11 @@
-const CURRENCY_LOCALES: Record<string, string> = {
-  USD: 'en-US',
-  EUR: 'de-DE',
-  GBP: 'en-GB',
-  NOK: 'nb-NO',
-  SEK: 'sv-SE',
-  DKK: 'da-DK',
-  CHF: 'de-CH',
-  JPY: 'ja-JP',
-  CAD: 'en-CA',
-  AUD: 'en-AU',
-  NZD: 'en-NZ',
-  PLN: 'pl-PL',
-  CZK: 'cs-CZ',
-  HUF: 'hu-HU',
-  BRL: 'pt-BR',
-  MXN: 'es-MX',
-  INR: 'en-IN',
-  CNY: 'zh-CN',
-  KRW: 'ko-KR',
-  TRY: 'tr-TR',
-  ZAR: 'en-ZA',
-  RUB: 'ru-RU',
-  ISK: 'is-IS',
-  THB: 'th-TH',
-  SGD: 'en-SG',
-  HKD: 'zh-HK',
-  TWD: 'zh-TW',
-  PHP: 'en-PH',
-  ILS: 'en',
-  AED: 'en',
-  SAR: 'en',
-  RON: 'ro-RO',
-  BGN: 'bg-BG',
-  HRK: 'hr-HR',
-  UAH: 'uk-UA',
-  CLP: 'es-CL',
-  COP: 'es-CO',
-  ARS: 'es-AR',
-  PEN: 'es-PE',
-  IDR: 'id-ID',
-  MYR: 'ms-MY',
-  VND: 'vi-VN',
-  LKR: 'en-LK',
-  PKR: 'en-PK',
-  BDT: 'en-BD',
-  NPR: 'en-NP',
-  EGP: 'en-EG',
-  NGN: 'en-NG',
-  KES: 'en-KE',
-  MAD: 'en-MA',
-  // French-Algerian formatting gives the idiomatic Latin "DA" symbol; the
-  // Arabic locale's د.ج would fall outside the PDF font's encoding.
-  DZD: 'fr-DZ',
-  GHS: 'en-GH',
-  QAR: 'en',
-  KWD: 'en',
-  BHD: 'en',
-  OMR: 'en',
-  JOD: 'en',
-}
-
-/**
- * Currencies whose narrow symbol falls outside the WinAnsi encoding used by
- * the default Helvetica font in @react-pdf/renderer (renders as "?" in PDFs).
- * For these we fall back to the ISO code in symbol mode.
- */
-const LATIN_FALLBACK_CURRENCIES = new Set([
-  'JPY',
-  'PLN',
-  'CZK',
-  'INR',
-  'KRW',
-  'TRY',
-  'RUB',
-  'THB',
-  'PHP',
-  'ILS',
-  'BGN',
-  'UAH',
-  'VND',
-  'BDT',
-  'NGN',
-  'GHS',
-])
-
-/**
- * Symbol overrides for currencies where CLDR's narrowSymbol is ambiguous or
- * locally non-idiomatic on business documents. Applied only in 'symbol' mode.
- */
-const CURRENCY_SYMBOL_OVERRIDES: Record<string, string> = {
-  CNY: 'RMB', // disambiguates from JPY ¥; matches Chinese invoice convention
-}
+import {
+  type CurrencyFormat,
+  currencyDefinition,
+  DEFAULT_CURRENCY_CODE,
+  DEFAULT_CURRENCY_FORMAT,
+} from '@/lib/currencies'
 
-export type CurrencyFormat = 'symbol' | 'code'
-export const DEFAULT_CURRENCY_FORMAT: CurrencyFormat = 'symbol'
+export { type CurrencyFormat, DEFAULT_CURRENCY_FORMAT } from '@/lib/currencies'
 
 /**
  * Format a number as currency using the correct locale for the given ISO 4217 currency code.
@@ -105,14 +17,15 @@ export const DEFAULT_CURRENCY_FORMAT: CurrencyFormat = 'symbol'
  */
 export function formatCurrency(
   amount: number,
-  currencyCode: string = 'USD',
+  currencyCode: string = DEFAULT_CURRENCY_CODE,
   format: CurrencyFormat = DEFAULT_CURRENCY_FORMAT
 ): string {
-  const locale = CURRENCY_LOCALES[currencyCode] || 'en-US'
+  const definition = currencyDefinition(currencyCode)
+  const locale = definition?.locale || 'en-US'
   // 'code' mode: always render the ISO code.
   // 'symbol' mode: prefer narrowSymbol, but fall back to code for currencies
   // whose symbol isn't WinAnsi-safe in the default PDF font.
-  const useCode = format === 'code' || LATIN_FALLBACK_CURRENCIES.has(currencyCode)
+  const useCode = format === 'code' || definition?.printCode === true
   const display = useCode ? 'code' : 'narrowSymbol'
   try {
     let result = new Intl.NumberFormat(locale, {
@@ -122,7 +35,8 @@ export function formatCurrency(
       minimumFractionDigits: 2,
       maximumFractionDigits: 2,
     }).format(amount)
-    if (format === 'symbol' && CURRENCY_SYMBOL_OVERRIDES[currencyCode]) {
+    const override = definition?.symbol
+    if (format === 'symbol' && override) {
       // Replace CLDR's currency token with our override (e.g. ¥ → RMB).
       const parts = new Intl.NumberFormat(locale, {
         style: 'currency',
@@ -133,7 +47,6 @@ export function formatCurrency(
       }).formatToParts(amount)
       const sym = parts.find((p) => p.type === 'currency')?.value
       if (sym) {
-        const override = CURRENCY_SYMBOL_OVERRIDES[currencyCode]
         // If the original symbol was glued to a digit/letter (e.g. "¥1,234.56"),
         // a multi-character override needs a space for readability.
         result = result
@@ -158,12 +71,13 @@ export function formatCurrency(
  *   getCurrencySymbol("EUR") → "€"
  */
 export function getCurrencySymbol(
-  currencyCode: string = 'USD',
+  currencyCode: string = DEFAULT_CURRENCY_CODE,
   format: CurrencyFormat = DEFAULT_CURRENCY_FORMAT
 ): string {
   if (format === 'code') return currencyCode
-  if (CURRENCY_SYMBOL_OVERRIDES[currencyCode]) return CURRENCY_SYMBOL_OVERRIDES[currencyCode]
-  if (LATIN_FALLBACK_CURRENCIES.has(currencyCode)) return currencyCode
+  const definition = currencyDefinition(currencyCode)
+  if (definition?.symbol) return definition.symbol
+  if (definition?.printCode) return currencyCode
   try {
     const parts = new Intl.NumberFormat('en', {
       style: 'currency',

+ 2 - 1
src/lib/payment-providers/paypal.ts

@@ -1,4 +1,5 @@
 import type { PaymentProvider, CheckoutRequest, CheckoutResult, VerifyResult } from './types'
+import { toPayPalValue } from './vendor-amounts'
 import { paypalApiBase } from './vendor-hosts'
 
 export interface PayPalConfig {
@@ -68,7 +69,7 @@ export class PayPalProvider implements PaymentProvider {
           {
             amount: {
               currency_code: req.currency,
-              value: req.amount.toFixed(2),
+              value: toPayPalValue(req.amount, req.currency),
             },
             description: req.description,
             invoice_id: req.invoiceNumber,

+ 3 - 2
src/lib/payment-providers/stripe.ts

@@ -1,4 +1,5 @@
 import type Stripe from 'stripe'
+import { fromStripeAmount, toStripeAmount } from './vendor-amounts'
 import { stripeClient } from './vendor-hosts'
 import type { PaymentProvider, CheckoutRequest, CheckoutResult, VerifyResult } from './types'
 
@@ -17,7 +18,7 @@ export class StripeProvider implements PaymentProvider {
         {
           price_data: {
             currency: req.currency.toLowerCase(),
-            unit_amount: Math.round(req.amount * 100),
+            unit_amount: toStripeAmount(req.amount, req.currency),
             product_data: {
               name: `Invoice ${req.invoiceNumber}`,
               description: req.description,
@@ -50,7 +51,7 @@ export class StripeProvider implements PaymentProvider {
       const session = await this.stripe.checkout.sessions.retrieve(externalId)
       return {
         paid: session.payment_status === 'paid',
-        amount: (session.amount_total ?? 0) / 100,
+        amount: fromStripeAmount(session.amount_total ?? 0, session.currency),
         serviceRecordId: session.metadata?.serviceRecordId ?? null,
         organizationId: session.metadata?.orgId ?? null,
       }

+ 83 - 0
src/lib/payment-providers/vendor-amounts.ts

@@ -0,0 +1,83 @@
+/**
+ * How each payment vendor wants an amount written.
+ *
+ * The app keeps every amount as a decimal number of the currency's main unit
+ * (12.50 means twelve and a half). The vendors do not: Stripe takes an integer
+ * in the currency's smallest unit, and what that unit is depends on the
+ * currency. Scaling every currency by 100 charges a yen invoice a hundred
+ * times over, so the scaling lives here and nowhere else.
+ */
+
+/**
+ * Stripe's zero-decimal currencies: the amount is sent as it is, 500 for
+ * 500 JPY. From https://docs.stripe.com/currencies#zero-decimal.
+ *
+ * UGX is on Stripe's list but is left out here, because the same page's
+ * special cases say it is still written as a two-decimal value.
+ */
+const STRIPE_ZERO_DECIMAL = new Set([
+  'BIF',
+  'CLP',
+  'DJF',
+  'GNF',
+  'JPY',
+  'KMF',
+  'KRW',
+  'MGA',
+  'PYG',
+  'RWF',
+  'VND',
+  'VUV',
+  'XAF',
+  'XOF',
+  'XPF',
+])
+
+/**
+ * Stripe's special cases: currencies with no fractions that are still written
+ * as two-decimal values whose decimals are always 00, so 5 ISK is sent as 500.
+ */
+const STRIPE_WHOLE_UNITS_IN_HUNDREDTHS = new Set(['ISK', 'UGX'])
+
+/**
+ * Stripe's three-decimal currencies: 5.124 KWD is sent as 5124, and the last
+ * digit must be 0. The app's amounts carry two decimals, so it always is.
+ */
+const STRIPE_THREE_DECIMAL = new Set(['BHD', 'JOD', 'KWD', 'OMR', 'TND'])
+
+function stripeScale(currency: string): number {
+  const code = currency.toUpperCase()
+  if (STRIPE_ZERO_DECIMAL.has(code)) return 1
+  if (STRIPE_THREE_DECIMAL.has(code)) return 1000
+  return 100
+}
+
+/** An amount in the app's terms, as the integer Stripe charges. */
+export function toStripeAmount(amount: number, currency: string): number {
+  const code = currency.toUpperCase()
+  if (STRIPE_WHOLE_UNITS_IN_HUNDREDTHS.has(code)) return Math.round(amount) * 100
+  if (STRIPE_THREE_DECIMAL.has(code)) return Math.round(amount * 100) * 10
+  return Math.round(amount * stripeScale(code))
+}
+
+/**
+ * The integer Stripe reports, back in the app's terms. A session that carries
+ * no currency is read the way two-decimal currencies are.
+ */
+export function fromStripeAmount(amount: number, currency: string | null | undefined): number {
+  return amount / stripeScale(currency ?? '')
+}
+
+/**
+ * PayPal's currencies without decimals: "If you pass a decimal amount, an
+ * error occurs". From
+ * https://developer.paypal.com/api/rest/reference/currency-codes/.
+ */
+const PAYPAL_NO_DECIMALS = new Set(['HUF', 'JPY', 'TWD'])
+
+/** An amount as the string PayPal's order API takes for that currency. */
+export function toPayPalValue(amount: number, currency: string): string {
+  return PAYPAL_NO_DECIMALS.has(currency.toUpperCase())
+    ? String(Math.round(amount))
+    : amount.toFixed(2)
+}

+ 31 - 0
src/lib/workshop-currency.ts

@@ -0,0 +1,31 @@
+import { SETTING_KEYS } from '@/features/settings/Schema/settingsSchema'
+import { type CurrencySettings, resolveCurrencySettings } from '@/lib/currencies'
+import { db } from '@/lib/db'
+import { formatCurrency } from '@/lib/format'
+
+/**
+ * The currency and format the workshop set under Localization, for server
+ * code that has no settings map of its own to read them from.
+ */
+export async function workshopCurrencySettings(
+  organizationId: string | null | undefined
+): Promise<CurrencySettings> {
+  const rows = organizationId
+    ? await db.appSetting.findMany({
+        where: {
+          organizationId,
+          key: { in: [SETTING_KEYS.CURRENCY_CODE, SETTING_KEYS.CURRENCY_FORMAT] },
+        },
+        select: { key: true, value: true },
+      })
+    : []
+  return resolveCurrencySettings(new Map(rows.map((r) => [r.key, r.value])))
+}
+
+/** Prints amounts the way the workshop set them up under Localization. */
+export async function workshopMoneyFormatter(
+  organizationId: string | null | undefined
+): Promise<(amount: number) => string> {
+  const { currencyCode, currencyFormat } = await workshopCurrencySettings(organizationId)
+  return (amount) => formatCurrency(amount, currencyCode, currencyFormat)
+}