Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/tough-houses-wave.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@exactly/server": patch
---

🌐 translate push notifications to spanish
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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:",
Expand Down
8 changes: 7 additions & 1 deletion pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions pnpm-workspace.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 3 additions & 2 deletions server/api/card.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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(
Expand Down
18 changes: 12 additions & 6 deletions server/hooks/activity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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)) {
Expand Down Expand Up @@ -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));
Expand Down
21 changes: 13 additions & 8 deletions server/hooks/block.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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));
Expand Down Expand Up @@ -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));
Expand Down
23 changes: 13 additions & 10 deletions server/hooks/bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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") {
Expand All @@ -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,
Expand Down
9 changes: 5 additions & 4 deletions server/hooks/manteca.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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);
Expand All @@ -252,8 +253,8 @@ async function handleDepositDetected(data: InferInput<typeof DepositDetectedData
.then(() => {
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) => {
Expand Down
35 changes: 23 additions & 12 deletions server/hooks/panda.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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") {
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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;
}
Expand Down
12 changes: 12 additions & 0 deletions server/i18n/en.json
Original file line number Diff line number Diff line change
@@ -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"
}
28 changes: 28 additions & 0 deletions server/i18n/es.json
Original file line number Diff line number Diff line change
@@ -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"
}
41 changes: 41 additions & 0 deletions server/i18n/index.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>) {
return {
en: instance.t(key, { ...resolve(options, "en"), lng: "en" }),
es: instance.t(key, { ...resolve(options, "es"), lng: "es" }),
};
}

function resolve(options: Record<string, unknown> | 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<string, unknown>)[lng] : v,
]),
);
}
1 change: 1 addition & 0 deletions server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading
Loading