diff --git a/.changeset/brave-otters-shake.md b/.changeset/brave-otters-shake.md new file mode 100644 index 0000000000..3c24c4524e --- /dev/null +++ b/.changeset/brave-otters-shake.md @@ -0,0 +1,5 @@ +--- +"@exactly/server": patch +--- + +✨ support directional pix endorsements diff --git a/package.json b/package.json index acc01aad43..126e95ec21 100644 --- a/package.json +++ b/package.json @@ -185,6 +185,7 @@ "comlink": "$comlink", "esbuild@0.18.20": "^0.25.0", "fast-uri@3.1.5": "^3.1.6", + "fflate@0.8.2": "^0.8.3", "form-data@4": "^4.0.6", "glob@11.0.3": "^11.1.0", "js-cookie@3.0.1": "^3.0.7", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 57db9416ef..a07c0c5565 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -108,6 +108,7 @@ overrides: comlink: ^4.4.2 esbuild@0.18.20: ^0.25.0 fast-uri@3.1.5: ^3.1.6 + fflate@0.8.2: ^0.8.3 form-data@4: ^4.0.6 glob@11.0.3: ^11.1.0 js-cookie@3.0.1: ^3.0.7 @@ -9702,8 +9703,8 @@ packages: fetch-nodeshim@0.4.10: resolution: {integrity: sha512-m6I8ALe4L4XpdETy7MJZWs6L1IVMbjs99bwbpIKphxX+0CTns4IKDWJY0LWfr4YsFjfg+z1TjzTMU8lKl8rG0w==} - fflate@0.8.2: - resolution: {integrity: sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==} + fflate@0.8.3: + resolution: {integrity: sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==} figures@3.2.0: resolution: {integrity: sha512-yaduQFRKLXYOGgEn6AZau90j3ggSOyiqXU0F9JZfeXYhNa+Jk4X+s45A2zg5jns87GAFa34BBm2kXw4XpNcbdg==} @@ -22641,7 +22642,7 @@ snapshots: '@vitest/ui@4.1.2(vitest@4.1.2)': dependencies: '@vitest/utils': 4.1.2 - fflate: 0.8.2 + fflate: 0.8.3 flatted: 3.4.2 pathe: 2.0.3 sirv: 3.0.2 @@ -26050,7 +26051,7 @@ snapshots: fetch-nodeshim@0.4.10: {} - fflate@0.8.2: {} + fflate@0.8.3: {} figures@3.2.0: dependencies: diff --git a/server/test/utils/bridge.test.ts b/server/test/utils/bridge.test.ts index c5469ee983..7aa59cab17 100644 --- a/server/test/utils/bridge.test.ts +++ b/server/test/utils/bridge.test.ts @@ -1172,6 +1172,65 @@ describe("bridge utils", () => { }); }); + it("returns ACTIVE with BRL once when bridge grants pix alongside its directional endorsements", async () => { + vi.spyOn(globalThis, "fetch").mockResolvedValueOnce( + fetchResponse({ + ...activeCustomer, + endorsements: [ + endorsement("base", "approved"), + endorsement("pix", "approved"), + endorsement("pix_offramp", "approved"), + endorsement("pix_onramp", "approved"), + ], + }), + ); + + await expect(bridge.getProvider({ credentialId: "cred-1", customerId: "cust-1" })).resolves.toStrictEqual({ + status: "ACTIVE", + onramp: { currencies: [...baseCurrencies, "USD", "BRL"] }, + offramp: { currencies: [...baseCurrencies, "USD", "BRL"] }, + futureRequirement: undefined, + }); + expect(captureException).not.toHaveBeenCalled(); + }); + + it("returns ACTIVE with BRL only on the direction its endorsement grants", async () => { + vi.spyOn(globalThis, "fetch").mockResolvedValueOnce( + fetchResponse({ + ...activeCustomer, + endorsements: [endorsement("base", "approved"), endorsement("pix_offramp", "approved")], + }), + ); + + await expect(bridge.getProvider({ credentialId: "cred-1", customerId: "cust-1" })).resolves.toStrictEqual({ + status: "ACTIVE", + onramp: { currencies: [...baseCurrencies, "USD"] }, + offramp: { currencies: [...baseCurrencies, "USD", "BRL"] }, + futureRequirement: undefined, + }); + expect(captureException).not.toHaveBeenCalled(); + }); + + it("keeps approved rails and captures when bridge returns an unrecognized endorsement", async () => { + vi.spyOn(globalThis, "fetch").mockResolvedValueOnce( + fetchResponse({ + ...activeCustomer, + endorsements: [endorsement("base", "approved"), { ...endorsement("sepa", "approved"), name: "ach_push" }], + }), + ); + + await expect(bridge.getProvider({ credentialId: "cred-1", customerId: "cust-1" })).resolves.toStrictEqual({ + status: "ACTIVE", + onramp: { currencies: [...baseCurrencies, "USD"] }, + offramp: { currencies: [...baseCurrencies, "USD"] }, + futureRequirement: undefined, + }); + expect(captureException).toHaveBeenCalledExactlyOnceWith(new Error("unknown bridge endorsement"), { + contexts: { bridge: { endorsement: "ach_push" } }, + level: "error", + }); + }); + it("returns the four crypto offramp options regardless of endorsements", async () => { vi.spyOn(globalThis, "fetch").mockResolvedValueOnce(fetchResponse({ ...activeCustomer, endorsements: [] })); @@ -2122,7 +2181,9 @@ describe("bridge utils", () => { const createCall = fetchSpy.mock.calls[2]; const body = JSON.parse(createCall?.[1]?.body as string) as { endorsements: string[] }; - expect(body.endorsements).toContain("pix"); + expect(body.endorsements).toContain("pix_offramp"); + expect(body.endorsements).toContain("pix_onramp"); + expect(body.endorsements).not.toContain("pix"); }); it("retries on timeout and succeeds", async () => { @@ -2394,6 +2455,24 @@ describe("bridge utils", () => { ); }); + it("returns BRL deposit details for a customer holding only pix_onramp", async () => { + vi.spyOn(globalThis, "fetch").mockResolvedValueOnce( + fetchResponse({ count: 1, data: [brlVirtualAccount(account)] }), + ); + + const customer = { ...activeCustomer, endorsements: [endorsement("pix_onramp", "approved")] }; + + await expect(bridge.getDepositDetails("BRL", account, customer)).resolves.toHaveLength(1); + }); + + it("rejects BRL deposit details for a customer holding only pix_offramp", async () => { + const customer = { ...activeCustomer, endorsements: [endorsement("pix_offramp", "approved")] }; + + await expect(bridge.getDepositDetails("BRL", account, customer)).rejects.toThrow( + bridge.ErrorCodes.NOT_AVAILABLE_CURRENCY, + ); + }); + it("returns GBP deposit details with Faster Payments info", async () => { vi.spyOn(globalThis, "fetch").mockResolvedValueOnce( fetchResponse({ count: 1, data: [gbpVirtualAccount(account)] }), @@ -3951,6 +4030,33 @@ describe("bridge utils", () => { }); }); + it("posts a BRL Pix key account for a customer holding only pix_offramp", async () => { + const customer = { ...activeCustomer, endorsements: [endorsement("pix_offramp", "approved")] }; + const fetchSpy = vi + .spyOn(globalThis, "fetch") + .mockResolvedValueOnce(fetchResponse(externalAccountResponse("brl"))); + + await bridge.createExternalAccount(customer, { + currency: "BRL", + accountOwnerName: "Joao Silva", + account: { pixKey: "12345678901", documentNumber: "12345678901" }, + }); + + expect(JSON.parse(fetchSpy.mock.calls[0]?.[1]?.body as string)).toMatchObject({ currency: "brl" }); + }); + + it("rejects a BRL Pix key account for a customer holding only pix_onramp", async () => { + const customer = { ...activeCustomer, endorsements: [endorsement("pix_onramp", "approved")] }; + + await expect( + bridge.createExternalAccount(customer, { + currency: "BRL", + accountOwnerName: "Joao Silva", + account: { pixKey: "12345678901", documentNumber: "12345678901" }, + }), + ).rejects.toThrow(bridge.ErrorCodes.NO_ENDORSEMENT); + }); + it("posts a BRL Pix key account", async () => { const customer = { ...activeCustomer, endorsements: [endorsement("pix", "approved")] }; const fetchSpy = vi @@ -4798,7 +4904,7 @@ const enabledPersonaAccount = { }; function endorsement( - name: "base" | "faster_payments" | "pix" | "sepa" | "spei", + name: "base" | "faster_payments" | "pix" | "pix_offramp" | "pix_onramp" | "sepa" | "spei", status: "approved" | "incomplete" | "revoked", ) { return { name, status, requirements: { complete: [], pending: [], missing: null, issues: [] } }; diff --git a/server/utils/ramps/bridge.ts b/server/utils/ramps/bridge.ts index 4e9060c06b..bb26b2f27d 100644 --- a/server/utils/ramps/bridge.ts +++ b/server/utils/ramps/bridge.ts @@ -142,7 +142,7 @@ export default function bridge(key: string, url: string) { ) { const approved = customer.endorsements.some( (endorsement) => - endorsement.status === "approved" && CurrencyByEndorsement[endorsement.name].includes(externalAccount.currency), + endorsement.status === "approved" && fiat(endorsement.name, "offramp").includes(externalAccount.currency), ); if (!approved) throw new Error(ErrorCodes.NO_ENDORSEMENT); return await request( @@ -566,9 +566,10 @@ export default function bridge(key: string, url: string) { } if (customer.status !== "active") throw new Error(ErrorCodes.NOT_ACTIVE_CUSTOMER); - const approvedEndorsements = customer.endorsements.filter((endorsement) => endorsement.status === "approved"); - const availableCurrencies = approvedEndorsements.flatMap((endorsement) => CurrencyByEndorsement[endorsement.name]); - if (!availableCurrencies.includes(currency)) throw new Error(ErrorCodes.NOT_AVAILABLE_CURRENCY); + const available = customer.endorsements.some( + (endorsement) => endorsement.status === "approved" && fiat(endorsement.name, "onramp").includes(currency), + ); + if (!available) throw new Error(ErrorCodes.NOT_AVAILABLE_CURRENCY); const virtualAccounts = await getVirtualAccounts(customer.id); let virtualAccount = virtualAccounts.find( ({ source_deposit_instructions, status }) => @@ -891,10 +892,10 @@ export default function bridge(key: string, url: string) { return { status: "ONBOARDING" as const, onramp: { - currencies: [...currencies.onramp, ...CurrencyByEndorsement.base], + currencies: [...currencies.onramp, ...CurrencyByEndorsement.base.currencies], }, offramp: { - currencies: [...currencies.offramp, ...CurrencyByEndorsement.base], + currencies: [...currencies.offramp, ...CurrencyByEndorsement.base.currencies], }, kycLink: await maybeKYCLink( bridgeUser, @@ -913,8 +914,8 @@ export default function bridge(key: string, url: string) { ) { return { status: "ONBOARDING" as const, - onramp: { currencies: [...currencies.onramp, ...CurrencyByEndorsement.base] }, - offramp: { currencies: [...currencies.offramp, ...CurrencyByEndorsement.base] }, + onramp: { currencies: [...currencies.onramp, ...CurrencyByEndorsement.base.currencies] }, + offramp: { currencies: [...currencies.offramp, ...CurrencyByEndorsement.base.currencies] }, kycLink: await maybeKYCLink( bridgeUser, (() => { @@ -929,7 +930,7 @@ export default function bridge(key: string, url: string) { break; } - const approvedCurrencies = bridgeUser.endorsements.flatMap((endorsement) => { + const approved = bridgeUser.endorsements.flatMap((endorsement) => { if (endorsement.status !== "approved") { // TODO handle pending tasks captureException(new Error("endorsement not approved"), { @@ -954,13 +955,23 @@ export default function bridge(key: string, url: string) { }); } - return CurrencyByEndorsement[endorsement.name]; + return [endorsement.name]; }); return { status: "ACTIVE" as const, - onramp: { currencies: [...currencies.onramp, ...approvedCurrencies] }, - offramp: { currencies: [...currencies.offramp, ...approvedCurrencies] }, + onramp: { + currencies: [ + ...currencies.onramp, + ...new Set(approved.flatMap((endorsement) => fiat(endorsement, "onramp"))), + ], + }, + offramp: { + currencies: [ + ...currencies.offramp, + ...new Set(approved.flatMap((endorsement) => fiat(endorsement, "offramp"))), + ], + }, futureRequirement: await futureRequirement( bridgeUser, (() => { @@ -983,8 +994,8 @@ export default function bridge(key: string, url: string) { if (personaAccount.attributes.fields.bridge_enable?.value !== true) { return { status: "ONBOARDING" as const, - onramp: { currencies: [...currencies.onramp, ...CurrencyByEndorsement.base] }, - offramp: { currencies: [...currencies.offramp, ...CurrencyByEndorsement.base] }, + onramp: { currencies: [...currencies.onramp, ...CurrencyByEndorsement.base.currencies] }, + offramp: { currencies: [...currencies.offramp, ...CurrencyByEndorsement.base.currencies] }, }; } const validDocument = persona.getDocumentForBridge(personaAccount.attributes.fields.documents.value); @@ -1010,7 +1021,7 @@ export default function bridge(key: string, url: string) { "base" as const, "sepa" as const, ...(countryCode === "MX" ? ["spei" as const] : []), - ...(countryCode === "BR" ? ["pix" as const] : []), + ...(countryCode === "BR" ? ["pix_offramp" as const, "pix_onramp" as const] : []), ...(countryCode === "GB" ? ["faster_payments" as const] : []), ]; @@ -1025,16 +1036,10 @@ export default function bridge(key: string, url: string) { })(), ), onramp: { - currencies: [ - ...currencies.onramp, - ...endorsements.flatMap((endorsement) => CurrencyByEndorsement[endorsement]), - ], + currencies: [...currencies.onramp, ...endorsements.flatMap((endorsement) => fiat(endorsement, "onramp"))], }, offramp: { - currencies: [ - ...currencies.offramp, - ...endorsements.flatMap((endorsement) => CurrencyByEndorsement[endorsement]), - ], + currencies: [...currencies.offramp, ...endorsements.flatMap((endorsement) => fiat(endorsement, "offramp"))], }, }; } @@ -1234,7 +1239,7 @@ export default function bridge(key: string, url: string) { const endorsements: (typeof Endorsements)[number][] = ["base", "sepa"]; if (countryCode === "MX") endorsements.push("spei"); - if (countryCode === "BR") endorsements.push("pix"); + if (countryCode === "BR") endorsements.push("pix_offramp", "pix_onramp"); if (countryCode === "GB") endorsements.push("faster_payments"); const identityDocument = await persona.getDocument(validDocument.id_document_id.value); @@ -1446,7 +1451,7 @@ const issues = new Set([ ]); const Denylist = new Set(["ID"]); -const Endorsements = ["base", "faster_payments", "pix", "sepa", "spei"] as const; // cspell:ignore spei, sepa +const Endorsements = ["base", "faster_payments", "pix", "pix_offramp", "pix_onramp", "sepa", "spei"] as const; // cspell:ignore spei, sepa export const BridgeCurrency = ["brl", "eur", "gbp", "mxn", "usd", "usdc", "usdt"] as const; const VirtualAccountStatus = ["activated", "deactivated"] as const; @@ -1475,14 +1480,24 @@ const CurrencyToBridge: Record<(typeof SupportedCurrency)[number], (typeof Bridg USDT: "usdt", } as const; -export const CurrencyByEndorsement: Record<(typeof Endorsements)[number], (typeof FiatCurrency)[number][]> = { - base: ["USD"], - faster_payments: ["GBP"], - pix: ["BRL"], - sepa: ["EUR"], - spei: ["MXN"], +export const CurrencyByEndorsement: Record< + (typeof Endorsements)[number], + { currencies: (typeof FiatCurrency)[number][]; only?: "offramp" | "onramp" } +> = { + base: { currencies: ["USD"] }, + faster_payments: { currencies: ["GBP"] }, + pix: { currencies: ["BRL"] }, + pix_offramp: { currencies: ["BRL"], only: "offramp" }, + pix_onramp: { currencies: ["BRL"], only: "onramp" }, + sepa: { currencies: ["EUR"] }, + spei: { currencies: ["MXN"] }, }; +function fiat(endorsement: (typeof Endorsements)[number], ramp: "offramp" | "onramp") { + const { currencies, only } = CurrencyByEndorsement[endorsement]; + return only && only !== ramp ? [] : currencies; +} + export const CryptoPaymentRail = ["evm", "solana", "stellar", "tron", "base"] as const; export const BridgeChain = ["optimism"] as const; @@ -1690,35 +1705,48 @@ const CustomerResponse = object({ id: string(), email: string(), status: picklist(CustomerStatus), - endorsements: array( - object({ - name: picklist(Endorsements), - status: picklist(EndorsementStatus), - additional_requirements: optional(array(picklist(AdditionalRequirements))), - requirements: object({ - complete: array(string()), - pending: array(string()), - missing: nullish(unknown()), - issues: array(union([string(), unknown()])), - }), - future_requirements: optional( - array( - object({ - effective_date: pipe( - string(), - transform((value) => { - if (!Number.isNaN(new Date(value).getTime())) return value; - captureException(new Error("invalid bridge future requirement effective date"), { - contexts: { bridge: { effectiveDate: value } }, - level: "error", - }); - }), - ), - pending: array(string()), - }), + endorsements: pipe( + array( + object({ + name: string(), + status: picklist(EndorsementStatus), + additional_requirements: optional(array(picklist(AdditionalRequirements))), + requirements: object({ + complete: array(string()), + pending: array(string()), + missing: nullish(unknown()), + issues: array(union([string(), unknown()])), + }), + future_requirements: optional( + array( + object({ + effective_date: pipe( + string(), + transform((value) => { + if (!Number.isNaN(new Date(value).getTime())) return value; + captureException(new Error("invalid bridge future requirement effective date"), { + contexts: { bridge: { effectiveDate: value } }, + level: "error", + }); + }), + ), + pending: array(string()), + }), + ), ), - ), - }), + }), + ), + transform((endorsements) => + endorsements.flatMap((endorsement) => { + const parsed = safeParse(picklist(Endorsements), endorsement.name); + if (parsed.success) return [{ ...endorsement, name: parsed.output }]; + captureException(new Error("unknown bridge endorsement"), { + contexts: { bridge: { endorsement: endorsement.name } }, + level: "error", + }); + return []; + }), + ), ), });