diff --git a/.changeset/tough-houses-wave.md b/.changeset/tough-houses-wave.md new file mode 100644 index 000000000..8d68ad16d --- /dev/null +++ b/.changeset/tough-houses-wave.md @@ -0,0 +1,5 @@ +--- +"@exactly/server": patch +--- + +🌐 translate push notifications to spanish diff --git a/package.json b/package.json index 2f8ceac3b..254ef823d 100644 --- a/package.json +++ b/package.json @@ -89,7 +89,7 @@ "fast-text-encoding": "^1.0.6", "hono": "catalog:", "i18n-iso-countries": "^7.14.0", - "i18next": "^25.10.10", + "i18next": "catalog:", "moti": "^0.30.0", "persona": "^5.7.0", "react": "catalog:", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bf1647d52..b39b43dc4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -39,6 +39,9 @@ catalogs: hono: specifier: ^4.12.9 version: 4.12.9 + i18next: + specifier: ^25.10.10 + version: 25.10.10 react: specifier: 19.2.0 version: 19.2.0 @@ -283,7 +286,7 @@ importers: specifier: ^7.14.0 version: 7.14.0 i18next: - specifier: ^25.10.10 + specifier: 'catalog:' version: 25.10.10(typescript@5.9.3) moti: specifier: ^0.30.0 @@ -789,6 +792,9 @@ importers: i18n-iso-countries: specifier: ^7.14.0 version: 7.14.0 + i18next: + specifier: 'catalog:' + version: 25.10.10(typescript@5.9.3) ioredis: specifier: ^5.10.1 version: 5.10.1 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 650263ebb..13bbb2026 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -13,6 +13,7 @@ catalog: "@tamagui/toast": *tamagui "@wagmi/core": ^3.4.1 hono: ^4.12.9 + i18next: ^25.10.10 ioredis: ^5.10.1 react: &react "19.2.0" react-dom: *react diff --git a/server/api/card.ts b/server/api/card.ts index dee223d5d..b4ba346c6 100644 --- a/server/api/card.ts +++ b/server/api/card.ts @@ -29,6 +29,7 @@ import { PLATINUM_PRODUCT_ID, SIGNATURE_PRODUCT_ID } from "@exactly/common/panda import { Address } from "@exactly/common/validation"; import database, { cards, credentials } from "../database"; +import t from "../i18n"; import auth from "../middleware/auth"; import { sendPushNotification } from "../utils/onesignal"; import { autoCredit, createCard, getCard, getPIN, getSecrets, getUser, setPIN, updateCard } from "../utils/panda"; @@ -382,8 +383,8 @@ function decrypt(base64Secret: string, base64Iv: string, secretKey: string): str if (mode) { sendPushNotification({ userId: account, - headings: { en: "Card mode" }, - contents: { en: "Credit mode is active" }, + headings: t("Card mode"), + contents: t("Credit mode is active"), }).catch((error: unknown) => captureException(error)); } return c.json( diff --git a/server/hooks/activity.ts b/server/hooks/activity.ts index 1e1dfa3b1..0504c5cc1 100644 --- a/server/hooks/activity.ts +++ b/server/hooks/activity.ts @@ -30,6 +30,7 @@ import { import { Address, Hash, Hex } from "@exactly/common/validation"; import database, { cards, credentials } from "../database"; +import t, { formatAmount } from "../i18n"; import { createWebhook, findWebhook, headerValidator, network } from "../utils/alchemy"; import appOrigin from "../utils/appOrigin"; import decodePublicKey from "../utils/decodePublicKey"; @@ -126,10 +127,15 @@ export default new Hono().post( const underlying = asset === ETH ? WETH : asset; sendPushNotification({ userId: account, - headings: { en: "Funds received" }, - contents: { - en: `${value ? `${value} ` : ""}${assetSymbol} received${marketsByAsset.has(underlying) ? " and instantly started earning yield" : ""}`, - }, + headings: t("Funds received"), + contents: t("fundsReceived", { + context: + [assetSymbol && "symbol", value && "value", marketsByAsset.has(underlying) && "yield"] + .filter(Boolean) + .join("_") || undefined, + value: value && formatAmount(value), + symbol: assetSymbol, + }), }).catch((error: unknown) => captureException(error)); if (pokes.has(account)) { @@ -249,8 +255,8 @@ export default new Hono().post( span.setAttribute("exa.mode", 1); sendPushNotification({ userId: account, - headings: { en: "Card mode changed" }, - contents: { en: "Credit mode activated" }, + headings: t("Card mode changed"), + contents: t("Credit mode activated"), }).catch((error: unknown) => captureException(error)); }) .catch((error: unknown) => captureException(error)); diff --git a/server/hooks/block.ts b/server/hooks/block.ts index 82a4b0bef..d9aa63f40 100644 --- a/server/hooks/block.ts +++ b/server/hooks/block.ts @@ -46,6 +46,7 @@ import revertReason from "@exactly/common/revertReason"; import shortenHex from "@exactly/common/shortenHex"; import { Address, Hash, Hex } from "@exactly/common/validation"; +import t, { formatAmount } from "../i18n"; import { headers as alchemyHeaders, createWebhook, findWebhook, headerValidator } from "../utils/alchemy"; import appOrigin from "../utils/appOrigin"; import ensClient from "../utils/ensClient"; @@ -358,10 +359,12 @@ function scheduleMessage(message: string) { ]).then(([decimals, symbol, ensName]) => sendPushNotification({ userId: account, - headings: { en: "Withdraw completed" }, - contents: { - en: `${formatUnits(amount, decimals)} ${symbol.slice(3)} sent to ${ensName ?? shortenHex(receiver)}`, - }, + headings: t("Withdraw completed"), + contents: t("{{amount}} {{symbol}} sent to {{recipient}}", { + amount: formatAmount(formatUnits(amount, decimals)), + symbol: symbol.slice(3), + recipient: ensName ?? shortenHex(receiver), + }), }), ), ).catch((error: unknown) => captureException(error)); @@ -528,10 +531,12 @@ function scheduleWithdraw(message: string) { ]).then(([decimals, symbol, ensName]) => sendPushNotification({ userId: account, - headings: { en: "Withdraw completed" }, - contents: { - en: `${formatUnits(amount, decimals)} ${symbol.slice(3)} sent to ${ensName ?? shortenHex(receiver)}`, - }, + headings: t("Withdraw completed"), + contents: t("{{amount}} {{symbol}} sent to {{recipient}}", { + amount: formatAmount(formatUnits(amount, decimals)), + symbol: symbol.slice(3), + recipient: ensName ?? shortenHex(receiver), + }), }), ), ).catch((error: unknown) => captureException(error)); diff --git a/server/hooks/bridge.ts b/server/hooks/bridge.ts index e7c34f69d..0617dfd56 100644 --- a/server/hooks/bridge.ts +++ b/server/hooks/bridge.ts @@ -10,6 +10,7 @@ import { literal, object, parse, picklist, string, unknown, variant } from "vali import { Address } from "@exactly/common/validation"; import database, { credentials } from "../database"; +import t, { formatAmount } from "../i18n"; import { sendPushNotification } from "../utils/onesignal"; import { searchAccounts } from "../utils/persona"; import { BridgeCurrency, getCustomer, publicKey } from "../utils/ramps/bridge"; @@ -175,18 +176,19 @@ export default new Hono().post( }); sendPushNotification({ userId: account, - headings: { en: "Fiat onramp activated" }, - contents: { en: "Your fiat onramp account has been activated" }, + headings: t("Fiat onramp activated"), + contents: t("Your fiat onramp account has been activated"), }).catch((error: unknown) => captureException(error, { level: "error" })); return c.json({ code: "ok" }, 200); case "virtual_account.activity.created": if (payload.event_object.type === "payment_submitted") { sendPushNotification({ userId: account, - headings: { en: "Deposited funds" }, - contents: { - en: `${payload.event_object.receipt.initial_amount} ${payload.event_object.currency.toUpperCase()} deposited`, - }, + headings: t("Deposited funds"), + contents: t("{{amount}} {{asset}} deposited", { + amount: formatAmount(payload.event_object.receipt.initial_amount), + asset: payload.event_object.currency.toUpperCase(), + }), }).catch((error: unknown) => captureException(error, { level: "error" })); } if (payload.event_object.type === "payment_processed") { @@ -207,10 +209,11 @@ export default new Hono().post( if (payload.event_object.state !== "payment_submitted") return c.json({ code: "ok" }, 200); sendPushNotification({ userId: account, - headings: { en: "Deposited funds" }, - contents: { - en: `${payload.event_object.receipt.initial_amount} ${payload.event_object.currency.toUpperCase()} deposited`, - }, + headings: t("Deposited funds"), + contents: t("{{amount}} {{asset}} deposited", { + amount: formatAmount(payload.event_object.receipt.initial_amount), + asset: payload.event_object.currency.toUpperCase(), + }), }).catch((error: unknown) => captureException(error, { level: "error" })); track({ userId: account, diff --git a/server/hooks/manteca.ts b/server/hooks/manteca.ts index cf503783f..59b161a99 100644 --- a/server/hooks/manteca.ts +++ b/server/hooks/manteca.ts @@ -23,6 +23,7 @@ import { import { Address } from "@exactly/common/validation"; import database, { credentials } from "../database"; +import t, { formatAmount } from "../i18n"; import { sendPushNotification } from "../utils/onesignal"; import { convertBalanceToUsdc, @@ -232,8 +233,8 @@ export default new Hono().post( }); sendPushNotification({ userId: credential.account, - headings: { en: "Fiat onramp activated" }, - contents: { en: "Your fiat onramp account has been activated" }, + headings: t("Fiat onramp activated"), + contents: t("Your fiat onramp account has been activated"), }).catch((error: unknown) => captureException(error, { level: "error" })); } return c.json({ code: "ok" }, 200); @@ -252,8 +253,8 @@ async function handleDepositDetected(data: InferInput { sendPushNotification({ userId: account, - headings: { en: "Deposited funds" }, - contents: { en: `${data.amount} ${data.asset} deposited` }, + headings: t("Deposited funds"), + contents: t("{{amount}} {{asset}} deposited", { amount: formatAmount(data.amount), asset: data.asset }), }).catch((error: unknown) => captureException(error, { level: "error" })); }) .catch((error: unknown) => { diff --git a/server/hooks/panda.ts b/server/hooks/panda.ts index 348e55731..770fd0547 100644 --- a/server/hooks/panda.ts +++ b/server/hooks/panda.ts @@ -51,6 +51,7 @@ import { Address, type Hash, type Hex } from "@exactly/common/validation"; import { MATURITY_INTERVAL, splitInstallments } from "@exactly/lib"; import database, { cards, credentials, transactions } from "../database/index"; +import t, { formatAmount } from "../i18n"; import keeper from "../utils/keeper"; import { sendPushNotification } from "../utils/onesignal"; import { collectors, createMutex, getMutex, getUser, headerValidator, signIssuerOp, updateUser } from "../utils/panda"; @@ -535,10 +536,11 @@ export default new Hono().post( ); sendPushNotification({ userId: account, - headings: { en: "Refund processed" }, - contents: { - en: `${refundAmountUsd} USDC from ${payload.body.spend.merchantName.trim()} have been refunded to your account`, - }, + headings: t("Refund processed"), + contents: t("{{refundAmount}} USDC from {{merchantName}} have been refunded to your account", { + refundAmount: formatAmount(refundAmountUsd), + merchantName: payload.body.spend.merchantName.trim(), + }), }).catch((error: unknown) => captureException(error)); trackTransactionRefund(account, refundAmountUsd, payload, card.credential.source); if (payload.action === "completed") { @@ -751,15 +753,24 @@ export default new Hono().post( payload.action === "created" || (payload.action === "completed" && payload.body.spend.amount > 0 && !payload.body.spend.authorizedAmount) // force capture ) { + const format = (locale: string) => + (payload.body.spend.localAmount / 100).toLocaleString(locale, { + style: "currency", + currency: payload.body.spend.localCurrency, + }); + const localAmount = { en: format("en-US"), es: format("es-AR") }; + const merchantName = payload.body.spend.merchantName.trim(); sendPushNotification({ userId: account, - headings: { en: "Card purchase" }, - contents: { - en: `${(payload.body.spend.localAmount / 100).toLocaleString(undefined, { - style: "currency", - currency: payload.body.spend.localCurrency, - })} at ${payload.body.spend.merchantName.trim()}. Paid ${{ 0: "with USDC", 1: "with credit" }[card.mode] ?? `in ${card.mode} installments`}`, - }, + headings: t("Card purchase"), + contents: t( + card.mode === 0 + ? "{{localAmount}} at {{merchantName}}. Paid with USDC" + : card.mode === 1 + ? "{{localAmount}} at {{merchantName}}. Paid with credit" + : "{{localAmount}} at {{merchantName}}. Paid in {{count}} installments", + { localAmount, merchantName, count: card.mode }, + ), }).catch((error: unknown) => captureException(error, { level: "error" })); } switch (payload.action) { @@ -1048,7 +1059,7 @@ async function prepareCollection( columns: { payload: true }, where: and(eq(transactions.id, payload.body.id), eq(transactions.cardId, payload.body.spend.cardId)), }); - if (!tx || !v.parse(TransactionPayload, tx.payload).bodies.some((t) => t.action === "created")) { + if (!tx || !v.parse(TransactionPayload, tx.payload).bodies.some((b) => b.action === "created")) { getActiveSpan()?.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_OP, "panda.tx.capture.force"); return payload.body.spend.amount; } diff --git a/server/i18n/en.json b/server/i18n/en.json new file mode 100644 index 000000000..7c55239d8 --- /dev/null +++ b/server/i18n/en.json @@ -0,0 +1,12 @@ +{ + "fundsReceived": "Funds received", + "fundsReceived_symbol": "{{symbol}} received", + "fundsReceived_symbol_value": "{{value}} {{symbol}} received", + "fundsReceived_symbol_value_yield": "{{value}} {{symbol}} received and instantly started earning yield", + "fundsReceived_symbol_yield": "{{symbol}} received and instantly started earning yield", + "fundsReceived_value": "{{value}} received", + "fundsReceived_value_yield": "{{value}} received and instantly started earning yield", + "fundsReceived_yield": "Funds received and instantly started earning yield", + "{{localAmount}} at {{merchantName}}. Paid in {{count}} installments_one": "{{localAmount}} at {{merchantName}}. Paid in {{count}} installment", + "{{localAmount}} at {{merchantName}}. Paid in {{count}} installments_other": "{{localAmount}} at {{merchantName}}. Paid in {{count}} installments" +} diff --git a/server/i18n/es.json b/server/i18n/es.json new file mode 100644 index 000000000..3c14ee33e --- /dev/null +++ b/server/i18n/es.json @@ -0,0 +1,28 @@ +{ + "Card mode changed": "Modo de tarjeta cambiado", + "Card mode": "Modo de tarjeta", + "Card purchase": "Compra con tarjeta", + "Credit mode activated": "Modo crédito activado", + "Credit mode is active": "El modo crédito está activo", + "Deposited funds": "Fondos depositados", + "Fiat onramp activated": "Rampa fiat activada", + "Funds received": "Fondos recibidos", + "Refund processed": "Reembolso procesado", + "Withdraw completed": "Retiro completado", + "Your fiat onramp account has been activated": "Tu cuenta de rampa fiat ha sido activada", + "fundsReceived": "Fondos recibidos", + "fundsReceived_symbol": "{{symbol}} recibidos", + "fundsReceived_symbol_value": "{{value}} {{symbol}} recibidos", + "fundsReceived_symbol_value_yield": "{{value}} {{symbol}} recibidos y empezaron a generar rendimiento", + "fundsReceived_symbol_yield": "{{symbol}} recibidos y empezaron a generar rendimiento", + "fundsReceived_value": "{{value}} recibidos", + "fundsReceived_value_yield": "{{value}} recibidos y empezaron a generar rendimiento", + "fundsReceived_yield": "Fondos recibidos y empezaron a generar rendimiento", + "{{amount}} {{asset}} deposited": "{{amount}} {{asset}} depositados", + "{{amount}} {{symbol}} sent to {{recipient}}": "{{amount}} {{symbol}} enviados a {{recipient}}", + "{{localAmount}} at {{merchantName}}. Paid in {{count}} installments_one": "{{localAmount}} en {{merchantName}}. Pagado en {{count}} cuota", + "{{localAmount}} at {{merchantName}}. Paid in {{count}} installments_other": "{{localAmount}} en {{merchantName}}. Pagado en {{count}} cuotas", + "{{localAmount}} at {{merchantName}}. Paid with USDC": "{{localAmount}} en {{merchantName}}. Pagado con USDC", + "{{localAmount}} at {{merchantName}}. Paid with credit": "{{localAmount}} en {{merchantName}}. Pagado con crédito", + "{{refundAmount}} USDC from {{merchantName}} have been refunded to your account": "{{refundAmount}} USDC de {{merchantName}} fueron reembolsados a tu cuenta" +} diff --git a/server/i18n/index.ts b/server/i18n/index.ts new file mode 100644 index 000000000..41fd27b2f --- /dev/null +++ b/server/i18n/index.ts @@ -0,0 +1,41 @@ +import { createInstance } from "i18next"; + +import en from "./en.json"; +import es from "./es.json"; + +const instance = createInstance(); +// eslint-disable-next-line @typescript-eslint/no-floating-promises -- initImmediate: false makes init synchronous +instance.init({ + initImmediate: false, + fallbackLng: "en", + keySeparator: false, + nsSeparator: false, + interpolation: { escapeValue: false }, + resources: { en: { translation: en }, es: { translation: es } }, +}); + +export function formatAmount(value: number | string) { + const n = typeof value === "string" ? Number(value) : value; + const options = + typeof value === "string" + ? { minimumFractionDigits: 0, maximumFractionDigits: value.split(".")[1]?.length ?? 0 } + : { maximumSignificantDigits: 6 }; + return { en: n.toLocaleString("en-US", options), es: n.toLocaleString("es-AR", options) }; +} + +export default function t(key: string, options?: Record) { + return { + en: instance.t(key, { ...resolve(options, "en"), lng: "en" }), + es: instance.t(key, { ...resolve(options, "es"), lng: "es" }), + }; +} + +function resolve(options: Record | undefined, lng: string) { + if (!options) return {}; + return Object.fromEntries( + Object.entries(options).map(([k, v]) => [ + k, + v != null && typeof v === "object" && lng in v ? (v as Record)[lng] : v, + ]), + ); +} diff --git a/server/package.json b/server/package.json index d4eada240..af8500d99 100644 --- a/server/package.json +++ b/server/package.json @@ -52,6 +52,7 @@ "hono": "catalog:", "hono-openapi": "^0.4.8", "i18n-iso-countries": "^7.14.0", + "i18next": "catalog:", "ioredis": "^5.10.1", "jose": "^6.2.2", "pg": "^8.20.0", diff --git a/server/test/hooks/activity.test.ts b/server/test/hooks/activity.test.ts index 5358eae9d..8cf585a73 100644 --- a/server/test/hooks/activity.test.ts +++ b/server/test/hooks/activity.test.ts @@ -783,6 +783,40 @@ describe("address activity", () => { expect(response.status).toBe(200); }); + it("sends translated notification without symbol when asset is missing", async () => { + const sendPushNotification = vi.spyOn(onesignal, "sendPushNotification"); + + const { asset: _, ...tokenWithoutAsset } = activityPayload.json.event.activity[1]; + const response = await appClient.index.$post({ + ...activityPayload, + json: { + ...activityPayload.json, + event: { + ...activityPayload.json.event, + activity: [ + { + ...tokenWithoutAsset, + toAddress: account, + rawContract: { ...activityPayload.json.event.activity[1].rawContract, address: inject("WETH") }, + }, + ], + }, + }, + }); + + await vi.waitUntil(() => sendPushNotification.mock.calls.length > 0); + + expect(sendPushNotification).toHaveBeenCalledWith({ + userId: account, + headings: { en: "Funds received", es: "Fondos recibidos" }, // cspell:ignore Fondos recibidos + contents: { + en: "99.973 received and instantly started earning yield", + es: "99,973 recibidos y empezaron a generar rendimiento", // cspell:ignore recibidos empezaron generar rendimiento + }, + }); + expect(response.status).toBe(200); + }); + it("doesn't send a notification for market shares", async () => { const sendPushNotification = vi.spyOn(onesignal, "sendPushNotification"); diff --git a/server/test/hooks/block.test.ts b/server/test/hooks/block.test.ts index d3fea3e87..22c2cf714 100644 --- a/server/test/hooks/block.test.ts +++ b/server/test/hooks/block.test.ts @@ -148,6 +148,57 @@ describe("proposal", () => { expect(hasExpectedTransfers(receipts, expected)).toBe(true); expect(setUser).toHaveBeenCalledWith({ id: bobAccount }); }); + + it("sends withdraw notification after proposal execution", async () => { + const [withdraw, anotherWithdraw] = proposals; + if (!withdraw || !anotherWithdraw) throw new Error("missing proposals"); + const sendPushNotification = vi.spyOn(onesignal, "sendPushNotification").mockResolvedValue({}); + vi.spyOn(ensClient, "getEnsName").mockResolvedValue("alice.eth"); + vi.spyOn(publicClient, "readContract").mockImplementation(({ functionName }) => { + if (functionName === "decimals") return Promise.resolve(6); + if (functionName === "symbol") return Promise.resolve("exaUSDC"); + return Promise.reject(new Error("unexpected readContract call")); + }); + const proposalExecutions = waitForSuccessfulProposalExecutions([withdraw.args.nonce, anotherWithdraw.args.nonce]); + + await Promise.all([ + appClient.index.$post({ + ...withdrawProposal, + json: { + ...withdrawProposal.json, + event: { + ...withdrawProposal.json.event, + data: { + ...withdrawProposal.json.event.data, + block: { + ...withdrawProposal.json.event.data.block, + logs: [ + { topics: withdraw.topics, data: withdraw.data, account: { address: withdraw.address } }, + { + topics: anotherWithdraw.topics, + data: anotherWithdraw.data, + account: { address: anotherWithdraw.address }, + }, + ], + }, + }, + }, + }, + }), + proposalExecutions, + ]); + await vi.waitUntil(() => sendPushNotification.mock.calls.length >= 2, 26_666); + expect(sendPushNotification).toHaveBeenCalledWith({ + userId: bobAccount, + headings: { en: "Withdraw completed", es: "Retiro completado" }, // cspell:ignore Retiro completado + contents: { en: "3 USDC sent to alice.eth", es: "3 USDC enviados a alice.eth" }, // cspell:ignore enviados + }); + expect(sendPushNotification).toHaveBeenCalledWith({ + userId: bobAccount, + headings: { en: "Withdraw completed", es: "Retiro completado" }, + contents: { en: "4 USDC sent to alice.eth", es: "4 USDC enviados a alice.eth" }, + }); + }); }); describe("with weth withdraw proposal", () => { @@ -1265,8 +1316,8 @@ describe("legacy withdraw", () => { await vi.waitUntil(() => zrem.mock.calls.some((call) => match.zrem(call)), 26_666); expect(sendPushNotification).toHaveBeenCalledWith({ userId: withdrawAccount, - headings: { en: "Withdraw completed" }, - contents: { en: "1.375 USDC sent to alice.eth" }, + headings: { en: "Withdraw completed", es: "Retiro completado" }, // cspell:ignore Retiro completado + contents: { en: "1.375 USDC sent to alice.eth", es: "1,375 USDC enviados a alice.eth" }, // cspell:ignore enviados }); const captureExceptionCalls = vi.mocked(captureException).mock.calls.slice(initialCaptureExceptionCalls); expect(captureExceptionCalls.filter((call) => match.capture(call))).toEqual([]); diff --git a/server/test/hooks/bridge.test.ts b/server/test/hooks/bridge.test.ts index 9141defb6..87a0aeb96 100644 --- a/server/test/hooks/bridge.test.ts +++ b/server/test/hooks/bridge.test.ts @@ -177,8 +177,8 @@ describe("bridge hook", () => { await expect(response.json()).resolves.toStrictEqual({ code: "ok" }); expect(sendPushNotification).toHaveBeenCalledWith({ userId: account, - headings: { en: "Deposited funds" }, - contents: { en: "1000 USD deposited" }, + headings: { en: "Deposited funds", es: "Fondos depositados" }, // cspell:ignore Fondos + contents: { en: "1,000 USD deposited", es: "1.000 USD depositados" }, // cspell:ignore depositados }); }); @@ -280,8 +280,8 @@ describe("bridge hook", () => { }); expect(sendPushNotification).toHaveBeenCalledWith({ userId: fallbackAccount, - headings: { en: "Fiat onramp activated" }, - contents: { en: "Your fiat onramp account has been activated" }, + headings: { en: "Fiat onramp activated", es: "Rampa fiat activada" }, // cspell:ignore Rampa activada + contents: { en: "Your fiat onramp account has been activated", es: "Tu cuenta de rampa fiat ha sido activada" }, // cspell:ignore cuenta sido }); }); @@ -402,8 +402,8 @@ describe("bridge hook", () => { }); expect(sendPushNotification).toHaveBeenCalledWith({ userId: account, - headings: { en: "Fiat onramp activated" }, - contents: { en: "Your fiat onramp account has been activated" }, + headings: { en: "Fiat onramp activated", es: "Rampa fiat activada" }, // cspell:ignore Rampa activada + contents: { en: "Your fiat onramp account has been activated", es: "Tu cuenta de rampa fiat ha sido activada" }, // cspell:ignore cuenta sido }); }); @@ -487,8 +487,8 @@ describe("bridge hook", () => { await expect(response.json()).resolves.toStrictEqual({ code: "ok" }); expect(sendPushNotification).toHaveBeenCalledWith({ userId: account, - headings: { en: "Deposited funds" }, - contents: { en: "500 USDC deposited" }, + headings: { en: "Deposited funds", es: "Fondos depositados" }, // cspell:ignore Fondos + contents: { en: "500 USDC deposited", es: "500 USDC depositados" }, // cspell:ignore depositados -- unchanged, 500 has no thousands separator }); }); diff --git a/server/test/hooks/manteca.test.ts b/server/test/hooks/manteca.test.ts index c4dab7363..ed939e484 100644 --- a/server/test/hooks/manteca.test.ts +++ b/server/test/hooks/manteca.test.ts @@ -173,6 +173,7 @@ describe("manteca hook", () => { describe("when a deposit is detected", () => { it("converts to USDC", async () => { vi.spyOn(manteca, "convertBalanceToUsdc").mockResolvedValue(); + const sendPushNotification = vi.spyOn(onesignal, "sendPushNotification"); const payload = { event: "DEPOSIT_DETECTED", data: { @@ -193,6 +194,11 @@ describe("manteca hook", () => { expect(response.status).toBe(200); await expect(response.json()).resolves.toStrictEqual({ code: "ok" }); expect(manteca.convertBalanceToUsdc).toHaveBeenCalledWith("456", "ARS"); + expect(sendPushNotification).toHaveBeenCalledWith({ + userId: account, + headings: { en: "Deposited funds", es: "Fondos depositados" }, // cspell:ignore Fondos depositados + contents: { en: "1,000 ARS deposited", es: "1.000 ARS depositados" }, // cspell:ignore depositados + }); }); it("returns ok if credential does not exist", async () => { @@ -481,8 +487,8 @@ describe("manteca hook", () => { }); expect(sendPushNotification).toHaveBeenCalledWith({ userId: account, - headings: { en: "Fiat onramp activated" }, - contents: { en: "Your fiat onramp account has been activated" }, + headings: { en: "Fiat onramp activated", es: "Rampa fiat activada" }, // cspell:ignore Rampa activada + contents: { en: "Your fiat onramp account has been activated", es: "Tu cuenta de rampa fiat ha sido activada" }, // cspell:ignore cuenta rampa sido }); }); diff --git a/server/test/hooks/panda.test.ts b/server/test/hooks/panda.test.ts index d3b8145bf..577049e11 100644 --- a/server/test/hooks/panda.test.ts +++ b/server/test/hooks/panda.test.ts @@ -47,6 +47,7 @@ import { proposalManager } from "@exactly/plugin/deploy.json"; import database, { cards, credentials, transactions } from "../../database"; import app from "../../hooks/panda"; import keeper from "../../utils/keeper"; +import * as onesignal from "../../utils/onesignal"; import * as panda from "../../utils/panda"; import publicClient from "../../utils/publicClient"; import * as sardine from "../../utils/sardine"; @@ -601,6 +602,44 @@ describe("card operations", () => { expect(response.status).toBe(200); }); + it("sends locale-aware card purchase notification", async () => { + const sendPushNotificationSpy = vi + .spyOn(onesignal, "sendPushNotification") + .mockImplementation(() => Promise.resolve(undefined as never)); + // @ts-expect-error mock implementation + vi.spyOn(keeper, "exaSend").mockImplementation(async (...args) => { + await args[2]?.onHash?.(zeroHash as Hash); + }); + const localAmount = 123_456; + const cardId = "locale-notify"; + await database.insert(cards).values([{ id: cardId, credentialId: "cred", lastFour: "9999", mode: 0 }]); + + const response = await appClient.index.$post({ + ...authorization, + json: { + ...authorization.json, + action: "created", + body: { + ...authorization.json.body, + id: cardId, + spend: { ...authorization.json.body.spend, cardId, localAmount, localCurrency: "ars" }, + }, + }, + }); + + expect(response.status).toBe(200); + await vi.waitFor(() => expect(sendPushNotificationSpy).toHaveBeenCalled()); + const call = sendPushNotificationSpy.mock.calls[0]?.[0]; + expect(call?.headings).toStrictEqual({ en: "Card purchase", es: "Compra con tarjeta" }); // cspell:ignore Compra tarjeta + const enAmount = (localAmount / 100).toLocaleString("en-US", { style: "currency", currency: "ars" }); + const esAmount = (localAmount / 100).toLocaleString("es-AR", { style: "currency", currency: "ars" }); + expect(enAmount).not.toBe(esAmount); + expect(call?.contents).toStrictEqual({ + en: `${enAmount} at 99999. Paid with USDC`, + es: `${esAmount} en 99999. Pagado con USDC`, // cspell:ignore Pagado + }); + }); + it("fails with transaction timeout", async () => { const error = new Error("timeout"); const track = vi.spyOn(segment, "track").mockReturnValue(); @@ -896,6 +935,7 @@ describe("card operations", () => { afterEach(() => vi.restoreAllMocks()); it("handles reversal", async () => { + const sendPushNotification = vi.spyOn(onesignal, "sendPushNotification"); const amount = 2073; const cardId = "card"; await keeper.exaSend( @@ -954,6 +994,14 @@ describe("card operations", () => { expect(deposit?.args.assets).toBe(BigInt(amount * 1e4)); expect(response.status).toBe(200); + expect(sendPushNotification).toHaveBeenCalledWith({ + userId: account, + headings: { en: "Refund processed", es: "Reembolso procesado" }, // cspell:ignore Reembolso procesado + contents: { + en: "20.73 USDC from 99999 have been refunded to your account", + es: "20,73 USDC de 99999 fueron reembolsados a tu cuenta", // cspell:ignore reembolsados cuenta fueron + }, + }); }); it("returns ok on reversal replay", async () => { diff --git a/server/test/i18n.test.ts b/server/test/i18n.test.ts new file mode 100644 index 000000000..23c61fa21 --- /dev/null +++ b/server/test/i18n.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, it } from "vitest"; + +import t, { formatAmount } from "../i18n"; + +describe("formatAmount()", () => { + describe("number input", () => { + it("preserves sub-micro values", () => { + expect(formatAmount(0.000_000_9)).toStrictEqual({ en: "0.0000009", es: "0,0000009" }); + }); + + it("preserves 6 fractional digits", () => { + expect(formatAmount(0.000_001)).toStrictEqual({ en: "0.000001", es: "0,000001" }); + }); + + it("formats regular decimals", () => { + expect(formatAmount(99.973)).toStrictEqual({ en: "99.973", es: "99,973" }); + }); + + it("formats integers", () => { + expect(formatAmount(5)).toStrictEqual({ en: "5", es: "5" }); + }); + + it("formats thousands with separator", () => { + expect(formatAmount(1000)).toStrictEqual({ en: "1,000", es: "1.000" }); + }); + }); + + describe("string input", () => { + it("formats regular decimals", () => { + expect(formatAmount("99.973")).toStrictEqual({ en: "99.973", es: "99,973" }); + }); + + it("preserves sub-micro values and trims trailing zeros", () => { + expect(formatAmount("0.00000090")).toStrictEqual({ en: "0.0000009", es: "0,0000009" }); + }); + + it("formats integers", () => { + expect(formatAmount("5")).toStrictEqual({ en: "5", es: "5" }); + }); + }); +}); + +describe("t()", () => { + it("returns en and es translations with no options", () => { + const result = t("Card purchase"); + expect(result).toStrictEqual({ en: "Card purchase", es: "Compra con tarjeta" }); // cspell:ignore Compra tarjeta + }); + + it("interpolates plain string values into both languages", () => { + const result = t("{{localAmount}} at {{merchantName}}. Paid with USDC", { + localAmount: "$1,234.56", + merchantName: "Store", + }); + expect(result).toStrictEqual({ + en: "$1,234.56 at Store. Paid with USDC", + es: "$1,234.56 en Store. Pagado con USDC", // cspell:ignore Pagado + }); + }); + + it("resolves per-language objects in interpolation values", () => { + const result = t("{{localAmount}} at {{merchantName}}. Paid with USDC", { + localAmount: { en: "$1,234.56", es: "$ 1.234,56" }, + merchantName: "Store", + }); + expect(result).toStrictEqual({ + en: "$1,234.56 at Store. Paid with USDC", + es: "$ 1.234,56 en Store. Pagado con USDC", // cspell:ignore Pagado + }); + }); + + it("mixes per-language and plain values", () => { + const result = t("{{localAmount}} at {{merchantName}}. Paid with credit", { + localAmount: { en: "A", es: "B" }, + merchantName: "Store", + }); + expect(result).toStrictEqual({ + en: "A at Store. Paid with credit", + es: "B en Store. Pagado con crédito", // cspell:ignore Pagado crédito + }); + }); +}); diff --git a/server/tsconfig.json b/server/tsconfig.json index 59d90d86b..8d512c4a3 100644 --- a/server/tsconfig.json +++ b/server/tsconfig.json @@ -20,6 +20,7 @@ "api/**/*.ts", "database/**/*.ts", "hooks/**/*.ts", + "i18n/**/*.ts", "middleware/**/*.ts", "utils/**/*.ts", "script/**/*.ts",