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..ecadf8248 100644 --- a/apps/api/.env.example +++ b/apps/api/.env.example @@ -155,10 +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 -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. 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=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/apps/api/src/api/controllers/alfredpay.controller.ts b/apps/api/src/api/controllers/alfredpay.controller.ts index 39f69e2e4..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"; @@ -341,9 +343,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, @@ -523,11 +528,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" }); } @@ -581,7 +590,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() }; @@ -667,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/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/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/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/api/services/alfredpay/alfredpay-limits.service.test.ts b/apps/api/src/api/services/alfredpay/alfredpay-limits.service.test.ts index 1108bdd86..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 @@ -5,6 +5,7 @@ import { DomesticCustomerType, FiatToken, type GetAllConfigsResponse, + getAnyFiatTokenDetails, RampDirection } from "@vortexfi/shared"; import { AlfredpayLimitsService } from "./alfredpay-limits.service"; @@ -88,4 +89,73 @@ describe("AlfredpayLimitsService.refresh", () => { minRaw: "100000" }); }); + + 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. + */ + test("keeps each customer type's configured bound where Alfred sets no limit", async () => { + const service = await refreshWith([arsMin]); + + 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" + }); + } + }); + + 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 746e50121..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,31 +126,34 @@ 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; - const limits: RawAmountLimits = { - maxRaw: toRaw(pair.maxQuantity, decimals), - minRaw: toRaw(pair.minQuantity, decimals) - }; + + // 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)) { - target.set(key, limits); - } + if (isWildcard && target.has(key)) continue; + // 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: maxQuantity === null ? configured.maxRaw : toRaw(maxQuantity, decimals), + minRaw: minQuantity === null ? configured.minRaw : toRaw(minQuantity, decimals) + }); } } 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/__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/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..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 @@ -43,12 +43,17 @@ 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 }; 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); 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..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) { @@ -667,7 +673,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..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 { @@ -61,15 +62,25 @@ 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); + // 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/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 => 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}`); @@ -516,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/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/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/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..8b6fc0a3c --- /dev/null +++ b/apps/api/src/tests/alfredpay-status-not-found.integration.test.ts @@ -0,0 +1,162 @@ +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, and UPDATE_REQUIRED for customers it lists as active, so no status read +// may 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); + }); +}); + +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); + }); +}); + +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); + }); + } +}); diff --git a/apps/api/src/tests/contracts/alfredpay.contract.test.ts b/apps/api/src/tests/contracts/alfredpay.contract.test.ts index e6f3962ec..43ffa8029 100644 --- a/apps/api/src/tests/contracts/alfredpay.contract.test.ts +++ b/apps/api/src/tests/contracts/alfredpay.contract.test.ts @@ -26,8 +26,11 @@ * 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 { + ALFREDPAY_BASE_URL, + ALFREDPAY_ONCHAIN_CURRENCY, AlfredpayApiService, AlfredpayChain, alfredpayConfigsResponseSchema, @@ -58,17 +61,21 @@ 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; +// 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; 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 +126,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" }); } /** @@ -173,7 +197,10 @@ 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"); +} +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. @@ -182,7 +209,7 @@ const TEST_ADDRESS = "0x1234567890123456789012345678901234567890"; // Sentinel used by production quote requests for anonymous rate discovery // (ALFREDPAY_ANONYMOUS_CUSTOMER_ID in quote/alfredpay-customer.ts — metadata.customerId // is tracking-only on quote requests). -const QUOTE_METADATA = { businessId: "vortex", customerId: "anonymous" }; +const QUOTE_METADATA = { customerId: "anonymous" }; function onrampQuoteRequest(fromAmount: string): CreateAlfredpayOnrampQuoteRequest { // Mirrors OnRampInitializeAlfredpayEngine: fiat -> USDC minted on Polygon. @@ -328,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 { @@ -387,8 +414,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 +434,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({ @@ -502,6 +540,19 @@ describe.skipIf(!RUN_LIVE || !HAS_CREDS)("Alfredpay external API contract — li 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`, @@ -528,7 +579,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: { businessId: "vortex", customerId: CUSTOMER_ID as string } + metadata: { customerId: CUSTOMER_ID as string }, + toCurrency: ALFREDPAY_ONCHAIN_CURRENCY }) ); if (!quote) return; @@ -542,11 +594,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) @@ -564,8 +618,8 @@ describe.skipIf(!RUN_LIVE || !HAS_CREDS)("Alfredpay external API contract — li api().createOfframpQuote({ chain: AlfredpayChain.MATIC, fromAmount: "30", - fromCurrency: AlfredpayOnChainCurrency.USDC, - metadata: { businessId: "vortex", customerId: CUSTOMER_ID as string }, + fromCurrency: ALFREDPAY_ONCHAIN_CURRENCY, + metadata: { customerId: CUSTOMER_ID as string }, paymentMethodType: AlfredpayPaymentMethodType.BANK, toCurrency: AlfredpayFiatCurrency.MXN }) @@ -578,7 +632,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 @@ -629,9 +683,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 () => { @@ -647,7 +702,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", @@ -695,7 +751,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 () => { 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 64a998723..b5de2ca09 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, @@ -196,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); @@ -416,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()); @@ -473,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(); @@ -1169,6 +1173,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, 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/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/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", diff --git a/docs/api/openapi/vortex.openapi.d.ts b/docs/api/openapi/vortex.openapi.d.ts index 3a9a82016..ceeae5be3 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 */ @@ -4941,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 67ff4cf38..2c363ecd0 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": { @@ -6184,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 5cfca6ca7..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. @@ -155,19 +155,21 @@ 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). -- **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. ### 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 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." diff --git a/docs/security-spec/05-integrations/alfredpay.md b/docs/security-spec/05-integrations/alfredpay.md index 27a08402e..34e47cffe 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. 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. **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. @@ -45,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 @@ -81,6 +83,14 @@ 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. **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 @@ -116,6 +126,16 @@ 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` 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] 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. @@ -150,7 +170,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 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" } ] }, diff --git a/packages/kyc/src/alfredpay/schemas.test.ts b/packages/kyc/src/alfredpay/schemas.test.ts index c0af11417..3e2b46cd9 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,9 +109,10 @@ 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); - expect(arKycSchema.safeParse({ ...ar, cuit: "20123456789" }).success).toBe(true); + 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(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 aebcca193..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"; @@ -10,7 +10,7 @@ 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 a valid 18-character CURP"), email: z.string().email(), firstName: z.string().min(1), lastName: z.string().min(1), @@ -37,29 +37,28 @@ 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 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(), + 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), 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/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/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..5dc260a77 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,130 @@ 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); + }); +}); + +/** + * 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).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; + + const order = await AlfredpayApiService.getInstance().createOnramp(request); + expect(order.transaction.transactionId).toBe("tx-2"); + expect(order.fiatPaymentInstructions).toEqual(instructions); + }); +}); + +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(); @@ -143,7 +266,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/alfredpayApiService.ts b/packages/shared/src/services/alfredpay/alfredpayApiService.ts index 1ca54de3c..cf268381e 100644 --- a/packages/shared/src/services/alfredpay/alfredpayApiService.ts +++ b/packages/shared/src/services/alfredpay/alfredpayApiService.ts @@ -1,8 +1,12 @@ 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"; +import { + alfredpayCreateOnrampResponseSchema, + alfredpayOfframpTransactionSchema, + alfredpayQuoteResponseSchema +} from "./schemas"; import { AlfredpayFee, AlfredpayFiatAccountFields, @@ -98,16 +102,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 +130,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" }; @@ -182,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) { @@ -301,7 +301,23 @@ 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. + // 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 { @@ -372,11 +388,9 @@ 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 - }, - method: "POST" + headers: { Authorization: this.authorization }, + method: "POST", + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS) }); if (!response.ok) { @@ -434,11 +448,9 @@ 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 - }, - method: "POST" + headers: { Authorization: this.authorization }, + method: "POST", + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS) }); if (!response.ok) { @@ -465,11 +477,9 @@ 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 - }, - method: "POST" + headers: { Authorization: this.authorization }, + method: "POST", + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS) }); if (!response.ok) { 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"; 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 294698855..17e403d8b 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 { @@ -366,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): @@ -377,8 +389,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;