From f5c3566da068f58035fbaeeb46259b13863fc71a Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Tue, 29 Sep 2026 09:59:14 +0200 Subject: [PATCH 01/25] feat(shared): call Alfredpay through Alfred's Penny adapter Alfredpay moved to a new platform and decommissioned the Penny hosts (production answers 503, the sandboxes no longer resolve). Its adapter keeps the Penny paths but authenticates with a partner API key sent as a bearer token, so the api-key/api-secret pair and ALFREDPAY_API_SECRET go away. The base URL now defaults to the adapter for the environment, like the other providers, instead of the dead dev host. --- .github/workflows/contracts.yml | 1 - apps/api/.env.example | 9 ++-- apps/api/src/test-utils/preload.ts | 1 - .../contracts/alfredpay.contract.test.ts | 4 +- packages/shared/src/constants.ts | 8 +++- .../shared/src/helpers/signUnsigned.test.ts | 1 - .../alfredpay/alfredpayApiService.test.ts | 44 ++++++++++++++++++- .../services/alfredpay/alfredpayApiService.ts | 31 ++++--------- 8 files changed, 65 insertions(+), 34 deletions(-) diff --git a/.github/workflows/contracts.yml b/.github/workflows/contracts.yml index 33122f063..d6df20107 100644 --- a/.github/workflows/contracts.yml +++ b/.github/workflows/contracts.yml @@ -45,7 +45,6 @@ jobs: env: ALFREDPAY_BASE_URL: ${{ secrets.CONTRACT_ALFREDPAY_BASE_URL }} ALFREDPAY_API_KEY: ${{ secrets.CONTRACT_ALFREDPAY_API_KEY }} - ALFREDPAY_API_SECRET: ${{ secrets.CONTRACT_ALFREDPAY_API_SECRET }} ALFREDPAY_CONTRACT_CUSTOMER_ID: ${{ secrets.CONTRACT_ALFREDPAY_CUSTOMER_ID }} ALFREDPAY_CONTRACT_FIAT_ACCOUNT_ID: ${{ secrets.CONTRACT_ALFREDPAY_FIAT_ACCOUNT_ID }} ALFREDPAY_CONTRACT_KYC_SUBMISSION_ID: ${{ secrets.CONTRACT_ALFREDPAY_KYC_SUBMISSION_ID }} diff --git a/apps/api/.env.example b/apps/api/.env.example index 2a73c3147..343cb86f0 100644 --- a/apps/api/.env.example +++ b/apps/api/.env.example @@ -155,10 +155,11 @@ RECIPIENT_INVITE_MAX_DISCOUNT_BPS=300 # Only the private key is needed - public key is derived from it WEBHOOK_PRIVATE_KEY=your-webhook-private-key -# AlfredPay -ALFREDPAY_BASE_URL=your-alfredpay-base-url -ALFREDPAY_API_KEY=your-alfredpay-api-key -ALFREDPAY_API_SECRET=your-alfredpay-api-secret +# AlfredPay, through Alfred's Penny adapter. Leave the base URL unset to follow SANDBOX_ENABLED +# (https://api.sandbox.alfredpay.io/adapters/penny, else https://api.alfredpay.io/adapters/penny). +# The API key is an Alfred partner key (alfk_...) from dashboard.alfredpay.io; there is no secret. +ALFREDPAY_BASE_URL= +ALFREDPAY_API_KEY=your-alfred-api-key # Monerium OAuth (the redirect URI must exactly match the dashboard callback registered with Monerium) MONERIUM_CLIENT_ID=your-monerium-auth-code-client-id diff --git a/apps/api/src/test-utils/preload.ts b/apps/api/src/test-utils/preload.ts index 2785b4e77..7cf4e280a 100644 --- a/apps/api/src/test-utils/preload.ts +++ b/apps/api/src/test-utils/preload.ts @@ -29,7 +29,6 @@ if (!process.env.RUN_LIVE_TESTS) { process.env.BRLA_PRIVATE_KEY = ""; process.env.ALFREDPAY_BASE_URL = "http://alfredpay.invalid"; process.env.ALFREDPAY_API_KEY = "test-alfredpay-api-key"; - process.env.ALFREDPAY_API_SECRET = "test-alfredpay-api-secret"; process.env.MONERIUM_API_URL = "http://monerium.invalid"; process.env.MONERIUM_ISSUE_FEE_EUR = "0"; process.env.MONERIUM_WHITELABEL_CLIENT_ID = "test-monerium-whitelabel-client-id"; diff --git a/apps/api/src/tests/contracts/alfredpay.contract.test.ts b/apps/api/src/tests/contracts/alfredpay.contract.test.ts index e6f3962ec..d12c3614d 100644 --- a/apps/api/src/tests/contracts/alfredpay.contract.test.ts +++ b/apps/api/src/tests/contracts/alfredpay.contract.test.ts @@ -58,7 +58,7 @@ import { assertLiveCoverage, runLive } from "../../test-utils/contract-support"; import { FakeAlfredpay } from "../../test-utils/fake-world/fake-anchors"; const RUN_LIVE = !!process.env.RUN_LIVE_TESTS; -const HAS_CREDS = !!(process.env.ALFREDPAY_API_KEY && process.env.ALFREDPAY_API_SECRET); +const HAS_CREDS = !!process.env.ALFREDPAY_API_KEY; const CUSTOMER_ID = process.env.ALFREDPAY_CONTRACT_CUSTOMER_ID; const FIAT_ACCOUNT_ID = process.env.ALFREDPAY_CONTRACT_FIAT_ACCOUNT_ID; const KYC_SUBMISSION_ID = process.env.ALFREDPAY_CONTRACT_KYC_SUBMISSION_ID; @@ -173,7 +173,7 @@ function kybFlowForm(email: string): SubmitKybInformationRequest { } if (RUN_LIVE && !HAS_CREDS) { - console.warn("[contract:live] Alfredpay live half skipped: ALFREDPAY_API_KEY/ALFREDPAY_API_SECRET not set"); + console.warn("[contract:live] Alfredpay live half skipped: ALFREDPAY_API_KEY not set"); } // Unremarkable placeholder wallet, mirroring the squidrouter suite. diff --git a/packages/shared/src/constants.ts b/packages/shared/src/constants.ts index 39979d459..47fce701e 100644 --- a/packages/shared/src/constants.ts +++ b/packages/shared/src/constants.ts @@ -13,9 +13,13 @@ export const BRLA_PRIVATE_KEY = getEnvVar("BRLA_PRIVATE_KEY"); export const ALCHEMY_API_KEY = getEnvVar("ALCHEMY_API_KEY"); -export const ALFREDPAY_BASE_URL = getEnvVar("ALFREDPAY_BASE_URL") || "https://penny-api-restricted-dev.alfredpay.io"; +// Alfred's Penny adapter: the legacy Penny request paths (/api/v1/third-party-service/penny/...) +// served by the new platform. The legacy Penny hosts are decommissioned. +export const ALFREDPAY_BASE_URL = + getEnvVar("ALFREDPAY_BASE_URL") || + (SANDBOX_ENABLED ? "https://api.sandbox.alfredpay.io/adapters/penny" : "https://api.alfredpay.io/adapters/penny"); +// An Alfred partner API key (`alfk_...`); it identifies the company, so no secret or business id is sent. export const ALFREDPAY_API_KEY = getEnvVar("ALFREDPAY_API_KEY"); -export const ALFREDPAY_API_SECRET = getEnvVar("ALFREDPAY_API_SECRET"); export const MYKOBO_BASE_URL = getEnvVar("MYKOBO_BASE_URL") || (SANDBOX_ENABLED ? "https://api-dev.mykobo.app" : "https://api.mykobo.app"); diff --git a/packages/shared/src/helpers/signUnsigned.test.ts b/packages/shared/src/helpers/signUnsigned.test.ts index 0a5250b4a..9b2d07252 100644 --- a/packages/shared/src/helpers/signUnsigned.test.ts +++ b/packages/shared/src/helpers/signUnsigned.test.ts @@ -7,7 +7,6 @@ import type { UnsignedTx } from "../endpoints/ramp.endpoints"; // process.env for the whole test run. Provide the env defaults other test files rely on before // that happens (same pattern as alfredpayApiService.test.ts), hence the dynamic import. process.env.ALFREDPAY_API_KEY ||= "test-key"; -process.env.ALFREDPAY_API_SECRET ||= "test-secret"; const { Networks } = await import("./networks"); const { createEvmClient, groupUnsignedTxsForSigning, signUnsignedTransactions } = await import("./signUnsigned"); diff --git a/packages/shared/src/services/alfredpay/alfredpayApiService.test.ts b/packages/shared/src/services/alfredpay/alfredpayApiService.test.ts index c4890ac59..8bbfaeb84 100644 --- a/packages/shared/src/services/alfredpay/alfredpayApiService.test.ts +++ b/packages/shared/src/services/alfredpay/alfredpayApiService.test.ts @@ -1,7 +1,6 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; process.env.ALFREDPAY_API_KEY ||= "test-key"; -process.env.ALFREDPAY_API_SECRET ||= "test-secret"; const { AlfredpayApiService, toAsciiFileName } = await import("./alfredpayApiService"); const { @@ -98,6 +97,49 @@ describe("uploads send an ASCII multipart filename", () => { }); }); +/** + * Alfred's migration guide authenticates the Penny adapter with the partner API key as a bearer + * token; the `api-key`/`api-secret` pair belonged to the decommissioned Penny hosts. Uploads build + * their own headers, so they are covered separately. + */ +describe("requests authenticate with the Alfred API key as a bearer token", () => { + let sentHeaders: Headers[]; + const realFetch = globalThis.fetch; + const service = AlfredpayApiService.getInstance(); + + beforeEach(() => { + sentHeaders = []; + globalThis.fetch = (async (_url: string, init: RequestInit) => { + sentHeaders.push(new Headers(init.headers)); + return Response.json({ supportedPairs: [] }); + }) as unknown as typeof fetch; + }); + + afterEach(() => { + globalThis.fetch = realFetch; + }); + + function expectBearerOnly(headers: Headers | undefined): void { + expect(headers?.get("authorization")).toBe(`Bearer ${process.env.ALFREDPAY_API_KEY}`); + expect(headers?.has("api-key")).toBe(false); + expect(headers?.has("api-secret")).toBe(false); + } + + test("JSON requests", async () => { + await service.getAllConfigs(); + expectBearerOnly(sentHeaders[0]); + }); + + test("every multipart upload", async () => { + const file = new File([new Uint8Array([1])], "doc.png", { type: "image/png" }); + await service.submitKycFile("cust-1", "sub-1", AlfredpayKycFileType.FRONT, file); + await service.submitKybFiles("cust-1", "sub-1", AlfredpayKybFileType.PROOF_ADDRESS, file); + await service.submitKybRelatedPersonFiles("cust-1", "person-1", AlfredpayKybRelatedPersonFileType.DOC_FRONT, file); + expect(sentHeaders).toHaveLength(3); + for (const headers of sentHeaders) expectBearerOnly(headers); + }); +}); + describe("offramp responses are validated at the service boundary", () => { const realFetch = globalThis.fetch; const service = AlfredpayApiService.getInstance(); diff --git a/packages/shared/src/services/alfredpay/alfredpayApiService.ts b/packages/shared/src/services/alfredpay/alfredpayApiService.ts index 1ca54de3c..7fba06ade 100644 --- a/packages/shared/src/services/alfredpay/alfredpayApiService.ts +++ b/packages/shared/src/services/alfredpay/alfredpayApiService.ts @@ -1,5 +1,5 @@ import Big from "big.js"; -import { ALFREDPAY_API_KEY, ALFREDPAY_API_SECRET, ALFREDPAY_BASE_URL } from "../.."; +import { ALFREDPAY_API_KEY, ALFREDPAY_BASE_URL } from "../.."; import logger from "../../logger"; import { ProviderHttpError } from "../providerHttpError"; import { alfredpayOfframpTransactionSchema, alfredpayQuoteResponseSchema } from "./schemas"; @@ -98,16 +98,13 @@ async function asAsciiNamedUpload(file: Blob): Promise { export class AlfredpayApiService { private static instance: AlfredpayApiService; - private apiKey: string; - - private apiSecret: string; + private authorization: string; private constructor() { - if (!ALFREDPAY_API_KEY || !ALFREDPAY_API_SECRET) { - throw new Error("ALFREDPAY_API_KEY or ALFREDPAY_API_SECRET not defined"); + if (!ALFREDPAY_API_KEY) { + throw new Error("ALFREDPAY_API_KEY not defined"); } - this.apiKey = ALFREDPAY_API_KEY; - this.apiSecret = ALFREDPAY_API_SECRET; + this.authorization = `Bearer ${ALFREDPAY_API_KEY}`; } public static getInstance(): AlfredpayApiService { @@ -129,8 +126,7 @@ export class AlfredpayApiService { ): Promise { const headers = { Accept: "application/json", - "api-key": this.apiKey, - "api-secret": this.apiSecret, + Authorization: this.authorization, "Content-Type": "application/json" }; @@ -372,10 +368,7 @@ export class AlfredpayApiService { const url = `${ALFREDPAY_BASE_URL}/api/v1/third-party-service/penny/customers/${customerId}/kyc/${submissionId}/files`; const response = await fetch(url, { body: formData, - headers: { - "api-key": this.apiKey, - "api-secret": this.apiSecret - }, + headers: { Authorization: this.authorization }, method: "POST" }); @@ -434,10 +427,7 @@ export class AlfredpayApiService { const url = `${ALFREDPAY_BASE_URL}/api/v1/third-party-service/penny/customers/${customerId}/kyb/${submissionId}/files`; const response = await fetch(url, { body: formData, - headers: { - "api-key": this.apiKey, - "api-secret": this.apiSecret - }, + headers: { Authorization: this.authorization }, method: "POST" }); @@ -465,10 +455,7 @@ export class AlfredpayApiService { const url = `${ALFREDPAY_BASE_URL}/api/v1/third-party-service/penny/customers/${customerId}/kyb/${relatedPersonId}/files/relate-person`; const response = await fetch(url, { body: formData, - headers: { - "api-key": this.apiKey, - "api-secret": this.apiSecret - }, + headers: { Authorization: this.authorization }, method: "POST" }); From 5201eca30586a22b64b03f45cf7d1479caaa92f4 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Tue, 29 Sep 2026 09:59:24 +0200 Subject: [PATCH 02/25] fix(api): stop sending a business id in Alfredpay quote metadata Alfred derives the company from the API key, and its migration guide says not to send a business id in the request body. Closing AlfredpayQuoteMetadata to { customerId } makes the compiler reject one if it comes back. --- .../phases/blocks/phases/alfredpay-mint/lifecycle.ts | 2 +- .../phases/blocks/phases/alfredpay-mint/simulation.ts | 2 +- .../phases/blocks/phases/alfredpay-offramp/execution.ts | 2 +- .../phases/blocks/phases/alfredpay-offramp/registration.ts | 2 +- .../phases/blocks/phases/alfredpay-offramp/simulation.ts | 2 +- apps/api/src/test-utils/fake-world/fake-anchors.ts | 2 +- apps/api/src/tests/contracts/alfredpay.contract.test.ts | 6 +++--- .../src/services/alfredpay/alfredpayApiService.test.ts | 2 +- packages/shared/src/services/alfredpay/types.ts | 7 +++++-- 9 files changed, 15 insertions(+), 12 deletions(-) diff --git a/apps/api/src/api/services/phases/blocks/phases/alfredpay-mint/lifecycle.ts b/apps/api/src/api/services/phases/blocks/phases/alfredpay-mint/lifecycle.ts index 1e558f0b5..da96aa777 100644 --- a/apps/api/src/api/services/phases/blocks/phases/alfredpay-mint/lifecycle.ts +++ b/apps/api/src/api/services/phases/blocks/phases/alfredpay-mint/lifecycle.ts @@ -54,7 +54,7 @@ export async function startAlfredpayMint( chain: AlfredpayChain.MATIC, fromAmount: new Big(ctx.quote.inputAmount).toString(), fromCurrency, - metadata: { businessId: "vortex", customerId }, + metadata: { customerId }, paymentMethodType: AlfredpayPaymentMethodType.BANK, toCurrency: ALFREDPAY_ONCHAIN_CURRENCY }); diff --git a/apps/api/src/api/services/phases/blocks/phases/alfredpay-mint/simulation.ts b/apps/api/src/api/services/phases/blocks/phases/alfredpay-mint/simulation.ts index 3598892d2..258baa75f 100644 --- a/apps/api/src/api/services/phases/blocks/phases/alfredpay-mint/simulation.ts +++ b/apps/api/src/api/services/phases/blocks/phases/alfredpay-mint/simulation.ts @@ -43,7 +43,7 @@ export async function simulateAlfredpayMint( chain: AlfredpayChain.MATIC, fromAmount: input.amount.toString(), fromCurrency: input.token as unknown as AlfredpayFiatCurrency, - metadata: { businessId: "vortex", customerId }, + metadata: { customerId }, paymentMethodType: AlfredpayPaymentMethodType.BANK, toCurrency: ALFREDPAY_ONCHAIN_CURRENCY }; diff --git a/apps/api/src/api/services/phases/blocks/phases/alfredpay-offramp/execution.ts b/apps/api/src/api/services/phases/blocks/phases/alfredpay-offramp/execution.ts index e6ff7eac2..05511d935 100644 --- a/apps/api/src/api/services/phases/blocks/phases/alfredpay-offramp/execution.ts +++ b/apps/api/src/api/services/phases/blocks/phases/alfredpay-offramp/execution.ts @@ -667,7 +667,7 @@ export class AlfredpayOfframpTransferExecutor extends BasePhaseHandler { chain: AlfredpayChain.MATIC, fromAmount: new Big(promised.inputAmountDecimal as unknown as string).toString(), fromCurrency: ALFREDPAY_ONCHAIN_CURRENCY, - metadata: { businessId: "vortex", customerId: alfredpayUserId }, + metadata: { customerId: alfredpayUserId }, paymentMethodType: AlfredpayPaymentMethodType.BANK, toCurrency }) diff --git a/apps/api/src/api/services/phases/blocks/phases/alfredpay-offramp/registration.ts b/apps/api/src/api/services/phases/blocks/phases/alfredpay-offramp/registration.ts index dadd82fe6..5d354cff6 100644 --- a/apps/api/src/api/services/phases/blocks/phases/alfredpay-offramp/registration.ts +++ b/apps/api/src/api/services/phases/blocks/phases/alfredpay-offramp/registration.ts @@ -61,7 +61,7 @@ export async function registerDomesticOfframp( chain: AlfredpayChain.MATIC, fromAmount: new Big(ctx.metadata.inputAmountDecimal as unknown as string).toString(), fromCurrency: ALFREDPAY_ONCHAIN_CURRENCY, - metadata: { businessId: "vortex", customerId }, + metadata: { customerId }, paymentMethodType: AlfredpayPaymentMethodType.BANK, toCurrency } satisfies CreateAlfredpayOfframpQuoteRequest); diff --git a/apps/api/src/api/services/phases/blocks/phases/alfredpay-offramp/simulation.ts b/apps/api/src/api/services/phases/blocks/phases/alfredpay-offramp/simulation.ts index 545526600..08b712147 100644 --- a/apps/api/src/api/services/phases/blocks/phases/alfredpay-offramp/simulation.ts +++ b/apps/api/src/api/services/phases/blocks/phases/alfredpay-offramp/simulation.ts @@ -173,7 +173,7 @@ export function simulateAlfredpayOfframp USDC minted on Polygon. @@ -528,7 +528,7 @@ describe.skipIf(!RUN_LIVE || !HAS_CREDS)("Alfredpay external API contract — li const quote = await runLive("alfredpay onramp quote (order)", () => api().createOnrampQuote({ ...onrampQuoteRequest("500"), - metadata: { businessId: "vortex", customerId: CUSTOMER_ID as string } + metadata: { customerId: CUSTOMER_ID as string } }) ); if (!quote) return; @@ -565,7 +565,7 @@ describe.skipIf(!RUN_LIVE || !HAS_CREDS)("Alfredpay external API contract — li chain: AlfredpayChain.MATIC, fromAmount: "30", fromCurrency: AlfredpayOnChainCurrency.USDC, - metadata: { businessId: "vortex", customerId: CUSTOMER_ID as string }, + metadata: { customerId: CUSTOMER_ID as string }, paymentMethodType: AlfredpayPaymentMethodType.BANK, toCurrency: AlfredpayFiatCurrency.MXN }) diff --git a/packages/shared/src/services/alfredpay/alfredpayApiService.test.ts b/packages/shared/src/services/alfredpay/alfredpayApiService.test.ts index 8bbfaeb84..5f42eac83 100644 --- a/packages/shared/src/services/alfredpay/alfredpayApiService.test.ts +++ b/packages/shared/src/services/alfredpay/alfredpayApiService.test.ts @@ -185,7 +185,7 @@ describe("offramp responses are validated at the service boundary", () => { chain: AlfredpayChain.MATIC, fromAmount: "1000", fromCurrency: AlfredpayOnChainCurrency.USDT, - metadata: { businessId: "business-1", customerId: "customer-1" }, + metadata: { customerId: "customer-1" }, paymentMethodType: AlfredpayPaymentMethodType.BANK, toCurrency: AlfredpayFiatCurrency.MXN }) diff --git a/packages/shared/src/services/alfredpay/types.ts b/packages/shared/src/services/alfredpay/types.ts index 294698855..78ba788be 100644 --- a/packages/shared/src/services/alfredpay/types.ts +++ b/packages/shared/src/services/alfredpay/types.ts @@ -138,10 +138,13 @@ export enum AlfredpayPaymentMethodType { BANK = "BANK" } +/** + * Tracking-only quote metadata. No `businessId`: Alfred derives the company from the API key and + * its migration guide says not to send one in the body. Closed on purpose, so an object literal + * carrying it again fails to compile. + */ export interface AlfredpayQuoteMetadata { - businessId: string; customerId: string; - [key: string]: unknown; } interface AlfredpayBaseQuoteRequest { From f0e93c03a330eddc31babdfd2af04bb4ef0837d5 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Tue, 29 Sep 2026 09:59:32 +0200 Subject: [PATCH 03/25] fix(api): keep approved Alfredpay customers on an upstream 404 GET /alfredpayStatus sent any customer back to onboarding when Alfredpay answered 404 for their submission. During the platform migration a lookup can 404 for data Alfred has not moved yet, and the new platform does not serve US, so a status read would have forced approved customers through KYC again. Unapproved customers still reset. --- .../api/controllers/alfredpay.controller.ts | 7 +- ...edpay-status-not-found.integration.test.ts | 86 +++++++++++++++++++ 2 files changed, 91 insertions(+), 2 deletions(-) create mode 100644 apps/api/src/tests/alfredpay-status-not-found.integration.test.ts diff --git a/apps/api/src/api/controllers/alfredpay.controller.ts b/apps/api/src/api/controllers/alfredpay.controller.ts index 39f69e2e4..6b07cd491 100644 --- a/apps/api/src/api/controllers/alfredpay.controller.ts +++ b/apps/api/src/api/controllers/alfredpay.controller.ts @@ -341,9 +341,12 @@ export class AlfredpayController { logger.error("Error refreshing Alfredpay status:", error); // If the upstream API returns 404 (KYC submission not found), the local status is stale. - // Reset to Consulted so the frontend re-triggers the KYC flow. + // Reset to Consulted so the frontend re-triggers the KYC flow. Never for an approved + // customer: a lookup can also 404 for data Alfred has not moved to its new platform (US is + // not served there yet), and a status read must not force an approved customer through KYC. const errorMessage = AlfredpayController.getErrorMessage(error).toLowerCase(); - if (errorMessage.includes("404") || errorMessage.includes("not found")) { + const isNotFound = errorMessage.includes("404") || errorMessage.includes("not found"); + if (isNotFound && alfredPayCustomer.status !== AlfredPayStatus.Success) { logger.info("Resetting stale AlfredPay status to pending due to upstream 404"); await alfredPayCustomer.update({ status: AlfredPayStatus.Consulted, diff --git a/apps/api/src/tests/alfredpay-status-not-found.integration.test.ts b/apps/api/src/tests/alfredpay-status-not-found.integration.test.ts new file mode 100644 index 000000000..1aabae439 --- /dev/null +++ b/apps/api/src/tests/alfredpay-status-not-found.integration.test.ts @@ -0,0 +1,86 @@ +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, mock } from "bun:test"; +import { AlfredPayStatus, AlfredpayApiError, AlfredpayApiService, DomesticCountry, DomesticCustomerType } from "@vortexfi/shared"; +import { createAlfredpayCustomer } from "../api/services/alfredpay/alfredpay-customer.service"; +import ProviderCustomer, { VerificationStatus } from "../models/providerCustomer.model"; +import { resetTestDatabase, setupTestDatabase } from "../test-utils/db"; +import { createTestUser } from "../test-utils/factories"; +import { type FakeSupabaseAuth, installFakeSupabaseAuth, testUserToken } from "../test-utils/fake-world/fake-auth"; +import { startTestApp, type TestApp } from "../test-utils/test-app"; + +// GET /alfredpayStatus treats an upstream 404 for the customer's submission as a stale local +// status and sends the customer back to onboarding. Alfred's platform migration can answer 404 for +// data it has not moved yet, so that reset must never demote an approved customer. + +let api: TestApp; +let fakeAuth: FakeSupabaseAuth; +const realGetInstance = AlfredpayApiService.getInstance; + +beforeAll(async () => { + await setupTestDatabase(); + fakeAuth = installFakeSupabaseAuth(); + api = await startTestApp(); +}); + +afterAll(async () => { + await api.close(); + fakeAuth.restore(); +}); + +beforeEach(async () => { + await resetTestDatabase(); +}); + +afterEach(() => { + AlfredpayApiService.getInstance = realGetInstance; +}); + +function submissionNotFound(): void { + AlfredpayApiService.getInstance = mock( + () => + ({ + getLastKycSubmission: mock(async () => { + throw new AlfredpayApiError({ + endpoint: "/api/v1/third-party-service/penny/customers/kyc/ap-not-found", + method: "GET", + responseBody: '{"errorCode":111404,"errorMessage":"Not found"}', + status: 404 + }); + }) + }) as unknown as AlfredpayApiService + ); +} + +async function statusAfterNotFound(email: string, stored: AlfredPayStatus) { + const user = await createTestUser({ email }); + await createAlfredpayCustomer(user.id, { + alfredPayId: "ap-not-found", + country: DomesticCountry.MX, + status: stored, + type: DomesticCustomerType.INDIVIDUAL + }); + submissionNotFound(); + + const response = await api.request("/v1/alfredpay/alfredpayStatus?country=MX", { + headers: { Authorization: `Bearer ${testUserToken(user.id, email)}` } + }); + expect(response.status).toBe(200); + const body = (await response.json()) as { status: AlfredPayStatus }; + const customer = await ProviderCustomer.findOne({ where: { providerCustomerId: "ap-not-found" } }); + return { customer, reported: body.status }; +} + +describe("GET /alfredpayStatus when Alfredpay answers 404", () => { + it("keeps an approved customer approved", async () => { + const { customer, reported } = await statusAfterNotFound("approved-404@example.com", AlfredPayStatus.Success); + + expect(reported).toBe(AlfredPayStatus.Success); + expect(customer?.status).toBe(VerificationStatus.Approved); + }); + + it("still resets an unapproved customer so onboarding restarts", async () => { + const { customer, reported } = await statusAfterNotFound("in-review-404@example.com", AlfredPayStatus.UserCompleted); + + expect(reported).toBe(AlfredPayStatus.Consulted); + expect(customer?.status).toBe(VerificationStatus.Pending); + }); +}); From cc24eeb2ca2d029a0de2b303de8acfb650a36190 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Tue, 29 Sep 2026 09:59:32 +0200 Subject: [PATCH 04/25] test(api): assert Alfredpay quote amounts stay decimal strings Alfred's native API serializes amounts in minor units. The quote contract's decimal regex accepts both "500" and "50000", so a unit switch in the adapter would pass the live suite unnoticed. A fixed input must now come back unchanged and the output must move the plausible way against it. --- .../tests/contracts/alfredpay.contract.test.ts | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/apps/api/src/tests/contracts/alfredpay.contract.test.ts b/apps/api/src/tests/contracts/alfredpay.contract.test.ts index 362036851..2deb7f731 100644 --- a/apps/api/src/tests/contracts/alfredpay.contract.test.ts +++ b/apps/api/src/tests/contracts/alfredpay.contract.test.ts @@ -387,8 +387,15 @@ describe.skipIf(!RUN_LIVE || !HAS_CREDS)("Alfredpay external API contract — li test( "POST /quotes responses satisfy the quote contract (both directions)", async () => { + // Amounts must stay Penny's decimal strings. Alfred's native API serializes minor units, and + // the decimal regex alone cannot tell 500 MXN ("500") from 500.00 in cents ("50000"): a fixed + // input must come back unchanged, and the output must move the right way against it. const onrampQuote = await runLive("alfredpay createOnrampQuote", () => api().createOnrampQuote(onrampQuoteRequest("500"))); - if (onrampQuote) alfredpayQuoteResponseSchema.parse(onrampQuote); + if (onrampQuote) { + alfredpayQuoteResponseSchema.parse(onrampQuote); + expect(new Big(onrampQuote.fromAmount).eq(500)).toBe(true); + expect(new Big(onrampQuote.toAmount).lt(onrampQuote.fromAmount)).toBe(true); // 500 MXN buys far fewer USDC + } const offrampQuote = await runLive("alfredpay createOfframpQuote", () => api().createOfframpQuote({ @@ -400,7 +407,11 @@ describe.skipIf(!RUN_LIVE || !HAS_CREDS)("Alfredpay external API contract — li toCurrency: AlfredpayFiatCurrency.MXN }) ); - if (offrampQuote) alfredpayQuoteResponseSchema.parse(offrampQuote); + if (offrampQuote) { + alfredpayQuoteResponseSchema.parse(offrampQuote); + expect(new Big(offrampQuote.fromAmount).eq(30)).toBe(true); + expect(new Big(offrampQuote.toAmount).gt(offrampQuote.fromAmount)).toBe(true); // 30 USDC pays out more MXN + } const exactOutputQuote = await runLive("alfredpay createOfframpQuote by output", () => api().createOfframpQuote({ From 023b3305a58c949c57b8e2392be6913f92948137 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Tue, 29 Sep 2026 09:59:32 +0200 Subject: [PATCH 05/25] docs(repo): document the Alfredpay Penny adapter in the security spec --- docs/security-spec/05-integrations/alfredpay.md | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/docs/security-spec/05-integrations/alfredpay.md b/docs/security-spec/05-integrations/alfredpay.md index 27a08402e..56e8a9ab7 100644 --- a/docs/security-spec/05-integrations/alfredpay.md +++ b/docs/security-spec/05-integrations/alfredpay.md @@ -9,6 +9,8 @@ Alfredpay is a fiat payment provider supporting on-ramp and off-ramp operations **Chains involved:** Polygon (Alfredpay-side, USDT / `ALFREDPAY_EVM_TOKEN`), EVM destinations via SquidRouter (Polygon → Base/other) **Customer types:** Individual (KYC) and Business (KYB) — selected via `AlfredpayCustomerType`. The controller maps Alfredpay's KYB status to the platform's `AlfredPayStatus` via `mapKybStatus`; KYC is handled by `mapKycStatus`. Branch in `alfredpay.controller.ts` on `AlfredpayCustomerType.BUSINESS`. +**Provider platform (Penny adapter, 2026-09):** Alfredpay moved to a new platform and serves the Penny API through an adapter that keeps the legacy request paths: `ALFREDPAY_BASE_URL` defaults to `https://api.sandbox.alfredpay.io/adapters/penny` when `SANDBOX_ENABLED`, else `https://api.alfredpay.io/adapters/penny`; the legacy Penny hosts are decommissioned. Requests authenticate with an Alfred partner API key (`alfk_…`) as a bearer token and carry no business id, since the key identifies the company. An unauthenticated route probe (2026-09-29, production and sandbox) found every route `AlfredpayApiService` calls except `POST …/customers/{customerId}/kyc/{submissionId}/retry`, which only the US individual retry path reaches. US is not served on the new platform, so `USD` stays in `DISABLED_FIAT_CURRENCIES` until Alfred brings it online. + **Verification outcome delivery:** Alfredpay exposes no verification webhook — `AlfredpayApiService` carries only request/response methods — so an outcome is only ever learned by polling `getKycStatus`/`getKybStatus`. `refreshAlfredpayCustomerStatus` owns that poll, persists the result, and queues the user's `verification_approved`/`verification_rejected` email; it runs both from the dashboard's status aggregation (TTL-throttled) and from `AlfredpayStatusWorker` (hourly) for users who never return. Alfredpay has no expiry status, so `verification_expired` is never produced for this provider. **Verification collection:** MX and CO individual KYC and company KYB are submitted through the authenticated API flow. Company KYB requires tax ID, incorporation, and address documents plus the authorized representative's ID front and back. Company documents are keyed by `submissionId`; the representative's documents are keyed by an Alfredpay-generated `idRelatedPerson`, which only exists once the company record is created — the client therefore fetches it back via `GET /findKybCustomerAndBusiness` (`getKybBusinessDetails`) between the two upload steps. That endpoint returns *every* business the customer has, so the response carries each business's `submissionId` and the client selects the related persons of the submission it is filing. US individual and company verification use Alfredpay's hosted redirect flow. AR supports individual KYC only; the shared client state machine rejects `country = AR` with `business = true` before making any provider request and does not allow an AR individual flow to toggle to business. @@ -81,6 +83,8 @@ For routed Alfredpay onramps (any non-passthrough output), the final quote outpu 29. **Cross-manager email identity adoption is an accepted risk** — Contact-email uniqueness is scoped to one manager, but Alfredpay identifies customers by email. Two managers may therefore submit the same normalized email, and conflict recovery adopts Alfredpay's existing customer when country and type match without independently proving that the second manager controls that provider identity. This is explicitly accepted as [RISK-019](../RISK-REGISTER.md). Manager isolation, immutable manager-scoped email uniqueness, country/type matching, and local provider-ID uniqueness limit accidental attachment; global/provider-scoped ownership or provider claim proof is required before overlapping manager email namespaces are supported. 30. **The demo Alfredpay stand-in MUST be unreachable outside an opted-in sandbox** — `installDemoProviders` (`api/services/demo/demo-alfredpay.provider.ts`, called once at startup) replaces `AlfredpayApiService.getInstance` with canned in-process KYB responses that always approve. It returns without doing anything unless `DEMO_PROVIDER_ENABLED=true`, and throws when that flag is set with any `DEPLOYMENT_ENV` other than `sandbox`; `config/vars.ts` repeats the check at load time so the process refuses to start rather than serving a mixed configuration. The flag is off by default precisely because a sandbox also serves partner integration testing, which must exercise the real provider. Only the business-KYB surface is faked — individual (KYC) customer creation and every other Alfredpay method fall through to the real client, so an unimplemented path fails visibly instead of returning invented data. The stand-in fabricates provider *status* only; it never writes `provider_customers`, and the demo restore that consumes it is itself sandbox-guarded. See `docs/adr-0004-sandbox-demo-environment.md`. 31. **An Alfredpay SELL order MUST be `CREATED` before Vortex's first provider-bound transfer** — a pre-transfer `FAILED` response terminates the ramp without moving the user's USDT; `ON_CHAIN_DEPOSIT_RECEIVED`, `TRADE_COMPLETED`, or either fiat-transfer state without a confirmed/replayed local transfer indicates an unexplained external side effect and requires reconciliation. A confirmed local transfer journal is replayed before this mutable status check. +32. **Alfredpay requests MUST authenticate with the Alfred partner API key as a bearer token and MUST NOT carry a business id** — `AlfredpayApiService` sends `Authorization: Bearer ` on every JSON request and multipart upload and never the legacy `api-key`/`api-secret` pair. Quote `metadata` carries only the tracking `customerId`: `AlfredpayQuoteMetadata` is closed, so a literal that adds `businessId` fails to compile. +33. **An upstream 404 on a status read MUST NOT demote an approved Alfredpay customer** — `GET /alfredpayStatus` resets a non-approved customer to `Consulted`/`pending` when Alfredpay answers 404 for their submission, so onboarding restarts; an approved customer keeps `approved`, because the platform migration can answer 404 for data Alfred has not moved yet and a status read must not force a fresh KYC. ## Threat Vectors & Mitigations @@ -116,6 +120,9 @@ For routed Alfredpay onramps (any non-passthrough output), the final quote outpu - [x] SquidRouter permit execution validates the permit data before executing. **PASS** — permit data validated via `isSignedTypedDataArray`. - [x] Alfredpay block executors use `RecoverablePhaseError` for transient failures. **PASS** — verified in the block execution modules. - [x] HTTPS enforced for Alfredpay API calls. **PASS** — base URL uses `https://`. +- [x] Every Alfredpay request, multipart uploads included, sends `Authorization: Bearer ` and no `api-key`/`api-secret`. **PASS** — `alfredpayApiService.test.ts`. +- [x] Alfredpay quote requests carry no `businessId`. **PASS** — `AlfredpayQuoteMetadata` admits only `customerId`, enforced by the compiler. +- [x] `/alfredpayStatus` keeps an approved customer on an upstream 404 and still resets a non-approved one. **PASS** — `alfredpay-status-not-found.integration.test.ts`. - [x] No Alfredpay credentials or user payment details in logs. **PASS** — no credential leakage observed in log statements. - [ ] Timeout configured for Alfredpay API calls. **FAIL F-014** — no explicit HTTP client timeout configured; relies on default system timeouts. - [x] `subsidizePreSwap` runs before `squidRouterSwap` on the onramp flow, and `finalSettlementSubsidy` runs before `alfredpayOfframpTransfer` on the offramp flow. **PASS** — flow tests pin both sequences. @@ -150,7 +157,7 @@ Alfredpay identity moved from `alfredpay_customers` (keyed by `user_id`) to `status_external` — Alfredpay's casing is inconsistent (the sandbox KYB status endpoint returns lowercase `pending`), and case-sensitive matching would silently skip status transitions; provider-specific APIs continue mapping them to the existing Alfredpay workflow contract. `CREATED` and pre-submission interactions map to - `started`; missing or stale submissions and unfinalized `PENDING` submissions map to `pending`; + `started`; missing or stale submissions (except for an already approved customer, invariant 33) and unfinalized `PENDING` submissions map to `pending`; `IN_REVIEW`, `COMPLETED`, and `FAILED` map to `in_review`, `approved`, and `rejected` respectively. - All controller lookups go through `findAlfredpayCustomer(userId, country[, type])`, which From e73647c9eb6ac4a0afd2800364d461cd126cc97e Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Wed, 30 Sep 2026 10:26:59 +0200 Subject: [PATCH 06/25] fix(api): keep configured Alfredpay limits where Alfred sets none The Penny adapter serves null minQuantity/maxQuantity on most pairs, meaning no limit on that side. The limits indexer passed null to Big, which threw and aborted every refresh, so provider bounds such as the ARS onramp minimum were never applied. A null bound now keeps our configured bound instead of reading as unlimited. --- .../alfredpay-limits.service.test.ts | 34 +++++++++++++++++++ .../alfredpay/alfredpay-limits.service.ts | 16 ++++----- .../src/services/alfredpay/schemas.test.ts | 10 ++++++ .../shared/src/services/alfredpay/schemas.ts | 7 ++-- .../shared/src/services/alfredpay/types.ts | 5 +-- 5 files changed, 59 insertions(+), 13 deletions(-) diff --git a/apps/api/src/api/services/alfredpay/alfredpay-limits.service.test.ts b/apps/api/src/api/services/alfredpay/alfredpay-limits.service.test.ts index 1108bdd86..a19936480 100644 --- a/apps/api/src/api/services/alfredpay/alfredpay-limits.service.test.ts +++ b/apps/api/src/api/services/alfredpay/alfredpay-limits.service.test.ts @@ -5,6 +5,7 @@ import { DomesticCustomerType, FiatToken, type GetAllConfigsResponse, + getAnyFiatTokenDetails, RampDirection } from "@vortexfi/shared"; import { AlfredpayLimitsService } from "./alfredpay-limits.service"; @@ -88,4 +89,37 @@ describe("AlfredpayLimitsService.refresh", () => { minRaw: "100000" }); }); + + /** + * The Penny adapter serves null quantities on most pairs (2026-09-30). `new Big(null)` threw and + * aborted the whole refresh, so no provider bound was ever applied. Both rows share one + * response: a throw on the null row would also lose the ARS minimum. + */ + test("keeps the configured bound where Alfred sets no limit", async () => { + AlfredpayApiService.getInstance = () => + ({ + getAllConfigs: async () => ({ + supportedPairs: [ + pair({ fromCurrency: "ARS", maxQuantity: null, minQuantity: "1234.56", toCurrency: "USDT" }), + pair({ decimals: "6", fromCurrency: "USDC", maxQuantity: null, minQuantity: null, toCurrency: "MXN" }) + ] + }) + }) as unknown as AlfredpayApiService; + + const service = new (AlfredpayLimitsService as unknown as { new (): AlfredpayLimitsService })(); + await (service as unknown as { refresh(): Promise }).refresh(); + + const configured = (fiat: FiatToken) => { + const limits = getAnyFiatTokenDetails(fiat).alfredpayLimits; + if (!limits) throw new Error(`no configured Alfredpay limits for ${fiat}`); + return limits; + }; + expect(service.getLimits(FiatToken.ARS, "USDT", DomesticCustomerType.INDIVIDUAL, RampDirection.BUY)).toEqual({ + maxRaw: configured(FiatToken.ARS).onramp.USDT[DomesticCustomerType.INDIVIDUAL].maxRaw, + minRaw: "123456" + }); + expect(service.getLimits(FiatToken.MXN, "USDC", DomesticCustomerType.BUSINESS, RampDirection.SELL)).toEqual( + configured(FiatToken.MXN).offramp.USDC[DomesticCustomerType.BUSINESS] + ); + }); }); diff --git a/apps/api/src/api/services/alfredpay/alfredpay-limits.service.ts b/apps/api/src/api/services/alfredpay/alfredpay-limits.service.ts index 746e50121..a88770130 100644 --- a/apps/api/src/api/services/alfredpay/alfredpay-limits.service.ts +++ b/apps/api/src/api/services/alfredpay/alfredpay-limits.service.ts @@ -131,19 +131,19 @@ export class AlfredpayLimitsService { if (!axes) return; const { direction, fiat, stablecoin } = axes; - const limits: RawAmountLimits = { - maxRaw: toRaw(pair.maxQuantity, decimals), - minRaw: toRaw(pair.minQuantity, decimals) - }; - const customers: DomesticCustomerType[] = pair.typeCustomer ? [pair.typeCustomer] : CUSTOMER_TYPES; const isWildcard = !pair.typeCustomer; for (const customer of customers) { const key = cacheKey(direction, fiat, stablecoin, customer); // Specific customer rows take precedence over the wildcard (null) row, regardless of response order. - if (!isWildcard || !target.has(key)) { - target.set(key, limits); - } + if (isWildcard && target.has(key)) continue; + // A null bound means Alfred sets no limit on that side: keep our configured bound there + // rather than treating it as unlimited. + const configured = this.fallback(fiat, stablecoin, customer, direction); + target.set(key, { + maxRaw: pair.maxQuantity === null ? configured.maxRaw : toRaw(pair.maxQuantity, decimals), + minRaw: pair.minQuantity === null ? configured.minRaw : toRaw(pair.minQuantity, decimals) + }); } } diff --git a/packages/shared/src/services/alfredpay/schemas.test.ts b/packages/shared/src/services/alfredpay/schemas.test.ts index 1686729e6..3265b6faf 100644 --- a/packages/shared/src/services/alfredpay/schemas.test.ts +++ b/packages/shared/src/services/alfredpay/schemas.test.ts @@ -67,6 +67,16 @@ describe("alfredpayConfigsResponseSchema", () => { expect(() => alfredpayConfigsResponseSchema.parse(body)).not.toThrow(); }); + test("accepts the null limits the Penny adapter serves (no limit on that side)", () => { + const body = { + supportedPairs: [ + { decimals: "2", fromCurrency: "ARS", maxQuantity: null, minQuantity: "1000.00", toCurrency: "USDC", typeCustomer: null }, + { decimals: "6", fromCurrency: "USDC", maxQuantity: null, minQuantity: null, toCurrency: "MXN", typeCustomer: null } + ] + }; + expect(() => alfredpayConfigsResponseSchema.parse(body)).not.toThrow(); + }); + test("rejects a pair with a missing consumed field (minQuantity)", () => { const body = { supportedPairs: [{ decimals: "2", fromCurrency: "MXN", maxQuantity: "100000", toCurrency: "USDC", typeCustomer: null }] diff --git a/packages/shared/src/services/alfredpay/schemas.ts b/packages/shared/src/services/alfredpay/schemas.ts index e32e5e64f..94ecb4036 100644 --- a/packages/shared/src/services/alfredpay/schemas.ts +++ b/packages/shared/src/services/alfredpay/schemas.ts @@ -83,13 +83,14 @@ const parseableTimestamp = z.string().refine(value => !Number.isNaN(Date.parse(v * One entry of the GET …/allConfigs `supportedPairs` array. The listing contains junk * rows (observed live, 2026-07-14): `decimals` may be null or "", `fromCurrency` may be * null. The limits indexer skips rows without a digit-string `decimals`, so the per-row - * contract is correspondingly loose. + * contract is correspondingly loose. Since the Penny adapter (observed 2026-09-30) most pairs + * carry null `minQuantity`/`maxQuantity`, meaning Alfred sets no limit on that side. */ export const alfredpayConfigPairSchema = z.looseObject({ decimals: z.string().regex(DIGITS_OR_EMPTY).nullable(), fromCurrency: z.string().min(1).nullable(), - maxQuantity: z.string().regex(DECIMAL_STRING), - minQuantity: z.string().regex(DECIMAL_STRING), + maxQuantity: z.string().regex(DECIMAL_STRING).nullable(), + minQuantity: z.string().regex(DECIMAL_STRING).nullable(), toCurrency: z.string().min(1), typeCustomer: z.enum(DomesticCustomerType).nullable() }) satisfies z.ZodType; diff --git a/packages/shared/src/services/alfredpay/types.ts b/packages/shared/src/services/alfredpay/types.ts index 78ba788be..d24a361ec 100644 --- a/packages/shared/src/services/alfredpay/types.ts +++ b/packages/shared/src/services/alfredpay/types.ts @@ -380,8 +380,9 @@ export interface AlfredpayConfigPair { fromCurrency: string | null; toCurrency: string; businessId: string | null; - maxQuantity: string; - minQuantity: string; + /** null: Alfred sets no limit on that side (most pairs on the Penny adapter, 2026-09-30). */ + maxQuantity: string | null; + minQuantity: string | null; decimals: string | null; typeCustomer: DomesticCustomerType | null; createdAt: string; From 8a9f393019bb98714cb70d85307dc1a808bc8305 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Wed, 30 Sep 2026 11:21:50 +0200 Subject: [PATCH 07/25] fix(shared): accept the adapter's flat onramp order response Alfred's Penny adapter answers POST .../onramp with the order fields flat and fiatPaymentInstructions beside them, where Penny nested the order under `transaction`. The mint lifecycle reads order.transaction.transactionId, so every onramp order would have failed right after creation. The client now returns the nested shape for both, in case Alfred restores Penny's format. --- .../alfredpay/alfredpayApiService.test.ts | 42 +++++++++++++++++++ .../services/alfredpay/alfredpayApiService.ts | 11 ++++- 2 files changed, 52 insertions(+), 1 deletion(-) diff --git a/packages/shared/src/services/alfredpay/alfredpayApiService.test.ts b/packages/shared/src/services/alfredpay/alfredpayApiService.test.ts index 5f42eac83..dbe520c60 100644 --- a/packages/shared/src/services/alfredpay/alfredpayApiService.test.ts +++ b/packages/shared/src/services/alfredpay/alfredpayApiService.test.ts @@ -140,6 +140,48 @@ describe("requests authenticate with the Alfred API key as a bearer token", () = }); }); +/** + * Alfred's Penny adapter answers POST …/onramp with the order flat and the payment instructions + * beside it, where Penny nested the order under `transaction`. The mint lifecycle reads + * `order.transaction.transactionId`, so both shapes must come back nested. + */ +describe("createOnramp returns the order nested under transaction", () => { + const realFetch = globalThis.fetch; + const instructions = { clabe: "646180157000000004", paymentType: "SPEI" }; + const request = { + amount: "500", + chain: AlfredpayChain.MATIC, + customerId: "customer-1", + depositAddress: "0x5afe00000000000000000000000000000000d0e5", + fromCurrency: AlfredpayFiatCurrency.MXN, + paymentMethodType: AlfredpayPaymentMethodType.BANK, + quoteId: "quote-1", + toCurrency: AlfredpayOnChainCurrency.USDT + }; + + afterEach(() => { + globalThis.fetch = realFetch; + }); + + test("the adapter's flat order", async () => { + globalThis.fetch = (async () => + Response.json({ fiatPaymentInstructions: instructions, status: "CREATED", transactionId: "tx-1" })) as unknown as typeof fetch; + + const order = await AlfredpayApiService.getInstance().createOnramp(request); + expect(order.transaction.transactionId).toBe("tx-1"); + expect(order.fiatPaymentInstructions).toEqual(instructions); + }); + + test("Penny's nested order", async () => { + globalThis.fetch = (async () => + Response.json({ fiatPaymentInstructions: instructions, transaction: { transactionId: "tx-2" } })) as unknown as typeof fetch; + + const order = await AlfredpayApiService.getInstance().createOnramp(request); + expect(order.transaction.transactionId).toBe("tx-2"); + expect(order.fiatPaymentInstructions).toEqual(instructions); + }); +}); + describe("offramp responses are validated at the service boundary", () => { const realFetch = globalThis.fetch; const service = AlfredpayApiService.getInstance(); diff --git a/packages/shared/src/services/alfredpay/alfredpayApiService.ts b/packages/shared/src/services/alfredpay/alfredpayApiService.ts index 7fba06ade..87973e0a8 100644 --- a/packages/shared/src/services/alfredpay/alfredpayApiService.ts +++ b/packages/shared/src/services/alfredpay/alfredpayApiService.ts @@ -297,7 +297,16 @@ export class AlfredpayApiService { public async createOnramp(request: CreateAlfredpayOnrampRequest): Promise { const path = "/api/v1/third-party-service/penny/onramp"; - return (await this.executeRequest(path, "POST", request)) as CreateAlfredpayOnrampResponse; + const response = await this.executeRequest( + path, + "POST", + request + ); + // Penny nested the order under `transaction`; Alfred's adapter returns it flat, with the payment + // instructions alongside (sandbox, 2026-09-30). ponytail: accepts both until Alfred says which stays. + if (response && "transaction" in response) return response; + const { fiatPaymentInstructions, ...transaction } = response as GetAlfredpayOnrampTransactionResponse; + return { fiatPaymentInstructions, transaction }; } public async getOnrampTransaction(transactionId: string): Promise { From d32cf56827bfa45790c39090e1da647f888024ec Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Wed, 30 Sep 2026 11:21:50 +0200 Subject: [PATCH 08/25] fix(kyc): require the CURP and CUIT the Alfred adapter validates The new platform accepts only a CURP with a valid check digit as the Mexican dni and rejects Argentine individuals without a CUIT, both with a bare 110002 Invalid field(s). Our forms invited an INE number and marked CUIT optional, so those users would fail at submission. Validating both in the shared schema turns that into a field error. --- .../e2e/onboarding-alfredpay-mxn.spec.ts | 3 +- .../onboarding/alfredpay/KycFormScreen.tsx | 4 +- .../components/Alfredpay/MxnKycFormScreen.tsx | 2 +- packages/kyc/src/alfredpay/schemas.test.ts | 20 ++++++- packages/kyc/src/alfredpay/schemas.ts | 56 +++++++++++-------- 5 files changed, 54 insertions(+), 31 deletions(-) diff --git a/apps/dashboard/e2e/onboarding-alfredpay-mxn.spec.ts b/apps/dashboard/e2e/onboarding-alfredpay-mxn.spec.ts index 28e5ab88e..8887c39b1 100644 --- a/apps/dashboard/e2e/onboarding-alfredpay-mxn.spec.ts +++ b/apps/dashboard/e2e/onboarding-alfredpay-mxn.spec.ts @@ -35,7 +35,8 @@ async function driveToInReview(page: Page): Promise { await page.locator('input[name="firstName"]').fill("Maria"); await page.locator('input[name="lastName"]').fill("Gomez"); await page.locator('input[name="dateOfBirth"]').fill("1990-05-20"); - await page.locator('input[name="dni"]').fill("GOMM900520MDFXYZ01"); + // Must be a CURP with a valid check digit: the form now rejects anything Alfred would. + await page.locator('input[name="dni"]').fill("GOXM900520MDFMXR05"); await page.locator('input[name="address"]').fill("Av Reforma 100"); await page.locator('input[name="city"]').fill("Ciudad de Mexico"); await page.locator('input[name="state"]').fill("CDMX"); diff --git a/apps/dashboard/src/components/onboarding/alfredpay/KycFormScreen.tsx b/apps/dashboard/src/components/onboarding/alfredpay/KycFormScreen.tsx index 3c641457d..bbde8ed8b 100644 --- a/apps/dashboard/src/components/onboarding/alfredpay/KycFormScreen.tsx +++ b/apps/dashboard/src/components/onboarding/alfredpay/KycFormScreen.tsx @@ -165,7 +165,7 @@ function MxKycForm({ onSubmit, onCancel, userEmail }: Omit - + @@ -279,7 +279,7 @@ function ArKycForm({ onSubmit, onCancel, userEmail }: Omit - + = { name: "email", type: "text" }, - { labelKey: "components.mxnKycForm.dni", name: "dni", placeholder: "CURP / INE number", type: "text" }, + { labelKey: "components.mxnKycForm.dni", name: "dni", placeholder: "CURP", type: "text" }, { autoComplete: "street-address", labelKey: "components.mxnKycForm.address", diff --git a/packages/kyc/src/alfredpay/schemas.test.ts b/packages/kyc/src/alfredpay/schemas.test.ts index c0af11417..8aa7a7d97 100644 --- a/packages/kyc/src/alfredpay/schemas.test.ts +++ b/packages/kyc/src/alfredpay/schemas.test.ts @@ -16,7 +16,7 @@ const mxn = { address: "Av. Reforma 1", city: "CDMX", dateOfBirth: "1990-05-04", - dni: "OEAF771012HMCRGR09", + dni: "OEAF771012HMCRGR08", email: "frida@example.com", firstName: "Frida", lastName: "Kahlo", @@ -38,6 +38,7 @@ const col = { }; const ar = { + cuit: "20123456786", address: "Av. Corrientes 1", city: "Buenos Aires", countryCode: "AR" as const, @@ -63,6 +64,19 @@ describe("mxnKycSchema", () => { expect(result.success).toBe(false); }); + it("accepts only a CURP with a valid check digit as dni", () => { + // An INE number, and the widely published example CURP whose check digit is wrong: Alfred rejects both. + for (const dni of ["1234567890123", "OEAF771012HMCRGR09"]) { + const result = mxnKycSchema.safeParse({ ...mxn, dni }); + expect(result.success).toBe(false); + expect(result.error?.issues[0]?.path).toEqual(["dni"]); + } + }); + + it("normalizes a lowercase CURP before checking it", () => { + expect(mxnKycSchema.parse({ ...mxn, dni: " oeaf771012hmcrgr08 " }).dni).toBe("OEAF771012HMCRGR08"); + }); + it("rejects a malformed email", () => { expect(mxnKycSchema.safeParse({ ...mxn, email: "frida@" }).success).toBe(false); }); @@ -95,8 +109,8 @@ describe("arKycSchema", () => { expect(arKycSchema.safeParse(ar).success).toBe(true); }); - it("treats CUIT as optional but requires exactly 11 digits when present", () => { - expect(arKycSchema.safeParse({ ...ar, cuit: "" }).success).toBe(true); + it("requires an 11-digit CUIT", () => { + expect(arKycSchema.safeParse({ ...ar, cuit: "" }).success).toBe(false); expect(arKycSchema.safeParse({ ...ar, cuit: "20123456789" }).success).toBe(true); const result = arKycSchema.safeParse({ ...ar, cuit: "2012345678" }); diff --git a/packages/kyc/src/alfredpay/schemas.ts b/packages/kyc/src/alfredpay/schemas.ts index aebcca193..56e6b40d0 100644 --- a/packages/kyc/src/alfredpay/schemas.ts +++ b/packages/kyc/src/alfredpay/schemas.ts @@ -6,11 +6,24 @@ import type { KybFormData, KybQuestionnaireData } from "./types"; export const KYC_FILE_ACCEPTED_TYPES = ["image/jpeg", "image/png", "application/pdf"]; export const KYC_FILE_MAX_BYTES = 5 * 1024 * 1024; +const CURP_ALPHABET = "0123456789ABCDEFGHIJKLMNÑOPQRSTUVWXYZ"; + +/** + * Alfred accepts only a CURP as the Mexican `dni` and verifies its check digit: an INE number or a + * CURP with a wrong last digit fails the submission with `110002 Invalid field(s): dni` (sandbox, + * 2026-09-30). Checking it here turns that into a field error the user can fix. + */ +function isValidCurp(value: string): boolean { + if (!/^[A-Z]{4}\d{6}[HMX][A-Z]{5}[0-9A-Z]\d$/.test(value)) return false; + const sum = [...value.slice(0, 17)].reduce((total, char, index) => total + CURP_ALPHABET.indexOf(char) * (18 - index), 0); + return (10 - (sum % 10)) % 10 === Number(value[17]); +} + export const mxnKycSchema = z.object({ address: z.string().min(1), city: z.string().min(1), dateOfBirth: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, "Use YYYY-MM-DD format"), - dni: z.string().min(1), + dni: z.string().trim().toUpperCase().refine(isValidCurp, "Enter your 18-character CURP"), email: z.string().email(), firstName: z.string().min(1), lastName: z.string().min(1), @@ -37,29 +50,24 @@ export const colKycSchema = z } }); -export const arKycSchema = z - .object({ - address: z.string().min(1), - city: z.string().min(1), - countryCode: z.literal("AR"), - cuit: z.string().optional(), - dateOfBirth: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, "Use YYYY-MM-DD format"), - dni: z.string().min(1), - email: z.string().email(), - firstName: z.string().min(1), - lastName: z.string().min(1), - nationalities: z.array(z.string().regex(/^[A-Z]{2}$/)).optional(), - pep: z.boolean(), - phoneNumber: z.string().regex(/^\+54\d{7,}$/, "Use Argentina format (+54...)"), - state: z.string().min(1), - typeDocumentAr: z.nativeEnum(AlfredpayArgentinaDocumentType), - zipCode: z.string().min(1) - }) - .superRefine((data, ctx) => { - if (data.cuit && !/^\d{11}$/.test(data.cuit)) { - ctx.addIssue({ code: z.ZodIssueCode.custom, message: "CUIT must be exactly 11 digits", path: ["cuit"] }); - } - }); +export const arKycSchema = z.object({ + address: z.string().min(1), + city: z.string().min(1), + countryCode: z.literal("AR"), + // Alfred requires it for every Argentine individual (`110002 Invalid field(s): cuit` without it). + cuit: z.string().regex(/^\d{11}$/, "CUIT must be exactly 11 digits"), + dateOfBirth: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, "Use YYYY-MM-DD format"), + dni: z.string().min(1), + email: z.string().email(), + firstName: z.string().min(1), + lastName: z.string().min(1), + nationalities: z.array(z.string().regex(/^[A-Z]{2}$/)).optional(), + pep: z.boolean(), + phoneNumber: z.string().regex(/^\+54\d{7,}$/, "Use Argentina format (+54...)"), + state: z.string().min(1), + typeDocumentAr: z.nativeEnum(AlfredpayArgentinaDocumentType), + zipCode: z.string().min(1) +}); export const kybFormSchema = z.object({ address: z.string().min(1), From 41696fb8f25ec84432ef28b547947fb292773172 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Wed, 30 Sep 2026 11:21:50 +0200 Subject: [PATCH 09/25] test(api): re-provision the Alfredpay contract fixtures on the adapter The pinned customers belonged to the decommissioned Penny sandbox. The new AR/CO/MX customers were provisioned on api.sandbox.alfredpay.io. The KYC flow now uses a valid CURP, and every placeholder upload gets distinct bytes, because Alfred rejects a document identical to one already on the submission. --- .../contracts/alfredpay.contract.test.ts | 40 ++++++++++++++----- 1 file changed, 30 insertions(+), 10 deletions(-) diff --git a/apps/api/src/tests/contracts/alfredpay.contract.test.ts b/apps/api/src/tests/contracts/alfredpay.contract.test.ts index 2deb7f731..55ed528f4 100644 --- a/apps/api/src/tests/contracts/alfredpay.contract.test.ts +++ b/apps/api/src/tests/contracts/alfredpay.contract.test.ts @@ -26,6 +26,7 @@ * RUN_LIVE_TESTS=1 ALFREDPAY_CONTRACT_RUN_KYB_FLOW=1 bun test alfredpay.contract */ import { describe, expect, test } from "bun:test"; +import { deflateSync } from "node:zlib"; import Big from "big.js"; import { AlfredpayApiService, @@ -64,11 +65,12 @@ const FIAT_ACCOUNT_ID = process.env.ALFREDPAY_CONTRACT_FIAT_ACCOUNT_ID; const KYC_SUBMISSION_ID = process.env.ALFREDPAY_CONTRACT_KYC_SUBMISSION_ID; const RUN_KYC_FLOW = !!process.env.ALFREDPAY_CONTRACT_RUN_KYC_FLOW; const RUN_KYB_FLOW = !!process.env.ALFREDPAY_CONTRACT_RUN_KYB_FLOW; -// Completed Argentina sandbox customer reserved for the create/list/delete account lifecycle. -const AR_COMPLETED_CUSTOMER_ID = "cd0a7a0d-1b2d-4894-bbb2-f05fe3b5c7df"; +// KYC-completed customers on the Penny adapter sandbox (api.sandbox.alfredpay.io, provisioned +// 2026-09-30), reserved for the create/list/delete account lifecycle. +const AR_COMPLETED_CUSTOMER_ID = "3094f6e7-0f71-4af0-8f28-a74230fcdd1e"; const AR_CONTRACT_ACCOUNT_NUMBER = "0720369388000033954918"; -const CO_COMPLETED_CUSTOMER_ID = "2be2683f-9594-4b8f-9578-69212b6240fd"; -const MX_COMPLETED_CUSTOMER_ID = "230ee85f-5f2d-4cbf-af7c-afa46591d9ef"; +const CO_COMPLETED_CUSTOMER_ID = "d2db9f83-3852-4d67-b234-82b032218c74"; +const MX_COMPLETED_CUSTOMER_ID = "e6b69e2c-04c2-4f86-ba8a-3b6a689ef479"; interface FiatAccountLifecycleCase { accountFields: AlfredpayFiatAccountFields; @@ -119,13 +121,30 @@ const FIAT_ACCOUNT_LIFECYCLE_CASES: FiatAccountLifecycleCase[] = [ // if Alfredpay ever clears the sandbox. const KYB_CUSTOMER_ID = "5f4a1e58-6b74-454c-bc89-defb8df593be"; -// 1x1 transparent PNG: the uploads only need a well-formed image of an accepted mime type. -const BLANK_PNG_BASE64 = - "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg=="; +let blankPngShade = 0; +/** + * A well-formed 1x1 RGBA PNG whose pixel differs on every call. Alfred rejects an upload whose + * bytes match a document already on the submission (`110002 Invalid field(s): fileBody`, observed + * 2026-09-30), so identical placeholders for a front and back side would fail the second upload. + */ function blankPng(name = "blank.png"): File { - const bytes = Uint8Array.from(atob(BLANK_PNG_BASE64), character => character.charCodeAt(0)); - return new File([bytes], name, { type: "image/png" }); + const chunk = (type: string, data: Uint8Array) => { + const body = Buffer.concat([Buffer.from(type, "ascii"), data]); + const frame = Buffer.alloc(8 + data.length + 4); + frame.writeUInt32BE(data.length, 0); + body.copy(frame, 4); + frame.writeUInt32BE(Bun.hash.crc32(body) >>> 0, 8 + data.length); + return frame; + }; + const shade = blankPngShade++ % 256; + const png = Buffer.concat([ + Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]), + chunk("IHDR", Buffer.from([0, 0, 0, 1, 0, 0, 0, 1, 8, 6, 0, 0, 0])), + chunk("IDAT", deflateSync(Buffer.from([0, shade, shade, shade, 255]))), + chunk("IEND", Buffer.alloc(0)) + ]); + return new File([png], name, { type: "image/png" }); } /** @@ -658,7 +677,8 @@ describe.skipIf(!RUN_LIVE || !HAS_CREDS || !RUN_KYC_FLOW)("Alfredpay individual city: "Ciudad de Mexico", country: "MX", dateOfBirth: "1990-05-20", - dni: "GOMM900520MDFXYZ01", + // Alfred validates `dni` as a CURP, check digit included (GET /v1/kyc/requirements/MEX). + dni: "GOXM900520MDFMXR05", email, firstName: "Maria", lastName: "Gomez", From 588742a8fd26dee6ebb71ffc27cf5e90bcc8d3a9 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Wed, 30 Sep 2026 16:54:44 +0200 Subject: [PATCH 10/25] fix(api): retry a failed provider order read before the offramp transfer The pre-transfer read in ensureLiveProviderOrder had no catch, so a 404, 401, 5xx or timeout became an unrecoverable phase error and the ramp failed with the user's deposit and the subsidy on the ephemeral. The adapter switch makes such reads routine (orders it cannot find, a wrong key during rollout). Nothing has left the ephemeral at that point, so the read is now retried like the other provider reads. --- .../phases/alfredpay-offramp/execution.ts | 12 ++++++-- .../src/test-utils/fake-world/fake-anchors.ts | 5 ++++ .../corridors/mxn-offramp.scenario.test.ts | 30 +++++++++++++++++++ 3 files changed, 44 insertions(+), 3 deletions(-) diff --git a/apps/api/src/api/services/phases/blocks/phases/alfredpay-offramp/execution.ts b/apps/api/src/api/services/phases/blocks/phases/alfredpay-offramp/execution.ts index 05511d935..766c4f69a 100644 --- a/apps/api/src/api/services/phases/blocks/phases/alfredpay-offramp/execution.ts +++ b/apps/api/src/api/services/phases/blocks/phases/alfredpay-offramp/execution.ts @@ -590,10 +590,16 @@ export class AlfredpayOfframpTransferExecutor extends BasePhaseHandler { "persisted replacement order could not be replayed; pausing before transfer" )); } else { - currentTx = await abortableCall(signal, () => alfredpayApiService.getOfframpTransaction(currentTransactionId)); - if (!currentTx) { + try { + currentTx = await abortableCall(signal, () => alfredpayApiService.getOfframpTransaction(currentTransactionId)); + } catch (error) { + throwIfAborted(signal); + if (error instanceof PhaseError) throw error; + // Nothing has left the ephemeral yet, so a failed read (404 for an order the provider cannot + // find, 401, 5xx, timeout, unexpected shape) is safe to retry. Failing the ramp here would + // strand the user's deposit and the subsidy on the ephemeral. throw this.createRecoverableError( - `AlfredpayOfframpTransferExecutor: Transaction ${currentTransactionId} not found in Alfredpay.` + `AlfredpayOfframpTransferExecutor: could not read provider order ${currentTransactionId}: ${error instanceof Error ? error.message : String(error)}` ); } if (currentTx.transactionId !== currentTransactionId) { diff --git a/apps/api/src/test-utils/fake-world/fake-anchors.ts b/apps/api/src/test-utils/fake-world/fake-anchors.ts index a5ccf17b2..b93671f7c 100644 --- a/apps/api/src/test-utils/fake-world/fake-anchors.ts +++ b/apps/api/src/test-utils/fake-world/fake-anchors.ts @@ -296,6 +296,8 @@ export class FakeAlfredpay { nextOfframpOrderStatus: AlfredpayOfframpStatus | null = null; /** Optional one-shot lifecycle status returned when that new order is re-read. */ nextOfframpRereadStatus: AlfredpayOfframpStatus | null = null; + /** Optional one-shot error thrown by the next getOfframpTransaction (provider outage/404 tests). */ + nextOfframpReadError: Error | null = null; /** Status reported for every order by getOfframpTransaction. */ offrampStatus: AlfredpayOfframpStatus = AlfredpayOfframpStatus.CREATED; /** Deposit address handed out for every offramp order. */ @@ -494,6 +496,9 @@ export class FakeAlfredpay { createOnrampQuote: async (request: CreateAlfredpayOnrampQuoteRequest): Promise => this.onrampQuote(request), getOfframpTransaction: async (transactionId: string): Promise => { + const readError = this.nextOfframpReadError; + this.nextOfframpReadError = null; + if (readError) throw readError; const transaction = this.offrampTransactions.get(transactionId); if (!transaction) { throw new Error(`FakeAlfredpay: unknown offramp transaction ${transactionId}`); diff --git a/apps/api/src/tests/corridors/mxn-offramp.scenario.test.ts b/apps/api/src/tests/corridors/mxn-offramp.scenario.test.ts index 64a998723..0ea312dc0 100644 --- a/apps/api/src/tests/corridors/mxn-offramp.scenario.test.ts +++ b/apps/api/src/tests/corridors/mxn-offramp.scenario.test.ts @@ -1,5 +1,6 @@ import { afterAll, beforeAll, beforeEach, describe, expect, it, mock, setSystemTime, spyOn } from "bun:test"; import { + AlfredpayApiError, ALFREDPAY_ERC20_DECIMALS, ALFREDPAY_ERC20_TOKEN, AlfredpayChain, @@ -1169,6 +1170,35 @@ describe("MXN offramp direct corridor (USDT on Polygon → spei, no-permit)", () 30000 ); + /** + * The pre-transfer order read runs after the user's USDT and any subsidy sit on the ephemeral. + * A provider error there (a 404 for an order the adapter cannot find after the platform switch, + * a 401, a 5xx, a timeout) used to fail the ramp outright and strand those funds. + */ + it.each([404, 503, 0])( + "recoverable: a provider error (status %p) reading the order before the transfer is retried, not fatal", + async status => { + const setup = await setUpRegisteredRamp(); + scriptHappyWorld(setup); + world.alfredpay.nextOfframpReadError = new AlfredpayApiError({ + endpoint: "/api/v1/third-party-service/penny/offramp/order", + method: "GET", + responseBody: '{"errorCode":111483,"errorMessage":"Offramp not found"}', + status + }); + + await processRampWithoutCompletionEmail(setup.rampId); + + const final = await RampState.findByPk(setup.rampId); + expect(final?.currentPhase).toBe("complete"); + const readLogs = final?.errorLogs.filter(log => log.error.includes("could not read provider order")) ?? []; + expect(readLogs.length).toBe(1); + expect(readLogs.every(log => log.phase === "alfredpayOfframpTransfer" && log.recoverable === true)).toBe(true); + expect(submissionsOf(setup.signedOfframpTransfer)).toBe(1); + }, + 30000 + ); + it("does not deposit into an order whose provider lifecycle is already advanced", async () => { for (const status of [ AlfredpayOfframpStatus.ON_CHAIN_DEPOSIT_RECEIVED, From 4f147eeceade2188a279b76c7b321967d0ef613d Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Wed, 30 Sep 2026 16:54:45 +0200 Subject: [PATCH 11/25] fix(shared): validate the adapter's onramp order and limit bounds createOnramp now parses the normalized order like createOfframp does, so a response without a transactionId fails inside the financial operation instead of showing the user instructions for an order we cannot track. A 409 limit body with a null maximum (the adapter's "no limit") now maps to the minimum breach. The multipart uploads get the same 30s timeout as the JSON calls. --- .../alfredpay/alfredpayApiService.test.ts | 41 ++++++++++++++++++- .../services/alfredpay/alfredpayApiService.ts | 34 ++++++++++----- 2 files changed, 64 insertions(+), 11 deletions(-) diff --git a/packages/shared/src/services/alfredpay/alfredpayApiService.test.ts b/packages/shared/src/services/alfredpay/alfredpayApiService.test.ts index dbe520c60..5dc260a77 100644 --- a/packages/shared/src/services/alfredpay/alfredpayApiService.test.ts +++ b/packages/shared/src/services/alfredpay/alfredpayApiService.test.ts @@ -168,10 +168,19 @@ describe("createOnramp returns the order nested under transaction", () => { Response.json({ fiatPaymentInstructions: instructions, status: "CREATED", transactionId: "tx-1" })) as unknown as typeof fetch; const order = await AlfredpayApiService.getInstance().createOnramp(request); - expect(order.transaction.transactionId).toBe("tx-1"); + expect(order.transaction).toEqual({ status: "CREATED", transactionId: "tx-1" }); expect(order.fiatPaymentInstructions).toEqual(instructions); }); + // An order we cannot track must not come back as an order: the mint would show the user payment + // instructions and then fail. + test("rejects a response without a transactionId, in either shape", async () => { + for (const body of [{}, { fiatPaymentInstructions: instructions, status: "CREATED" }, { transaction: null }]) { + globalThis.fetch = (async () => Response.json(body)) as unknown as typeof fetch; + await expect(AlfredpayApiService.getInstance().createOnramp(request)).rejects.toThrow(); + } + }); + test("Penny's nested order", async () => { globalThis.fetch = (async () => Response.json({ fiatPaymentInstructions: instructions, transaction: { transactionId: "tx-2" } })) as unknown as typeof fetch; @@ -182,6 +191,36 @@ describe("createOnramp returns the order nested under transaction", () => { }); }); +describe("a 409 trade-limit response maps to the side that was breached", () => { + const realFetch = globalThis.fetch; + + afterEach(() => { + globalThis.fetch = realFetch; + }); + + function respondWithLimit(errorMetadata: Record): void { + globalThis.fetch = (async () => + new Response(JSON.stringify({ errorCode: 111426, errorMetadata }), { status: 409 })) as unknown as typeof fetch; + } + + const quote = () => AlfredpayApiService.getInstance().getAllConfigs(); + + test("a null maximum is no limit: the minimum was breached", async () => { + respondWithLimit({ fromCurrency: "MXN", maxQuantity: null, minQuantity: 150 }); + await expect(quote()).rejects.toMatchObject({ kind: "below", quantity: "150" }); + }); + + test("a maximum means the maximum was breached", async () => { + respondWithLimit({ fromCurrency: "MXN", maxQuantity: 1000, minQuantity: null }); + await expect(quote()).rejects.toMatchObject({ kind: "above", quantity: "1000" }); + }); + + test("neither bound is not a trade-limit error", async () => { + respondWithLimit({ fromCurrency: "MXN" }); + await expect(quote()).rejects.toMatchObject({ status: 409 }); + }); +}); + describe("offramp responses are validated at the service boundary", () => { const realFetch = globalThis.fetch; const service = AlfredpayApiService.getInstance(); diff --git a/packages/shared/src/services/alfredpay/alfredpayApiService.ts b/packages/shared/src/services/alfredpay/alfredpayApiService.ts index 87973e0a8..cf268381e 100644 --- a/packages/shared/src/services/alfredpay/alfredpayApiService.ts +++ b/packages/shared/src/services/alfredpay/alfredpayApiService.ts @@ -2,7 +2,11 @@ import Big from "big.js"; import { ALFREDPAY_API_KEY, ALFREDPAY_BASE_URL } from "../.."; import logger from "../../logger"; import { ProviderHttpError } from "../providerHttpError"; -import { alfredpayOfframpTransactionSchema, alfredpayQuoteResponseSchema } from "./schemas"; +import { + alfredpayCreateOnrampResponseSchema, + alfredpayOfframpTransactionSchema, + alfredpayQuoteResponseSchema +} from "./schemas"; import { AlfredpayFee, AlfredpayFiatAccountFields, @@ -178,9 +182,9 @@ export class AlfredpayApiService { ); // The wire carries the quantities as JSON numbers (see alfredpayLimitErrorBodySchema); // the error exposes them as strings. - throw maxQuantity !== undefined - ? AlfredpayTradeLimitError.above(String(maxQuantity), fromCurrency) - : AlfredpayTradeLimitError.below(String(minQuantity), fromCurrency); + // `!= null`: Alfred's adapter uses null for "no limit on that side" (see allConfigs). + if (maxQuantity != null) throw AlfredpayTradeLimitError.above(String(maxQuantity), fromCurrency); + if (minQuantity != null) throw AlfredpayTradeLimitError.below(String(minQuantity), fromCurrency); } } catch (parseError) { if (parseError instanceof AlfredpayTradeLimitError) { @@ -304,9 +308,16 @@ export class AlfredpayApiService { ); // Penny nested the order under `transaction`; Alfred's adapter returns it flat, with the payment // instructions alongside (sandbox, 2026-09-30). ponytail: accepts both until Alfred says which stays. - if (response && "transaction" in response) return response; - const { fiatPaymentInstructions, ...transaction } = response as GetAlfredpayOnrampTransactionResponse; - return { fiatPaymentInstructions, transaction }; + // Validated like the offramp order: an order without an id must fail here (the financial operation + // then stays unresolved) rather than show the user instructions for an order we cannot track. + const order = + response && "transaction" in response + ? response + : (({ fiatPaymentInstructions, ...transaction }: Partial) => ({ + fiatPaymentInstructions, + transaction + }))(response ?? {}); + return alfredpayCreateOnrampResponseSchema.parse(order) as unknown as CreateAlfredpayOnrampResponse; } public async getOnrampTransaction(transactionId: string): Promise { @@ -378,7 +389,8 @@ export class AlfredpayApiService { const response = await fetch(url, { body: formData, headers: { Authorization: this.authorization }, - method: "POST" + method: "POST", + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS) }); if (!response.ok) { @@ -437,7 +449,8 @@ export class AlfredpayApiService { const response = await fetch(url, { body: formData, headers: { Authorization: this.authorization }, - method: "POST" + method: "POST", + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS) }); if (!response.ok) { @@ -465,7 +478,8 @@ export class AlfredpayApiService { const response = await fetch(url, { body: formData, headers: { Authorization: this.authorization }, - method: "POST" + method: "POST", + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS) }); if (!response.ok) { From b70c909aae692cb3248895b897064d77d3e7bb2f Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Wed, 30 Sep 2026 16:54:45 +0200 Subject: [PATCH 12/25] fix(kyc): verify the CUIT check digit the provider enforces The sandbox rejects an Argentine CUIT with a wrong check digit, like the CURP one, and accepts separators. The CURP and CUIT checks move to @vortexfi/shared so the API validator can use them too. --- packages/kyc/src/alfredpay/schemas.test.ts | 5 +-- packages/kyc/src/alfredpay/schemas.ts | 25 +++++---------- .../services/alfredpay/identifiers.test.ts | 31 +++++++++++++++++++ .../src/services/alfredpay/identifiers.ts | 24 ++++++++++++++ .../shared/src/services/alfredpay/index.ts | 1 + 5 files changed, 67 insertions(+), 19 deletions(-) create mode 100644 packages/shared/src/services/alfredpay/identifiers.test.ts create mode 100644 packages/shared/src/services/alfredpay/identifiers.ts diff --git a/packages/kyc/src/alfredpay/schemas.test.ts b/packages/kyc/src/alfredpay/schemas.test.ts index 8aa7a7d97..3e2b46cd9 100644 --- a/packages/kyc/src/alfredpay/schemas.test.ts +++ b/packages/kyc/src/alfredpay/schemas.test.ts @@ -109,9 +109,10 @@ describe("arKycSchema", () => { expect(arKycSchema.safeParse(ar).success).toBe(true); }); - it("requires an 11-digit CUIT", () => { + it("requires a CUIT with a valid check digit, written with or without separators", () => { expect(arKycSchema.safeParse({ ...ar, cuit: "" }).success).toBe(false); - expect(arKycSchema.safeParse({ ...ar, cuit: "20123456789" }).success).toBe(true); + expect(arKycSchema.safeParse({ ...ar, cuit: "20123456789" }).success).toBe(false); + expect(arKycSchema.parse({ ...ar, cuit: "20-12345678-6" }).cuit).toBe("20123456786"); const result = arKycSchema.safeParse({ ...ar, cuit: "2012345678" }); expect(result.success).toBe(false); diff --git a/packages/kyc/src/alfredpay/schemas.ts b/packages/kyc/src/alfredpay/schemas.ts index 56e6b40d0..225ed025d 100644 --- a/packages/kyc/src/alfredpay/schemas.ts +++ b/packages/kyc/src/alfredpay/schemas.ts @@ -1,4 +1,4 @@ -import { AlfredpayArgentinaDocumentType, AlfredpayColombiaDocumentType } from "@vortexfi/shared"; +import { AlfredpayArgentinaDocumentType, AlfredpayColombiaDocumentType, isValidCuit, isValidCurp } from "@vortexfi/shared"; import { z } from "zod"; import type { KybFormData, KybQuestionnaireData } from "./types"; @@ -6,24 +6,11 @@ import type { KybFormData, KybQuestionnaireData } from "./types"; export const KYC_FILE_ACCEPTED_TYPES = ["image/jpeg", "image/png", "application/pdf"]; export const KYC_FILE_MAX_BYTES = 5 * 1024 * 1024; -const CURP_ALPHABET = "0123456789ABCDEFGHIJKLMNÑOPQRSTUVWXYZ"; - -/** - * Alfred accepts only a CURP as the Mexican `dni` and verifies its check digit: an INE number or a - * CURP with a wrong last digit fails the submission with `110002 Invalid field(s): dni` (sandbox, - * 2026-09-30). Checking it here turns that into a field error the user can fix. - */ -function isValidCurp(value: string): boolean { - if (!/^[A-Z]{4}\d{6}[HMX][A-Z]{5}[0-9A-Z]\d$/.test(value)) return false; - const sum = [...value.slice(0, 17)].reduce((total, char, index) => total + CURP_ALPHABET.indexOf(char) * (18 - index), 0); - return (10 - (sum % 10)) % 10 === Number(value[17]); -} - export const mxnKycSchema = z.object({ address: z.string().min(1), city: z.string().min(1), dateOfBirth: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, "Use YYYY-MM-DD format"), - dni: z.string().trim().toUpperCase().refine(isValidCurp, "Enter your 18-character CURP"), + dni: z.string().trim().toUpperCase().refine(isValidCurp, "Enter a valid 18-character CURP"), email: z.string().email(), firstName: z.string().min(1), lastName: z.string().min(1), @@ -54,8 +41,12 @@ export const arKycSchema = z.object({ address: z.string().min(1), city: z.string().min(1), countryCode: z.literal("AR"), - // Alfred requires it for every Argentine individual (`110002 Invalid field(s): cuit` without it). - cuit: z.string().regex(/^\d{11}$/, "CUIT must be exactly 11 digits"), + // Alfred requires a CUIT with a valid check digit for every Argentine individual. Separators + // (20-12345678-6) are dropped so the usual written form passes. + cuit: z + .string() + .transform(value => value.replace(/\D/g, "")) + .refine(isValidCuit, "Enter a valid 11-digit CUIT"), dateOfBirth: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, "Use YYYY-MM-DD format"), dni: z.string().min(1), email: z.string().email(), diff --git a/packages/shared/src/services/alfredpay/identifiers.test.ts b/packages/shared/src/services/alfredpay/identifiers.test.ts new file mode 100644 index 000000000..b71ef2013 --- /dev/null +++ b/packages/shared/src/services/alfredpay/identifiers.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, test } from "bun:test"; +import { isValidCuit, isValidCurp } from "./identifiers"; + +// Vectors checked against Alfred's sandbox on 2026-09-30: it accepted every "valid" value below +// and rejected the others with 110002 Invalid field(s). +describe("isValidCurp", () => { + test("accepts CURPs with a correct check digit", () => { + expect(isValidCurp("GOXM900520MDFMXR05")).toBe(true); + expect(isValidCurp("OEAF771012HMCRGR08")).toBe(true); + }); + + test("rejects a wrong check digit, an INE number and lowercase input", () => { + expect(isValidCurp("GOXM900520MDFMXR01")).toBe(false); + // The widely published example CURP; its check digit is wrong. + expect(isValidCurp("OEAF771012HMCRGR09")).toBe(false); + expect(isValidCurp("1234567890123")).toBe(false); + expect(isValidCurp("goxm900520mdfmxr05")).toBe(false); + }); +}); + +describe("isValidCuit", () => { + test("accepts a CUIT with a correct check digit", () => { + expect(isValidCuit("20123456786")).toBe(true); + }); + + test("rejects a wrong check digit, separators and the wrong length", () => { + expect(isValidCuit("20123456789")).toBe(false); + expect(isValidCuit("20-12345678-6")).toBe(false); + expect(isValidCuit("2012345678")).toBe(false); + }); +}); diff --git a/packages/shared/src/services/alfredpay/identifiers.ts b/packages/shared/src/services/alfredpay/identifiers.ts new file mode 100644 index 000000000..cb2f79a4d --- /dev/null +++ b/packages/shared/src/services/alfredpay/identifiers.ts @@ -0,0 +1,24 @@ +/** + * National identifiers Alfred validates on individual KYC submissions (sandbox, 2026-09-30): a + * wrong check digit fails the whole submission with `110002 Invalid field(s)`. Shared so the KYC + * forms and the API validator reject the same values Alfred would. + */ + +const CURP_ALPHABET = "0123456789ABCDEFGHIJKLMNÑOPQRSTUVWXYZ"; + +/** Mexican CURP: 18 characters, the last one a check digit over the first 17. Expects uppercase. */ +export function isValidCurp(value: string): boolean { + if (!/^[A-Z]{4}\d{6}[HMX][A-Z]{5}[0-9A-Z]\d$/.test(value)) return false; + const sum = [...value.slice(0, 17)].reduce((total, char, index) => total + CURP_ALPHABET.indexOf(char) * (18 - index), 0); + return (10 - (sum % 10)) % 10 === Number(value[17]); +} + +const CUIT_WEIGHTS = [5, 4, 3, 2, 7, 6, 5, 4, 3, 2]; + +/** Argentine CUIT/CUIL: 11 digits, the last one a mod-11 check digit. Separators must be stripped first. */ +export function isValidCuit(digits: string): boolean { + if (!/^\d{11}$/.test(digits)) return false; + const remainder = CUIT_WEIGHTS.reduce((total, weight, index) => total + weight * Number(digits[index]), 0) % 11; + const check = remainder === 0 ? 0 : 11 - remainder; + return check !== 10 && check === Number(digits[10]); +} diff --git a/packages/shared/src/services/alfredpay/index.ts b/packages/shared/src/services/alfredpay/index.ts index 0fe3545b0..31c65bd24 100644 --- a/packages/shared/src/services/alfredpay/index.ts +++ b/packages/shared/src/services/alfredpay/index.ts @@ -1,3 +1,4 @@ export * from "./alfredpayApiService"; +export * from "./identifiers"; export * from "./schemas"; export * from "./types"; From 9b7a73022b6db4568bbb42ece2c77288fe64ebe4 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Wed, 30 Sep 2026 16:54:45 +0200 Subject: [PATCH 13/25] fix(api): reject invalid CURP and CUIT at the KYC API boundary Integrators submit KYC without our forms, and the provider answers a bad CURP or a missing or wrong CUIT with an opaque 422 that reaches them as a 500. validateKycSubmission now returns a 400 for both, and the OpenAPI schema and corridor guide document the rules. --- .../src/api/middlewares/validators.test.ts | 23 ++++++++++++++++++- apps/api/src/api/middlewares/validators.ts | 9 ++++++-- docs/api/openapi/vortex.openapi.d.ts | 4 ++++ docs/api/openapi/vortex.openapi.json | 13 +++++++++-- docs/api/pages/09-fiat-corridors.md | 2 ++ docs/api/scripts/check-openapi.ts | 2 +- 6 files changed, 47 insertions(+), 6 deletions(-) diff --git a/apps/api/src/api/middlewares/validators.test.ts b/apps/api/src/api/middlewares/validators.test.ts index 97e9e5f48..2fee81221 100644 --- a/apps/api/src/api/middlewares/validators.test.ts +++ b/apps/api/src/api/middlewares/validators.test.ts @@ -266,7 +266,7 @@ describe("validateKycSubmission", () => { const req = { body: { country: "AR", - cuit: "20123456789", + cuit: "20-12345678-6", nationalities: ["AR"], pep: false, phoneNumber: "+5491112345678" @@ -282,4 +282,25 @@ describe("validateKycSubmission", () => { expect(nextMock.mock.calls[0]?.[0]).toBeUndefined(); expect(res.statusCode).toBeUndefined(); }); + + // Integrators reach this route without our forms; these are the values the provider rejects with + // an opaque 422, so they must stop here with a 400 instead. + function kycError(body: Record): string | undefined { + const nextMock = mock((_error?: unknown) => undefined); + validateKycSubmission({ body } as unknown as Request, buildRes(), nextMock as unknown as NextFunction); + return (nextMock.mock.calls[0]?.[0] as APIError | undefined)?.message; + } + + it("requires a CUIT with a valid check digit for Argentina", () => { + const ar = { country: "AR", nationalities: ["AR"], pep: false, phoneNumber: "+5491112345678" }; + expect(kycError(ar)).toBe("CUIT must be 11 digits with a valid check digit"); + expect(kycError({ ...ar, cuit: "20123456789" })).toBe("CUIT must be 11 digits with a valid check digit"); + expect(kycError({ ...ar, cuit: "20123456786" })).toBeUndefined(); + }); + + it("requires a CURP with a valid check digit as the Mexican dni", () => { + expect(kycError({ country: "MX", dni: "1234567890123" })).toBe("dni must be a valid 18-character CURP"); + expect(kycError({ country: "MX", dni: "OEAF771012HMCRGR09" })).toBe("dni must be a valid 18-character CURP"); + expect(kycError({ country: "MX", dni: "OEAF771012HMCRGR08" })).toBeUndefined(); + }); }); diff --git a/apps/api/src/api/middlewares/validators.ts b/apps/api/src/api/middlewares/validators.ts index 5d1741135..325080e70 100644 --- a/apps/api/src/api/middlewares/validators.ts +++ b/apps/api/src/api/middlewares/validators.ts @@ -13,6 +13,8 @@ import { isSupportedFiatCurrency, isValidAveniaAccountType, isValidCpf, + isValidCuit, + isValidCurp, isValidCurrencyForDirection, isValidDirection, isValidKYCDocType, @@ -562,11 +564,14 @@ const countryValidators: Record s AR: ({ phoneNumber, cuit, nationalities, pep }) => { if (!phoneNumber) return "Phone number is required for Argentina"; if (!phoneNumber.startsWith("+54")) return "Phone number must use Argentina country code (+54)"; - if (cuit && !/^\d{11}$/.test(cuit)) return "CUIT must be exactly 11 digits"; + // The provider rejects an Argentine individual without a CUIT or with a wrong check digit. + if (!cuit || !isValidCuit(cuit.replace(/\D/g, ""))) return "CUIT must be 11 digits with a valid check digit"; if (nationalities && !nationalities.every(n => /^[A-Z]{2}$/.test(n))) return "Nationalities must use alpha-2 country codes"; if (typeof pep !== "boolean") return "PEP declaration is required for Argentina"; return null; - } + }, + // The provider accepts only a CURP with a valid check digit as the Mexican `dni`. + MX: ({ dni }) => (typeof dni === "string" && isValidCurp(dni) ? null : "dni must be a valid 18-character CURP") }; /** diff --git a/docs/api/openapi/vortex.openapi.d.ts b/docs/api/openapi/vortex.openapi.d.ts index 3a9a82016..e994bdb33 100644 --- a/docs/api/openapi/vortex.openapi.d.ts +++ b/docs/api/openapi/vortex.openapi.d.ts @@ -3382,12 +3382,16 @@ export interface components { } & ({ /** @constant */ country?: "MX"; + /** @description CURP, 18 characters in upper case. The last character is a check digit, and a CURP with a wrong one is rejected with 400. */ + dni?: string; } | { /** @constant */ country?: "CO"; } | { /** @constant */ country?: "AR"; + /** @description CUIT or CUIL, 11 digits (separators are ignored). The last digit is a check digit, and a CUIT with a wrong one is rejected with 400. */ + cuit: string; }); SuccessResponse: { /** @constant */ diff --git a/docs/api/openapi/vortex.openapi.json b/docs/api/openapi/vortex.openapi.json index 67ff4cf38..8e064caa6 100644 --- a/docs/api/openapi/vortex.openapi.json +++ b/docs/api/openapi/vortex.openapi.json @@ -1700,7 +1700,7 @@ "type": "string" } }, - "required": ["phoneNumber", "pep"] + "required": ["phoneNumber", "pep", "cuit"] } } ], @@ -3887,6 +3887,11 @@ "properties": { "country": { "const": "MX" + }, + "dni": { + "description": "CURP, 18 characters in upper case. The last character is a check digit, and a CURP with a wrong one is rejected with 400.", + "pattern": "^[A-Z]{4}[0-9]{6}[HMX][A-Z]{5}[0-9A-Z][0-9]$", + "type": "string" } }, "required": ["email"] @@ -3903,9 +3908,13 @@ "properties": { "country": { "const": "AR" + }, + "cuit": { + "description": "CUIT or CUIL, 11 digits (separators are ignored). The last digit is a check digit, and a CUIT with a wrong one is rejected with 400.", + "type": "string" } }, - "required": ["email", "phoneNumber", "countryCode", "nationalities", "typeDocumentAr", "pep"] + "required": ["email", "phoneNumber", "countryCode", "nationalities", "typeDocumentAr", "pep", "cuit"] } ], "properties": { diff --git a/docs/api/pages/09-fiat-corridors.md b/docs/api/pages/09-fiat-corridors.md index 5cfca6ca7..99cd5a098 100644 --- a/docs/api/pages/09-fiat-corridors.md +++ b/docs/api/pages/09-fiat-corridors.md @@ -155,6 +155,8 @@ All four corridors support buys and sells on EVM networks; AssetHub is not avail Each corridor requires the user to complete KYC for the corridor's country before a ramp can be registered. The identity documents collected differ per country (for example INE, resident card, or passport in Mexico; cédula in Colombia; DNI in Argentina); requirements discovery (see Discovering Onboarding Requirements above) publishes the exact document list and accepted media types per country and customer type. +Identity numbers are checked before a submission is created: a Mexican `dni` must be a CURP with a valid check digit, and Argentine individuals need a CUIT or CUIL (11 digits, separators ignored) with a valid check digit. Invalid values are rejected with `400`. + Onboarding can be completed three ways: - **Vortex app or hosted Widget** — always available. Business users can be sent straight into verification with the [KYB Deep Link](https://api-docs.vortexfinance.co/kyb-deep-link). diff --git a/docs/api/scripts/check-openapi.ts b/docs/api/scripts/check-openapi.ts index ed6d3ac70..e087a325e 100644 --- a/docs/api/scripts/check-openapi.ts +++ b/docs/api/scripts/check-openapi.ts @@ -743,7 +743,7 @@ if ( !JSON.stringify(kycInformationRequest.allOf).includes('"pattern":"^\\\\+54"') || !JSON.stringify(kycInformationRequest.allOf).includes('"pattern":"^\\\\d{11}$"') || !JSON.stringify(kycInformationRequest.allOf).includes('"pattern":"^[A-Z]{2}$"') || - !JSON.stringify(kycInformationRequest.allOf).includes('"required":["phoneNumber","pep"]') + !JSON.stringify(kycInformationRequest.allOf).includes('"required":["phoneNumber","pep","cuit"]') ) { throw new Error( "DomesticSubmitKycInformationRequest must document Argentina phone, CUIT, nationality, and PEP requirements." From 4ebae391446a132ecb50e9e6a226febc6e7575de Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Wed, 30 Sep 2026 16:54:45 +0200 Subject: [PATCH 14/25] fix(api): skip unusable rows in the Alfredpay limits listing One absent or empty bound, an unknown customer type or an empty listing aborted or wiped the whole refresh, and a row without any bound could shadow a real one. Rows now count only with a decimal bound on the scale the quote path reads back. --- .../alfredpay-limits.service.test.ts | 86 +++++++++++++------ .../alfredpay/alfredpay-limits.service.ts | 36 +++++--- 2 files changed, 84 insertions(+), 38 deletions(-) diff --git a/apps/api/src/api/services/alfredpay/alfredpay-limits.service.test.ts b/apps/api/src/api/services/alfredpay/alfredpay-limits.service.test.ts index a19936480..ac6868e1b 100644 --- a/apps/api/src/api/services/alfredpay/alfredpay-limits.service.test.ts +++ b/apps/api/src/api/services/alfredpay/alfredpay-limits.service.test.ts @@ -90,36 +90,72 @@ describe("AlfredpayLimitsService.refresh", () => { }); }); + async function refreshWith(...batches: AlfredpayConfigPair[][]): Promise { + const service = new (AlfredpayLimitsService as unknown as { new (): AlfredpayLimitsService })(); + for (const supportedPairs of batches) { + AlfredpayApiService.getInstance = () => ({ getAllConfigs: async () => ({ supportedPairs }) }) as unknown as AlfredpayApiService; + await (service as unknown as { refresh(): Promise }).refresh(); + } + return service; + } + + const configured = (fiat: FiatToken) => { + const limits = getAnyFiatTokenDetails(fiat).alfredpayLimits; + if (!limits) throw new Error(`no configured Alfredpay limits for ${fiat}`); + return limits; + }; + const arsMin = pair({ fromCurrency: "ARS", maxQuantity: null, minQuantity: "1234.56", toCurrency: "USDT" }); + /** * The Penny adapter serves null quantities on most pairs (2026-09-30). `new Big(null)` threw and - * aborted the whole refresh, so no provider bound was ever applied. Both rows share one - * response: a throw on the null row would also lose the ARS minimum. + * aborted the whole refresh, so no provider bound was ever applied. */ - test("keeps the configured bound where Alfred sets no limit", async () => { - AlfredpayApiService.getInstance = () => - ({ - getAllConfigs: async () => ({ - supportedPairs: [ - pair({ fromCurrency: "ARS", maxQuantity: null, minQuantity: "1234.56", toCurrency: "USDT" }), - pair({ decimals: "6", fromCurrency: "USDC", maxQuantity: null, minQuantity: null, toCurrency: "MXN" }) - ] - }) - }) as unknown as AlfredpayApiService; + test("keeps each customer type's configured bound where Alfred sets no limit", async () => { + const service = await refreshWith([arsMin]); - const service = new (AlfredpayLimitsService as unknown as { new (): AlfredpayLimitsService })(); - await (service as unknown as { refresh(): Promise }).refresh(); + for (const customer of [DomesticCustomerType.INDIVIDUAL, DomesticCustomerType.BUSINESS]) { + expect(service.getLimits(FiatToken.ARS, "USDT", customer, RampDirection.BUY)).toEqual({ + maxRaw: configured(FiatToken.ARS).onramp.USDT[customer].maxRaw, + minRaw: "123456" + }); + } + }); - const configured = (fiat: FiatToken) => { - const limits = getAnyFiatTokenDetails(fiat).alfredpayLimits; - if (!limits) throw new Error(`no configured Alfredpay limits for ${fiat}`); - return limits; - }; - expect(service.getLimits(FiatToken.ARS, "USDT", DomesticCustomerType.INDIVIDUAL, RampDirection.BUY)).toEqual({ - maxRaw: configured(FiatToken.ARS).onramp.USDT[DomesticCustomerType.INDIVIDUAL].maxRaw, - minRaw: "123456" - }); - expect(service.getLimits(FiatToken.MXN, "USDC", DomesticCustomerType.BUSINESS, RampDirection.SELL)).toEqual( - configured(FiatToken.MXN).offramp.USDC[DomesticCustomerType.BUSINESS] + test("a malformed bound or an unknown customer type does not lose the other rows", async () => { + const service = await refreshWith([ + pair({ maxQuantity: undefined as unknown as null, minQuantity: "" }), + pair({ maxQuantity: null, minQuantity: "10.00", typeCustomer: "COMPANY" as DomesticCustomerType }), + arsMin + ]); + + expect(service.getLimits(FiatToken.ARS, "USDT", DomesticCustomerType.INDIVIDUAL, RampDirection.BUY).minRaw).toBe("123456"); + }); + + test("a row without any bound does not shadow a real row for the same pair", async () => { + const real = pair({ maxQuantity: "1000.00", minQuantity: "99.00" }); + const service = await refreshWith([ + pair({ maxQuantity: null, minQuantity: null }), + pair({ maxQuantity: null, minQuantity: null, typeCustomer: DomesticCustomerType.INDIVIDUAL }), + real + ]); + + for (const customer of [DomesticCustomerType.INDIVIDUAL, DomesticCustomerType.BUSINESS]) { + expect(service.getLimits(FiatToken.MXN, "USDC", customer, RampDirection.BUY)).toEqual({ maxRaw: "100000", minRaw: "9900" }); + } + }); + + test("an empty listing keeps the previous limits", async () => { + const service = await refreshWith([arsMin], []); + + expect(service.getLimits(FiatToken.ARS, "USDT", DomesticCustomerType.INDIVIDUAL, RampDirection.BUY).minRaw).toBe("123456"); + }); + + test("a row scaled against the currency's convention is skipped", async () => { + // BUY limits are read back with the fiat's 2 decimals; a "6" row would be 10^4 off. + const service = await refreshWith([pair({ decimals: "6", maxQuantity: null, minQuantity: "150.00" })]); + + expect(service.getLimits(FiatToken.MXN, "USDC", DomesticCustomerType.INDIVIDUAL, RampDirection.BUY)).toEqual( + configured(FiatToken.MXN).onramp.USDC[DomesticCustomerType.INDIVIDUAL] ); }); }); diff --git a/apps/api/src/api/services/alfredpay/alfredpay-limits.service.ts b/apps/api/src/api/services/alfredpay/alfredpay-limits.service.ts index a88770130..a2cf0a719 100644 --- a/apps/api/src/api/services/alfredpay/alfredpay-limits.service.ts +++ b/apps/api/src/api/services/alfredpay/alfredpay-limits.service.ts @@ -36,6 +36,11 @@ function cacheKey( return `${direction}:${fiat}:${stablecoin}:${customer}`; } +/** A provider bound is only a decimal string; null, absent or "" means Alfred sets no limit on that side. */ +function isDecimalQuantity(value: unknown): value is string { + return typeof value === "string" && /^\d+(\.\d+)?$/.test(value); +} + function toRaw(quantityDecimal: string, decimals: number): string { return new Big(quantityDecimal).mul(new Big(10).pow(decimals)).round(0, Big.roundDown).toFixed(0); } @@ -107,6 +112,8 @@ export class AlfredpayLimitsService { private async refresh(): Promise { try { const { supportedPairs } = await AlfredpayApiService.getInstance().getAllConfigs(); + // An unparseable 2xx body arrives as an empty listing; it must not wipe the limits we have. + if (supportedPairs.length === 0) throw new Error("allConfigs returned no pairs"); const nextCache = new Map(); for (const pair of supportedPairs) { this.indexPair(nextCache, pair); @@ -119,30 +126,33 @@ export class AlfredpayLimitsService { } private indexPair(target: Map, pair: AlfredpayConfigPair): void { - // The /allConfigs listing contains junk rows: decimals null/"" and even null - // currencies. Only digit-string decimals are trustworthy — Number(null) is 0 and - // would silently shrink the raw limits by 10^decimals. Capped at two digits (sane - // currency precision): an oversized exponent would make Big(10).pow throw and - // abort the whole refresh on one bad row. - if (typeof pair.decimals !== "string" || !/^\d{1,2}$/.test(pair.decimals)) return; - const decimals = Number(pair.decimals); - const axes = this.deriveAxes(pair); if (!axes) return; - const { direction, fiat, stablecoin } = axes; + + // Limits are read back scaled by the fiat's decimals for BUY and the stablecoin's (6) for SELL + // (resolveAlfredpayQuoteLimits). The listing also carries junk rows (decimals null, "" or huge); + // a row scaled any other way would mix scales with our configured bound, so it is skipped. + const decimals = direction === RampDirection.BUY ? getAnyFiatTokenDetails(fiat).decimals : 6; + if (pair.decimals !== String(decimals)) return; + if (pair.typeCustomer && !CUSTOMER_TYPES.includes(pair.typeCustomer)) return; + + // A row without any bound says nothing and must not shadow another row for the same pair. + const minQuantity = isDecimalQuantity(pair.minQuantity) ? pair.minQuantity : null; + const maxQuantity = isDecimalQuantity(pair.maxQuantity) ? pair.maxQuantity : null; + if (minQuantity === null && maxQuantity === null) return; + const customers: DomesticCustomerType[] = pair.typeCustomer ? [pair.typeCustomer] : CUSTOMER_TYPES; const isWildcard = !pair.typeCustomer; for (const customer of customers) { const key = cacheKey(direction, fiat, stablecoin, customer); // Specific customer rows take precedence over the wildcard (null) row, regardless of response order. if (isWildcard && target.has(key)) continue; - // A null bound means Alfred sets no limit on that side: keep our configured bound there - // rather than treating it as unlimited. + // Where Alfred sets no limit on a side, keep our configured bound rather than reading it as unlimited. const configured = this.fallback(fiat, stablecoin, customer, direction); target.set(key, { - maxRaw: pair.maxQuantity === null ? configured.maxRaw : toRaw(pair.maxQuantity, decimals), - minRaw: pair.minQuantity === null ? configured.minRaw : toRaw(pair.minQuantity, decimals) + maxRaw: maxQuantity === null ? configured.maxRaw : toRaw(maxQuantity, decimals), + minRaw: minQuantity === null ? configured.minRaw : toRaw(minQuantity, decimals) }); } } From cdac70d2c5a7533813d98160c5a564e6924d2d90 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Wed, 30 Sep 2026 16:54:45 +0200 Subject: [PATCH 15/25] fix(api): keep approved customers on an empty KYC status read GET /getKycStatus reset any customer to onboarding when the provider returned no submission, the case /alfredpayStatus already guards. --- .../api/controllers/alfredpay.controller.ts | 14 ++++++--- ...edpay-status-not-found.integration.test.ts | 31 +++++++++++++++++++ 2 files changed, 40 insertions(+), 5 deletions(-) diff --git a/apps/api/src/api/controllers/alfredpay.controller.ts b/apps/api/src/api/controllers/alfredpay.controller.ts index 6b07cd491..00dde6176 100644 --- a/apps/api/src/api/controllers/alfredpay.controller.ts +++ b/apps/api/src/api/controllers/alfredpay.controller.ts @@ -526,11 +526,15 @@ export class AlfredpayController { : (await alfredpayService.getLastKycSubmission(alfredPayCustomer.alfredPayId))?.submissionId; if (!submissionId) { - await alfredPayCustomer.update({ - status: AlfredPayStatus.Consulted, - statusExternal: null, - verificationStatus: VerificationStatus.Pending - }); + // Same rule as /alfredpayStatus: a read that finds nothing must not send an approved + // customer back through KYC (invariant 33). + if (alfredPayCustomer.status !== AlfredPayStatus.Success) { + await alfredPayCustomer.update({ + status: AlfredPayStatus.Consulted, + statusExternal: null, + verificationStatus: VerificationStatus.Pending + }); + } return res.status(404).json({ error: "No KYC attempt found" }); } diff --git a/apps/api/src/tests/alfredpay-status-not-found.integration.test.ts b/apps/api/src/tests/alfredpay-status-not-found.integration.test.ts index 1aabae439..8ab98bebb 100644 --- a/apps/api/src/tests/alfredpay-status-not-found.integration.test.ts +++ b/apps/api/src/tests/alfredpay-status-not-found.integration.test.ts @@ -84,3 +84,34 @@ describe("GET /alfredpayStatus when Alfredpay answers 404", () => { expect(customer?.status).toBe(VerificationStatus.Pending); }); }); + +describe("GET /getKycStatus when Alfredpay reports no submission", () => { + async function statusWithoutSubmission(email: string, stored: AlfredPayStatus) { + const user = await createTestUser({ email }); + await createAlfredpayCustomer(user.id, { + alfredPayId: "ap-not-found", + country: DomesticCountry.MX, + status: stored, + type: DomesticCustomerType.INDIVIDUAL + }); + AlfredpayApiService.getInstance = mock( + () => ({ getLastKycSubmission: mock(async () => ({})) }) as unknown as AlfredpayApiService + ); + + const response = await api.request("/v1/alfredpay/getKycStatus?country=MX", { + headers: { Authorization: `Bearer ${testUserToken(user.id, email)}` } + }); + expect(response.status).toBe(404); + return ProviderCustomer.findOne({ where: { providerCustomerId: "ap-not-found" } }); + } + + it("keeps an approved customer approved", async () => { + const customer = await statusWithoutSubmission("approved-empty@example.com", AlfredPayStatus.Success); + expect(customer?.status).toBe(VerificationStatus.Approved); + }); + + it("still resets an unapproved customer", async () => { + const customer = await statusWithoutSubmission("in-review-empty@example.com", AlfredPayStatus.UserCompleted); + expect(customer?.status).toBe(VerificationStatus.Pending); + }); +}); From 3ceb46e39921ba158dfe5a9df50562993e74d7ed Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Wed, 30 Sep 2026 16:54:46 +0200 Subject: [PATCH 16/25] fix(api): reject Alfredpay onramp quotes that do not echo the input A fixed-input quote echoes its input. If the adapter ever switched to minor units like Alfred's native API, every onramp figure would be off by that factor; the offramp side already checks this. --- .../alfredpay-onramp-direct.flow.test.ts | 19 ++++++++++++++++++- .../phases/alfredpay-mint/simulation.ts | 5 +++++ 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/apps/api/src/api/services/phases/blocks/__tests__/alfredpay-onramp-direct.flow.test.ts b/apps/api/src/api/services/phases/blocks/__tests__/alfredpay-onramp-direct.flow.test.ts index 59d2174bd..ae221416b 100644 --- a/apps/api/src/api/services/phases/blocks/__tests__/alfredpay-onramp-direct.flow.test.ts +++ b/apps/api/src/api/services/phases/blocks/__tests__/alfredpay-onramp-direct.flow.test.ts @@ -143,7 +143,8 @@ describe("Alfredpay direct onramp flow", () => { ); expect(squidCalculations).toBe(0); - expect(capturedProviderRequests[0]?.metadata.customerId).toBe("anonymous"); + // Exactly the tracking id: Alfred derives the company from the API key and refuses a business id. + expect(capturedProviderRequests[0]?.metadata).toEqual({ customerId: "anonymous" }); expect(output).toMatchObject({ amountRaw: "95990000", chain: Networks.Polygon, token: ALFREDPAY_EVM_TOKEN }); expect(metadata.blocks.squidRouterSwap).toMatchObject({ effectiveExchangeRate: "1", @@ -164,4 +165,20 @@ describe("Alfredpay direct onramp flow", () => { outputAmountRaw: "94990000" }); }); + it("rejects a quote whose fromAmount does not echo the requested input", async () => { + AlfredpayApiService.getInstance = mock(() => ({ + createOnrampQuote: async () => ({ + expiration: new Date(Date.now() + 30_000).toISOString(), + fees: [{ amount: "2", currency: FiatToken.MXN }], + // 100.00 MXN in minor units, as Alfred's native API would serialize it. + fromAmount: "10000", + quoteId: "alfred-quote", + toAmount: "98" + }) + })) as unknown as typeof AlfredpayApiService.getInstance; + + await expect(makeAlfredpayOnrampDirectFlow(ALFREDPAY_EVM_TOKEN).simulate(buildCtx(ALFREDPAY_EVM_TOKEN))).rejects.toThrow( + "does not match the requested" + ); + }); }); diff --git a/apps/api/src/api/services/phases/blocks/phases/alfredpay-mint/simulation.ts b/apps/api/src/api/services/phases/blocks/phases/alfredpay-mint/simulation.ts index 258baa75f..97cb1f179 100644 --- a/apps/api/src/api/services/phases/blocks/phases/alfredpay-mint/simulation.ts +++ b/apps/api/src/api/services/phases/blocks/phases/alfredpay-mint/simulation.ts @@ -49,6 +49,11 @@ export async function simulateAlfredpayMint( }; const quote = await AlfredpayApiService.getInstance().createOnrampQuote(quoteRequest); const inputAmountDecimal = new Big(quote.fromAmount); + // A fixed-input quote echoes the input. Anything else means the amounts are not in the units we + // sent (Alfred's native API uses minor units), and every figure below would be off by that factor. + if (!inputAmountDecimal.eq(input.amount)) { + throw new Error(`AlfredpayMint: quote fromAmount ${quote.fromAmount} does not match the requested ${input.amount}`); + } const outputAmountDecimal = new Big(quote.toAmount); const fee = AlfredpayApiService.sumFeesByCurrency(quote.fees, input.token as unknown as AlfredpayFiatCurrency); const outputAmountRaw = multiplyByPowerOfTen(outputAmountDecimal, ALFREDPAY_ERC20_DECIMALS).toFixed(0, 0); From 4c19f455036a2c9c4213ddd948dd9046c4ddbcb8 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Wed, 30 Sep 2026 16:54:46 +0200 Subject: [PATCH 17/25] test(api): run the live Alfredpay contract only against the sandbox The live half creates customers, accounts and orders, and the base URL now defaults to production outside sandbox deployments. Order tests use USDT like production, and the onramp test pins the paymentType partners receive. --- .../contracts/alfredpay.contract.test.ts | 28 +++++++++++++------ 1 file changed, 20 insertions(+), 8 deletions(-) diff --git a/apps/api/src/tests/contracts/alfredpay.contract.test.ts b/apps/api/src/tests/contracts/alfredpay.contract.test.ts index 55ed528f4..bf8a4aefa 100644 --- a/apps/api/src/tests/contracts/alfredpay.contract.test.ts +++ b/apps/api/src/tests/contracts/alfredpay.contract.test.ts @@ -29,6 +29,8 @@ import { describe, expect, test } from "bun:test"; import { deflateSync } from "node:zlib"; import Big from "big.js"; import { + ALFREDPAY_BASE_URL, + ALFREDPAY_ONCHAIN_CURRENCY, AlfredpayApiService, AlfredpayChain, alfredpayConfigsResponseSchema, @@ -60,6 +62,9 @@ import { FakeAlfredpay } from "../../test-utils/fake-world/fake-anchors"; const RUN_LIVE = !!process.env.RUN_LIVE_TESTS; const HAS_CREDS = !!process.env.ALFREDPAY_API_KEY; +// The live half creates customers, accounts and orders: never against anything but the sandbox. +const ON_SANDBOX = ALFREDPAY_BASE_URL.startsWith("https://api.sandbox.alfredpay.io/"); +const LIVE = RUN_LIVE && HAS_CREDS && ON_SANDBOX; const CUSTOMER_ID = process.env.ALFREDPAY_CONTRACT_CUSTOMER_ID; const FIAT_ACCOUNT_ID = process.env.ALFREDPAY_CONTRACT_FIAT_ACCOUNT_ID; const KYC_SUBMISSION_ID = process.env.ALFREDPAY_CONTRACT_KYC_SUBMISSION_ID; @@ -194,6 +199,9 @@ function kybFlowForm(email: string): SubmitKybInformationRequest { if (RUN_LIVE && !HAS_CREDS) { console.warn("[contract:live] Alfredpay live half skipped: ALFREDPAY_API_KEY not set"); } +if (RUN_LIVE && HAS_CREDS && !ON_SANDBOX) { + console.warn(`[contract:live] Alfredpay live half skipped: ${ALFREDPAY_BASE_URL} is not the sandbox`); +} // Unremarkable placeholder wallet, mirroring the squidrouter suite. const TEST_ADDRESS = "0x1234567890123456789012345678901234567890"; @@ -347,7 +355,7 @@ describe("Alfredpay external API contract — hermetic (fake)", () => { }); }); -describe.skipIf(!RUN_LIVE || !HAS_CREDS)("Alfredpay external API contract — live", () => { +describe.skipIf(!LIVE)("Alfredpay external API contract — live", () => { const api = () => AlfredpayApiService.getInstance(); async function runFiatAccountLifecycle(accountCase: FiatAccountLifecycleCase): Promise { @@ -558,7 +566,8 @@ describe.skipIf(!RUN_LIVE || !HAS_CREDS)("Alfredpay external API contract — li const quote = await runLive("alfredpay onramp quote (order)", () => api().createOnrampQuote({ ...onrampQuoteRequest("500"), - metadata: { customerId: CUSTOMER_ID as string } + metadata: { customerId: CUSTOMER_ID as string }, + toCurrency: ALFREDPAY_ONCHAIN_CURRENCY }) ); if (!quote) return; @@ -572,11 +581,13 @@ describe.skipIf(!RUN_LIVE || !HAS_CREDS)("Alfredpay external API contract — li fromCurrency: AlfredpayFiatCurrency.MXN, paymentMethodType: AlfredpayPaymentMethodType.BANK, quoteId: quote.quoteId, - toCurrency: AlfredpayOnChainCurrency.USDC + toCurrency: ALFREDPAY_ONCHAIN_CURRENCY }) ); if (!order) return; alfredpayCreateOnrampResponseSchema.parse(order); + // Forwarded to partners as achPaymentData, where the wire contract requires it. + expect(typeof order.fiatPaymentInstructions.paymentType).toBe("string"); const transaction = await runLive("alfredpay getOnrampTransaction", () => api().getOnrampTransaction(order.transaction.transactionId) @@ -594,7 +605,7 @@ describe.skipIf(!RUN_LIVE || !HAS_CREDS)("Alfredpay external API contract — li api().createOfframpQuote({ chain: AlfredpayChain.MATIC, fromAmount: "30", - fromCurrency: AlfredpayOnChainCurrency.USDC, + fromCurrency: ALFREDPAY_ONCHAIN_CURRENCY, metadata: { customerId: CUSTOMER_ID as string }, paymentMethodType: AlfredpayPaymentMethodType.BANK, toCurrency: AlfredpayFiatCurrency.MXN @@ -608,7 +619,7 @@ describe.skipIf(!RUN_LIVE || !HAS_CREDS)("Alfredpay external API contract — li chain: AlfredpayChain.MATIC, customerId: CUSTOMER_ID as string, fiatAccountId: FIAT_ACCOUNT_ID as string, - fromCurrency: AlfredpayOnChainCurrency.USDC, + fromCurrency: ALFREDPAY_ONCHAIN_CURRENCY, originAddress: TEST_ADDRESS, quoteId: quote.quoteId, toCurrency: AlfredpayFiatCurrency.MXN @@ -659,9 +670,10 @@ describe.skipIf(!RUN_LIVE || !HAS_CREDS)("Alfredpay external API contract — li /** * Provisions the completed individual fixture consumed by the MX fiat-account lifecycle. This is * opt-in because Alfredpay has no customer deletion endpoint and every run leaves a customer behind. - * The sandbox guarantees KYC acceptance regardless of placeholder personal data/documents. + * The sandbox approves placeholder personal data and documents, as long as the CURP's check digit is + * valid and no two uploads on the submission are byte-identical (see blankPng). */ -describe.skipIf(!RUN_LIVE || !HAS_CREDS || !RUN_KYC_FLOW)("Alfredpay individual KYC sandbox flow — live", () => { +describe.skipIf(!LIVE || !RUN_KYC_FLOW)("Alfredpay individual KYC sandbox flow — live", () => { test( "a Mexican individual customer reaches completed KYC", async () => { @@ -726,7 +738,7 @@ describe.skipIf(!RUN_LIVE || !HAS_CREDS || !RUN_KYC_FLOW)("Alfredpay individual * then rejected by the sandbox's verification (FAILED, ~30s) because the uploads are placeholder * images — see the note on the status step. */ -describe.skipIf(!RUN_LIVE || !HAS_CREDS || !RUN_KYB_FLOW)("Alfredpay KYB sandbox flow — live", () => { +describe.skipIf(!LIVE || !RUN_KYB_FLOW)("Alfredpay KYB sandbox flow — live", () => { test( "a Mexican company KYB completes every step end to end", async () => { From 07b0cadfcc52b198f16ae75b68c23e4677ca834a Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Wed, 30 Sep 2026 16:54:46 +0200 Subject: [PATCH 18/25] fix(frontend): label the Mexican KYC ID field CURP --- apps/frontend/src/translations/en.json | 2 +- apps/frontend/src/translations/pt.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/frontend/src/translations/en.json b/apps/frontend/src/translations/en.json index 7829cc4be..4d29328ae 100644 --- a/apps/frontend/src/translations/en.json +++ b/apps/frontend/src/translations/en.json @@ -649,7 +649,7 @@ "city": "City", "continue": "Continue", "dateOfBirth": "Date of Birth", - "dni": "Document Number", + "dni": "CURP", "documentType": "Document Type", "email": "Email", "firstName": "First Name", diff --git a/apps/frontend/src/translations/pt.json b/apps/frontend/src/translations/pt.json index 7a817a73f..e8ba0e381 100644 --- a/apps/frontend/src/translations/pt.json +++ b/apps/frontend/src/translations/pt.json @@ -652,7 +652,7 @@ "city": "Cidade", "continue": "Continuar", "dateOfBirth": "Data de Nascimento", - "dni": "Número do Documento", + "dni": "CURP", "documentType": "Tipo de Documento", "email": "E-mail", "firstName": "Nome", From fcfba07943af8ba53aa10685b72404894a966cdf Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Wed, 30 Sep 2026 16:54:46 +0200 Subject: [PATCH 19/25] docs(repo): record the Alfredpay review fixes in the spec and env --- apps/api/.env.example | 9 +++++---- docs/security-spec/05-integrations/alfredpay.md | 16 ++++++++++++---- 2 files changed, 17 insertions(+), 8 deletions(-) diff --git a/apps/api/.env.example b/apps/api/.env.example index 343cb86f0..ecadf8248 100644 --- a/apps/api/.env.example +++ b/apps/api/.env.example @@ -155,11 +155,12 @@ RECIPIENT_INVITE_MAX_DISCOUNT_BPS=300 # Only the private key is needed - public key is derived from it WEBHOOK_PRIVATE_KEY=your-webhook-private-key -# AlfredPay, through Alfred's Penny adapter. Leave the base URL unset to follow SANDBOX_ENABLED -# (https://api.sandbox.alfredpay.io/adapters/penny, else https://api.alfredpay.io/adapters/penny). +# AlfredPay, through Alfred's Penny adapter. Unset, the base URL follows SANDBOX_ENABLED (only true +# on DEPLOYMENT_ENV=sandbox): the sandbox adapter there, the production adapter everywhere else. So +# local and staging setups with a sandbox key must set it explicitly, as below. # The API key is an Alfred partner key (alfk_...) from dashboard.alfredpay.io; there is no secret. -ALFREDPAY_BASE_URL= -ALFREDPAY_API_KEY=your-alfred-api-key +ALFREDPAY_BASE_URL=https://api.sandbox.alfredpay.io/adapters/penny +ALFREDPAY_API_KEY=your-alfred-sandbox-api-key # Monerium OAuth (the redirect URI must exactly match the dashboard callback registered with Monerium) MONERIUM_CLIENT_ID=your-monerium-auth-code-client-id diff --git a/docs/security-spec/05-integrations/alfredpay.md b/docs/security-spec/05-integrations/alfredpay.md index 56e8a9ab7..655b7c84b 100644 --- a/docs/security-spec/05-integrations/alfredpay.md +++ b/docs/security-spec/05-integrations/alfredpay.md @@ -9,7 +9,7 @@ Alfredpay is a fiat payment provider supporting on-ramp and off-ramp operations **Chains involved:** Polygon (Alfredpay-side, USDT / `ALFREDPAY_EVM_TOKEN`), EVM destinations via SquidRouter (Polygon → Base/other) **Customer types:** Individual (KYC) and Business (KYB) — selected via `AlfredpayCustomerType`. The controller maps Alfredpay's KYB status to the platform's `AlfredPayStatus` via `mapKybStatus`; KYC is handled by `mapKycStatus`. Branch in `alfredpay.controller.ts` on `AlfredpayCustomerType.BUSINESS`. -**Provider platform (Penny adapter, 2026-09):** Alfredpay moved to a new platform and serves the Penny API through an adapter that keeps the legacy request paths: `ALFREDPAY_BASE_URL` defaults to `https://api.sandbox.alfredpay.io/adapters/penny` when `SANDBOX_ENABLED`, else `https://api.alfredpay.io/adapters/penny`; the legacy Penny hosts are decommissioned. Requests authenticate with an Alfred partner API key (`alfk_…`) as a bearer token and carry no business id, since the key identifies the company. An unauthenticated route probe (2026-09-29, production and sandbox) found every route `AlfredpayApiService` calls except `POST …/customers/{customerId}/kyc/{submissionId}/retry`, which only the US individual retry path reaches. US is not served on the new platform, so `USD` stays in `DISABLED_FIAT_CURRENCIES` until Alfred brings it online. +**Provider platform (Penny adapter, 2026-09):** Alfredpay moved to a new platform and serves the Penny API through an adapter that keeps the legacy request paths: `ALFREDPAY_BASE_URL` defaults to `https://api.sandbox.alfredpay.io/adapters/penny` when `SANDBOX_ENABLED`, else `https://api.alfredpay.io/adapters/penny`; the legacy Penny hosts are decommissioned. Requests authenticate with an Alfred partner API key (`alfk_…`) as a bearer token and carry no business id, since the key identifies the company. An unauthenticated route probe (2026-09-29, production and sandbox) found every route `AlfredpayApiService` calls except `POST …/customers/{customerId}/kyc/{submissionId}/retry`, which only the US individual retry path reaches. US is not served on the new platform, so `USD` stays in `DISABLED_FIAT_CURRENCIES` until Alfred brings it online. Open (2026-09-30): the adapter reports KYC `UPDATE_REQUIRED` for migrated customers that Alfred's own API lists as `ACTIVE` (and `COMPLETED` for one listed as `NOT_STARTED`); the status routes map it to `started`, which would block those customers from ramping until Alfred answers. **Verification outcome delivery:** Alfredpay exposes no verification webhook — `AlfredpayApiService` carries only request/response methods — so an outcome is only ever learned by polling `getKycStatus`/`getKybStatus`. `refreshAlfredpayCustomerStatus` owns that poll, persists the result, and queues the user's `verification_approved`/`verification_rejected` email; it runs both from the dashboard's status aggregation (TTL-throttled) and from `AlfredpayStatusWorker` (hourly) for users who never return. Alfredpay has no expiry status, so `verification_expired` is never produced for this provider. @@ -47,7 +47,7 @@ For routed Alfredpay onramps (any non-passthrough output), the final quote outpu **Request validation:** Alfredpay middleware (`alfredpay.middleware.ts`) validates the `country` parameter against the `AlfredPayCountry` enum for all Alfredpay-related requests. The country-prefixed aliases `/v1/mx/*`, `/v1/co/*`, and `/v1/ar/*` mount the same authenticated router as `/v1/alfredpay/*`; on those aliases, the path country is canonical and replaces any query or body country before validation and corridor authorization. The legacy `/v1/alfredpay/*` prefix remains available and continues to require the country in the request. -**Customer, KYC/KYB, and fiat-account routes:** These routes accept either a Supabase Bearer token or a user-scoped secret API key via `requirePartnerOrUserAuth()`. The controller resolves the effective profile so a manager-selected child or direct child credential uses the child's provider records. `GET /alfredpayStatus` accepts an optional `type` selector so headless business flows resolve the business customer explicitly; omitting it retains the active-entity lookup used by existing UI consumers. Managed-child mutations require the controlling manager's current country corridor, any non-null customer-type narrowing, and canonical corridor/type support. KYC/KYB action routes reject admin impersonation while status and business-detail reads remain available. Fiat-account creation and deletion deliberately remain available during admin impersonation as an accepted durable operator capability under RISK-018. Customer creation uses the child's immutable managed-profile contact email and provisioned entity type; it never inherits the manager's login email. Fiat-account PII is passed directly to Alfredpay and is not persisted in Vortex's database, browser storage, analytics, or notifications. Provider 4xx rejections are sanitized before reaching callers; provider 5xx and transport failures remain opaque. +**Customer, KYC/KYB, and fiat-account routes:** These routes accept either a Supabase Bearer token or a user-scoped secret API key via `requirePartnerOrUserAuth()`. The controller resolves the effective profile so a manager-selected child or direct child credential uses the child's provider records. `GET /alfredpayStatus` accepts an optional `type` selector so headless business flows resolve the business customer explicitly; omitting it retains the active-entity lookup used by existing UI consumers. Managed-child mutations require the controlling manager's current country corridor, any non-null customer-type narrowing, and canonical corridor/type support. KYC/KYB action routes reject admin impersonation while status and business-detail reads remain available. Fiat-account creation and deletion deliberately remain available during admin impersonation as an accepted durable operator capability under RISK-018. Customer creation uses the child's immutable managed-profile contact email and provisioned entity type; it never inherits the manager's login email. Fiat-account PII is passed directly to Alfredpay and is not persisted in Vortex's database, browser storage, analytics, or notifications. On the fiat-account routes, provider 4xx rejections are sanitized before reaching callers; provider 5xx and transport failures remain opaque. The KYC/KYB submission routes still return a provider rejection as a 500 carrying the provider message (known gap), which is why the identifiers the provider validates are checked first (invariant 37). ## Security Invariants @@ -84,7 +84,11 @@ For routed Alfredpay onramps (any non-passthrough output), the final quote outpu 30. **The demo Alfredpay stand-in MUST be unreachable outside an opted-in sandbox** — `installDemoProviders` (`api/services/demo/demo-alfredpay.provider.ts`, called once at startup) replaces `AlfredpayApiService.getInstance` with canned in-process KYB responses that always approve. It returns without doing anything unless `DEMO_PROVIDER_ENABLED=true`, and throws when that flag is set with any `DEPLOYMENT_ENV` other than `sandbox`; `config/vars.ts` repeats the check at load time so the process refuses to start rather than serving a mixed configuration. The flag is off by default precisely because a sandbox also serves partner integration testing, which must exercise the real provider. Only the business-KYB surface is faked — individual (KYC) customer creation and every other Alfredpay method fall through to the real client, so an unimplemented path fails visibly instead of returning invented data. The stand-in fabricates provider *status* only; it never writes `provider_customers`, and the demo restore that consumes it is itself sandbox-guarded. See `docs/adr-0004-sandbox-demo-environment.md`. 31. **An Alfredpay SELL order MUST be `CREATED` before Vortex's first provider-bound transfer** — a pre-transfer `FAILED` response terminates the ramp without moving the user's USDT; `ON_CHAIN_DEPOSIT_RECEIVED`, `TRADE_COMPLETED`, or either fiat-transfer state without a confirmed/replayed local transfer indicates an unexplained external side effect and requires reconciliation. A confirmed local transfer journal is replayed before this mutable status check. 32. **Alfredpay requests MUST authenticate with the Alfred partner API key as a bearer token and MUST NOT carry a business id** — `AlfredpayApiService` sends `Authorization: Bearer ` on every JSON request and multipart upload and never the legacy `api-key`/`api-secret` pair. Quote `metadata` carries only the tracking `customerId`: `AlfredpayQuoteMetadata` is closed, so a literal that adds `businessId` fails to compile. -33. **An upstream 404 on a status read MUST NOT demote an approved Alfredpay customer** — `GET /alfredpayStatus` resets a non-approved customer to `Consulted`/`pending` when Alfredpay answers 404 for their submission, so onboarding restarts; an approved customer keeps `approved`, because the platform migration can answer 404 for data Alfred has not moved yet and a status read must not force a fresh KYC. +33. **An upstream 404 on a status read MUST NOT demote an approved Alfredpay customer** — `GET /alfredpayStatus` and `GET /getKycStatus` reset a non-approved customer to `Consulted`/`pending` when Alfredpay answers 404 or no submission, so onboarding restarts; an approved customer keeps `approved`, because the platform migration can answer 404 for data Alfred has not moved yet and a status read must not force a fresh KYC. +34. **A failed provider read before the offramp transfer MUST be retried, not fatal** — `ensureLiveProviderOrder` (`alfredpay-offramp/execution.ts`) turns any non-phase error from `getOfframpTransaction` (404 for an order the adapter cannot find, 401, 5xx, transport, schema mismatch) into a recoverable error. The user's deposit and any subsidy already sit on the ephemeral and nothing has left it, so failing the ramp would strand them. +35. **Provider limits MUST fail toward our configured limits** — `AlfredpayLimitsService` indexes a bound only when it is a decimal string on the scale the quote path reads back (BUY: the fiat's decimals, SELL: 6). A null or absent bound keeps our configured bound for that side (the adapter serves null for "no limit"); rows without any bound, with another scale or with an unknown customer type are skipped; an empty listing keeps the previous limits. +36. **Provider onramp orders and quotes MUST be validated before use** — `createOnramp` normalizes the adapter's flat order (Penny nested it under `transaction`) and parses it with `alfredpayCreateOnrampResponseSchema`, so an order without an id fails inside the financial operation instead of showing the user instructions for an untrackable order. The mint simulation requires the quote to echo the requested `fromAmount`, which catches a change of units (Alfred's native API uses minor units). +37. **Individual KYC identifiers MUST be checked at the API boundary** — the Mexican `dni` must be a CURP with a valid check digit and Argentine individuals need a CUIT with a valid mod-11 check digit, enforced by `validateKycSubmission` and by the shared form schemas through `isValidCurp`/`isValidCuit` (`@vortexfi/shared`). The provider rejects both with an opaque `110002 Invalid field(s)` otherwise. ## Threat Vectors & Mitigations @@ -122,7 +126,11 @@ For routed Alfredpay onramps (any non-passthrough output), the final quote outpu - [x] HTTPS enforced for Alfredpay API calls. **PASS** — base URL uses `https://`. - [x] Every Alfredpay request, multipart uploads included, sends `Authorization: Bearer ` and no `api-key`/`api-secret`. **PASS** — `alfredpayApiService.test.ts`. - [x] Alfredpay quote requests carry no `businessId`. **PASS** — `AlfredpayQuoteMetadata` admits only `customerId`, enforced by the compiler. -- [x] `/alfredpayStatus` keeps an approved customer on an upstream 404 and still resets a non-approved one. **PASS** — `alfredpay-status-not-found.integration.test.ts`. +- [x] `/alfredpayStatus` and `/getKycStatus` keep an approved customer on an upstream 404 or an empty submission read and still reset a non-approved one. **PASS** — `alfredpay-status-not-found.integration.test.ts`. +- [x] A 404, 503 or transport error reading the order before the offramp transfer is retried and the ramp completes. **PASS** — `mxn-offramp.scenario.test.ts` ("reading the order before the transfer"). +- [x] Limits keep configured bounds for null/absent provider bounds, skip unusable rows and survive an empty listing. **PASS** — `alfredpay-limits.service.test.ts`. +- [x] `createOnramp` rejects an order without a `transactionId` in either shape; a 409 with a null maximum maps to the minimum breach; the mint rejects a quote that does not echo the input. **PASS** — `alfredpayApiService.test.ts`, `alfredpay-onramp-direct.flow.test.ts`. +- [x] CURP/CUIT check digits are enforced by the API validator and the form schemas. **PASS** — `validators.test.ts`, `identifiers.test.ts`, `packages/kyc/.../schemas.test.ts`. - [x] No Alfredpay credentials or user payment details in logs. **PASS** — no credential leakage observed in log statements. - [ ] Timeout configured for Alfredpay API calls. **FAIL F-014** — no explicit HTTP client timeout configured; relies on default system timeouts. - [x] `subsidizePreSwap` runs before `squidRouterSwap` on the onramp flow, and `finalSettlementSubsidy` runs before `alfredpayOfframpTransfer` on the offramp flow. **PASS** — flow tests pin both sequences. From 6f2d847adc69f30b1e02505a851413da9b7972e0 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Thu, 1 Oct 2026 18:53:44 +0200 Subject: [PATCH 20/25] fix(api): keep approved Alfredpay customers on an UPDATE_REQUIRED read Alfred's adapter reports UPDATE_REQUIRED for migrated customers its own API lists as ACTIVE. The status routes stored it, which demoted approved customers to started, and /retryKyc only reopens FAILED, so they could neither ramp nor re-verify. The provider-customer view, which every status write goes through, now ignores it for an approved customer, and /getKycStatus reports the stored status instead of the raw mapping. --- .../api/controllers/alfredpay.controller.ts | 2 +- .../alfredpay/alfredpay-customer.service.ts | 8 ++++ ...edpay-status-not-found.integration.test.ts | 47 ++++++++++++++++++- 3 files changed, 55 insertions(+), 2 deletions(-) diff --git a/apps/api/src/api/controllers/alfredpay.controller.ts b/apps/api/src/api/controllers/alfredpay.controller.ts index 00dde6176..44f8b60d5 100644 --- a/apps/api/src/api/controllers/alfredpay.controller.ts +++ b/apps/api/src/api/controllers/alfredpay.controller.ts @@ -588,7 +588,7 @@ export class AlfredpayController { alfred_pay_id: alfredPayCustomer.alfredPayId, country: alfredPayCustomer.country, lastFailure: updateData.lastFailureReasons?.[0] || alfredPayCustomer.lastFailureReasons?.[0], // Get the latest failure reason - status: (newStatus || alfredPayCustomer.status) as AlfredPayStatus, + status: alfredPayCustomer.status, updated_at: alfredPayCustomer.updatedAt.toISOString() }; diff --git a/apps/api/src/api/services/alfredpay/alfredpay-customer.service.ts b/apps/api/src/api/services/alfredpay/alfredpay-customer.service.ts index eb7b38514..5c9bebfac 100644 --- a/apps/api/src/api/services/alfredpay/alfredpay-customer.service.ts +++ b/apps/api/src/api/services/alfredpay/alfredpay-customer.service.ts @@ -157,6 +157,14 @@ function toView(record: ProviderCustomer): AlfredpayCustomerView { status: toAlfredPayStatus(record), type: customerTypeToAlfredpayType(record.customerType), async update(changes) { + // Alfred's new platform reports UPDATE_REQUIRED for customers it lists as ACTIVE (2026-09-30), and + // /retryKyc only reopens FAILED, so a status read must not move an approved customer there. + if ( + this.status === AlfredPayStatus.Success && + changes.statusExternal?.toUpperCase() === AlfredpayKycStatus.UPDATE_REQUIRED + ) { + return; + } await record.update({ ...(changes.verificationStatus !== undefined ? { status: changes.verificationStatus } diff --git a/apps/api/src/tests/alfredpay-status-not-found.integration.test.ts b/apps/api/src/tests/alfredpay-status-not-found.integration.test.ts index 8ab98bebb..8b6fc0a3c 100644 --- a/apps/api/src/tests/alfredpay-status-not-found.integration.test.ts +++ b/apps/api/src/tests/alfredpay-status-not-found.integration.test.ts @@ -9,7 +9,8 @@ import { startTestApp, type TestApp } from "../test-utils/test-app"; // GET /alfredpayStatus treats an upstream 404 for the customer's submission as a stale local // status and sends the customer back to onboarding. Alfred's platform migration can answer 404 for -// data it has not moved yet, so that reset must never demote an approved customer. +// data it has not moved yet, and UPDATE_REQUIRED for customers it lists as active, so no status read +// may demote an approved customer. let api: TestApp; let fakeAuth: FakeSupabaseAuth; @@ -115,3 +116,47 @@ describe("GET /getKycStatus when Alfredpay reports no submission", () => { expect(customer?.status).toBe(VerificationStatus.Pending); }); }); + +describe("status reads when Alfredpay reports UPDATE_REQUIRED", () => { + async function readStatus(route: "alfredpayStatus" | "getKycStatus", email: string, stored: AlfredPayStatus) { + const user = await createTestUser({ email }); + await createAlfredpayCustomer(user.id, { + alfredPayId: "ap-update-required", + country: DomesticCountry.MX, + status: stored, + type: DomesticCustomerType.INDIVIDUAL + }); + AlfredpayApiService.getInstance = mock( + () => + ({ + getKycStatus: mock(async () => ({ status: "UPDATE_REQUIRED", updatedAt: "2026-09-15T00:00:00.000Z" })), + getLastKycSubmission: mock(async () => ({ submissionId: "sub-update-required" })) + }) as unknown as AlfredpayApiService + ); + + const response = await api.request(`/v1/alfredpay/${route}?country=MX`, { + headers: { Authorization: `Bearer ${testUserToken(user.id, email)}` } + }); + expect(response.status).toBe(200); + const body = (await response.json()) as { status: AlfredPayStatus }; + const customer = await ProviderCustomer.findOne({ where: { providerCustomerId: "ap-update-required" } }); + return { customer, reported: body.status }; + } + + for (const route of ["alfredpayStatus", "getKycStatus"] as const) { + it(`GET /${route} keeps an approved customer approved`, async () => { + const { customer, reported } = await readStatus(route, `approved-update-${route}@example.com`, AlfredPayStatus.Success); + + expect(reported).toBe(AlfredPayStatus.Success); + expect(customer?.status).toBe(VerificationStatus.Approved); + expect(customer?.statusExternal).not.toBe("UPDATE_REQUIRED"); + }); + + it(`GET /${route} still moves an unapproved customer to UPDATE_REQUIRED`, async () => { + const { customer, reported } = await readStatus(route, `review-update-${route}@example.com`, AlfredPayStatus.UserCompleted); + + expect(reported).toBe(AlfredPayStatus.UpdateRequired); + expect(customer?.status).toBe(VerificationStatus.Started); + }); + } +}); From 24b83717a3d6fbdf5faab08cfba6af424da0bd72 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Thu, 1 Oct 2026 18:53:51 +0200 Subject: [PATCH 21/25] fix(api): pay Alfredpay offramps only to the customer's listed accounts Registration passed the caller's fiatAccountId straight to createOfframp, so only the provider checked that the account belongs to the customer, and nothing verified Alfred's new platform still does. Payout accounts also did not survive the migration, so a saved id now fails with a generic provider error. The preflight now requires the id in the customer's account list and answers 400 before an order exists. The nightly asserts the listing is scoped per customer. --- .../alfredpay-offramp.registration.test.ts | 70 ++++++++++++++++--- .../phases/alfredpay-offramp/registration.ts | 13 +++- .../src/test-utils/fake-world/fake-anchors.ts | 15 ++++ .../contracts/alfredpay.contract.test.ts | 13 ++++ .../alfredpay-currencies.scenario.test.ts | 9 ++- .../corridors/mxn-offramp.scenario.test.ts | 9 ++- docs/api/pages/09-fiat-corridors.md | 2 +- 7 files changed, 114 insertions(+), 17 deletions(-) diff --git a/apps/api/src/api/services/phases/blocks/__tests__/alfredpay-offramp.registration.test.ts b/apps/api/src/api/services/phases/blocks/__tests__/alfredpay-offramp.registration.test.ts index 47e90d36a..937ebae78 100644 --- a/apps/api/src/api/services/phases/blocks/__tests__/alfredpay-offramp.registration.test.ts +++ b/apps/api/src/api/services/phases/blocks/__tests__/alfredpay-offramp.registration.test.ts @@ -59,6 +59,8 @@ const metadata: AlfredpayOfframpMetadata = { toToken: "0x2222222222222222222222222222222222222222" as const }; +const listOwnedAccounts = async () => [{ fiatAccountId: "fiat-1" }]; + function context() { return { authenticatedUser: { id: "user-1" }, @@ -94,7 +96,8 @@ describe("Alfredpay offramp registration", () => { quoteId: "quote-new", toAmount: "1980", toCurrency: FiatToken.MXN - })) + })), + listFiatAccounts: listOwnedAccounts } as never; const result = await registerDomesticOfframp(context(), { resolveCustomerId: async () => "customer-1", @@ -127,7 +130,8 @@ describe("Alfredpay offramp registration", () => { quoteId: "quote-new", toAmount: "1979", toCurrency: FiatToken.MXN - })) + })), + listFiatAccounts: listOwnedAccounts } as never; await expect( registerDomesticOfframp(context(), { resolveCustomerId: async () => "customer-1", service }) @@ -148,7 +152,8 @@ describe("Alfredpay offramp registration", () => { quoteId: "quote-new", toAmount: "1980", toCurrency: FiatToken.MXN - })) + })), + listFiatAccounts: listOwnedAccounts } as never; await expect( registerDomesticOfframp(context(), { resolveCustomerId: async () => "customer-1", service }) @@ -169,7 +174,8 @@ describe("Alfredpay offramp registration", () => { quoteId: "quote-new", toAmount: "1980", toCurrency: FiatToken.COP - })) + })), + listFiatAccounts: listOwnedAccounts } as never; await expect( registerDomesticOfframp(context(), { resolveCustomerId: async () => "customer-1", service }) @@ -190,7 +196,8 @@ describe("Alfredpay offramp registration", () => { quoteId: "quote-near-expiry", toAmount: "1980", toCurrency: FiatToken.MXN - })) + })), + listFiatAccounts: listOwnedAccounts } as never; await expect( @@ -224,7 +231,8 @@ describe("Alfredpay offramp registration", () => { quoteId: "quote-new", toAmount: "1980", toCurrency: FiatToken.MXN - })) + })), + listFiatAccounts: listOwnedAccounts } as never; await expect( @@ -258,7 +266,8 @@ describe("Alfredpay offramp registration", () => { quoteId: "quote-new", toAmount: "1980", toCurrency: FiatToken.MXN - })) + })), + listFiatAccounts: listOwnedAccounts } as never; await expect( @@ -292,7 +301,8 @@ describe("Alfredpay offramp registration", () => { quoteId: "quote-short-lived", toAmount: "1980", toCurrency: FiatToken.MXN - })) + })), + listFiatAccounts: listOwnedAccounts } as never; const result = await registerDomesticOfframp(context(), { @@ -330,7 +340,8 @@ describe("Alfredpay offramp registration", () => { quoteId: "quote-new", toAmount: "1980", toCurrency: FiatToken.MXN - })) + })), + listFiatAccounts: listOwnedAccounts } as never; await expect( @@ -339,4 +350,45 @@ describe("Alfredpay offramp registration", () => { expect(createOrder).toHaveBeenCalledTimes(1); } }); + + it("rejects a payout account that is not in the customer's account list before creating an order", async () => { + const createOrder = mock(async () => ({})); + const listFiatAccounts = mock(async () => [{ fiatAccountId: "fiat-other" }]); + const service = { + createOfframp: createOrder, + createOfframpQuote: mock(async () => ({ + chain: AlfredpayChain.MATIC, + expiration: safeExpiration, + fees: [{ amount: "1", currency: "MXN" }], + fromAmount: "99", + fromCurrency: AlfredpayOnChainCurrency.USDT, + quoteId: "quote-new", + toAmount: "1980", + toCurrency: FiatToken.MXN + })), + listFiatAccounts + } as never; + + await expect( + registerDomesticOfframp(context(), { resolveCustomerId: async () => "customer-1", service }) + ).rejects.toMatchObject({ message: "This payout account is no longer registered. Add it again and retry.", status: 400 }); + expect(listFiatAccounts).toHaveBeenCalledWith("customer-1"); + expect(createOrder).not.toHaveBeenCalled(); + }); + + it("fails the preflight when the account list cannot be read", async () => { + const createOrder = mock(async () => ({})); + const service = { + createOfframp: createOrder, + createOfframpQuote: mock(async () => ({})), + listFiatAccounts: mock(async () => { + throw new Error("Request failed with status '503'"); + }) + } as never; + + await expect( + registerDomesticOfframp(context(), { resolveCustomerId: async () => "customer-1", service }) + ).rejects.toThrow("preflight failed before order creation"); + expect(createOrder).not.toHaveBeenCalled(); + }); }); diff --git a/apps/api/src/api/services/phases/blocks/phases/alfredpay-offramp/registration.ts b/apps/api/src/api/services/phases/blocks/phases/alfredpay-offramp/registration.ts index 5d354cff6..691829b64 100644 --- a/apps/api/src/api/services/phases/blocks/phases/alfredpay-offramp/registration.ts +++ b/apps/api/src/api/services/phases/blocks/phases/alfredpay-offramp/registration.ts @@ -34,7 +34,7 @@ export async function registerDomesticOfframp( ctx: RegisterCtx, dependencies: { resolveCustomerId?: typeof resolveAlfredpayCustomerId; - service?: Pick; + service?: Pick; sumFees?: typeof AlfredpayApiService.sumFeesByCurrency; } = {} ): Promise> { @@ -50,6 +50,7 @@ export async function registerDomesticOfframp( ); let customerId: string; let freshQuote: Awaited>; + let ownsFiatAccount: boolean; const service = dependencies.service ?? AlfredpayApiService.getInstance(); const toCurrency = ctx.metadata.currency as unknown as AlfredpayFiatCurrency; try { @@ -65,11 +66,21 @@ export async function registerDomesticOfframp( paymentMethodType: AlfredpayPaymentMethodType.BANK, toCurrency } satisfies CreateAlfredpayOfframpQuoteRequest); + // The payout account id comes from the caller. Accounts did not survive Alfred's platform + // migration, and the provider is the only other check that the account belongs to this customer. + const accounts = await service.listFiatAccounts(customerId); + ownsFiatAccount = accounts.some(account => account.fiatAccountId === ctx.input.fiatAccountId); } catch (error) { throw new FinancialOperationRejectedError( `Alfredpay offramp registration preflight failed before order creation: ${error instanceof Error ? error.message : String(error)}` ); } + if (!ownsFiatAccount) { + throw new APIError({ + message: "This payout account is no longer registered. Add it again and retry.", + status: httpStatus.BAD_REQUEST + }); + } const originalInput = new Big(ctx.metadata.inputAmountDecimal as unknown as string); const freshInput = new Big(freshQuote.fromAmount); const originalOutput = new Big(ctx.metadata.outputAmountDecimal as unknown as string); diff --git a/apps/api/src/test-utils/fake-world/fake-anchors.ts b/apps/api/src/test-utils/fake-world/fake-anchors.ts index b93671f7c..388eccfad 100644 --- a/apps/api/src/test-utils/fake-world/fake-anchors.ts +++ b/apps/api/src/test-utils/fake-world/fake-anchors.ts @@ -19,6 +19,7 @@ import { type CreateAlfredpayOnrampRequest, type CreateAlfredpayOnrampResponse, type DomesticFiatAccount, + DomesticFiatAccountType, type DomesticOfframpQuote, type DomesticOnrampQuote, type GetAlfredpayOnrampTransactionResponse, @@ -521,6 +522,20 @@ export class FakeAlfredpay { this.fiatAccountsByCustomer.get(customerId) ?? [] }; + /** Lists a payout account for the customer, which offramp registration requires. */ + addFiatAccount(customerId: string, fiatAccountId: string): void { + this.fiatAccountsByCustomer.set(customerId, [ + ...(this.fiatAccountsByCustomer.get(customerId) ?? []), + { + accountNumber: "646180157000000004", + accountType: "checking", + customerId, + fiatAccountId, + type: DomesticFiatAccountType.SPEI + } + ]); + } + asService(): AlfredpayApiService { return unimplementedProxy(this.impl, "FakeAlfredpay"); } diff --git a/apps/api/src/tests/contracts/alfredpay.contract.test.ts b/apps/api/src/tests/contracts/alfredpay.contract.test.ts index bf8a4aefa..43ffa8029 100644 --- a/apps/api/src/tests/contracts/alfredpay.contract.test.ts +++ b/apps/api/src/tests/contracts/alfredpay.contract.test.ts @@ -540,6 +540,19 @@ describe.skipIf(!LIVE)("Alfredpay external API contract — live", () => { 60_000 ); + // Offramp registration accepts a payout account only if it is in this customer's list. + test.skipIf(!FIAT_ACCOUNT_ID)( + "GET /fiatAccounts lists only the customer's own accounts", + async () => { + const accounts = await runLive("alfredpay listFiatAccounts for another customer", () => + api().listFiatAccounts(AR_COMPLETED_CUSTOMER_ID) + ); + if (!accounts) return; + expect(accounts.some(account => account.fiatAccountId === FIAT_ACCOUNT_ID)).toBe(false); + }, + 60_000 + ); + for (const accountCase of FIAT_ACCOUNT_LIFECYCLE_CASES) { test( `POST, GET and DELETE a ${accountCase.country} fiat account satisfy their contracts`, diff --git a/apps/api/src/tests/corridors/alfredpay-currencies.scenario.test.ts b/apps/api/src/tests/corridors/alfredpay-currencies.scenario.test.ts index f8af9ac8f..e16dd3f62 100644 --- a/apps/api/src/tests/corridors/alfredpay-currencies.scenario.test.ts +++ b/apps/api/src/tests/corridors/alfredpay-currencies.scenario.test.ts @@ -506,7 +506,8 @@ describe("Alfredpay currency corridors (USD/COP/ARS, on- and offramp)", () => { failNoncesProbe(); const user = await createTestUser(); - await createTestAlfredpayCustomer(user.id, { country: currency.country }); + const customer = await createTestAlfredpayCustomer(user.id, { country: currency.country }); + world.alfredpay.addFiatAccount(customer.providerCustomerId as string, "test-fiat-account-1"); const quote = await createQuoteViaApi({ from: Networks.Polygon, inputAmount: currency.offrampInputAmount, @@ -710,7 +711,8 @@ describe("Alfredpay currency corridors (USD/COP/ARS, on- and offramp)", () => { failNoncesProbe(); const user = await createTestUser(); - await createTestAlfredpayCustomer(user.id, { country: currency.country }); + const customer = await createTestAlfredpayCustomer(user.id, { country: currency.country }); + world.alfredpay.addFiatAccount(customer.providerCustomerId as string, "test-fiat-account-1"); const quote = await createQuoteViaApi({ from: source.network, inputAmount: currency.offrampInputAmount, @@ -1017,7 +1019,8 @@ describe("Alfredpay currency corridors (USD/COP/ARS, on- and offramp)", () => { const userWallet = privateKeyToAccount(generatePrivateKey()); const user = await createTestUser(); - await createTestAlfredpayCustomer(user.id, { country: currency.country }); + const customer = await createTestAlfredpayCustomer(user.id, { country: currency.country }); + world.alfredpay.addFiatAccount(customer.providerCustomerId as string, "test-fiat-account-1"); const quote = await createQuoteViaApi({ from: Networks.Polygon, inputAmount: currency.offrampInputAmount, diff --git a/apps/api/src/tests/corridors/mxn-offramp.scenario.test.ts b/apps/api/src/tests/corridors/mxn-offramp.scenario.test.ts index 0ea312dc0..b5de2ca09 100644 --- a/apps/api/src/tests/corridors/mxn-offramp.scenario.test.ts +++ b/apps/api/src/tests/corridors/mxn-offramp.scenario.test.ts @@ -197,7 +197,8 @@ describe("MXN offramp direct corridor (USDT on Polygon → spei, no-permit)", () const userWallet = privateKeyToAccount(generatePrivateKey()); const user = await createTestUser(); - await createTestAlfredpayCustomer(user.id); + const customer = await createTestAlfredpayCustomer(user.id); + world.alfredpay.addFiatAccount(customer.providerCustomerId as string, FIAT_ACCOUNT_ID); const quote = await createQuoteViaApi(inputAmount); const ramp = await registerViaApi(quote.id, user.id, ephemeral, userWallet); @@ -417,7 +418,8 @@ describe("MXN offramp direct corridor (USDT on Polygon → spei, no-permit)", () it("registration can retry safely after a pre-order provider quote drift", async () => { const user = await createTestUser(); - await createTestAlfredpayCustomer(user.id); + const customer = await createTestAlfredpayCustomer(user.id); + world.alfredpay.addFiatAccount(customer.providerCustomerId as string, FIAT_ACCOUNT_ID); const quote = await createQuoteViaApi(); const ephemeral = privateKeyToAccount(generatePrivateKey()); const userWallet = privateKeyToAccount(generatePrivateKey()); @@ -474,7 +476,8 @@ describe("MXN offramp direct corridor (USDT on Polygon → spei, no-permit)", () expect(quote.outputAmount).toBe("19980.00"); const user = await createTestUser(); - await createTestAlfredpayCustomer(user.id); + const customer = await createTestAlfredpayCustomer(user.id); + world.alfredpay.addFiatAccount(customer.providerCustomerId as string, FIAT_ACCOUNT_ID); const ephemeral = privateKeyToAccount(generatePrivateKey()); const userWallet = privateKeyToAccount(generatePrivateKey()); world.squidRouter.computeToAmountMin = () => parseUnits("900", 6).toString(); diff --git a/docs/api/pages/09-fiat-corridors.md b/docs/api/pages/09-fiat-corridors.md index 99cd5a098..70dd6bdf2 100644 --- a/docs/api/pages/09-fiat-corridors.md +++ b/docs/api/pages/09-fiat-corridors.md @@ -169,7 +169,7 @@ Ramp registration resolves KYC and payment identity from the effective profile, ### Fiat Accounts -Sells pay out to a saved bank account referenced by `fiatAccountId` in the register call. It is required for sells and optional for buys. The account is created during onboarding in the Vortex app or Widget; the ID is opaque to the SDK and the API client. +Sells pay out to a saved bank account referenced by `fiatAccountId` in the register call. It is required for sells and optional for buys. The account is created during onboarding in the Vortex app or Widget; the ID is opaque to the SDK and the API client. Registration checks that the account is still saved for the user and answers `400` otherwise, so a removed account must be added again before the sell. ### Payment Instructions On Buys From abecd90047548674ed39d046d3edc810fc41695e Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Thu, 1 Oct 2026 18:53:56 +0200 Subject: [PATCH 22/25] feat(shared): pause MX/CO business verification in onboarding discovery Alfred's new platform requires KYB fields our form and API do not collect (business type, operating address, signer details and more), so every MX/CO business submission fails at the provider. One predicate marks the pause for discovery, the KYC machine and the API, and discovery stops advertising those flows until the KYB rework. --- .../routes/v1/onboarding-requirements.route.test.ts | 12 +++++++++--- .../endpoints/onboarding-requirements.endpoints.ts | 2 ++ packages/shared/src/services/alfredpay/types.ts | 9 +++++++++ 3 files changed, 20 insertions(+), 3 deletions(-) diff --git a/apps/api/src/api/routes/v1/onboarding-requirements.route.test.ts b/apps/api/src/api/routes/v1/onboarding-requirements.route.test.ts index d30976156..39b465dee 100644 --- a/apps/api/src/api/routes/v1/onboarding-requirements.route.test.ts +++ b/apps/api/src/api/routes/v1/onboarding-requirements.route.test.ts @@ -12,17 +12,23 @@ describe("GET /v1/onboarding/requirements", () => { try { const { port } = server.address() as AddressInfo; - const response = await fetch(`http://127.0.0.1:${port}/v1/onboarding/requirements?country=MX&customerType=business`); + const response = await fetch(`http://127.0.0.1:${port}/v1/onboarding/requirements?country=MX&customerType=individual`); expect(response.status).toBe(200); const body = (await response.json()) as Record; expect(body).toMatchObject({ country: "MX", - customerType: "business", - flow: "mx-business-api-kyb", + customerType: "individual", + flow: "mx-individual-api-kyc", family: "domestic" }); expect(body).not.toHaveProperty("fields"); + + // Business verification is paused in MX and CO, so discovery must not advertise it. + for (const country of ["MX", "CO"]) { + const paused = await fetch(`http://127.0.0.1:${port}/v1/onboarding/requirements?country=${country}&customerType=business`); + expect(paused.status).toBe(404); + } } finally { server.close(); } diff --git a/packages/shared/src/endpoints/onboarding-requirements.endpoints.ts b/packages/shared/src/endpoints/onboarding-requirements.endpoints.ts index 030c256dd..bd769ad4b 100644 --- a/packages/shared/src/endpoints/onboarding-requirements.endpoints.ts +++ b/packages/shared/src/endpoints/onboarding-requirements.endpoints.ts @@ -1,4 +1,5 @@ import type { CorridorCustomerType } from "../corridors"; +import { isAlfredpayBusinessKybPaused } from "../services/alfredpay/types"; export type OnboardingRequirementsCountry = "AR" | "BR" | "CO" | "MX" | "US"; export type OnboardingFlowMode = "api" | "hosted" | "hybrid"; @@ -426,5 +427,6 @@ export function getOnboardingRequirements( country: OnboardingRequirementsCountry, customerType: CorridorCustomerType ): GetOnboardingRequirementsResponse | undefined { + if (customerType === "business" && isAlfredpayBusinessKybPaused(country)) return undefined; return ONBOARDING_REQUIREMENTS[country][customerType]; } diff --git a/packages/shared/src/services/alfredpay/types.ts b/packages/shared/src/services/alfredpay/types.ts index d24a361ec..17e403d8b 100644 --- a/packages/shared/src/services/alfredpay/types.ts +++ b/packages/shared/src/services/alfredpay/types.ts @@ -369,6 +369,15 @@ const ALFREDPAY_FIAT_TOKEN_SET: ReadonlySet = new Set([ export const isDomesticToken = (token: RampCurrency): token is FiatToken => ALFREDPAY_FIAT_TOKEN_SET.has(token); +/** + * Alfred's new platform requires KYB fields our MX/CO form and API do not collect yet (2026-09-30), so + * business verification there is paused instead of failing at submission. Remove once that is reworked. + */ +export const isAlfredpayBusinessKybPaused = (country: string | undefined): boolean => + country === DomesticCountry.MX || country === DomesticCountry.CO; + +export const ALFREDPAY_BUSINESS_KYB_PAUSED_MESSAGE = "Business verification in Mexico and Colombia is temporarily unavailable"; + /** * Raw shape returned by `GET …/allConfigs`. `typeCustomer: null` means the pair applies * to both customer types. The listing contains junk rows (observed live, 2026-07-14): From 8db833d6f9a6e86025e0017e3aeaa8239d936332 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Thu, 1 Oct 2026 18:54:04 +0200 Subject: [PATCH 23/25] fix(kyc): stop MX/CO business verification before the provider Business users in the widget and dashboard filled the whole KYB form and then failed at submission. The machine now fails a paused MX/CO business flow up front with a clear message, both for business input and for an individual who switches to business. The KYB tests lift the pause so the form flow stays covered for its rework. --- .../e2e/onboarding-alfredpay-mxn-kyb.spec.ts | 21 ++++- packages/kyc/src/alfredpay/machine.test.ts | 81 ++++++++++++++++--- packages/kyc/src/alfredpay/machine.ts | 19 ++++- 3 files changed, 105 insertions(+), 16 deletions(-) diff --git a/apps/dashboard/e2e/onboarding-alfredpay-mxn-kyb.spec.ts b/apps/dashboard/e2e/onboarding-alfredpay-mxn-kyb.spec.ts index cff33a401..53637b5c9 100644 --- a/apps/dashboard/e2e/onboarding-alfredpay-mxn-kyb.spec.ts +++ b/apps/dashboard/e2e/onboarding-alfredpay-mxn-kyb.spec.ts @@ -4,7 +4,26 @@ import { seedSession } from "./support/session"; const documentFile = { buffer: Buffer.from("e2e-kyb-document"), mimeType: "application/pdf", name: "document.pdf" }; -test("Alfredpay MX business KYB submits the questionnaire and six documents, and reaches provider approval", async ({ +test("Alfredpay MX business KYB is paused with a clear message", async ({ page }) => { + await mockBackend(page, { alfredpayKyc: {}, companyMode: true }); + await seedSession(page); + await page.goto("/overview"); + + await expect(page.getByText("No corridors added yet")).toBeVisible({ timeout: 20_000 }); + await page.getByRole("button", { name: "Add corridor" }).click(); + const addDialog = page.getByRole("dialog"); + await addDialog.getByRole("combobox").click(); + await page.getByRole("option", { name: /Mexico/ }).click(); + await addDialog.getByRole("button", { name: "Add card" }).click(); + + await page.getByRole("button", { name: "Start KYB" }).click(); + await expect( + page.getByRole("dialog").getByText("Business verification in Mexico and Colombia is temporarily unavailable") + ).toBeVisible({ timeout: 20_000 }); +}); + +// Paused with MX/CO business verification (isAlfredpayBusinessKybPaused); kept for the KYB form rework. +test.skip("Alfredpay MX business KYB submits the questionnaire and six documents, and reaches provider approval", async ({ page }) => { const backend = await mockBackend(page, { alfredpayKyc: {}, companyMode: true }); diff --git a/packages/kyc/src/alfredpay/machine.test.ts b/packages/kyc/src/alfredpay/machine.test.ts index 490bb2f98..04d374953 100644 --- a/packages/kyc/src/alfredpay/machine.test.ts +++ b/packages/kyc/src/alfredpay/machine.test.ts @@ -66,11 +66,16 @@ const kybBusinessFiles = { taxIdDocument: {} as File } as KybBusinessFiles; +// MX/CO business verification is paused in production; the KYB tests lift the pause to keep the +// form flow covered for its rework. +const kybFlowEnabled = { isBusinessKybPaused: () => false }; + function createTestActor( actors: Parameters[0]["actors"], - input: AlfredpayKycContext = baseInput + input: AlfredpayKycContext = baseInput, + guards?: typeof kybFlowEnabled ) { - return createActor(alfredpayKycMachine.provide({ actors }), { input }); + return createActor(alfredpayKycMachine.provide({ actors, guards }), { input }); } beforeEach(() => { @@ -369,7 +374,8 @@ describe("alfredpayKycMachine", () => { submitKybInfo: fromPromise(async () => ({ submissionId: "kyb-sub-1" }) as SubmitKybInformationResponse), submitKybRelatedPersonBundleFiles: fromPromise(async () => undefined) }, - kybInput + kybInput, + kybFlowEnabled ); actor.start(); @@ -397,7 +403,8 @@ describe("alfredpayKycMachine", () => { checkStatus: fromPromise(async () => statusOf(AlfredPayStatus.Consulted)), submitKybInfo: fromPromise(async () => ({ submissionId: "" }) as SubmitKybInformationResponse) }, - kybInput + kybInput, + kybFlowEnabled ); actor.start(); @@ -417,7 +424,8 @@ describe("alfredpayKycMachine", () => { submitKybBusinessFiles: fromPromise(async () => undefined), submitKybInfo: fromPromise(async () => ({ submissionId: "kyb-sub-1" }) as SubmitKybInformationResponse) }, - kybInput + kybInput, + kybFlowEnabled ); actor.start(); @@ -452,7 +460,8 @@ describe("alfredpayKycMachine", () => { bundledIds = input.kybRelatedPersonIds; }) }, - kybInput + kybInput, + kybFlowEnabled ); actor.start(); @@ -480,7 +489,8 @@ describe("alfredpayKycMachine", () => { submitKybBusinessFiles: fromPromise(async () => undefined), submitKybInfo: fromPromise(async () => ({ submissionId: "kyb-sub-current" }) as SubmitKybInformationResponse) }, - kybInput + kybInput, + kybFlowEnabled ); actor.start(); @@ -494,6 +504,55 @@ describe("alfredpayKycMachine", () => { expect(actor.getSnapshot().context.error?.message).toContain("no relatedPersons[].idRelatedPerson"); }); + it("pauses MX and CO business verification before making a provider request", async () => { + for (const country of ["MX", "CO"]) { + let statusCalls = 0; + const actor = createTestActor( + { + checkStatus: fromPromise(async () => { + statusCalls += 1; + return statusOf(AlfredPayStatus.Consulted); + }) + }, + { ...baseInput, business: true, country } + ); + actor.start(); + + await waitFor(actor, s => s.matches("Failure")); + expect(statusCalls).toBe(0); + expect(actor.getSnapshot().context.error?.message).toBe( + "Business verification in Mexico and Colombia is temporarily unavailable" + ); + } + }); + + it("stops an MX individual who switches to business before a business customer is created", async () => { + let createCalls = 0; + const actor = createTestActor( + { + checkStatus: fromPromise(async () => { + throw new Error("Request failed with status 404"); + }), + createCustomer: fromPromise(async () => { + createCalls += 1; + return { createdAt: new Date().toISOString() }; + }) + }, + { ...baseInput, country: "MX" } + ); + actor.start(); + + await waitFor(actor, s => s.matches("CustomerDefinition")); + actor.send({ type: "TOGGLE_BUSINESS" }); + actor.send({ type: "USER_ACCEPT" }); + + await waitFor(actor, s => s.matches("Failure")); + expect(createCalls).toBe(0); + expect(actor.getSnapshot().context.error?.message).toBe( + "Business verification in Mexico and Colombia is temporarily unavailable" + ); + }); + it("rejects AR business before making a provider request", async () => { let statusCalls = 0; const actor = createTestActor( @@ -597,7 +656,7 @@ describe("alfredpayKycMachine KYB actors (real, recording API)", () => { it("merges the company form and the questionnaire into one Alfredpay payload", async () => { const { api, calls } = recordingApi(); - const machine = createAlfredpayKycMachine({ api, openVerificationUrl: () => {} }); + const machine = createAlfredpayKycMachine({ api, openVerificationUrl: () => {} }).provide({ guards: kybFlowEnabled }); const actor = createActor(machine, { input: { business: true, country: "MX" } }); actor.start(); @@ -611,7 +670,7 @@ describe("alfredpayKycMachine KYB actors (real, recording API)", () => { it("uploads all four company documents and the representative pair against the discovered person", async () => { const { api, calls } = recordingApi(); - const machine = createAlfredpayKycMachine({ api, openVerificationUrl: () => {} }); + const machine = createAlfredpayKycMachine({ api, openVerificationUrl: () => {} }).provide({ guards: kybFlowEnabled }); const actor = createActor(machine, { input: { business: true, country: "MX" } }); actor.start(); @@ -640,7 +699,7 @@ describe("alfredpayKycMachine KYB actors (real, recording API)", () => { it("uploads the licence and AML policy for a regulated business", async () => { const { api, calls } = recordingApi(); - const machine = createAlfredpayKycMachine({ api, openVerificationUrl: () => {} }); + const machine = createAlfredpayKycMachine({ api, openVerificationUrl: () => {} }).provide({ guards: kybFlowEnabled }); const actor = createActor(machine, { input: { business: true, country: "MX" } }); actor.start(); @@ -676,7 +735,7 @@ describe("alfredpayKycMachine KYB actors (real, recording API)", () => { it("refuses to upload a regulated business's documents when the licence or AML policy is missing", async () => { const { api, calls } = recordingApi(); - const machine = createAlfredpayKycMachine({ api, openVerificationUrl: () => {} }); + const machine = createAlfredpayKycMachine({ api, openVerificationUrl: () => {} }).provide({ guards: kybFlowEnabled }); const actor = createActor(machine, { input: { business: true, country: "MX" } }); actor.start(); diff --git a/packages/kyc/src/alfredpay/machine.ts b/packages/kyc/src/alfredpay/machine.ts index 3b4b4ea5d..e1af68d7b 100644 --- a/packages/kyc/src/alfredpay/machine.ts +++ b/packages/kyc/src/alfredpay/machine.ts @@ -1,9 +1,11 @@ import { + ALFREDPAY_BUSINESS_KYB_PAUSED_MESSAGE, AlfredPayStatus, AlfredpayKybFileType, AlfredpayKybRelatedPersonFileType, AlfredpayKycFileType, DomesticCustomerType, + isAlfredpayBusinessKybPaused, type SubmitKybInformationResponse, type SubmitKycInformationResponse } from "@vortexfi/shared"; @@ -72,7 +74,11 @@ export function createAlfredpayKycMachine({ api, openVerificationUrl }: Alfredpa if (context.verificationUrl) { openVerificationUrl(context.verificationUrl); } - } + }, + rejectPausedBusinessKyb: assign({ + error: () => + new AlfredpayKycMachineError(ALFREDPAY_BUSINESS_KYB_PAUSED_MESSAGE, AlfredpayKycMachineErrorType.UnknownError) + }) }, actors: { checkStatus: fromPromise(async ({ input }: { input: AlfredpayKycContext }) => { @@ -298,6 +304,9 @@ export function createAlfredpayKycMachine({ api, openVerificationUrl }: Alfredpa throw new Error("Aborted"); }) }, + guards: { + isBusinessKybPaused: ({ context }) => !!context.business && isAlfredpayBusinessKybPaused(context.country) + }, types: { context: {} as AlfredpayKycContext, events: {} as @@ -434,9 +443,10 @@ export function createAlfredpayKycMachine({ api, openVerificationUrl }: Alfredpa }), guard: ({ context }) => context.country !== "AR" }, - USER_ACCEPT: { - target: "CreatingCustomer" - } + USER_ACCEPT: [ + { actions: "rejectPausedBusinessKyb", guard: "isBusinessKybPaused", target: "Failure" }, + { target: "CreatingCustomer" } + ] } }, Done: { @@ -915,6 +925,7 @@ export function createAlfredpayKycMachine({ api, openVerificationUrl }: Alfredpa guard: ({ context }) => context.country === "AR" && !!context.business, target: "Failure" }, + { actions: "rejectPausedBusinessKyb", guard: "isBusinessKybPaused", target: "Failure" }, { target: "CheckingStatus" } ] }, From 992ee6c7ca409d03878366d6aa7c0d54b31edea3 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Thu, 1 Oct 2026 18:54:04 +0200 Subject: [PATCH 24/25] fix(api): answer 503 for new MX/CO Alfredpay business customers A partner that skips discovery would otherwise create a business customer whose KYB submission can only fail at the provider. The check runs after the duplicate-customer check so an existing customer still gets its specific answer. The managed delegation test moves to the US corridor, which still onboards businesses. --- .../api/controllers/alfredpay.controller.ts | 6 ++ ...ay-business-kyb-paused.integration.test.ts | 58 +++++++++++++++++++ ...edpay-managed-customer.integration.test.ts | 6 +- docs/api/openapi/vortex.openapi.d.ts | 9 +++ docs/api/openapi/vortex.openapi.json | 10 ++++ docs/api/pages/09-fiat-corridors.md | 8 +-- 6 files changed, 90 insertions(+), 7 deletions(-) create mode 100644 apps/api/src/tests/alfredpay-business-kyb-paused.integration.test.ts diff --git a/apps/api/src/api/controllers/alfredpay.controller.ts b/apps/api/src/api/controllers/alfredpay.controller.ts index 44f8b60d5..8a773f333 100644 --- a/apps/api/src/api/controllers/alfredpay.controller.ts +++ b/apps/api/src/api/controllers/alfredpay.controller.ts @@ -1,4 +1,5 @@ import { + ALFREDPAY_BUSINESS_KYB_PAUSED_MESSAGE, AlfredPayStatus, AlfredpayApiError, AlfredpayApiService, @@ -19,6 +20,7 @@ import { DomesticGetKycStatusResponse, DomesticStatusRequest, DomesticStatusResponse, + isAlfredpayBusinessKybPaused, SubmitKybInformationRequest, SubmitKycInformationRequest } from "@vortexfi/shared"; @@ -674,6 +676,10 @@ export class AlfredpayController { return res.status(400).json({ error: "Business customer already exists" }); } + if (isAlfredpayBusinessKybPaused(country)) { + return res.status(httpStatus.SERVICE_UNAVAILABLE).json({ error: ALFREDPAY_BUSINESS_KYB_PAUSED_MESSAGE }); + } + const alfredpayService = AlfredpayApiService.getInstance(); let customerId: string; diff --git a/apps/api/src/tests/alfredpay-business-kyb-paused.integration.test.ts b/apps/api/src/tests/alfredpay-business-kyb-paused.integration.test.ts new file mode 100644 index 000000000..0b983ed73 --- /dev/null +++ b/apps/api/src/tests/alfredpay-business-kyb-paused.integration.test.ts @@ -0,0 +1,58 @@ +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, mock } from "bun:test"; +import { AlfredpayApiService } from "@vortexfi/shared"; +import { resetTestDatabase, setupTestDatabase } from "../test-utils/db"; +import { createTestUser } from "../test-utils/factories"; +import { type FakeSupabaseAuth, installFakeSupabaseAuth, testUserToken } from "../test-utils/fake-world/fake-auth"; +import { startTestApp, type TestApp } from "../test-utils/test-app"; + +// MX/CO business verification is paused until our KYB contract carries the fields Alfred's new +// platform requires; a partner calling the API directly must get a clear answer instead of a business +// customer whose KYB submission can only fail. + +let api: TestApp; +let fakeAuth: FakeSupabaseAuth; +const realGetInstance = AlfredpayApiService.getInstance; + +beforeAll(async () => { + await setupTestDatabase(); + fakeAuth = installFakeSupabaseAuth(); + api = await startTestApp(); +}); + +afterAll(async () => { + await api.close(); + fakeAuth.restore(); +}); + +beforeEach(async () => { + await resetTestDatabase(); +}); + +afterEach(() => { + AlfredpayApiService.getInstance = realGetInstance; +}); + +describe("business verification paused for MX and CO", () => { + for (const path of ["/v1/alfredpay/createBusinessCustomer", "/v1/co/createBusinessCustomer"]) { + it(`POST ${path} answers 503 without calling the provider`, async () => { + const providerCalled = mock(() => { + throw new Error("provider must not be called"); + }); + AlfredpayApiService.getInstance = providerCalled as unknown as typeof AlfredpayApiService.getInstance; + const email = `paused-${path.replaceAll("/", "-")}@example.com`; + const user = await createTestUser({ email }); + + const response = await api.request(path, { + body: JSON.stringify({ country: path.startsWith("/v1/co/") ? undefined : "MX" }), + headers: { Authorization: `Bearer ${testUserToken(user.id, email)}`, "Content-Type": "application/json" }, + method: "POST" + }); + + expect(response.status).toBe(503); + expect(await response.json()).toEqual({ + error: "Business verification in Mexico and Colombia is temporarily unavailable" + }); + expect(providerCalled).not.toHaveBeenCalled(); + }); + } +}); diff --git a/apps/api/src/tests/alfredpay-managed-customer.integration.test.ts b/apps/api/src/tests/alfredpay-managed-customer.integration.test.ts index 935594435..db9a1e8b2 100644 --- a/apps/api/src/tests/alfredpay-managed-customer.integration.test.ts +++ b/apps/api/src/tests/alfredpay-managed-customer.integration.test.ts @@ -120,14 +120,14 @@ describe("managed Alfredpay customer creation", () => { }); it("allows manager secret delegation for business creation", async () => { - const manager = await createManager(["CO"]); + const manager = await createManager(["US"]); const child = await createChild(manager.id, "business", "business@example.com"); const credential = await createTestApiKey({ userId: manager.id }); const createCustomer = mock(async () => ({ customerId: "alfred-business", createdAt: new Date().toISOString() })); provider(createCustomer); const response = await fetch(`${baseUrl}/createBusinessCustomer`, { - body: JSON.stringify({ country: "CO" }), + body: JSON.stringify({ country: "US" }), headers: { "Content-Type": "application/json", "X-API-Key": credential.plaintextKey, @@ -137,7 +137,7 @@ describe("managed Alfredpay customer creation", () => { }); expect(response.status).toBe(200); - expect(createCustomer).toHaveBeenCalledWith("business@example.com", DomesticCustomerType.BUSINESS, "CO"); + expect(createCustomer).toHaveBeenCalledWith("business@example.com", DomesticCustomerType.BUSINESS, "US"); }); it("rejects the wrong child type and disallowed corridor before provider access", async () => { diff --git a/docs/api/openapi/vortex.openapi.d.ts b/docs/api/openapi/vortex.openapi.d.ts index e994bdb33..ceeae5be3 100644 --- a/docs/api/openapi/vortex.openapi.d.ts +++ b/docs/api/openapi/vortex.openapi.d.ts @@ -4945,6 +4945,15 @@ export interface operations { "application/json": components["schemas"]["DomesticErrorResponse"]; }; }; + /** @description Business verification is paused for this country (Colombia and Mexico). */ + 503: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["DomesticErrorResponse"]; + }; + }; }; }; createDomesticIndividualCustomer: { diff --git a/docs/api/openapi/vortex.openapi.json b/docs/api/openapi/vortex.openapi.json index 8e064caa6..2c363ecd0 100644 --- a/docs/api/openapi/vortex.openapi.json +++ b/docs/api/openapi/vortex.openapi.json @@ -6193,6 +6193,16 @@ } }, "description": "The provider returned an invalid existing-customer response." + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DomesticErrorResponse" + } + } + }, + "description": "Business verification is paused for this country (Colombia and Mexico)." } }, "security": [ diff --git a/docs/api/pages/09-fiat-corridors.md b/docs/api/pages/09-fiat-corridors.md index 70dd6bdf2..299b53070 100644 --- a/docs/api/pages/09-fiat-corridors.md +++ b/docs/api/pages/09-fiat-corridors.md @@ -25,8 +25,8 @@ Pin the `requirementsVersion` you integrated against and re-check discovery when |---|---|---| | `BR` | `hybrid` (API + hosted liveness) | `api` (KYB Level 1) | | `AR` | `api` | not supported | -| `CO` | `api` | `api` | -| `MX` | `api` | `api` | +| `CO` | `api` | paused | +| `MX` | `api` | paused | | `US` | `hosted` | `hosted` | EUR onboarding is not part of discovery. The active EUR ramp accepts only users whose approved provider profile, Polygon EOA, and IBAN were provisioned out of band and bound to their Vortex legal entity. Automated onboarding, wallet linking, IBAN provisioning, and external-user import are not part of the current integration. Provider state is always authoritative: no discovery step, client notification, or completion event can mark a verification approved. @@ -160,10 +160,10 @@ Identity numbers are checked before a submission is created: a Mexican `dni` mus Onboarding can be completed three ways: - **Vortex app or hosted Widget** — always available. Business users can be sent straight into verification with the [KYB Deep Link](https://api-docs.vortexfinance.co/kyb-deep-link). -- **API-driven** (`mode: "api"` in discovery) — Argentina individuals, and Colombia and Mexico individuals and businesses. The discovered steps create the provider customer, create the KYC/KYB submission, upload each required document (businesses also upload identity documents for each related person), and finalize the submission. Discovery publishes these steps under `/v1/domestic/*`, which takes the country from each step's `fixedBody` discriminator. The `/v1/ar`, `/v1/co`, and `/v1/mx` prefixes are equivalent and pin the country through the URL instead, overriding any country supplied in the query or body. The legacy `/v1/alfredpay/*` prefix remains supported as a migration alias. Request shapes come from the referenced OpenAPI schemas, and `derivedValues` carry the `submissionId` from the create-submission response into the upload and finalize calls. +- **API-driven** (`mode: "api"` in discovery) — Argentina, Colombia and Mexico individuals. The discovered steps create the provider customer, create the KYC/KYB submission, upload each required document (businesses also upload identity documents for each related person), and finalize the submission. Discovery publishes these steps under `/v1/domestic/*`, which takes the country from each step's `fixedBody` discriminator. The `/v1/ar`, `/v1/co`, and `/v1/mx` prefixes are equivalent and pin the country through the URL instead, overriding any country supplied in the query or body. The legacy `/v1/alfredpay/*` prefix remains supported as a migration alias. Request shapes come from the referenced OpenAPI schemas, and `derivedValues` carry the `submissionId` from the create-submission response into the upload and finalize calls. - **Provider-hosted** (`mode: "hosted"` in discovery) — United States, both customer types. After creating the provider customer, open the provider-hosted verification URL, then report `kycRedirectOpened` and, when the user says they finished, `kycRedirectFinished`. Both notifications are bookkeeping only — they never approve a verification; the provider's decision is authoritative. -Argentina business onboarding is not supported. After finalizing any flow, track the outcome through `GET /v1/onboarding/status`; provider review is asynchronous and there is no synchronous approval response. +Argentina business onboarding is not supported. Colombia and Mexico business onboarding is paused while the provider's business verification requirements change: discovery returns `404` for these combinations and `createBusinessCustomer` answers `503`. After finalizing any flow, track the outcome through `GET /v1/onboarding/status`; provider review is asynchronous and there is no synchronous approval response. Ramp registration resolves KYC and payment identity from the effective profile, not from payment or identity fields in the request. Authenticate as the user through a user-scoped key or Supabase Bearer session. Alternatively, an enabled managed-profile manager may use its secret key or session with `X-Managed-Profile-Id`; Vortex verifies the direct child relationship, corridor, immutable customer type, optional manager narrowing, and canonical corridor/type support before resolving the child's KYC/provider records. See [Authentication And API Keys](https://api-docs.vortexfinance.co/authentication-and-partner-keys). Quotes remain available anonymously for rate discovery; eligibility is enforced at registration time, not quote time. From 9affe4d1b3bb165cdcb8289f076733c4bd22a6e5 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Thu, 1 Oct 2026 18:54:07 +0200 Subject: [PATCH 25/25] docs(api): record the Alfredpay status, payout and KYB pause invariants --- docs/security-spec/05-integrations/alfredpay.md | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/docs/security-spec/05-integrations/alfredpay.md b/docs/security-spec/05-integrations/alfredpay.md index 655b7c84b..34e47cffe 100644 --- a/docs/security-spec/05-integrations/alfredpay.md +++ b/docs/security-spec/05-integrations/alfredpay.md @@ -9,7 +9,7 @@ Alfredpay is a fiat payment provider supporting on-ramp and off-ramp operations **Chains involved:** Polygon (Alfredpay-side, USDT / `ALFREDPAY_EVM_TOKEN`), EVM destinations via SquidRouter (Polygon → Base/other) **Customer types:** Individual (KYC) and Business (KYB) — selected via `AlfredpayCustomerType`. The controller maps Alfredpay's KYB status to the platform's `AlfredPayStatus` via `mapKybStatus`; KYC is handled by `mapKycStatus`. Branch in `alfredpay.controller.ts` on `AlfredpayCustomerType.BUSINESS`. -**Provider platform (Penny adapter, 2026-09):** Alfredpay moved to a new platform and serves the Penny API through an adapter that keeps the legacy request paths: `ALFREDPAY_BASE_URL` defaults to `https://api.sandbox.alfredpay.io/adapters/penny` when `SANDBOX_ENABLED`, else `https://api.alfredpay.io/adapters/penny`; the legacy Penny hosts are decommissioned. Requests authenticate with an Alfred partner API key (`alfk_…`) as a bearer token and carry no business id, since the key identifies the company. An unauthenticated route probe (2026-09-29, production and sandbox) found every route `AlfredpayApiService` calls except `POST …/customers/{customerId}/kyc/{submissionId}/retry`, which only the US individual retry path reaches. US is not served on the new platform, so `USD` stays in `DISABLED_FIAT_CURRENCIES` until Alfred brings it online. Open (2026-09-30): the adapter reports KYC `UPDATE_REQUIRED` for migrated customers that Alfred's own API lists as `ACTIVE` (and `COMPLETED` for one listed as `NOT_STARTED`); the status routes map it to `started`, which would block those customers from ramping until Alfred answers. +**Provider platform (Penny adapter, 2026-09):** Alfredpay moved to a new platform and serves the Penny API through an adapter that keeps the legacy request paths: `ALFREDPAY_BASE_URL` defaults to `https://api.sandbox.alfredpay.io/adapters/penny` when `SANDBOX_ENABLED`, else `https://api.alfredpay.io/adapters/penny`; the legacy Penny hosts are decommissioned. Requests authenticate with an Alfred partner API key (`alfk_…`) as a bearer token and carry no business id, since the key identifies the company. An unauthenticated route probe (2026-09-29, production and sandbox) found every route `AlfredpayApiService` calls except `POST …/customers/{customerId}/kyc/{submissionId}/retry`, which only the US individual retry path reaches. US is not served on the new platform, so `USD` stays in `DISABLED_FIAT_CURRENCIES` until Alfred brings it online. The adapter reports KYC `UPDATE_REQUIRED` for migrated customers that Alfred's own API lists as `ACTIVE` (and `COMPLETED` for one listed as `NOT_STARTED`, 2026-09-30); invariant 33 keeps approved customers approved until Alfred explains it. The new platform also requires KYB fields our form and API do not collect, so MX/CO business verification is paused (invariant 39). **Verification outcome delivery:** Alfredpay exposes no verification webhook — `AlfredpayApiService` carries only request/response methods — so an outcome is only ever learned by polling `getKycStatus`/`getKybStatus`. `refreshAlfredpayCustomerStatus` owns that poll, persists the result, and queues the user's `verification_approved`/`verification_rejected` email; it runs both from the dashboard's status aggregation (TTL-throttled) and from `AlfredpayStatusWorker` (hourly) for users who never return. Alfredpay has no expiry status, so `verification_expired` is never produced for this provider. @@ -84,11 +84,13 @@ For routed Alfredpay onramps (any non-passthrough output), the final quote outpu 30. **The demo Alfredpay stand-in MUST be unreachable outside an opted-in sandbox** — `installDemoProviders` (`api/services/demo/demo-alfredpay.provider.ts`, called once at startup) replaces `AlfredpayApiService.getInstance` with canned in-process KYB responses that always approve. It returns without doing anything unless `DEMO_PROVIDER_ENABLED=true`, and throws when that flag is set with any `DEPLOYMENT_ENV` other than `sandbox`; `config/vars.ts` repeats the check at load time so the process refuses to start rather than serving a mixed configuration. The flag is off by default precisely because a sandbox also serves partner integration testing, which must exercise the real provider. Only the business-KYB surface is faked — individual (KYC) customer creation and every other Alfredpay method fall through to the real client, so an unimplemented path fails visibly instead of returning invented data. The stand-in fabricates provider *status* only; it never writes `provider_customers`, and the demo restore that consumes it is itself sandbox-guarded. See `docs/adr-0004-sandbox-demo-environment.md`. 31. **An Alfredpay SELL order MUST be `CREATED` before Vortex's first provider-bound transfer** — a pre-transfer `FAILED` response terminates the ramp without moving the user's USDT; `ON_CHAIN_DEPOSIT_RECEIVED`, `TRADE_COMPLETED`, or either fiat-transfer state without a confirmed/replayed local transfer indicates an unexplained external side effect and requires reconciliation. A confirmed local transfer journal is replayed before this mutable status check. 32. **Alfredpay requests MUST authenticate with the Alfred partner API key as a bearer token and MUST NOT carry a business id** — `AlfredpayApiService` sends `Authorization: Bearer ` on every JSON request and multipart upload and never the legacy `api-key`/`api-secret` pair. Quote `metadata` carries only the tracking `customerId`: `AlfredpayQuoteMetadata` is closed, so a literal that adds `businessId` fails to compile. -33. **An upstream 404 on a status read MUST NOT demote an approved Alfredpay customer** — `GET /alfredpayStatus` and `GET /getKycStatus` reset a non-approved customer to `Consulted`/`pending` when Alfredpay answers 404 or no submission, so onboarding restarts; an approved customer keeps `approved`, because the platform migration can answer 404 for data Alfred has not moved yet and a status read must not force a fresh KYC. +33. **A status read MUST NOT demote an approved Alfredpay customer on a 404 or `UPDATE_REQUIRED`** — `GET /alfredpayStatus` and `GET /getKycStatus` reset a non-approved customer to `Consulted`/`pending` when Alfredpay answers 404 or no submission, so onboarding restarts; an approved customer keeps `approved`, because the platform migration can answer 404 for data Alfred has not moved yet and a status read must not force a fresh KYC. Every status write goes through the provider-customer view's `update`, which ignores a provider `UPDATE_REQUIRED` for an approved customer: the adapter reports it for customers Alfred lists as active, and `/retryKyc` only reopens `FAILED`, so the demotion would lock them out. 34. **A failed provider read before the offramp transfer MUST be retried, not fatal** — `ensureLiveProviderOrder` (`alfredpay-offramp/execution.ts`) turns any non-phase error from `getOfframpTransaction` (404 for an order the adapter cannot find, 401, 5xx, transport, schema mismatch) into a recoverable error. The user's deposit and any subsidy already sit on the ephemeral and nothing has left it, so failing the ramp would strand them. 35. **Provider limits MUST fail toward our configured limits** — `AlfredpayLimitsService` indexes a bound only when it is a decimal string on the scale the quote path reads back (BUY: the fiat's decimals, SELL: 6). A null or absent bound keeps our configured bound for that side (the adapter serves null for "no limit"); rows without any bound, with another scale or with an unknown customer type are skipped; an empty listing keeps the previous limits. 36. **Provider onramp orders and quotes MUST be validated before use** — `createOnramp` normalizes the adapter's flat order (Penny nested it under `transaction`) and parses it with `alfredpayCreateOnrampResponseSchema`, so an order without an id fails inside the financial operation instead of showing the user instructions for an untrackable order. The mint simulation requires the quote to echo the requested `fromAmount`, which catches a change of units (Alfred's native API uses minor units). 37. **Individual KYC identifiers MUST be checked at the API boundary** — the Mexican `dni` must be a CURP with a valid check digit and Argentine individuals need a CUIT with a valid mod-11 check digit, enforced by `validateKycSubmission` and by the shared form schemas through `isValidCurp`/`isValidCuit` (`@vortexfi/shared`). The provider rejects both with an opaque `110002 Invalid field(s)` otherwise. +38. **An offramp MUST pay out only to a payout account listed for the customer** — `registerDomesticOfframp` reads `listFiatAccounts(customerId)` in its preflight and rejects a caller-supplied `fiatAccountId` that is not in the list with a 400 before `createOfframp`. Payout accounts did not survive Alfred's platform migration, and ownership was otherwise enforced only by the provider. The listing is scoped per customer (live check, 2026-10-01; nightly contract assertion); a failed listing aborts registration like the quote refresh. +39. **MX/CO business verification MUST stay paused until the KYB contract is reworked** — Alfred's new platform requires KYB fields our form and API do not carry, so `isAlfredpayBusinessKybPaused` (`@vortexfi/shared`) stops it in two places: the shared KYC machine fails a MX/CO business flow before any provider call, `POST createBusinessCustomer` answers 503 for MX/CO before creating a provider customer, and onboarding discovery returns 404 for those combinations. ## Threat Vectors & Mitigations @@ -131,6 +133,9 @@ For routed Alfredpay onramps (any non-passthrough output), the final quote outpu - [x] Limits keep configured bounds for null/absent provider bounds, skip unusable rows and survive an empty listing. **PASS** — `alfredpay-limits.service.test.ts`. - [x] `createOnramp` rejects an order without a `transactionId` in either shape; a 409 with a null maximum maps to the minimum breach; the mint rejects a quote that does not echo the input. **PASS** — `alfredpayApiService.test.ts`, `alfredpay-onramp-direct.flow.test.ts`. - [x] CURP/CUIT check digits are enforced by the API validator and the form schemas. **PASS** — `validators.test.ts`, `identifiers.test.ts`, `packages/kyc/.../schemas.test.ts`. +- [x] A provider `UPDATE_REQUIRED` leaves an approved customer approved on both status routes and still moves an unapproved one. **PASS** — `alfredpay-status-not-found.integration.test.ts`. +- [x] Offramp registration rejects a `fiatAccountId` missing from the customer's account list before creating an order, and the nightly asserts the listing is per customer. **PASS** — `alfredpay-offramp.registration.test.ts`, `alfredpay.contract.test.ts`. +- [x] MX/CO business verification is paused in the KYC machine, at customer creation and in discovery. **PASS** — `machine.test.ts`, `alfredpay-business-kyb-paused.integration.test.ts`, `onboarding-requirements.route.test.ts`, dashboard `onboarding-alfredpay-mxn-kyb.spec.ts`. - [x] No Alfredpay credentials or user payment details in logs. **PASS** — no credential leakage observed in log statements. - [ ] Timeout configured for Alfredpay API calls. **FAIL F-014** — no explicit HTTP client timeout configured; relies on default system timeouts. - [x] `subsidizePreSwap` runs before `squidRouterSwap` on the onramp flow, and `finalSettlementSubsidy` runs before `alfredpayOfframpTransfer` on the offramp flow. **PASS** — flow tests pin both sequences.