diff --git a/.agents/skills/sentry-vortex/SKILL.md b/.agents/skills/sentry-vortex/SKILL.md index bd8942fe1..e4382e6fb 100644 --- a/.agents/skills/sentry-vortex/SKILL.md +++ b/.agents/skills/sentry-vortex/SKILL.md @@ -1,6 +1,6 @@ --- name: sentry-vortex -description: Audit code against Vortex's Sentry conventions and guide correct error instrumentation. Triggers on: sentry, captureException, error reporting, error monitoring, beforeSend, ignoreErrors, adding an API service method, new error class, new XState machine error handling, ErrorBoundary, "is this reported to Sentry". +description: "Scoped to apps/frontend (the React web app). Audit code against Vortex's Sentry conventions and guide correct error instrumentation. Triggers when working under apps/frontend on: sentry, captureException, error reporting, error monitoring, beforeSend, ignoreErrors, adding an API service method, new error class, new XState machine error handling, ErrorBoundary, \"is this reported to Sentry\"." user-invocable: true argument-hint: "[files or 'current changes' — defaults to the working-tree diff]" --- diff --git a/.agents/skills/vortex-integration/SKILL.md b/.agents/skills/vortex-integration/SKILL.md index cb71f976e..11df4df00 100644 --- a/.agents/skills/vortex-integration/SKILL.md +++ b/.agents/skills/vortex-integration/SKILL.md @@ -1,6 +1,6 @@ --- name: vortex-integration -description: Use when integrating Vortex or @vortexfi/sdk, including quotes, BRL PIX onramps/offramps, ramp register/update/start/status flows, webhook verification, ephemeral key custody, supported corridors, sandbox/production auth, and recovery from ramp errors. +description: Use when integrating Vortex or @vortexfi/sdk, including quotes, onramps/offramps for BRL (PIX), EUR (SEPA), USD (ACH), MXN (SPEI), COP, and ARS (CBU), ramp register/update/start/status flows, webhook verification, ephemeral key custody, supported corridors, sandbox/production auth, and recovery from ramp errors. --- # Vortex Integration Skill @@ -14,14 +14,17 @@ A machine-loadable capability catalog for AI coding agents integrating Vortex in ## Global Context (read once) - **SDK**: `@vortexfi/sdk` (JavaScript/TypeScript). Install: `npm i @vortexfi/sdk`. -- **API base URLs**: production `https://api.vortexfinance.co`, sandbox `https://api.sandbox.vortexfinance.co`. +- **API base URLs**: production `https://api.vortexfinance.co`, sandbox `https://api-sandbox.vortexfinance.co`. - **Auth keys**: partner integrations use a key pair. - `pk_live_*` / `pk_test_*` — public key, sent in request bodies for partner attribution. - `sk_live_*` / `sk_test_*` — secret key, sent in the `X-API-Key` header. **Never expose `sk_*` in a browser or mobile app.** + - **Ramp registration requires a user-linked `sk_*` key in every corridor** — the register call is rejected unless the authenticated key resolves to a user account. KYC identity (BRL tax ID, Alfredpay customer, Mykobo customer) is derived from that account, never from request fields. - **Decimals**: all amounts are strings. Never parse them through JS `Number` — use `BigInt`, `decimal.js`, or equivalent. - **Quote TTL**: quotes expire (see `expiresAt`). Re-quote, never reuse stale quotes. -- **Ramp counts**: ephemeral keys sign exactly 5 presigned transactions per ramp. The API rejects anything else. -- **Currently implemented corridors**: BRL (PIX) onramp and offramp. EUR (SEPA) types exist in the SDK but the handlers throw `"Euro onramp handler not implemented yet"` / `"Euro offramp handler not implemented yet"` at runtime. Treat EUR as `status: planned`. +- **Presigned counts**: this is **per ephemeral-signed transaction, not per ramp**. Each transaction an ephemeral key signs must be submitted as 5 presigned variants — 1 primary plus exactly 4 backups with consecutive nonces in `meta.additionalTxs` (`NUMBER_OF_PRESIGNED_TXS = 5`); the API rejects any other backup count. A ramp can contain several ephemeral-signed transactions across its phases. (The SDK builds these for you; only raw-API integrations need to construct them.) +- **Currently implemented corridors** (all live in the SDK): BRL via PIX, EUR via SEPA (Mykobo), USD via ACH, MXN via SPEI, COP via ACH, ARS via CBU. All support both onramp (BUY) and offramp (SELL). EUR and the bank-transfer corridors deliver to EVM networks only (no AssetHub). +- **EUR enum value**: EUR quotes use `FiatToken.EURC` (not `EUR`) as the currency value, with `"sepa"` as the rail identifier. +- **taxId is deprecated for BRL**: the user's tax ID is derived server-side from the user-linked `sk_*` key. Sending a `taxId` that mismatches the derived one is rejected; stop sending it in new integrations. - **No secret in markdown**: never paste API keys into source files, logs, screenshots, or support tickets. --- @@ -100,7 +103,6 @@ For the best price across all networks, use `POST /v1/quotes/best` (same body, o ## Common failures - `MissingRequiredFieldsError` — missing input/output/amount/network. - `InvalidNetworkError` — `network` not in the supported list (see `discover-supported-corridors`). -- `400` with `EuroOnrampHandlerNotImplemented` — EUR corridors are planned, not active. - Quote returned but unused for > TTL → `QuoteExpiredError` on subsequent `registerRamp`. Re-quote. --- @@ -119,18 +121,17 @@ triggers: ``` ## When to use -The user is in Brazil (or has BRL/PIX access) and wants to buy crypto. KYC must be completed beforehand through the Vortex app or Widget — `taxId` (CPF/CNPJ) is the required link. +The user is in Brazil (or has BRL/PIX access) and wants to buy crypto. KYC must be completed beforehand through the Vortex app or Widget; the user's CPF/CNPJ is resolved server-side from their user-linked `sk_*` key. ## Prerequisites - Fresh quote with `rampType: BUY`, `from: "pix"`, `inputCurrency: FiatToken.BRL`. - `destinationAddress` — the user's wallet on the target network. -- `taxId` — the user's CPF or CNPJ; must match a KYC'd Vortex subaccount. +- The user has completed BRL KYC; `taxId` is **deprecated** — it is derived from the authenticated key, and a mismatching value is rejected. ## SDK recipe ```js const { rampProcess } = await vortex.registerRamp(quote, { - destinationAddress: "0xUserWalletAddress", - taxId: "12345678900" + destinationAddress: "0xUserWalletAddress" }); // Show user the PIX payment instructions @@ -147,22 +148,22 @@ The SDK generates ephemeral keypairs, signs internal txs, and submits them in `r ## REST fallback ```bash -# 1. Register +# 1. Register. signingAccounts holds the PUBLIC addresses of the ephemerals you +# generated for this ramp: one Substrate and one EVM account (the only two +# account types the API accepts). Partner attribution is carried by the quote's +# apiKey, so register takes no publicKey field. curl -X POST https://api.vortexfinance.co/v1/ramp/register \ -H "Content-Type: application/json" \ -H "X-API-Key: $VORTEX_SECRET_KEY" \ -d '{ "quoteId": "QUOTE_ID", - "ephemeralAccounts": [ - { "type": "EVM", "address": "0x..." }, + "signingAccounts": [ { "type": "Substrate", "address": "5..." }, - { "type": "Stellar", "address": "G..." } + { "type": "EVM", "address": "0x..." } ], "additionalData": { - "destinationAddress": "0xUserWalletAddress", - "taxId": "12345678900" - }, - "publicKey": "'"$VORTEX_PUBLIC_KEY"'" + "destinationAddress": "0xUserWalletAddress" + } }' # 2. Sign the returned `unsignedTxs` with the corresponding ephemeral keys (see AI_AGENT_INTEGRATION D.4) @@ -178,10 +179,10 @@ curl -X POST https://api.vortexfinance.co/v1/ramp/start \ If you implement this without the SDK, follow the raw-API contract in [`AI_AGENT_INTEGRATION` § D.3–D.5](https://api-docs.vortexfinance.co/ai-agent-integration). ## Common failures -- `SubaccountNotFoundError` — the `taxId` has no KYC'd Vortex subaccount. Direct the user to KYC first. +- `SubaccountNotFoundError` — the tax ID derived from the authenticated key has no KYC'd Vortex subaccount. Direct the user to KYC first. - `KycInvalidError` — KYC exists but is not approved. - `AmountExceedsLimitError` — quote amount above the user's KYC tier limit. -- `MissingBrlParametersError` — `destinationAddress` or `taxId` missing. +- Missing `destinationAddress` → `400 "Parameter destinationAddress is required for onramp"`. (The SDK's `MissingBrlParametersError` maps only the legacy `"...destinationAddress and taxId..."` message and will not fire for this; treat the raw 400 message as authoritative.) - `QuoteExpiredError` — re-quote and call `registerRamp` again. - `TimeWindowExceededError` on `startRamp` — too long elapsed since `registerRamp`; restart the flow. @@ -206,25 +207,26 @@ The user holds crypto on an EVM chain and wants to receive BRL via PIX. Unlike o ## Prerequisites - Fresh quote with `rampType: SELL`, `to: "pix"`, `outputCurrency: FiatToken.BRL`. - `pixDestination` — recipient's PIX key (validate via `GET /v1/brla/validatePixKey` if uncertain). -- `receiverTaxId` — CPF/CNPJ of the PIX recipient. -- `taxId` — the user's KYC'd CPF/CNPJ. - `walletAddress` — the user's source wallet address. +- Optional: `receiverTaxId` — CPF/CNPJ of the PIX recipient when paying out to someone other than the user. `taxId` is **deprecated** (derived from the authenticated key). ## SDK recipe ```js const { rampProcess, unsignedTransactions } = await vortex.registerRamp(quote, { pixDestination: "user@example.com", - receiverTaxId: "12345678900", - taxId: "12345678900", walletAddress: "0xUserWalletAddress" }); -// Identify which txs the END USER must sign (vs. ephemerals, which SDK signed already) -const userTxs = await vortex.getUserTransactions(rampProcess, "0xUserWalletAddress"); - -// userTxs typically includes: SquidRouter approve, SquidRouter swap, AssetHub→Pendulum XCM -// Have the user sign each one and submit on-chain. Collect the resulting tx hashes. +// Easiest path: let the SDK classify, sign, broadcast, and submit the user-owned txs +// via your wallet callbacks (e.g. @wagmi/core): +await vortex.submitUserTransactions(rampProcess.id, unsignedTransactions, { + signTypedData: payload => signTypedData(wagmiConfig, payload), + sendTransaction: tx => sendTransaction(wagmiConfig, tx) +}); +// Lower-level alternative: filter the user txs yourself, have the user sign and +// broadcast them, then push the hashes back: +const userTxs = await vortex.getUserTransactions(rampProcess, "0xUserWalletAddress"); await vortex.updateRamp(quote, rampProcess.id, { squidRouterApproveHash: "0xapprove...", squidRouterSwapHash: "0xswap...", @@ -239,7 +241,7 @@ await vortex.startRamp(rampProcess.id); Same three-step pattern: `POST /v1/ramp/register` → user signs → `POST /v1/ramp/update` with the collected hashes → `POST /v1/ramp/start`. See [`AI_AGENT_INTEGRATION` § D.3–D.5](https://api-docs.vortexfinance.co/ai-agent-integration) for the exact body shapes. ## Common failures -- `MissingBrlOfframpParametersError` — `receiverTaxId`, `pixDestination`, or `taxId` missing. +- Missing `pixDestination` → `400 "pixDestination is required for offramp to BRL"`. Missing `walletAddress` → `400 "User address must be provided for offramping."` (The SDK's `MissingBrlOfframpParametersError` maps only the legacy `"receiverTaxId, pixDestination and taxId..."` message and will not fire for these; treat the raw 400 message as authoritative.) - `InvalidPixKeyError` — PIX key format invalid or unreachable. Validate beforehand with `GET /v1/brla/validatePixKey`. - `InvalidPresignedTxsError` on `updateRamp` — hash format wrong, or the on-chain tx does not match the unsigned tx that was issued. Re-sign exactly what `getUserTransactions` returned. - `NoPresignedTransactionsError` on `startRamp` — `updateRamp` was not called or did not include the required hashes. @@ -247,6 +249,164 @@ Same three-step pattern: `POST /v1/ramp/register` → user signs → `POST /v1/r --- +```yaml +--- +name: start-ramp-eur-sepa +description: Initiate a EUR onramp or offramp via SEPA. Onramp shows IBAN transfer instructions; offramp requires user-signed on-chain transactions. +triggers: + - "EUR onramp" + - "EUR offramp" + - "SEPA" + - "buy crypto with euro" + - "sell crypto for euro" + - "IBAN payment" +--- +``` + +## When to use +The user has a SEPA bank account and wants to buy or sell crypto for EUR. EUR onboarding is individual KYC only and requires a connected wallet; complete it through the Vortex app or Widget first. EUR delivers to EVM networks only (no AssetHub). + +## Prerequisites +- Quote with `inputCurrency: FiatToken.EURC` and `from: "sepa"` (buy), or `outputCurrency: FiatToken.EURC` and `to: "sepa"` (sell). Note: the enum value is `EURC`, not `EUR`. +- `destinationAddress`, `email`, `ipAddress` — required for both directions. +- `walletAddress` — additionally required for offramp (the user's source wallet). + +## SDK recipe (onramp) +```js +const quote = await vortex.createQuote({ + rampType: RampDirection.BUY, + from: EPaymentMethod.SEPA, + to: Networks.Base, + network: Networks.Base, + inputAmount: "100", + inputCurrency: FiatToken.EURC, + outputCurrency: EvmToken.USDC +}); + +const { rampProcess } = await vortex.registerRamp(quote, { + destinationAddress: "0xUserWalletAddress", + email: "user@example.com", + ipAddress: "203.0.113.1" +}); + +// SEPA transfer instructions are on the ramp after registration: +console.log(rampProcess.ibanPaymentData.iban); +console.log(rampProcess.ibanPaymentData.receiverName); +console.log(rampProcess.ibanPaymentData.reference); + +// After the user completed the SEPA transfer: +await vortex.startRamp(rampProcess.id); +``` + +No user-signed on-chain transactions are required for EUR onramps. + +## SDK recipe (offramp) +```js +const { rampProcess, unsignedTransactions } = await vortex.registerRamp(quote, { + destinationAddress: "0xUserWalletAddress", + email: "user@example.com", + ipAddress: "203.0.113.1", + walletAddress: "0xUserWalletAddress" +}); + +// User signs and broadcasts squidRouterApprove + squidRouterSwap, then: +await vortex.updateRamp(quote, rampProcess.id, { + squidRouterApproveHash: "0xapprove...", + squidRouterSwapHash: "0xswap..." +}); +await vortex.startRamp(rampProcess.id); +``` + +`submitUserTransactions` (see `start-offramp-brl`) works here as well. + +## Common failures +- `MissingMykoboOnrampParametersError` / `MissingMykoboOfframpParametersError` — `destinationAddress`, `email`, `ipAddress`, or `walletAddress` missing. +- `MykoboKycRequiredError` — the user has not completed EUR KYC; onboard via the Vortex app or Widget. +- `503` "EUR ramps are currently disabled" on **register** — EUR is feature-gated server-side. EURC quotes still succeed while the gate is on, so a successful quote does not prove the corridor is enabled; probe registration in sandbox before shipping. + +--- + +```yaml +--- +name: start-ramp-bank-transfer +description: Initiate an onramp or offramp for USD (ACH), MXN (SPEI), COP (ACH), or ARS (CBU) through Vortex's local payment partners. +triggers: + - "USD onramp" + - "MXN onramp" + - "COP offramp" + - "ARS ramp" + - "SPEI" + - "ACH" + - "CBU" + - "bank transfer ramp" +--- +``` + +## When to use +The user wants to ramp USD, MXN, COP, or ARS over their domestic banking rail. These corridors **require a user-linked `sk_*` key**: registration resolves the user's KYC and payment profile from the authenticated account. Partner-scoped keys cannot register ramps here. EVM networks only (no AssetHub). + +| Fiat | Rail identifier | Payment rail | +|------|-----------------|--------------| +| `USD` | `"ach"` | ACH bank transfer | +| `MXN` | `"spei"` | SPEI transfer | +| `COP` | `"ach"` | Colombian bank transfer | +| `ARS` | `"cbu"` | CBU bank transfer | + +## Prerequisites +- The user completed KYC for the corridor's country via the Vortex app or Widget, and the SDK is authenticated with that user's own `sk_*` key. +- Buy: `destinationAddress` (required); `fiatAccountId`, `walletAddress` optional. +- Sell: `fiatAccountId` and `walletAddress` (both required). List saved accounts with `vortex.listAlfredpayFiatAccounts(country)`. + +## SDK recipe (onramp, MXN shown — substitute fiat + rail for USD/COP/ARS) +```js +const quote = await vortex.createQuote({ + rampType: RampDirection.BUY, + from: EPaymentMethod.SPEI, + to: Networks.Polygon, + network: Networks.Polygon, + inputAmount: "201", + inputCurrency: FiatToken.MXN, + outputCurrency: EvmToken.USDC +}); + +const { rampProcess } = await vortex.registerRamp(quote, { + destinationAddress: "0xUserWalletAddress" +}); + +const started = await vortex.startRamp(rampProcess.id); + +// Bank transfer instructions the user must pay are on the START response: +console.log(started.achPaymentData); +``` + +No user-signed on-chain transactions on buys. Unlike BRL there is no QR code — display the `achPaymentData` deposit instructions verbatim; the ramp continues automatically once the fiat deposit is confirmed. + +## SDK recipe (offramp) +```js +const accounts = await vortex.listAlfredpayFiatAccounts("MEX"); + +const { rampProcess, unsignedTransactions } = await vortex.registerRamp(quote, { + fiatAccountId: accounts[0].id, + walletAddress: "0xUserWalletAddress" +}); + +await vortex.submitUserTransactions(rampProcess.id, unsignedTransactions, { + signTypedData: payload => signTypedData(wagmiConfig, payload), + sendTransaction: tx => sendTransaction(wagmiConfig, tx) +}); +await vortex.startRamp(rampProcess.id); +``` + +The SDK cannot **create** fiat accounts; they are created during onboarding in the Vortex app or Widget. `fiatAccountId` is opaque to the SDK. + +## Common failures +- `MissingAlfredpayOnrampParametersError` / `MissingAlfredpayOfframpParametersError` — `destinationAddress`, `fiatAccountId`, or `walletAddress` missing. +- `AlfredpayOnrampKycRequiredError` — the authenticated user has no approved KYC for the corridor's country. +- `400` "requires an API key linked to a user" on register — the `sk_*` key is partner-scoped, not user-linked. Mint a user key after email OTP sign-in. +- `InsufficientBalanceError` — the offramp pre-flight found the source wallet balance below the quote's input amount. + +--- + ```yaml --- name: poll-ramp-status @@ -322,7 +482,8 @@ First-time integration, environment migration, or when an agent needs to decide | Key | Where it goes | Purpose | |-----|---------------|---------| | `pk_live_*` / `pk_test_*` | Anywhere (browser-safe) | Partner attribution. Sent inside request bodies as `publicKey`. | -| `sk_live_*` / `sk_test_*` | Server-side only | API auth. Sent as `X-API-Key` header. **Never** ship to browser/mobile bundles. | +| `sk_live_*` / `sk_test_*` (partner-scoped) | Server-side only | Webhook management and partner attribution. Sent as `X-API-Key` header. **Cannot register ramps** unless the key is also linked to a user. **Never** ship to browser/mobile bundles. | +| `sk_live_*` / `sk_test_*` (user-linked) | Server-side only | Required for ramp registration in every corridor; corridor identity (BRL taxId, Alfredpay/Mykobo customer) is derived from the linked account. Minted programmatically after email OTP sign-in; shown once at creation. | ## SDK recipe ```js @@ -434,7 +595,7 @@ export async function verifyVortexWebhook(req, apiBaseUrl) { "sessionId": "...", "transactionId": "...", "transactionStatus": "PENDING | COMPLETE | FAILED", - "transactionType": "onramp | offramp" + "transactionType": "BUY | SELL" } } ``` @@ -492,7 +653,7 @@ try { const quote = await vortex.createQuote({ /* candidate combination */ }); // → corridor is live } catch (err) { - if (err.name === "InvalidNetworkError" || err.message.includes("not implemented")) { + if (err.name === "InvalidNetworkError" || err.name === "MissingRequiredFieldsError") { // → corridor not supported } else { throw err; @@ -500,11 +661,11 @@ try { } ``` -## Current corridor reality (May 2026) -- **BRL via PIX**: onramp and offramp both live. -- **EUR via SEPA**: SDK types exist (`EurOnrampQuote`, `EurOfframpQuote`) but handlers throw `"Euro onramp/offramp handler not implemented yet"` at runtime. Treat as `planned`. -- **ARS via CBU**: supported via the AlfredPay corridor; route resolver determines availability per-combination. -- **USD / MXN / COP via ACH / SPEI / WIRE**: supported via the AlfredPay corridor; route resolver determines availability per-combination. +## Current corridor reality (July 2026) +- **BRL via PIX**: onramp and offramp both live. `taxId` deprecated — derived from the user-linked key. +- **EUR via SEPA (Mykobo)**: onramp and offramp fully implemented in the SDK (`FiatToken.EURC`, rail `"sepa"`), but registration is feature-gated server-side and currently returns `503` "EUR ramps are currently disabled" when the gate is on. Quotes succeed regardless — probe registration, not quoting. +- **USD (ACH) / MXN (SPEI) / COP (ACH) / ARS (CBU)**: onramp and offramp live via the AlfredPay corridor; requires a user-linked `sk_*` key. Route resolver determines availability per-combination. +- All corridors deliver to EVM networks; AssetHub is only available for BRL routes. ## Common failures - Filtering by a fiat that has no payment methods → empty array, not an error. @@ -551,8 +712,11 @@ Include this payload (with secrets redacted) in any support ticket. | `QuoteNotFoundError` | Wrong env or stale id | Verify base URL; re-quote | | `InvalidNetworkError` | Network not in `Networks` enum | Use `discover-supported-corridors` | | `MissingRequiredFieldsError` / `MissingBrlParametersError` / `MissingBrlOfframpParametersError` | Body field missing | Fill the missing field; do not retry blindly | -| `SubaccountNotFoundError` / `KycInvalidError` | KYC issue | Direct user through KYC; do not retry programmatically | +| `SubaccountNotFoundError` / `KycInvalidError` | BRL KYC issue | Direct user through KYC; do not retry programmatically | +| `MykoboKycRequiredError` / `AlfredpayOnrampKycRequiredError` | EUR / bank-transfer-corridor KYC issue | Onboard the user via the Vortex app or Widget; do not retry programmatically | | `AmountExceedsLimitError` | Above KYC tier | Lower amount or upgrade KYC | +| `InsufficientBalanceError` | Offramp pre-flight: source wallet balance below the quoted input | Top up the wallet or lower the amount, then re-register from a fresh quote | +| `EphemeralNotFreshError` / `EphemeralFreshnessCheckError` | Generated ephemeral account was not fresh, or freshness could not be verified | Safe to retry `registerRamp` — the SDK generates new ephemerals each attempt | | `InvalidPixKeyError` | Bad recipient PIX key | Validate via `GET /v1/brla/validatePixKey`, then re-register | | `InvalidPresignedTxsError` | Submitted signed tx does not match the issued unsigned tx (chainId, nonce, gas, recipient, or value mismatch) | Re-sign exactly what `getUserTransactions` returned; do not reuse old signatures | | `NoPresignedTransactionsError` | `startRamp` called before `updateRamp` | Submit the required hashes via `updateRamp` first | diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 000000000..22b260647 --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,16 @@ +{ + "$comment": "Team-shared, version-controlled Claude Code settings. Personal overrides go in the git-ignored settings.local.json. deny takes precedence over any allow.", + "permissions": { + "deny": [ + "Bash(git push --force*)", + "Bash(git push -f *)", + "Bash(git push origin --force*)", + "Read(./.env)", + "Read(./.env.*)", + "Read(**/.env)", + "Read(**/.env.*)", + "Read(./.api-key.json)", + "Read(**/.api-key.json)" + ] + } +} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f55d17c93..7c7784d0a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -84,6 +84,12 @@ jobs: - name: 🧪 SDK coverage gate run: cd packages/sdk && bun run test:coverage + - name: 🧪 KYC package tests (coverage-gated) + run: cd packages/kyc && bun run test:coverage + + - name: 🧪 Dashboard tests + run: cd apps/dashboard && bun run test + - name: 🧪 Rebalancer tests (coverage-gated) run: cd apps/rebalancer && bun run test:coverage diff --git a/.github/workflows/contracts.yml b/.github/workflows/contracts.yml index 0787c41ce..5567ce825 100644 --- a/.github/workflows/contracts.yml +++ b/.github/workflows/contracts.yml @@ -36,6 +36,21 @@ jobs: - name: 🧪 Live contract suites working-directory: apps/api + # Sandbox credentials and pre-provisioned fixtures (see .env.example). A missing + # secret resolves to "" -> the suite's live half skips -> CONTRACT_EXPECT_LIVE + # fails that suite loudly instead of letting the drift detector rot as green. + 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 }} + BRLA_BASE_URL: ${{ secrets.CONTRACT_BRLA_BASE_URL }} + BRLA_API_KEY: ${{ secrets.CONTRACT_BRLA_API_KEY }} + BRLA_PRIVATE_KEY: ${{ secrets.CONTRACT_BRLA_PRIVATE_KEY }} + AVENIA_CONTRACT_SUBACCOUNT_ID: ${{ secrets.CONTRACT_AVENIA_SUBACCOUNT_ID }} + COINGECKO_API_KEY: ${{ secrets.COINGECKO_API_KEY }} run: bun test src/tests/contracts/ # Non-blocking runs are only useful if somebody hears about failures. diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 54be8861d..92223f110 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -45,6 +45,26 @@ jobs: path: apps/frontend/playwright-report/ retention-days: 7 + # The dashboard runs its own Playwright config (own Vite server on 5174). Always run it, + # even when the frontend journeys failed — one broken app should not hide the other's status. + - name: 🌐 Install Playwright browsers (dashboard) + if: always() + working-directory: apps/dashboard + run: bunx playwright install --with-deps chromium + + - name: 🧪 Dashboard E2E journeys + if: always() + working-directory: apps/dashboard + run: bun run test:e2e + + - name: 📤 Upload dashboard report on failure + if: failure() + uses: actions/upload-artifact@v4 + with: + name: playwright-report-dashboard + path: apps/dashboard/playwright-report/ + retention-days: 7 + # Non-blocking runs are only useful if somebody hears about failures. # Uses the same webhook token the backend's Slack notifier uses # (repo secret SLACK_WEB_HOOK_TOKEN); skips silently when unset. diff --git a/.gitignore b/.gitignore index 64e43d4ec..3ef2cba4d 100644 --- a/.gitignore +++ b/.gitignore @@ -54,7 +54,9 @@ storybook-static CLAUDE.local.md -.claude +# Ignore everything under .claude except the team-shared settings.json +.claude/* +!.claude/settings.json /.roo/* # hardhat generated files in workspace contract projects @@ -67,6 +69,8 @@ contracts/*/.env # Playwright E2E artifacts apps/frontend/test-results/ apps/frontend/playwright-report/ +apps/dashboard/test-results/ +apps/dashboard/playwright-report/ # Local credentials and agent-tool artifacts .api-key.json diff --git a/CLAUDE.md b/CLAUDE.md index fd22663b7..f5036edbd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,179 +1,112 @@ # CLAUDE.md -This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. +Guidance for Claude Code (claude.ai/code) working in this repository. This root file +holds **cross-cutting** context only. Each app/package has its own `CLAUDE.md` with +scoped architecture and commands — `cd` into the relevant one before working there, and +read it first. ## Project Overview -Vortex is a cross-border payments gateway built on the Pendulum blockchain. It enables on-ramping and off-ramping of fiat currencies through stablecoins using cross-chain swaps via XCM (Cross-Consensus Messaging). +Vortex is a cross-border payments gateway built on the Pendulum blockchain. It enables +on-ramping and off-ramping of fiat currencies through stablecoins using cross-chain swaps +via XCM (Cross-Consensus Messaging). -## Monorepo Structure +## Repository Map -This is a **Bun monorepo** using workspaces: +Full wayfinding is in [`MAP.md`](MAP.md). This is a **Bun monorepo** using workspaces: -- **apps/frontend** - React 19 + Vite web application -- **apps/api** - Express backend service (PostgreSQL + Sequelize) -- **apps/rebalancer** - Liquidity rebalancing service -- **packages/shared** - Shared utilities, types, token configs, and helpers -- **packages/sdk** - Public SDK for Vortex API integration +- **apps/frontend** — React 19 + Vite web app → [`apps/frontend/CLAUDE.md`](apps/frontend/CLAUDE.md) +- **apps/api** — Express backend (PostgreSQL + Sequelize) → [`apps/api/CLAUDE.md`](apps/api/CLAUDE.md) +- **apps/rebalancer** — liquidity rebalancing service → [`apps/rebalancer/CLAUDE.md`](apps/rebalancer/CLAUDE.md) +- **packages/shared** — `@vortexfi/shared` utilities/configs → [`packages/shared/CLAUDE.md`](packages/shared/CLAUDE.md) +- **packages/sdk** — `@vortexfi/sdk` public SDK → [`packages/sdk/CLAUDE.md`](packages/sdk/CLAUDE.md) -## Essential Commands +## Monorepo Commands -> Always use `bun`- never `npm`, `yarn`, or `pnpm`. Run `bun lint:fix` after any code change. +> Always use `bun` — never `npm`, `yarn`, or `pnpm`. Run `bun lint:fix` after any code +> change — **except in `packages/sdk`, which is linted by ESLint** (`bun lint` inside that +> package); Biome does not govern it. Per-app test/dev/migrate commands live in each +> subdirectory's `CLAUDE.md`. ```bash -# Install all dependencies -bun install - -# Development (runs frontend, backend, and shared concurrently) -bun dev - -# Individual app development -bun dev:frontend # Frontend at http://127.0.0.1:5173 -bun dev:backend # Backend at http://localhost:3000 +bun install # install all dependencies +bun dev # frontend + backend + shared concurrently +bun dev:frontend # http://127.0.0.1:5173 +bun dev:backend # http://localhost:3000 bun dev:rebalancer -# Build -bun build # Build all (shared -> sdk -> frontend -> backend) -bun build:shared # Must build shared first when making changes -bun build:frontend -bun build:backend - -# Linting and formatting (uses Biome) -bun lint # Run linter -bun lint:fix # Auto-fix lint issues -bun format # Format all files -bun verify # Check without fixing - -# Type checking -bun typecheck - -# Database (from apps/api) -cd apps/api -bun migrate # Run migrations -bun migrate:revert # Revert all migrations -bun migrate:revert-last # Revert last migration -bun seed:phase-metadata # Seed phase configuration - -# Testing -cd apps/frontend && bun test # Frontend tests (Vitest) -cd apps/api && bun test # Backend tests -``` - -## Architecture - -### State Machine Pattern - -The ramping process uses a state machine with defined phases: -- **Offramp**: prepareTransactions → squidRouter → pendulumFundEphemeral → subsidizePreSwap → nablaApprove → nablaSwap → subsidizePostSwap → performBrlaPayout → pendulumCleanup -- **Onramp**: brlaTeleport → createMoonbeamEphemeral → executeMoonbeamToPendulumXCM → subsidizePreSwap → nablaApprove → nablaSwap → executePendulumToAssetHubXCM → pendulumCleanup - -Phase metadata and valid transitions are stored in PostgreSQL and seeded via `seed:phase-metadata`. - -### Frontend Architecture - -- **State**: Zustand stores (`stores/`) + React Context (`contexts/`) -- **Forms**: React Hook Form with Zod validation (not Yup) -- **Data Fetching**: TanStack Query -- **Routing**: TanStack Router (route tree auto-generated in `routeTree.gen.ts`) -- **State Machines**: XState machines in `machines/` for complex flows (KYC, ramp process) -- **Wallet Integration**: Wagmi/AppKit (EVM) + Talisman (Polkadot) - -### Backend Architecture - -- **API Layer**: Express routes in `api/routes/`, controllers in `api/controllers/` -- **Services**: Business logic in `api/services/` -- **Models**: Sequelize models in `models/` (RampState, QuoteTicket, Partner, etc.) -- **Workers**: Background jobs in `api/workers/` -- **Cross-chain**: XCM handlers, Nabla AMM integration, Stellar/BRLA APIs - -### Shared Package (`@vortexfi/shared`) - -Contains cross-package utilities: -- Token configurations and network definitions -- Endpoint helpers for API calls -- Contract ABIs and addresses -- Decimal/BigNumber helpers -- Logger configuration - -**Important**: Always rebuild shared when making changes: `bun build:shared` +bun build # build all (shared -> sdk -> frontend -> backend) +bun build:shared # rebuild shared (see below) -After ANY change to `packages/shared`, run `bun build:shared` before running frontend/api. - -## Code Style Guidelines - -From `.clinerules/`: - -### General -- Prefer composition over inheritance -- Create ADRs in `/docs/adr` for major architectural changes - -### Frontend-Specific -- Avoid `useState` unless absolutely needed; prefer derived data and `useRef` -- Avoid `useEffect` except for external system synchronization -- Avoid `setTimeout` (always comment why if used) -- Extract complex conditional rendering into new components -- Skip useless comments; only comment race conditions, TODOs, or genuinely confusing code +bun lint # Biome lint bun lint:fix # auto-fix +bun format # format all bun verify # check without fixing +bun typecheck # type check +``` -### XState v5 -- Use `setup({ ... }).createMachine(...)` API- not `createMachine` directly -- Actor refs from `useActor` / `useSelector` from `@xstate/react` -- Machine files live in `apps/frontend/src/machines/` +### Always rebuild shared after changing it -### Biome Configuration -- Line width: 128 -- Indent: 2 spaces -- Semicolons: always -- Trailing commas: none -- Quote style: double -- Sorted Tailwind classes enforced via `useSortedClasses` rule +`packages/shared` is consumed as built output. **After ANY change to `packages/shared`, +run `bun build:shared` before running frontend/api** — otherwise they use stale code. ## Token Exhaustiveness `FiatToken` currently has 6 values: `EURC`, `ARS`, `BRL`, `USD`, `MXN`, `COP`. Any `Record` must include ALL six. Missing entries cause TypeScript errors -when shared is rebuilt. Check: tokenAvailability, mapFiatToDestination, success page -ARRIVAL_TEXT_BY_TOKEN, sep10 tokenMapping. +when shared is rebuilt. Check: `tokenAvailability`, `mapFiatToDestination`, success page +`ARRIVAL_TEXT_BY_TOKEN`, sep10 `tokenMapping`. + +## Code Style + +Biome config: line width 128, 2-space indent, semicolons always, no trailing commas, +double quotes, sorted Tailwind classes (`useSortedClasses`). General: prefer composition +over inheritance; create ADRs in `/docs/adr` for major architectural changes. +Frontend-specific and XState conventions live in +[`apps/frontend/CLAUDE.md`](apps/frontend/CLAUDE.md). ## No Over-Engineering -- Don't add features, refactors, or "improvements" beyond what was asked -- Don't add docstrings/comments to code you didn't touch -- Don't create helpers/utilities for one-time operations -- Don't validate inputs that can't be invalid (internal calls, typed params) -- Three similar lines is better than a premature abstraction +- Don't add features, refactors, or "improvements" beyond what was asked. +- Don't add docstrings/comments to code you didn't touch. +- Don't create helpers/utilities for one-time operations. +- Don't validate inputs that can't be invalid (internal calls, typed params). +- Three similar lines is better than a premature abstraction. ## Testing -### Test Coverage Requirements - These apply to every agent working in this repo: -- **Bug fixes and regressions**: if a bug or regression slipped past the existing tests, add a test that reproduces it (and fails without the fix) before/with the fix — so it can't silently come back. Write the test at the level that actually covers the gap (unit or integration). Only skip when a test genuinely can't capture it (e.g. purely cosmetic, environment/config, or third-party behavior) — and say why you skipped. -- **New features**: always ship the appropriate tests alongside the feature. Cover the core behavior and the edge cases that matter, not just the happy path. - -### Backend Integration Tests -```bash -cd apps/api -bun test phase-processor.integration.test.ts --timeout X -``` -State is stored in `lastRampState.json`. For recovery testing, copy failed state to `failedRampStateRecovery.json` and run the recovery test. +- **Bug fixes and regressions**: if a bug slipped past existing tests, add a test that + reproduces it (and fails without the fix) before/with the fix, so it can't silently + come back. Write it at the level that covers the gap (unit or integration). Only skip + when a test genuinely can't capture it (purely cosmetic, environment/config, or + third-party behavior) — and say why you skipped. +- **New features**: always ship the appropriate tests alongside the feature. Cover the + core behavior and the edge cases that matter, not just the happy path. -### Frontend Tests -```bash -cd apps/frontend -bun test -``` +Per-app test commands and integration-test state handling live in each subdirectory's +`CLAUDE.md`. ## Security Spec Sync -`docs/security-spec/` is the audit-facing source of truth for security-sensitive behavior, and it must not go stale. Any change to an API feature or its business logic — auth, admin routes, quote/ramp state, signing, fees, partner pricing, integrations, migrations/schema that affect invariants, or cross-chain fund flow — must be cross-checked against the matching spec file and updated in the same change whenever the behavior it documents changed. This applies to every agent working in this repo, not just the one that first touched the code. +`docs/security-spec/` is the audit-facing source of truth for security-sensitive +behavior, and it must not go stale. Any change to an API feature or its business logic — +auth, admin routes, quote/ramp state, signing, fees, partner pricing, integrations, +migrations/schema that affect invariants, or cross-chain fund flow — must be cross-checked +against the matching spec file and updated in the same change whenever the behavior it +documents changed. This applies to every agent working in this repo. -Keep this lightweight: grep/read only the relevant spec path from `docs/security-spec/README.md`; skip this for cosmetic refactors, test-only changes, or implementation changes that do not alter security-relevant behavior. If a change alters documented behavior but you are unsure which spec file owns it, say so rather than leaving the spec silently stale. +Keep this lightweight: grep/read only the relevant spec path from +`docs/security-spec/README.md`; skip it for cosmetic refactors, test-only changes, or +implementation changes that do not alter security-relevant behavior. If a change alters +documented behavior but you are unsure which spec file owns it, say so rather than leaving +the spec silently stale. ## Type Issues -If IDE doesn't detect `@pendulum-chain/types` properly, ensure all `@polkadot/*` packages match versions in the types package. The root `package.json` uses `catalog:` for version management. +If the IDE doesn't detect `@pendulum-chain/types` properly, ensure all `@polkadot/*` +packages match versions in the types package. The root `package.json` uses `catalog:` for +version management. --- @@ -233,4 +166,5 @@ For multi-step tasks, state a brief plan: 3. [Step] → verify: [check] ``` -Strong success criteria let you loop independently. Weak criteria ("make it work") require constant clarification. +Strong success criteria let you loop independently. Weak criteria ("make it work") +require constant clarification. diff --git a/MAP.md b/MAP.md new file mode 100644 index 000000000..1178c030a --- /dev/null +++ b/MAP.md @@ -0,0 +1,47 @@ +# Repository Map + +Wayfinding for the Vortex monorepo: what kind of code lives where. Each app/package +has its own `CLAUDE.md` with scoped commands and conventions — `cd` into the relevant +one before working there. + +## Apps + +| Path | What lives here | +|------|-----------------| +| `apps/frontend` | React 19 + Vite web app — the user-facing ramp UI. Zustand, TanStack Query/Router, XState, Wagmi/Talisman wallets. → [`CLAUDE.md`](apps/frontend/CLAUDE.md) | +| `apps/api` | Express backend (PostgreSQL + Sequelize). Ramp state machine, quotes, partners, webhooks, XCM/Nabla/Stellar/BRLA integrations. → [`CLAUDE.md`](apps/api/CLAUDE.md) | +| `apps/rebalancer` | Standalone liquidity rebalancing service. → [`CLAUDE.md`](apps/rebalancer/CLAUDE.md) | + +## Packages + +| Path | What lives here | +|------|-----------------| +| `packages/shared` | `@vortexfi/shared` — token/network configs, contract ABIs & addresses, decimal/BigNumber helpers, endpoint helpers, logger. Consumed by every app. → [`CLAUDE.md`](packages/shared/CLAUDE.md) | +| `packages/sdk` | `@vortexfi/sdk` — the public integration SDK shipped to partners. → [`CLAUDE.md`](packages/sdk/CLAUDE.md) | + +## Contracts + +| Path | What lives here | +|------|-----------------| +| `contracts` | Solidity contracts: `cctp-settlement` and `relayer`. | +| `relayer-contract` | Relayer contract security-audit material. | + +## Docs & specs + +| Path | What lives here | +|------|-----------------| +| `docs/security-spec` | Audit-facing source of truth for security-sensitive behavior. Keep in sync with code changes (see root `CLAUDE.md` → Security Spec Sync). | +| `docs/api` | Public API docs — OpenAPI spec (`openapi/`) and prose pages (`pages/`). Whitelabeled. | +| `docs/architecture`, `docs/features`, `docs/qa`, `docs/refactoring` | Architecture notes, feature write-ups, QA and refactoring records. | +| `docs/testing-strategy.md`, `docs/test-audit-findings.md` | Testing approach and audit findings. | +| `memory-bank` | Long-form project context: product/tech context, decision log, phases, progress. | + +## Tooling & config + +| Path | What lives here | +|------|-----------------| +| `scripts` | Repo tooling — `check-coverage.ts`, `coverage-report.ts` (LCOV-based coverage gate). | +| `supabase` | Supabase config, DB migrations, snippets, email templates. | +| `.agents/skills` | Repo-scoped agent skills: `vortex-integration` (partner integration recipes), `sentry-vortex` (frontend error-instrumentation audit). | +| `.clinerules` | Cline coding rules (general, useful prompts, frontend). | +| `.claude` | Claude Code config — shared `settings.json` (deny rules), personal `settings.local.json` (ignored), worktrees. | diff --git a/apps/api/.env.example b/apps/api/.env.example index 0fc472eb0..09c4eb9c1 100644 --- a/apps/api/.env.example +++ b/apps/api/.env.example @@ -42,7 +42,6 @@ PENDULUM_FUNDING_SEED=your-pendulum-funding-seed MOONBEAM_EXECUTOR_PRIVATE_KEY=your-moonbeam-executor-private-key # Optional. If unset, falls back to MOONBEAM_EXECUTOR_PRIVATE_KEY for EVM funding. EVM_FUNDING_PRIVATE_KEY= -CLIENT_DOMAIN_SECRET=your-client-domain-secret # Optional EVM payout address used as fallback when a partner has no payout_address_evm set. DEFAULT_VORTEX_EVM_PAYOUT_ADDRESS= @@ -103,6 +102,25 @@ ALFREDPAY_BASE_URL=your-alfredpay-base-url ALFREDPAY_API_KEY=your-alfredpay-api-key ALFREDPAY_API_SECRET=your-alfredpay-api-secret +# Monerium OAuth (the redirect URI must exactly match the dashboard callback registered with Monerium) +MONERIUM_CLIENT_ID=your-monerium-auth-code-client-id +MONERIUM_API_URL=https://api.monerium.dev +MONERIUM_REDIRECT_URI=http://localhost:5174/dashboard/monerium/callback + +# BRLA / Avenia +# BRLA_BASE_URL= +BRLA_API_KEY=your-brla-api-key +BRLA_PRIVATE_KEY=your-brla-private-key + # Integration test helpers (only required for phase-processor integration tests) # BACKEND_TEST_STARTER_ACCOUNT= # TAX_ID= + +# External API contract tests (RUN_LIVE_TESTS=1, see docs/features/contract-tests.md). +# Pre-provisioned SANDBOX fixtures — the fixture-gated live tests create real (unpaid) +# sandbox transactions, so only ever point these at sandbox objects. Tests skip cleanly +# when unset. +# ALFREDPAY_CONTRACT_CUSTOMER_ID= # KYC-approved MX sandbox customer +# ALFREDPAY_CONTRACT_FIAT_ACCOUNT_ID= # SPEI fiat account of that customer +# ALFREDPAY_CONTRACT_KYC_SUBMISSION_ID= # a KYC submission of that customer +# AVENIA_CONTRACT_SUBACCOUNT_ID= # KYC-approved Avenia sandbox subaccount diff --git a/apps/api/CLAUDE.md b/apps/api/CLAUDE.md new file mode 100644 index 000000000..631def2a1 --- /dev/null +++ b/apps/api/CLAUDE.md @@ -0,0 +1,50 @@ +# apps/api — Vortex backend + +Express + PostgreSQL/Sequelize service. Root `CLAUDE.md` holds cross-cutting rules +(over-engineering, security-spec sync, testing policy); this file holds API-scoped +architecture and commands. Run commands from `apps/api/` unless noted. + +## Architecture + +- **API layer**: routes in `src/api/routes/`, controllers in `src/api/controllers/`. +- **Services**: business logic in `src/api/services/`. +- **Models**: Sequelize models in `src/models/` (RampState, QuoteTicket, Partner, …). +- **Workers**: background jobs in `src/api/workers/`. +- **Cross-chain**: XCM handlers, Nabla AMM integration, Stellar/BRLA APIs. +- **Middlewares / observability / errors / helpers**: under `src/api/`. + +### Ramp state machine + +The ramping process runs through defined phases; metadata and valid transitions live in +PostgreSQL, seeded via `bun seed:phase-metadata`. + +- **Offramp**: prepareTransactions → squidRouter → pendulumFundEphemeral → subsidizePreSwap → nablaApprove → nablaSwap → subsidizePostSwap → performBrlaPayout → pendulumCleanup +- **Onramp**: brlaTeleport → createMoonbeamEphemeral → executeMoonbeamToPendulumXCM → subsidizePreSwap → nablaApprove → nablaSwap → executePendulumToAssetHubXCM → pendulumCleanup + +## Commands (from `apps/api/`) + +```bash +bun test # full backend suite +bun test # single file, e.g. bun test ramp.service.test.ts +bun test phase-processor.integration.test.ts --timeout X # integration test + +bun migrate # run migrations +bun migrate:revert # revert ALL migrations (destructive — dev only) +bun migrate:revert-last # revert last migration +bun seed:phase-metadata # seed phase configuration +``` + +Lint with the repo Biome config: from repo root `bun lint:fix`, or target a path with +`bunx @biomejs/biome check apps/api/src`. Dev server: `bun dev:backend` from root +(backend at http://localhost:3000). + +## Integration test state + +Integration tests store state in `lastRampState.json`. For recovery testing, copy a +failed state into `failedRampStateRecovery.json` and run the recovery test. + +## Security spec + +Changes to auth, admin routes, quote/ramp state, signing, fees, partner pricing, +integrations, or migrations that affect invariants must be cross-checked against +`docs/security-spec/` in the same change. See root `CLAUDE.md` → Security Spec Sync. diff --git a/apps/api/README.md b/apps/api/README.md index f7af4e8f7..2e36c4a3c 100644 --- a/apps/api/README.md +++ b/apps/api/README.md @@ -116,7 +116,6 @@ yarn seed:phase-metadata - `FUNDING_SECRET`: Secret key to sign the funding transactions on Stellar. - `PENDULUM_FUNDING_SEED`: Seed phrase to sign the funding transactions on Pendulum. - `MOONBEAM_EXECUTOR_PRIVATE_KEY`: Private key to sign the transactions on Moonbeam. -- `CLIENT_DOMAIN_SECRET`: Secret for client domain verification. ### Database Configuration diff --git a/apps/api/package.json b/apps/api/package.json index fdb4f9c98..a307ece87 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -48,7 +48,8 @@ "uuid": "^11.1.0", "viem": "catalog:", "web3": "^4.16.0", - "winston": "^3.1.0" + "winston": "^3.1.0", + "zod": "catalog:" }, "description": "", "devDependencies": { @@ -98,7 +99,8 @@ "test": "bun test", "test:coverage": "bun test --coverage --coverage-reporter=lcov && bun ../../scripts/check-coverage.ts coverage/lcov.info 0.55 0.64", "test:db:start": "./scripts/test-db.sh start", - "test:db:stop": "./scripts/test-db.sh stop" + "test:db:stop": "./scripts/test-db.sh stop", + "typecheck": "tsc --noEmit" }, "version": "1.0.0" } diff --git a/apps/api/scripts/schema-parity-checks.sql b/apps/api/scripts/schema-parity-checks.sql new file mode 100644 index 000000000..95a447899 --- /dev/null +++ b/apps/api/scripts/schema-parity-checks.sql @@ -0,0 +1,218 @@ +-- Parity checks for the 038-049 schema migration (run after each deploy of the 038-049 set; see docs/runbooks/dashboard-schema-production-rollout.md). +-- Read-only. Mirrors the backfill rules of migrations 038/039/040 exactly, so: +-- * PARITY checks must return 0 — any non-zero row count is a real backfill gap. +-- * INFO checks are expected to be non-zero; they size the deliberately-skipped buckets. +-- +-- Section 0: sanity — if these return 0 on a database that has data, the readonly role +-- is being filtered by row-level security (new tables have RLS enabled with no policies; +-- a non-owner role needs BYPASSRLS) and every other result below is meaningless. + +SELECT '0. sanity: provider_customers visible' AS check, count(*) AS rows FROM provider_customers +UNION ALL +SELECT '0. sanity: customer_entities visible', count(*) FROM customer_entities +UNION ALL +SELECT '0. sanity: legacy mykobo_customers visible', count(*) FROM mykobo_customers +UNION ALL +SELECT '0. sanity: legacy tax_ids visible', count(*) FROM tax_ids; + +-- --------------------------------------------------------------------------- +-- Section 1: PARITY — every eligible legacy row must exist in the new schema. +-- All counts must be 0. +-- --------------------------------------------------------------------------- + +-- 1a. Profiles without any customer entity (038 backfilled one per profile). +SELECT '1a. PARITY profiles missing customer_entity (expect 0)' AS check, count(*) AS rows +FROM profiles p +WHERE NOT EXISTS (SELECT 1 FROM customer_entities ce WHERE ce.profile_id = p.id) + +UNION ALL + +-- 1b. Mykobo: eligible = has an owning entity; keyed by email. +SELECT '1b. PARITY mykobo rows missing in provider_customers (expect 0)', count(*) +FROM mykobo_customers m +JOIN customer_entities ce ON ce.profile_id = m.user_id +WHERE NOT EXISTS ( + SELECT 1 FROM provider_customers pc + WHERE pc.provider = 'mykobo' AND pc.provider_customer_id = m.email +) + +UNION ALL + +-- 1c. Alfredpay: eligible = latest row per (user, country, type) with an owning entity. +SELECT '1c. PARITY alfredpay rows missing in provider_customers (expect 0)', count(*) +FROM ( + SELECT DISTINCT ON (a.user_id, a.country, a.type) a.* + FROM alfredpay_customers a + ORDER BY a.user_id, a.country, a.type, a.updated_at DESC +) s +JOIN customer_entities ce ON ce.profile_id = s.user_id +WHERE NOT EXISTS ( + SELECT 1 FROM provider_customers pc + WHERE pc.provider = 'alfredpay' AND pc.provider_customer_id = s.alfred_pay_id +) + +UNION ALL + +-- 1d. Avenia: eligible = owned tax_ids rows (user_id set) with an owning entity; keyed by hash. +SELECT '1d. PARITY owned tax_ids missing in provider_customers (expect 0)', count(*) +FROM tax_ids t +JOIN customer_entities ce ON ce.profile_id = t.user_id +WHERE t.user_id IS NOT NULL + AND NOT EXISTS ( + SELECT 1 FROM provider_customers pc + WHERE pc.provider = 'avenia' + AND pc.tax_reference_hash = encode(sha256(convert_to(t.tax_id, 'UTF8')), 'hex') + ) + +UNION ALL + +-- 1e. Every migrated/created provider account has a KYC case (040 created one per row). +SELECT '1e. PARITY provider_customers without any kyc_case (expect 0)', count(*) +FROM provider_customers pc +WHERE NOT EXISTS (SELECT 1 FROM kyc_cases k WHERE k.provider_customer_id = pc.id) + +UNION ALL + +-- 1f. Partner split: every legacy (name, ramp_type) pricing row survives as a pricing config +-- on the canonical (folded-by-name) partner. +SELECT '1f. PARITY partners_legacy pricing rows missing a pricing config (expect 0)', count(*) +FROM (SELECT DISTINCT name, ramp_type FROM partners_legacy) pl +WHERE NOT EXISTS ( + SELECT 1 + FROM partners p + JOIN partner_pricing_configs cfg ON cfg.partner_id = p.id + WHERE p.name = pl.name AND cfg.ramp_type::text = pl.ramp_type::text +) + +UNION ALL + +-- 1g. Duplicate customer entities per (profile, type) — migration 049's unique index +-- should make this structurally impossible; 0 confirms the index is doing its job. +SELECT '1g. PARITY duplicate customer_entities per (profile,type) (expect 0)', count(*) +FROM ( + SELECT profile_id, type + FROM customer_entities + WHERE profile_id IS NOT NULL + GROUP BY profile_id, type + HAVING count(*) > 1 +) d; + +-- --------------------------------------------------------------------------- +-- Section 2: INFO — deliberately skipped / operationally interesting buckets. +-- Non-zero is expected; the numbers tell you whether the skipped rows matter. +-- --------------------------------------------------------------------------- + +-- 2a. Quarantined unowned Avenia rows (never migrated by design). If any of these have a +-- real subaccount, that user's KYC status is invisible to the new schema. +SELECT '2a. INFO tax_ids quarantined (user_id IS NULL)' AS check, count(*) AS rows +FROM tax_ids WHERE user_id IS NULL + +UNION ALL + +SELECT '2b. INFO quarantined rows that have a real subaccount', count(*) +FROM tax_ids WHERE user_id IS NULL AND COALESCE(sub_account_id, '') <> '' + +UNION ALL + +-- 2c/2d. Legacy rows whose owner has no customer entity — the backfill JOIN silently +-- dropped these. Should be 0 given 038 covered every profile; non-zero means the +-- legacy user_id points at a deleted/foreign profile. +SELECT '2c. INFO mykobo rows whose owner has no entity', count(*) +FROM mykobo_customers m +WHERE NOT EXISTS (SELECT 1 FROM customer_entities ce WHERE ce.profile_id = m.user_id) + +UNION ALL + +SELECT '2d. INFO alfredpay rows whose owner has no entity', count(*) +FROM alfredpay_customers a +WHERE NOT EXISTS (SELECT 1 FROM customer_entities ce WHERE ce.profile_id = a.user_id) + +UNION ALL + +SELECT '2e. INFO owned tax_ids whose owner has no entity', count(*) +FROM tax_ids t +WHERE t.user_id IS NOT NULL + AND NOT EXISTS (SELECT 1 FROM customer_entities ce WHERE ce.profile_id = t.user_id) + +UNION ALL + +-- 2f. Alfredpay duplicate folds: older rows per (user, country, type) superseded by the +-- latest one. Informational — mirrors the runtime updatedAt-DESC semantics. +SELECT '2f. INFO alfredpay historical duplicates folded', count(*) +FROM alfredpay_customers a +WHERE EXISTS ( + SELECT 1 FROM alfredpay_customers b + WHERE b.user_id = a.user_id AND b.country = a.country AND b.type = a.type + AND b.updated_at > a.updated_at +) + +UNION ALL + +-- 2g. Orphaned partner API keys (partner deleted/renamed before 041's backfill). These are +-- revoked under the new validators; confirm nothing you rely on is in here. +SELECT '2g. INFO api_keys orphaned (partner_name set, partner_id NULL, active)', count(*) +FROM api_keys +WHERE partner_name IS NOT NULL AND partner_id IS NULL AND is_active + +UNION ALL + +-- 2h. Users currently blocked by the one-active-ramp lock (non-terminal ramp). Stale test +-- ramps in mid-flow phases lock their user out until moved to a terminal phase. +SELECT '2h. INFO users with a non-terminal ramp (ramp-locked)', count(DISTINCT user_id) +FROM ramp_states +WHERE current_phase NOT IN ('complete', 'failed', 'timedOut') AND user_id IS NOT NULL + +UNION ALL + +SELECT '2i. INFO ... of which wedged past initial (need manual terminal-ization)', count(DISTINCT user_id) +FROM ramp_states +WHERE current_phase NOT IN ('complete', 'failed', 'timedOut', 'initial') AND user_id IS NOT NULL; + +-- --------------------------------------------------------------------------- +-- Section 3: STATUS DRIFT -- same account, different status between legacy and new. +-- Legacy statuses are pushed through migration 045's canonicalization first, so a row +-- listed here means the account genuinely changed state after the migration (the new +-- schema is authoritative) -- or a mapping bug. Expected empty right after deploy. +-- --------------------------------------------------------------------------- + +SELECT '3a. mykobo status drift' AS check, m.email AS key, m.status::text AS legacy_status, pc.status AS new_status +FROM mykobo_customers m +JOIN provider_customers pc ON pc.provider = 'mykobo' AND pc.provider_customer_id = m.email +WHERE CASE + WHEN m.status::text IN ('APPROVED', 'SUCCESS', 'Accepted') THEN 'approved' + WHEN m.status::text IN ('REJECTED', 'FAILED', 'Rejected') THEN 'rejected' + WHEN m.status::text IN ('USER_COMPLETED', 'VERIFYING', 'Requested', 'PENDING') THEN 'in_review' + ELSE 'pending' + END IS DISTINCT FROM pc.status; + +SELECT '3b. alfredpay status drift' AS check, s.alfred_pay_id AS key, s.status::text AS legacy_status, pc.status AS new_status +FROM ( + SELECT DISTINCT ON (a.user_id, a.country, a.type) a.* + FROM alfredpay_customers a + ORDER BY a.user_id, a.country, a.type, a.updated_at DESC +) s +JOIN provider_customers pc ON pc.provider = 'alfredpay' AND pc.provider_customer_id = s.alfred_pay_id +WHERE CASE + WHEN s.status::text IN ('APPROVED', 'SUCCESS') THEN 'approved' + WHEN s.status::text IN ('REJECTED', 'FAILED') THEN 'rejected' + WHEN s.status::text IN ('USER_COMPLETED', 'VERIFYING') THEN 'in_review' + WHEN s.status::text IN ('CONSULTED', 'LINK_OPENED', 'UPDATE_REQUIRED') THEN 'started' + WHEN s.status::text = 'PENDING' THEN 'in_review' + ELSE 'pending' + END IS DISTINCT FROM pc.status; + +SELECT '3c. avenia status drift' AS check, + repeat('*', GREATEST(length(pc.tax_reference) - 4, 0)) || right(pc.tax_reference, 4) AS key, + t.internal_status::text AS legacy_status, pc.status AS new_status +FROM tax_ids t +JOIN provider_customers pc + ON pc.provider = 'avenia' + AND pc.tax_reference_hash = encode(sha256(convert_to(t.tax_id, 'UTF8')), 'hex') +WHERE t.user_id IS NOT NULL + AND CASE + WHEN COALESCE(t.internal_status::text, 'Consulted') = 'Accepted' THEN 'approved' + WHEN COALESCE(t.internal_status::text, 'Consulted') = 'Rejected' THEN 'rejected' + WHEN COALESCE(t.internal_status::text, 'Consulted') = 'Requested' THEN 'in_review' + WHEN COALESCE(t.internal_status::text, 'Consulted') = 'Consulted' THEN 'started' + ELSE 'pending' + END IS DISTINCT FROM pc.status; diff --git a/apps/api/src/api/controllers/admin/partnerApiKeys.controller.ts b/apps/api/src/api/controllers/admin/partnerApiKeys.controller.ts index 35ce23c4c..e7d2d09fe 100644 --- a/apps/api/src/api/controllers/admin/partnerApiKeys.controller.ts +++ b/apps/api/src/api/controllers/admin/partnerApiKeys.controller.ts @@ -16,15 +16,15 @@ export async function createApiKey(req: Request<{ partnerName: string }>, res: R const partnerName = req.params.partnerName; const { name, expiresAt, userId } = req.body; - // Verify at least one partner with this name exists and is active - const partners = await Partner.findAll({ + // Resolve the (unique-name) partner; keys bind to it by FK + const partner = await Partner.findOne({ where: { isActive: true, name: partnerName } }); - if (partners.length === 0) { + if (!partner) { res.status(httpStatus.NOT_FOUND).json({ error: { code: "PARTNER_NOT_FOUND", @@ -77,7 +77,7 @@ export async function createApiKey(req: Request<{ partnerName: string }>, res: R const expirationDate = expiresAt ? new Date(expiresAt) : null; - // Create public key record + // Create public key record (partner_name kept as informational backup; auth resolves partner_id) const publicKeyRecord = await ApiKey.create({ expiresAt: expirationDate, isActive: true, @@ -86,6 +86,7 @@ export async function createApiKey(req: Request<{ partnerName: string }>, res: R keyType: "public", keyValue: publicKey, name: name ? `${name} (Public)` : "Public Key", + partnerId: partner.id, partnerName, userId: resolvedUserId }); @@ -99,6 +100,7 @@ export async function createApiKey(req: Request<{ partnerName: string }>, res: R keyType: "secret", keyValue: null, name: name ? `${name} (Secret)` : "Secret Key", + partnerId: partner.id, partnerName, userId: resolvedUserId }); @@ -108,7 +110,7 @@ export async function createApiKey(req: Request<{ partnerName: string }>, res: R createdAt: publicKeyRecord.createdAt, expiresAt: expirationDate, isActive: true, - partnerCount: partners.length, + partnerId: partner.id, partnerName, publicKey: { id: publicKeyRecord.id, @@ -149,11 +151,11 @@ export async function listApiKeys(req: Request<{ partnerName: string }>, res: Re const partnerName = req.params.partnerName; // Verify partner exists - const partners = await Partner.findAll({ + const partner = await Partner.findOne({ where: { name: partnerName } }); - if (partners.length === 0) { + if (!partner) { res.status(httpStatus.NOT_FOUND).json({ error: { code: "PARTNER_NOT_FOUND", @@ -180,7 +182,7 @@ export async function listApiKeys(req: Request<{ partnerName: string }>, res: Re "updatedAt" ], order: [["createdAt", "DESC"]], - where: { partnerName } + where: { partnerId: partner.id } }); res.status(httpStatus.OK).json({ @@ -197,7 +199,7 @@ export async function listApiKeys(req: Request<{ partnerName: string }>, res: Re updatedAt: key.updatedAt, userId: key.userId })), - partnerCount: partners.length, + partnerId: partner.id, partnerName }); } catch (error) { @@ -220,11 +222,23 @@ export async function revokeApiKey(req: Request<{ partnerName: string; keyId: st try { const { partnerName, keyId } = req.params; + const partner = await Partner.findOne({ where: { name: partnerName } }); + if (!partner) { + res.status(httpStatus.NOT_FOUND).json({ + error: { + code: "PARTNER_NOT_FOUND", + message: `No partners found with name: ${partnerName}`, + status: httpStatus.NOT_FOUND + } + }); + return; + } + // Find the API key const apiKey = await ApiKey.findOne({ where: { id: keyId, - partnerName + partnerId: partner.id } }); @@ -240,7 +254,7 @@ export async function revokeApiKey(req: Request<{ partnerName: string; keyId: st } // Soft delete by setting isActive to false - await apiKey.update({ isActive: false }); + await apiKey.update({ isActive: false, revokedAt: new Date() }); res.status(httpStatus.NO_CONTENT).send(); } catch (error) { diff --git a/apps/api/src/api/controllers/admin/profilePartnerAssignments.controller.test.ts b/apps/api/src/api/controllers/admin/profilePartnerAssignments.controller.test.ts index 80d0eeca5..63194e7e6 100644 --- a/apps/api/src/api/controllers/admin/profilePartnerAssignments.controller.test.ts +++ b/apps/api/src/api/controllers/admin/profilePartnerAssignments.controller.test.ts @@ -1,5 +1,4 @@ import {afterEach, describe, expect, it, mock} from "bun:test"; -import {RampDirection} from "@vortexfi/shared"; import {Request, Response} from "express"; import httpStatus from "http-status"; import {Op, Transaction, UniqueConstraintError} from "sequelize"; @@ -37,7 +36,7 @@ function createResponse() { describe("createProfilePartnerAssignment", () => { const originalTransaction = sequelize.transaction; const originalUserFindByPk = User.findByPk; - const originalPartnerFindAll = Partner.findAll; + const originalPartnerFindOne = Partner.findOne; const originalAssignmentUpdate = ProfilePartnerAssignment.update; const originalAssignmentCreate = ProfilePartnerAssignment.create; const originalAssignmentFindAll = ProfilePartnerAssignment.findAll; @@ -49,7 +48,7 @@ describe("createProfilePartnerAssignment", () => { afterEach(() => { sequelize.transaction = originalTransaction; User.findByPk = originalUserFindByPk; - Partner.findAll = originalPartnerFindAll; + Partner.findOne = originalPartnerFindOne; ProfilePartnerAssignment.update = originalAssignmentUpdate; ProfilePartnerAssignment.create = originalAssignmentCreate; ProfilePartnerAssignment.findAll = originalAssignmentFindAll; @@ -58,33 +57,29 @@ describe("createProfilePartnerAssignment", () => { function mockValidAssignmentDependencies() { const transactionMock = mock(async (callback: (tx: unknown) => Promise) => callback(transaction)); const userFindByPkMock = mock(async () => ({ id: "user-1" })); - const partnerFindAllMock = mock(async () => [ - { id: "buy-partner-1", name: "Acme", rampType: RampDirection.BUY }, - { id: "sell-partner-1", name: "Acme", rampType: RampDirection.SELL } - ]); + const partnerFindOneMock = mock(async () => ({ id: "partner-1", isActive: true, name: "Acme" })); const assignmentUpdateMock = mock(async () => [1]); const assignmentCreateMock = mock(async () => ({ createdAt, expiresAt: null, - buyPartnerId: "buy-partner-1", id: "assignment-2", isActive: true, + partnerId: "partner-1", partnerName: "Acme", - sellPartnerId: "sell-partner-1", updatedAt, userId: "user-1" })); sequelize.transaction = transactionMock as unknown as typeof sequelize.transaction; User.findByPk = userFindByPkMock as unknown as typeof User.findByPk; - Partner.findAll = partnerFindAllMock as unknown as typeof Partner.findAll; + Partner.findOne = partnerFindOneMock as unknown as typeof Partner.findOne; ProfilePartnerAssignment.update = assignmentUpdateMock as unknown as typeof ProfilePartnerAssignment.update; ProfilePartnerAssignment.create = assignmentCreateMock as unknown as typeof ProfilePartnerAssignment.create; return { assignmentCreateMock, assignmentUpdateMock, - partnerFindAllMock, + partnerFindOneMock, transactionMock, userFindByPkMock }; @@ -122,11 +117,10 @@ describe("createProfilePartnerAssignment", () => { ); expect(assignmentCreateMock).toHaveBeenCalledWith( { - buyPartnerId: "buy-partner-1", expiresAt: null, isActive: true, + partnerId: "partner-1", partnerName: "Acme", - sellPartnerId: "sell-partner-1", userId: "user-1" }, { transaction } @@ -160,12 +154,9 @@ describe("createProfilePartnerAssignment", () => { }); }); - it("rejects ambiguous active partners for the same ramp type", async () => { + it("returns 404 when no active partner has the requested name", async () => { mockValidAssignmentDependencies(); - Partner.findAll = mock(async () => [ - { id: "buy-partner-1", name: "Acme", rampType: RampDirection.BUY }, - { id: "buy-partner-2", name: "Acme", rampType: RampDirection.BUY } - ]) as unknown as typeof Partner.findAll; + Partner.findOne = mock(async () => null) as unknown as typeof Partner.findOne; const res = createResponse(); await createProfilePartnerAssignment( @@ -178,12 +169,12 @@ describe("createProfilePartnerAssignment", () => { res as unknown as Response ); - expect(res.statusCode).toBe(httpStatus.CONFLICT); + expect(res.statusCode).toBe(httpStatus.NOT_FOUND); expect(res.body).toEqual({ error: { - code: "AMBIGUOUS_PARTNER_ASSIGNMENT", - message: `Multiple active ${RampDirection.BUY} partners found with this name`, - status: httpStatus.CONFLICT + code: "PARTNER_NOT_FOUND", + message: "No active partners found with name: Acme", + status: httpStatus.NOT_FOUND } }); }); diff --git a/apps/api/src/api/controllers/admin/profilePartnerAssignments.controller.ts b/apps/api/src/api/controllers/admin/profilePartnerAssignments.controller.ts index 78df9239c..16280a234 100644 --- a/apps/api/src/api/controllers/admin/profilePartnerAssignments.controller.ts +++ b/apps/api/src/api/controllers/admin/profilePartnerAssignments.controller.ts @@ -1,4 +1,3 @@ -import { RampDirection } from "@vortexfi/shared"; import { Request, Response } from "express"; import httpStatus from "http-status"; import { Op, Transaction, UniqueConstraintError, WhereOptions } from "sequelize"; @@ -10,15 +9,6 @@ import User from "../../../models/user.model"; const PROFILE_NOT_FOUND_AFTER_LOCK = "PROFILE_NOT_FOUND_AFTER_LOCK"; -function getUniquePartnerIdForRamp(partners: Partner[], rampType: RampDirection): string | null { - const rampPartners = partners.filter(partner => partner.rampType === rampType); - if (rampPartners.length > 1) { - throw new Error(`Multiple active ${rampType} partners found with this name`); - } - - return rampPartners[0]?.id ?? null; -} - function parseExpiration(expiresAt: unknown): Date | null { if (!expiresAt) { return null; @@ -38,13 +28,12 @@ function parseExpiration(expiresAt: unknown): Date | null { function serializeAssignment(assignment: ProfilePartnerAssignment) { return { - buyPartnerId: assignment.buyPartnerId, createdAt: assignment.createdAt, expiresAt: assignment.expiresAt, id: assignment.id, isActive: assignment.isActive, + partnerId: assignment.partnerId, partnerName: assignment.partnerName, - sellPartnerId: assignment.sellPartnerId, updatedAt: assignment.updatedAt, userId: assignment.userId }; @@ -77,14 +66,14 @@ export async function createProfilePartnerAssignment(req: Request, res: Response return; } - const partners = await Partner.findAll({ + const partner = await Partner.findOne({ where: { isActive: true, name: partnerName } }); - if (partners.length === 0) { + if (!partner) { res.status(httpStatus.NOT_FOUND).json({ error: { code: "PARTNER_NOT_FOUND", @@ -95,9 +84,6 @@ export async function createProfilePartnerAssignment(req: Request, res: Response return; } - const buyPartnerId = getUniquePartnerIdForRamp(partners, RampDirection.BUY); - const sellPartnerId = getUniquePartnerIdForRamp(partners, RampDirection.SELL); - const expirationDate = parseExpiration(expiresAt); const assignment = await sequelize.transaction(async transaction => { @@ -123,11 +109,10 @@ export async function createProfilePartnerAssignment(req: Request, res: Response return ProfilePartnerAssignment.create( { - buyPartnerId, expiresAt: expirationDate, isActive: true, + partnerId: partner.id, partnerName, - sellPartnerId, userId }, { transaction } @@ -135,21 +120,9 @@ export async function createProfilePartnerAssignment(req: Request, res: Response }); res.status(httpStatus.CREATED).json({ - assignment: serializeAssignment(assignment), - partnerCount: partners.length + assignment: serializeAssignment(assignment) }); } catch (error) { - if (error instanceof Error && error.message.startsWith("Multiple active")) { - res.status(httpStatus.CONFLICT).json({ - error: { - code: "AMBIGUOUS_PARTNER_ASSIGNMENT", - message: error.message, - status: httpStatus.CONFLICT - } - }); - return; - } - if (error instanceof Error && error.message.startsWith("expiresAt")) { res.status(httpStatus.BAD_REQUEST).json({ error: { diff --git a/apps/api/src/api/controllers/alfredpay.controller.ts b/apps/api/src/api/controllers/alfredpay.controller.ts index 6ea82acd0..b819580ed 100644 --- a/apps/api/src/api/controllers/alfredpay.controller.ts +++ b/apps/api/src/api/controllers/alfredpay.controller.ts @@ -24,8 +24,14 @@ import { import { Request, Response } from "express"; import httpStatus from "http-status"; import logger from "../../config/logger"; -import AlfredPayCustomer from "../../models/alfredPayCustomer.model"; +import { VerificationStatus } from "../../models/providerCustomer.model"; import { getEffectiveUserId } from "../middlewares/effectiveUser"; +import { + createAlfredpayCustomer, + findAlfredpayCustomer, + normalizeAlfredpayProviderStatus, + resolveAlfredpayKybSubmissionId +} from "../services/alfredpay/alfredpay-customer.service"; import { ALFREDPAY_EFFECTIVE_USER_REQUIRED_MESSAGE } from "../services/quote/alfredpay-customer"; export class AlfredpayController { @@ -60,7 +66,7 @@ export class AlfredpayController { } private static mapKycStatus(status: AlfredpayKycStatus): AlfredPayStatus | null { - switch (status) { + switch (normalizeAlfredpayProviderStatus(status)) { case AlfredpayKycStatus.IN_REVIEW: return AlfredPayStatus.Verifying; case AlfredpayKycStatus.FAILED: @@ -76,7 +82,7 @@ export class AlfredpayController { } private static mapKybStatus(status: AlfredpayKybStatus): AlfredPayStatus | null { - switch (status) { + switch (normalizeAlfredpayProviderStatus(status)) { case AlfredpayKybStatus.IN_REVIEW: return AlfredPayStatus.Verifying; case AlfredpayKybStatus.FAILED: @@ -96,10 +102,7 @@ export class AlfredpayController { const { country } = req.query as unknown as AlfredpayStatusRequest; const userId = AlfredpayController.getRequiredUserId(req); - const alfredPayCustomer = await AlfredPayCustomer.findOne({ - order: [["updatedAt", "DESC"]], - where: { country: country as AlfredPayCountry, userId } - }); + const alfredPayCustomer = await findAlfredpayCustomer(userId, country as AlfredPayCountry); if (!alfredPayCustomer) { return res.status(404).json({ error: "Alfredpay customer not found" }); @@ -109,24 +112,38 @@ export class AlfredpayController { const isBusiness = alfredPayCustomer.type === AlfredpayCustomerType.BUSINESS; try { - const lastSubmission = isBusiness - ? await alfredpayService.getLastKybSubmission(alfredPayCustomer.alfredPayId) - : await alfredpayService.getLastKycSubmission(alfredPayCustomer.alfredPayId); + const submissionId = isBusiness + ? await resolveAlfredpayKybSubmissionId(alfredPayCustomer.alfredPayId) + : (await alfredpayService.getLastKycSubmission(alfredPayCustomer.alfredPayId))?.submissionId; - if (lastSubmission && lastSubmission.submissionId) { + if (submissionId) { const statusResponse = isBusiness - ? await alfredpayService.getKybStatus(alfredPayCustomer.alfredPayId, lastSubmission.submissionId) - : await alfredpayService.getKycStatus(alfredPayCustomer.alfredPayId, lastSubmission.submissionId); + ? await alfredpayService.getKybStatus(alfredPayCustomer.alfredPayId, submissionId) + : await alfredpayService.getKycStatus(alfredPayCustomer.alfredPayId, submissionId); const newStatus = isBusiness ? AlfredpayController.mapKybStatus(statusResponse.status) : AlfredpayController.mapKycStatus(statusResponse.status); - const updateData: Partial = {}; + const updateData: Partial<{ + status: AlfredPayStatus; + verificationStatus: VerificationStatus; + statusExternal: string | null; + lastFailureReasons: string[]; + providerCaseId: string; + }> = { + providerCaseId: submissionId, + statusExternal: normalizeAlfredpayProviderStatus(statusResponse.status) + }; if (newStatus && newStatus !== alfredPayCustomer.status) { updateData.status = newStatus; } + // PENDING = unfinalized/invalid submission, not a rejection — reflect it as our Pending. + if (normalizeAlfredpayProviderStatus(statusResponse.status) === AlfredpayKycStatus.PENDING) { + updateData.verificationStatus = VerificationStatus.Pending; + } + if (newStatus === AlfredPayStatus.Failed && statusResponse.metadata?.failureReason) { updateData.lastFailureReasons = [statusResponse.metadata.failureReason]; } @@ -142,10 +159,12 @@ export class AlfredpayController { // Reset to Consulted so the frontend re-triggers the KYC flow. const errorMessage = AlfredpayController.getErrorMessage(error).toLowerCase(); if (errorMessage.includes("404") || errorMessage.includes("not found")) { - if (alfredPayCustomer.status === AlfredPayStatus.Success) { - logger.info("Resetting stale AlfredPay status to Consulted due to upstream 404"); - await alfredPayCustomer.update({ status: AlfredPayStatus.Consulted }); - } + logger.info("Resetting stale AlfredPay status to pending due to upstream 404"); + await alfredPayCustomer.update({ + status: AlfredPayStatus.Consulted, + statusExternal: null, + verificationStatus: VerificationStatus.Pending + }); } } @@ -173,9 +192,7 @@ export class AlfredpayController { } // Check if customer already exists in our DB - const existingDbCustomer = await AlfredPayCustomer.findOne({ - where: { country: country as AlfredPayCountry, userId } - }); + const existingDbCustomer = await findAlfredpayCustomer(userId, country as AlfredPayCountry); if (existingDbCustomer) { return res.status(400).json({ error: "Customer already exists" }); @@ -198,12 +215,11 @@ export class AlfredpayController { } } - await AlfredPayCustomer.create({ + await createAlfredpayCustomer(userId, { alfredPayId: customerId, country: country as AlfredPayCountry, status: AlfredPayStatus.Consulted, - type: AlfredpayCustomerType.INDIVIDUAL, - userId + type: AlfredpayCustomerType.INDIVIDUAL }); const response: AlfredpayCreateCustomerResponse = { @@ -222,9 +238,7 @@ export class AlfredpayController { const { country } = req.query as unknown as AlfredpayGetKycRedirectLinkRequest; const userId = AlfredpayController.getRequiredUserId(req); - const alfredPayCustomer = await AlfredPayCustomer.findOne({ - where: { country: country as AlfredPayCountry, userId } - }); + const alfredPayCustomer = await findAlfredpayCustomer(userId, country as AlfredPayCountry); if (!alfredPayCustomer) { return res.status(404).json({ error: "Alfredpay customer not found" }); @@ -240,8 +254,9 @@ export class AlfredpayController { const lastSubmission = await alfredpayService.getLastKycSubmission(alfredPayCustomer.alfredPayId); if (lastSubmission && lastSubmission.submissionId) { const statusRes = await alfredpayService.getKycStatus(alfredPayCustomer.alfredPayId, lastSubmission.submissionId); - if (statusRes.status === "COMPLETED" || statusRes.status === "IN_REVIEW") { - return res.status(400).json({ error: `KYC is in status ${statusRes.status}` }); + const probeStatus = normalizeAlfredpayProviderStatus(statusRes.status); + if (probeStatus === AlfredpayKycStatus.COMPLETED || probeStatus === AlfredpayKycStatus.IN_REVIEW) { + return res.status(400).json({ error: `KYC is in status ${probeStatus}` }); } } } catch { @@ -251,6 +266,10 @@ export class AlfredpayController { const normalizedCountry = country.toLowerCase() === "us" ? "USA" : country; const linkResponse = await alfredpayService.getKycRedirectLink(alfredPayCustomer.alfredPayId, normalizedCountry); + if (linkResponse.submissionId) { + await alfredPayCustomer.update({ providerCaseId: linkResponse.submissionId }); + } + res.json(linkResponse as AlfredpayGetKycRedirectLinkResponse); } catch (error) { logger.error("Error getting KYC redirect link:", error); @@ -264,16 +283,13 @@ export class AlfredpayController { const userId = AlfredpayController.getRequiredUserId(req); const selectedType = type || AlfredpayCustomerType.INDIVIDUAL; - const alfredPayCustomer = await AlfredPayCustomer.findOne({ - order: [["updatedAt", "DESC"]], - where: { country: country as AlfredPayCountry, type: selectedType, userId } - }); + const alfredPayCustomer = await findAlfredpayCustomer(userId, country as AlfredPayCountry, selectedType); if (!alfredPayCustomer) { return res.status(404).json({ error: "Alfredpay customer not found" }); } - await alfredPayCustomer.update({ status: AlfredPayStatus.LinkOpened }); + await alfredPayCustomer.update({ status: AlfredPayStatus.LinkOpened, statusExternal: null }); res.json({ success: true }); } catch (error) { @@ -288,16 +304,13 @@ export class AlfredpayController { const userId = AlfredpayController.getRequiredUserId(req); const selectedType = type || AlfredpayCustomerType.INDIVIDUAL; - const alfredPayCustomer = await AlfredPayCustomer.findOne({ - order: [["updatedAt", "DESC"]], - where: { country: country as AlfredPayCountry, type: selectedType, userId } - }); + const alfredPayCustomer = await findAlfredpayCustomer(userId, country as AlfredPayCountry, selectedType); if (!alfredPayCustomer) { return res.status(404).json({ error: "Alfredpay customer not found" }); } - await alfredPayCustomer.update({ status: AlfredPayStatus.UserCompleted }); + await alfredPayCustomer.update({ status: AlfredPayStatus.UserCompleted, statusExternal: null }); res.json({ success: true }); } catch (error) { @@ -312,10 +325,7 @@ export class AlfredpayController { const userId = AlfredpayController.getRequiredUserId(req); const selectedType = type || AlfredpayCustomerType.INDIVIDUAL; - const alfredPayCustomer = await AlfredPayCustomer.findOne({ - order: [["updatedAt", "DESC"]], - where: { country: country as AlfredPayCountry, type: selectedType, userId } - }); + const alfredPayCustomer = await findAlfredpayCustomer(userId, country as AlfredPayCountry, selectedType); if (!alfredPayCustomer) { return res.status(404).json({ error: "Alfredpay customer not found" }); @@ -324,27 +334,46 @@ export class AlfredpayController { const alfredpayService = AlfredpayApiService.getInstance(); const isBusiness = selectedType === AlfredpayCustomerType.BUSINESS; - const lastSubmission = isBusiness - ? await alfredpayService.getLastKybSubmission(alfredPayCustomer.alfredPayId) - : await alfredpayService.getLastKycSubmission(alfredPayCustomer.alfredPayId); + const submissionId = isBusiness + ? await resolveAlfredpayKybSubmissionId(alfredPayCustomer.alfredPayId) + : (await alfredpayService.getLastKycSubmission(alfredPayCustomer.alfredPayId))?.submissionId; - if (!lastSubmission || !lastSubmission.submissionId) { + if (!submissionId) { + await alfredPayCustomer.update({ + status: AlfredPayStatus.Consulted, + statusExternal: null, + verificationStatus: VerificationStatus.Pending + }); return res.status(404).json({ error: "No KYC attempt found" }); } const statusResponse = isBusiness - ? await alfredpayService.getKybStatus(alfredPayCustomer.alfredPayId, lastSubmission.submissionId) - : await alfredpayService.getKycStatus(alfredPayCustomer.alfredPayId, lastSubmission.submissionId); + ? await alfredpayService.getKybStatus(alfredPayCustomer.alfredPayId, submissionId) + : await alfredpayService.getKycStatus(alfredPayCustomer.alfredPayId, submissionId); const newStatus = isBusiness ? AlfredpayController.mapKybStatus(statusResponse.status) : AlfredpayController.mapKycStatus(statusResponse.status); - const updateData: Partial = {}; + const updateData: Partial<{ + status: AlfredPayStatus; + verificationStatus: VerificationStatus; + statusExternal: string | null; + lastFailureReasons: string[]; + providerCaseId: string; + }> = { + providerCaseId: submissionId, + statusExternal: normalizeAlfredpayProviderStatus(statusResponse.status) + }; if (newStatus && newStatus !== alfredPayCustomer.status) { updateData.status = newStatus; } + // PENDING = unfinalized/invalid submission, not a rejection — reflect it as our Pending. + if (normalizeAlfredpayProviderStatus(statusResponse.status) === AlfredpayKycStatus.PENDING) { + updateData.verificationStatus = VerificationStatus.Pending; + } + if (newStatus === AlfredPayStatus.Failed && statusResponse.metadata?.failureReason) { updateData.lastFailureReasons = [statusResponse.metadata.failureReason]; } @@ -374,10 +403,7 @@ export class AlfredpayController { const userId = AlfredpayController.getRequiredUserId(req); const selectedType = type || AlfredpayCustomerType.INDIVIDUAL; - const alfredPayCustomer = await AlfredPayCustomer.findOne({ - order: [["updatedAt", "DESC"]], - where: { country: country as AlfredPayCountry, type: selectedType, userId } - }); + const alfredPayCustomer = await findAlfredpayCustomer(userId, country as AlfredPayCountry, selectedType); if (!alfredPayCustomer) { return res.status(404).json({ error: "Alfredpay customer not found" }); @@ -386,36 +412,44 @@ export class AlfredpayController { const alfredpayService = AlfredpayApiService.getInstance(); const isBusiness = selectedType === AlfredpayCustomerType.BUSINESS; - const lastSubmission = isBusiness - ? await alfredpayService.getLastKybSubmission(alfredPayCustomer.alfredPayId) - : await alfredpayService.getLastKycSubmission(alfredPayCustomer.alfredPayId); + const submissionId = isBusiness + ? await resolveAlfredpayKybSubmissionId(alfredPayCustomer.alfredPayId) + : (await alfredpayService.getLastKycSubmission(alfredPayCustomer.alfredPayId))?.submissionId; - if (!lastSubmission || !lastSubmission.submissionId) { + if (!submissionId) { return res.status(400).json({ error: "No KYC submission found to retry" }); } const statusRes = isBusiness - ? await alfredpayService.getKybStatus(alfredPayCustomer.alfredPayId, lastSubmission.submissionId) - : await alfredpayService.getKycStatus(alfredPayCustomer.alfredPayId, lastSubmission.submissionId); + ? await alfredpayService.getKybStatus(alfredPayCustomer.alfredPayId, submissionId) + : await alfredpayService.getKycStatus(alfredPayCustomer.alfredPayId, submissionId); - if (statusRes.status !== AlfredpayKycStatus.FAILED) { + if (normalizeAlfredpayProviderStatus(statusRes.status) !== AlfredpayKycStatus.FAILED) { return res.status(400).json({ error: `Cannot retry KYC. Current status is ${statusRes.status}` }); } if (isBusiness) { - await alfredpayService.retryKybSubmission(alfredPayCustomer.alfredPayId, lastSubmission.submissionId); + await alfredpayService.retryKybSubmission(alfredPayCustomer.alfredPayId, submissionId); const linkResponse = await alfredpayService.getKybRedirectLink(alfredPayCustomer.alfredPayId); - await alfredPayCustomer.update({ status: AlfredPayStatus.Consulted }); + await alfredPayCustomer.update({ + status: AlfredPayStatus.Consulted, + statusExternal: null, + ...(linkResponse.submissionId ? { providerCaseId: linkResponse.submissionId } : {}) + }); return res.json(linkResponse as AlfredpayGetKybRedirectLinkResponse); } else if (country === "MX" || country === "CO" || country === "AR") { // MX/CO use API-based (form) KYC — no redirect link needed. // Just reset status so the user can re-fill the form. - await alfredPayCustomer.update({ status: AlfredPayStatus.Consulted }); + await alfredPayCustomer.update({ status: AlfredPayStatus.Consulted, statusExternal: null }); return res.json({ success: true }); } else { - await alfredpayService.retryKycSubmission(alfredPayCustomer.alfredPayId, lastSubmission.submissionId); + await alfredpayService.retryKycSubmission(alfredPayCustomer.alfredPayId, submissionId); const linkResponse = await alfredpayService.getKycRedirectLink(alfredPayCustomer.alfredPayId, country); - await alfredPayCustomer.update({ status: AlfredPayStatus.Consulted }); + await alfredPayCustomer.update({ + status: AlfredPayStatus.Consulted, + statusExternal: null, + ...(linkResponse.submissionId ? { providerCaseId: linkResponse.submissionId } : {}) + }); return res.json(linkResponse as AlfredpayGetKycRedirectLinkResponse); } } catch (error) { @@ -436,9 +470,7 @@ export class AlfredpayController { const type = AlfredpayCustomerType.BUSINESS; - const existingDbCustomer = await AlfredPayCustomer.findOne({ - where: { country: country as AlfredPayCountry, type, userId } - }); + const existingDbCustomer = await findAlfredpayCustomer(userId, country as AlfredPayCountry, type); if (existingDbCustomer) { return res.status(400).json({ error: "Business customer already exists" }); @@ -461,12 +493,11 @@ export class AlfredpayController { } } - await AlfredPayCustomer.create({ + await createAlfredpayCustomer(userId, { alfredPayId: customerId, country: country as AlfredPayCountry, status: AlfredPayStatus.Consulted, - type, - userId + type }); const response: AlfredpayCreateCustomerResponse = { @@ -485,9 +516,11 @@ export class AlfredpayController { const { country } = req.query as unknown as AlfredpayGetKycRedirectLinkRequest; const userId = AlfredpayController.getRequiredUserId(req); - const alfredPayCustomer = await AlfredPayCustomer.findOne({ - where: { country: country as AlfredPayCountry, type: AlfredpayCustomerType.BUSINESS, userId } - }); + const alfredPayCustomer = await findAlfredpayCustomer( + userId, + country as AlfredPayCountry, + AlfredpayCustomerType.BUSINESS + ); if (!alfredPayCustomer) { return res.status(404).json({ error: "Alfredpay business customer not found" }); @@ -503,8 +536,9 @@ export class AlfredpayController { const lastSubmission = await alfredpayService.getLastKybSubmission(alfredPayCustomer.alfredPayId); if (lastSubmission && lastSubmission.submissionId) { const statusRes = await alfredpayService.getKybStatus(alfredPayCustomer.alfredPayId, lastSubmission.submissionId); - if (statusRes.status === AlfredpayKybStatus.COMPLETED || statusRes.status === AlfredpayKybStatus.IN_REVIEW) { - return res.status(400).json({ error: `KYB is in status ${statusRes.status}` }); + const probeStatus = normalizeAlfredpayProviderStatus(statusRes.status); + if (probeStatus === AlfredpayKybStatus.COMPLETED || probeStatus === AlfredpayKybStatus.IN_REVIEW) { + return res.status(400).json({ error: `KYB is in status ${probeStatus}` }); } } } catch { @@ -513,6 +547,10 @@ export class AlfredpayController { const linkResponse = await alfredpayService.getKybRedirectLink(alfredPayCustomer.alfredPayId); + if (linkResponse.submissionId) { + await alfredPayCustomer.update({ providerCaseId: linkResponse.submissionId }); + } + res.json(linkResponse as AlfredpayGetKybRedirectLinkResponse); } catch (error) { logger.error("Error getting KYB redirect link:", error); @@ -525,9 +563,11 @@ export class AlfredpayController { const { country, ...kycData } = req.body as SubmitKycInformationRequest; const userId = AlfredpayController.getRequiredUserId(req); - const alfredPayCustomer = await AlfredPayCustomer.findOne({ - where: { country: country as AlfredPayCountry, type: AlfredpayCustomerType.INDIVIDUAL, userId } - }); + const alfredPayCustomer = await findAlfredpayCustomer( + userId, + country as AlfredPayCountry, + AlfredpayCustomerType.INDIVIDUAL + ); if (!alfredPayCustomer) { return res.status(404).json({ error: "Alfredpay customer not found" }); @@ -550,6 +590,10 @@ export class AlfredpayController { } } + if (result.submissionId) { + await alfredPayCustomer.update({ providerCaseId: result.submissionId }); + } + res.json(result); } catch (error) { logger.error("Error submitting KYC information:", error); @@ -567,9 +611,11 @@ export class AlfredpayController { return res.status(400).json({ error: "No file uploaded" }); } - const alfredPayCustomer = await AlfredPayCustomer.findOne({ - where: { country: country as AlfredPayCountry, type: AlfredpayCustomerType.INDIVIDUAL, userId } - }); + const alfredPayCustomer = await findAlfredpayCustomer( + userId, + country as AlfredPayCountry, + AlfredpayCustomerType.INDIVIDUAL + ); if (!alfredPayCustomer) { return res.status(404).json({ error: "Alfredpay customer not found" }); @@ -597,9 +643,11 @@ export class AlfredpayController { const { country, submissionId } = req.body as { country: string; submissionId: string }; const userId = AlfredpayController.getRequiredUserId(req); - const alfredPayCustomer = await AlfredPayCustomer.findOne({ - where: { country: country as AlfredPayCountry, type: AlfredpayCustomerType.INDIVIDUAL, userId } - }); + const alfredPayCustomer = await findAlfredpayCustomer( + userId, + country as AlfredPayCountry, + AlfredpayCustomerType.INDIVIDUAL + ); if (!alfredPayCustomer) { return res.status(404).json({ error: "Alfredpay customer not found" }); @@ -621,31 +669,87 @@ export class AlfredpayController { const { country, ...kybData } = req.body as SubmitKybInformationRequest & { country: string }; const userId = AlfredpayController.getRequiredUserId(req); - const alfredPayCustomer = await AlfredPayCustomer.findOne({ - where: { country: country as AlfredPayCountry, type: AlfredpayCustomerType.BUSINESS, userId } - }); + const alfredPayCustomer = await findAlfredpayCustomer( + userId, + country as AlfredPayCountry, + AlfredpayCustomerType.BUSINESS + ); if (!alfredPayCustomer) { return res.status(404).json({ error: "Alfredpay business customer not found" }); } const alfredpayService = AlfredpayApiService.getInstance(); - let result: Awaited>; + let result: Awaited> | undefined; + + // Alfredpay refuses a fresh POST while a submission is still PENDING/CREATED (never finalized + // or filed with invalid data) — update that submission in place so the flow can resume. + let pendingSubmissionId: string | undefined; try { - result = await alfredpayService.submitKybInformation(alfredPayCustomer.alfredPayId, { ...kybData, country }); + const existingSubmissionId = await resolveAlfredpayKybSubmissionId(alfredPayCustomer.alfredPayId); + if (existingSubmissionId) { + const statusRes = await alfredpayService.getKybStatus(alfredPayCustomer.alfredPayId, existingSubmissionId); + logger.info(`Existing KYB submission ${existingSubmissionId} has status ${statusRes.status}`); + const probeStatus = normalizeAlfredpayProviderStatus(statusRes.status); + if (probeStatus === AlfredpayKybStatus.PENDING || probeStatus === AlfredpayKybStatus.CREATED) { + pendingSubmissionId = existingSubmissionId; + } else if (probeStatus === AlfredpayKybStatus.IN_REVIEW || probeStatus === AlfredpayKybStatus.COMPLETED) { + res.status(httpStatus.CONFLICT).json({ error: `KYB is in status ${probeStatus}` }); + return; + } + } } catch (error) { - const errorMessage = (error as Error)?.message || ""; - if (errorMessage.includes("422") && errorMessage.includes("KYC record cannot be retried")) { - logger.info("KYB record cannot be retried, fetching existing submission"); - const existingSubmission = await alfredpayService.getLastKybSubmission(alfredPayCustomer.alfredPayId); - result = { submissionId: existingSubmission.submissionId } as Awaited< - ReturnType - >; - } else { - throw error; + logger.info( + `No previous KYB submission found or error probing it, submitting a new one: ${AlfredpayController.getErrorMessage(error)}` + ); + } + if (pendingSubmissionId) { + // Outside the probe's catch: a failing PUT is the user's real error (Alfredpay rejecting the + // corrected data) — surface it instead of falling through to a fresh POST it refuses anyway. + await alfredpayService.updateKybInformation(alfredPayCustomer.alfredPayId, pendingSubmissionId, { + ...kybData, + country + }); + result = { submissionId: pendingSubmissionId }; + } + + if (!result) { + try { + result = await alfredpayService.submitKybInformation(alfredPayCustomer.alfredPayId, { ...kybData, country }); + } catch (error) { + const errorMessage = AlfredpayController.getErrorMessage(error); + const kybAlreadyExists = errorMessage.includes("111405") || errorMessage.includes("Customer KYB already exists"); + const cannotRetry = errorMessage.includes("422") && errorMessage.includes("KYC record cannot be retried"); + if (!kybAlreadyExists && !cannotRetry) { + throw error; + } + const existingSubmissionId = await resolveAlfredpayKybSubmissionId(alfredPayCustomer.alfredPayId); + if (!existingSubmissionId) { + throw error; + } + if (kybAlreadyExists) { + const statusRes = await alfredpayService.getKybStatus(alfredPayCustomer.alfredPayId, existingSubmissionId); + const providerStatus = normalizeAlfredpayProviderStatus(statusRes.status); + if (providerStatus !== AlfredpayKybStatus.PENDING && providerStatus !== AlfredpayKybStatus.CREATED) { + res.status(httpStatus.CONFLICT).json({ error: `KYB is in status ${providerStatus}` }); + return; + } + logger.info(`KYB already exists upstream, updating submission ${existingSubmissionId} in place`); + await alfredpayService.updateKybInformation(alfredPayCustomer.alfredPayId, existingSubmissionId, { + ...kybData, + country + }); + } else { + logger.info("KYB record cannot be retried, reusing existing submission"); + } + result = { submissionId: existingSubmissionId }; } } + if (result.submissionId) { + await alfredPayCustomer.update({ providerCaseId: result.submissionId }); + } + res.json(result); } catch (error) { logger.error("Error submitting KYB information:", error); @@ -659,9 +763,11 @@ export class AlfredpayController { const { country } = req.query as { country: string }; const userId = AlfredpayController.getRequiredUserId(req); - const alfredPayCustomer = await AlfredPayCustomer.findOne({ - where: { country: country as AlfredPayCountry, type: AlfredpayCustomerType.BUSINESS, userId } - }); + const alfredPayCustomer = await findAlfredpayCustomer( + userId, + country as AlfredPayCountry, + AlfredpayCustomerType.BUSINESS + ); if (!alfredPayCustomer) { return res.status(404).json({ error: "Alfredpay business customer not found" }); @@ -670,8 +776,11 @@ export class AlfredpayController { const alfredpayService = AlfredpayApiService.getInstance(); const details = await alfredpayService.getKybBusinessDetails(alfredPayCustomer.alfredPayId); + // submissionId is what lets the caller pick the related persons belonging to the submission it is + // actually filing — a customer that retried can carry several businesses here. const minimized = details.map(business => ({ - relatedPersons: (business.relatedPersons ?? []).map(person => ({ idRelatedPerson: person.idRelatedPerson })) + relatedPersons: (business.relatedPersons ?? []).map(person => ({ idRelatedPerson: person.idRelatedPerson })), + submissionId: business.submissionId })); res.json(minimized); @@ -691,9 +800,11 @@ export class AlfredpayController { return res.status(400).json({ error: "No file uploaded" }); } - const alfredPayCustomer = await AlfredPayCustomer.findOne({ - where: { country: country as AlfredPayCountry, type: AlfredpayCustomerType.BUSINESS, userId } - }); + const alfredPayCustomer = await findAlfredpayCustomer( + userId, + country as AlfredPayCountry, + AlfredpayCustomerType.BUSINESS + ); if (!alfredPayCustomer) { return res.status(404).json({ error: "Alfredpay business customer not found" }); @@ -730,9 +841,11 @@ export class AlfredpayController { return res.status(400).json({ error: "No file uploaded" }); } - const alfredPayCustomer = await AlfredPayCustomer.findOne({ - where: { country: country as AlfredPayCountry, type: AlfredpayCustomerType.BUSINESS, userId } - }); + const alfredPayCustomer = await findAlfredpayCustomer( + userId, + country as AlfredPayCountry, + AlfredpayCustomerType.BUSINESS + ); if (!alfredPayCustomer) { return res.status(404).json({ error: "Alfredpay business customer not found" }); @@ -766,9 +879,11 @@ export class AlfredpayController { const { country, submissionId } = req.body as { country: string; submissionId: string }; const userId = AlfredpayController.getRequiredUserId(req); - const alfredPayCustomer = await AlfredPayCustomer.findOne({ - where: { country: country as AlfredPayCountry, type: AlfredpayCustomerType.BUSINESS, userId } - }); + const alfredPayCustomer = await findAlfredpayCustomer( + userId, + country as AlfredPayCountry, + AlfredpayCustomerType.BUSINESS + ); if (!alfredPayCustomer) { return res.status(404).json({ error: "Alfredpay business customer not found" }); @@ -811,10 +926,7 @@ export class AlfredpayController { } = req.body as AlfredpayAddFiatAccountRequest; const userId = AlfredpayController.getFiatAccountUserId(req); - const alfredPayCustomer = await AlfredPayCustomer.findOne({ - order: [["updatedAt", "DESC"]], - where: { country: country as AlfredPayCountry, userId } - }); + const alfredPayCustomer = await findAlfredpayCustomer(userId, country as AlfredPayCountry); if (!alfredPayCustomer) { return res.status(404).json({ error: "Alfredpay customer not found" }); @@ -896,10 +1008,7 @@ export class AlfredpayController { const { country } = req.query as { country: string }; const userId = AlfredpayController.getFiatAccountUserId(req); - const alfredPayCustomer = await AlfredPayCustomer.findOne({ - order: [["updatedAt", "DESC"]], - where: { country: country as AlfredPayCountry, userId } - }); + const alfredPayCustomer = await findAlfredpayCustomer(userId, country as AlfredPayCountry); if (!alfredPayCustomer) { return res.status(404).json({ error: "Alfredpay customer not found" }); @@ -920,10 +1029,7 @@ export class AlfredpayController { const { country } = req.query as { country: string }; const userId = AlfredpayController.getFiatAccountUserId(req); - const alfredPayCustomer = await AlfredPayCustomer.findOne({ - order: [["updatedAt", "DESC"]], - where: { country: country as AlfredPayCountry, userId } - }); + const alfredPayCustomer = await findAlfredpayCustomer(userId, country as AlfredPayCountry); if (!alfredPayCustomer) { return res.status(404).json({ error: "Alfredpay customer not found" }); diff --git a/apps/api/src/api/controllers/auth.controller.ts b/apps/api/src/api/controllers/auth.controller.ts index b3ab0de36..280a9069a 100644 --- a/apps/api/src/api/controllers/auth.controller.ts +++ b/apps/api/src/api/controllers/auth.controller.ts @@ -2,6 +2,7 @@ import { Request, Response } from "express"; import logger from "../../config/logger"; import User from "../../models/user.model"; import { RefreshTokenError, SupabaseAuthService } from "../services/auth"; +import { getOrCreateCustomerEntityForProfile } from "../services/customer-entity.service"; export class AuthController { /** @@ -88,6 +89,15 @@ export class AuthController { id: result.user_id }); + // Eagerly create the owning customer entity. Kept out of the OTP error mapping: + // the Supabase session is already minted, so a failure here must not surface as + // "Invalid OTP" — entity-scoped reads lazily create it as a fallback anyway. + try { + await getOrCreateCustomerEntityForProfile(result.user_id); + } catch (entityError) { + logger.error("Failed to create customer entity for new profile:", entityError); + } + return res.json({ access_token: result.access_token, refresh_token: result.refresh_token, diff --git a/apps/api/src/api/controllers/brla.controller.test.ts b/apps/api/src/api/controllers/brla.controller.test.ts index f0a14aa00..8a0f99709 100644 --- a/apps/api/src/api/controllers/brla.controller.test.ts +++ b/apps/api/src/api/controllers/brla.controller.test.ts @@ -1,9 +1,20 @@ -import {AveniaAccountType, BrlaApiError, BrlaApiService} from "@vortexfi/shared"; +import {AveniaAccountType, BrlaApiError, BrlaApiService, KycAttemptResult, KycAttemptStatus} from "@vortexfi/shared"; import {afterEach, beforeEach, describe, expect, it, mock} from "bun:test"; import httpStatus from "http-status"; import logger from "../../config/logger"; +import CustomerEntity from "../../models/customerEntity.model"; +import KycCase from "../../models/kycCase.model"; +import ProviderCustomer, {VerificationStatus} from "../../models/providerCustomer.model"; import TaxId, {TaxIdInternalStatus} from "../../models/taxId.model"; -import {createSubaccount, getAveniaUser} from "./brla.controller"; +import User from "../../models/user.model"; +import { + createSubaccount, + fetchSubaccountKycStatus, + getAveniaUser, + getKybAttemptStatus, + initiateKybLevel1, + recordInitialKycAttempt +} from "./brla.controller"; function createResponse() { const res = { @@ -22,8 +33,32 @@ function createResponse() { return res; } +// getOrCreateCustomerEntityForProfile resolves each profile to a deterministic entity id. +// Type-less lookups resolve via findOne (oldest-entity default); typed ones via findOrCreate. +function mockEntityPerProfile() { + CustomerEntity.findOne = mock(async (options: { where: { profileId: string } }) => ({ + id: `entity-${options.where.profileId}` + })) as unknown as typeof CustomerEntity.findOne; + CustomerEntity.findOrCreate = mock(async (options: { where: { profileId: string } }) => [ + { id: `entity-${options.where.profileId}` }, + false + ]) as unknown as typeof CustomerEntity.findOrCreate; +} + +const originalUserFindByPk = User.findByPk; + +beforeEach(() => { + User.findByPk = mock(async () => null) as unknown as typeof User.findByPk; +}); + +afterEach(() => { + User.findByPk = originalUserFindByPk; +}); + describe("getAveniaUser", () => { - const originalFindOne = TaxId.findOne; + const originalFindOne = ProviderCustomer.findOne; + const originalEntityFindOne = CustomerEntity.findOne; + const originalEntityFindOrCreate = CustomerEntity.findOrCreate; const originalGetInstance = BrlaApiService.getInstance; const originalLoggerError = logger.error; const originalLoggerInfo = logger.info; @@ -34,14 +69,21 @@ describe("getAveniaUser", () => { }); afterEach(() => { - TaxId.findOne = originalFindOne; + ProviderCustomer.findOne = originalFindOne; + CustomerEntity.findOne = originalEntityFindOne; + CustomerEntity.findOrCreate = originalEntityFindOrCreate; BrlaApiService.getInstance = originalGetInstance; logger.error = originalLoggerError; logger.info = originalLoggerInfo; }); - function mockConfirmedAveniaUser(userId: string | null = null) { - TaxId.findOne = mock(async () => ({ subAccountId: "subaccount-1", userId })) as typeof TaxId.findOne; + function mockConfirmedAveniaUser(ownerUserId: string | null = null) { + mockEntityPerProfile(); + ProviderCustomer.findOne = mock(async () => ({ + customerEntityId: `entity-${ownerUserId}`, + providerSubaccountId: "subaccount-1", + status: VerificationStatus.Approved + })) as typeof ProviderCustomer.findOne; BrlaApiService.getInstance = mock( () => ({ @@ -141,7 +183,12 @@ describe("getAveniaUser", () => { }); it("still parses a BrlaApiError 400 into a 400 'Invalid request' with details (message-format invariant)", async () => { - TaxId.findOne = mock(async () => ({ subAccountId: "subaccount-1", userId: "user-1" })) as typeof TaxId.findOne; + mockEntityPerProfile(); + ProviderCustomer.findOne = mock(async () => ({ + customerEntityId: "entity-user-1", + providerSubaccountId: "subaccount-1", + status: VerificationStatus.Approved + })) as typeof ProviderCustomer.findOne; BrlaApiService.getInstance = mock( () => ({ @@ -170,30 +217,457 @@ describe("getAveniaUser", () => { }); }); +describe("recordInitialKycAttempt", () => { + const originalProviderFindOne = ProviderCustomer.findOne; + const originalProviderCreate = ProviderCustomer.create; + const originalEntityFindOne = CustomerEntity.findOne; + const originalEntityFindOrCreate = CustomerEntity.findOrCreate; + const originalKycCaseFindOne = KycCase.findOne; + const originalKycCaseCreate = KycCase.create; + + afterEach(() => { + ProviderCustomer.findOne = originalProviderFindOne; + ProviderCustomer.create = originalProviderCreate; + CustomerEntity.findOne = originalEntityFindOne; + CustomerEntity.findOrCreate = originalEntityFindOrCreate; + KycCase.findOne = originalKycCaseFindOne; + KycCase.create = originalKycCaseCreate; + }); + + it("records the first valid Avenia interaction as started", async () => { + mockEntityPerProfile(); + ProviderCustomer.findOne = mock(async () => null) as typeof ProviderCustomer.findOne; + const providerCreate = mock(async (values: Record) => ({ id: "customer-1", ...values })); + ProviderCustomer.create = providerCreate as unknown as typeof ProviderCustomer.create; + KycCase.findOne = mock(async () => null) as typeof KycCase.findOne; + KycCase.create = mock(async () => ({})) as unknown as typeof KycCase.create; + + const res = createResponse(); + await recordInitialKycAttempt({ body: { taxId: "08786985906" }, userId: "user-1" } as any, res as any); + + expect(res.statusCode).toBe(httpStatus.OK); + expect(providerCreate.mock.calls[0]?.[0]).toMatchObject({ status: VerificationStatus.Started }); + }); +}); + +describe("fetchSubaccountKycStatus", () => { + const originalProviderFindOne = ProviderCustomer.findOne; + const originalEntityFindOne = CustomerEntity.findOne; + const originalEntityFindOrCreate = CustomerEntity.findOrCreate; + const originalKycCaseFindOne = KycCase.findOne; + const originalGetInstance = BrlaApiService.getInstance; + + afterEach(() => { + ProviderCustomer.findOne = originalProviderFindOne; + CustomerEntity.findOne = originalEntityFindOne; + CustomerEntity.findOrCreate = originalEntityFindOrCreate; + KycCase.findOne = originalKycCaseFindOne; + BrlaApiService.getInstance = originalGetInstance; + }); + + it("maps a missing Avenia attempt to pending", async () => { + mockEntityPerProfile(); + const update = mock(async () => undefined); + ProviderCustomer.findOne = mock(async () => ({ + customerEntityId: "entity-user-1", + id: "customer-1", + providerSubaccountId: "subaccount-1", + status: VerificationStatus.InReview, + statusExternal: null, + update + })) as unknown as typeof ProviderCustomer.findOne; + const kycUpdate = mock(async () => undefined); + KycCase.findOne = mock(async () => ({ update: kycUpdate })) as unknown as typeof KycCase.findOne; + BrlaApiService.getInstance = mock( + () => + ({ + getKycAttempts: mock(async () => ({ attempts: [] })), + subaccountInfo: mock(async () => ({ accountInfo: { identityStatus: "PENDING" } })) + }) as unknown as BrlaApiService + ); + + const res = createResponse(); + await fetchSubaccountKycStatus({ query: { taxId: "08786985906" }, userId: "user-1" } as any, res as any); + + expect(res.statusCode).toBe(httpStatus.NOT_FOUND); + expect(update).toHaveBeenCalledWith({ status: VerificationStatus.Pending, statusExternal: null }); + expect(kycUpdate).toHaveBeenCalledWith(expect.objectContaining({ status: VerificationStatus.Pending })); + }); + + function mockOwnedRecordWithAttempt(status: VerificationStatus, attempt: unknown) { + mockEntityPerProfile(); + const update = mock(async () => undefined); + ProviderCustomer.findOne = mock(async () => ({ + customerEntityId: "entity-user-1", + id: "customer-1", + providerSubaccountId: "subaccount-1", + status, + statusExternal: null, + update + })) as unknown as typeof ProviderCustomer.findOne; + const kycUpdate = mock(async () => undefined); + KycCase.findOne = mock(async () => ({ update: kycUpdate })) as unknown as typeof KycCase.findOne; + BrlaApiService.getInstance = mock( + () => + ({ + getKycAttempts: mock(async () => ({ attempts: [attempt] })) + }) as unknown as BrlaApiService + ); + return { kycUpdate, update }; + } + + // Regression: a rejected account whose retried attempt is approved used to stay `rejected` + // forever (the outcome update only matched `in_review`), so ramp registration failed with + // "No completed Avenia profile found" despite a successful KYC. + it("flips a rejected account to approved when a retried attempt is approved", async () => { + const { kycUpdate, update } = mockOwnedRecordWithAttempt(VerificationStatus.Rejected, { + levelName: "KYC_1", + result: KycAttemptResult.APPROVED, + status: KycAttemptStatus.COMPLETED + }); + + const res = createResponse(); + await fetchSubaccountKycStatus({ query: { taxId: "08786985906" }, userId: "user-1" } as any, res as any); + + expect(res.statusCode).toBe(httpStatus.OK); + expect((res.body as { result: string }).result).toBe(KycAttemptResult.APPROVED); + expect(update).toHaveBeenCalledWith({ status: VerificationStatus.Approved, statusExternal: KycAttemptStatus.COMPLETED }); + expect(kycUpdate).toHaveBeenCalledWith(expect.objectContaining({ status: VerificationStatus.Approved })); + }); + + it("returns a rejected account to in_review while a retried attempt is processing", async () => { + const { update } = mockOwnedRecordWithAttempt(VerificationStatus.Rejected, { + levelName: "KYC_1", + result: "", + status: KycAttemptStatus.PROCESSING + }); + + const res = createResponse(); + await fetchSubaccountKycStatus({ query: { taxId: "08786985906" }, userId: "user-1" } as any, res as any); + + expect(res.statusCode).toBe(httpStatus.OK); + expect(update).toHaveBeenCalledWith({ status: VerificationStatus.InReview, statusExternal: KycAttemptStatus.PROCESSING }); + }); + + it("never downgrades an approved account on a stale rejected attempt read", async () => { + const { kycUpdate, update } = mockOwnedRecordWithAttempt(VerificationStatus.Approved, { + levelName: "KYC_1", + result: KycAttemptResult.REJECTED, + status: KycAttemptStatus.COMPLETED + }); + + const res = createResponse(); + await fetchSubaccountKycStatus({ query: { taxId: "08786985906" }, userId: "user-1" } as any, res as any); + + expect(res.statusCode).toBe(httpStatus.OK); + expect(update).not.toHaveBeenCalled(); + expect(kycUpdate).not.toHaveBeenCalled(); + }); +}); + +describe("Avenia company KYB", () => { + const originalProviderFindOne = ProviderCustomer.findOne; + const originalProviderFindByPk = ProviderCustomer.findByPk; + const originalEntityFindOne = CustomerEntity.findOne; + const originalEntityFindOrCreate = CustomerEntity.findOrCreate; + const originalKycCaseFindOne = KycCase.findOne; + const originalKycCaseCreate = KycCase.create; + const originalGetInstance = BrlaApiService.getInstance; + + afterEach(() => { + ProviderCustomer.findOne = originalProviderFindOne; + ProviderCustomer.findByPk = originalProviderFindByPk; + CustomerEntity.findOne = originalEntityFindOne; + CustomerEntity.findOrCreate = originalEntityFindOrCreate; + KycCase.findOne = originalKycCaseFindOne; + KycCase.create = originalKycCaseCreate; + BrlaApiService.getInstance = originalGetInstance; + }); + + it("binds the initiated provider attempt to the owned KYB case", async () => { + mockEntityPerProfile(); + const customerUpdate = mock(async () => undefined); + ProviderCustomer.findOne = mock(async () => ({ + customerEntityId: "entity-user-1", + customerType: "business", + id: "customer-1", + providerSubaccountId: "subaccount-1", + statusExternal: null, + update: customerUpdate + })) as unknown as typeof ProviderCustomer.findOne; + const caseUpdate = mock(async () => undefined); + KycCase.findOne = mock(async () => ({ update: caseUpdate })) as unknown as typeof KycCase.findOne; + BrlaApiService.getInstance = mock( + () => + ({ + initiateKybLevel1: mock(async () => ({ + attemptId: "attempt-1", + authorizedRepresentativeUrl: "https://avenia.example/representative", + basicCompanyDataUrl: "https://avenia.example/company" + })) + }) as unknown as BrlaApiService + ); + + const res = createResponse(); + await initiateKybLevel1({ query: { subAccountId: "subaccount-1" }, userId: "user-1" } as any, res as any); + + expect(res.statusCode).toBe(httpStatus.OK); + // Nothing is submitted until the user finishes the hosted steps: the account stays pending + // (resumable), never in_review, until Avenia reports PROCESSING. + expect(customerUpdate).toHaveBeenCalledWith({ + status: VerificationStatus.Pending, + statusExternal: KycAttemptStatus.PENDING + }); + expect(caseUpdate).toHaveBeenCalledWith( + expect.objectContaining({ + providerCaseId: "attempt-1", + status: VerificationStatus.Pending, + statusExternal: KycAttemptStatus.PENDING + }) + ); + }); + + it("re-issues KYB links while the existing attempt is still PENDING, rebinding the case", async () => { + mockEntityPerProfile(); + const customerUpdate = mock(async () => undefined); + ProviderCustomer.findOne = mock(async () => ({ + customerEntityId: "entity-user-1", + customerType: "business", + id: "customer-1", + providerSubaccountId: "subaccount-1", + status: VerificationStatus.Pending, + statusExternal: KycAttemptStatus.PENDING, + update: customerUpdate + })) as unknown as typeof ProviderCustomer.findOne; + const caseUpdate = mock(async () => undefined); + KycCase.findOne = mock(async () => ({ + providerCaseId: "attempt-1", + statusExternal: KycAttemptStatus.PENDING, + update: caseUpdate + })) as unknown as typeof KycCase.findOne; + const initiateMock = mock(async () => ({ + attemptId: "attempt-2", + authorizedRepresentativeUrl: "https://avenia.example/representative", + basicCompanyDataUrl: "https://avenia.example/company" + })); + BrlaApiService.getInstance = mock( + () => + ({ + getKybAttemptStatus: mock(async () => ({ attempt: { id: "attempt-1", status: KycAttemptStatus.PENDING } })), + initiateKybLevel1: initiateMock + }) as unknown as BrlaApiService + ); + + const res = createResponse(); + await initiateKybLevel1({ query: { subAccountId: "subaccount-1" }, userId: "user-1" } as any, res as any); + + expect(res.statusCode).toBe(httpStatus.OK); + expect(initiateMock).toHaveBeenCalled(); + expect(caseUpdate).toHaveBeenCalledWith( + expect.objectContaining({ providerCaseId: "attempt-2", status: VerificationStatus.Pending }) + ); + }); + + it("rejects re-initiation when the stored PENDING is stale and Avenia is already processing", async () => { + mockEntityPerProfile(); + ProviderCustomer.findOne = mock(async () => ({ + customerEntityId: "entity-user-1", + customerType: "business", + id: "customer-1", + providerSubaccountId: "subaccount-1", + status: VerificationStatus.Pending, + statusExternal: KycAttemptStatus.PENDING + })) as unknown as typeof ProviderCustomer.findOne; + KycCase.findOne = mock(async () => ({ + providerCaseId: "attempt-1", + statusExternal: KycAttemptStatus.PENDING + })) as unknown as typeof KycCase.findOne; + const initiateMock = mock(async () => ({ attemptId: "attempt-2" })); + BrlaApiService.getInstance = mock( + () => + ({ + // The user finished the hosted steps in another tab; our row still says PENDING. + getKybAttemptStatus: mock(async () => ({ attempt: { id: "attempt-1", status: KycAttemptStatus.PROCESSING } })), + initiateKybLevel1: initiateMock + }) as unknown as BrlaApiService + ); + + const res = createResponse(); + await initiateKybLevel1({ query: { subAccountId: "subaccount-1" }, userId: "user-1" } as any, res as any); + + expect(res.statusCode).toBe(httpStatus.CONFLICT); + expect(initiateMock).not.toHaveBeenCalled(); + }); + + it("still re-issues KYB links when the live probe is unavailable", async () => { + mockEntityPerProfile(); + const customerUpdate = mock(async () => undefined); + ProviderCustomer.findOne = mock(async () => ({ + customerEntityId: "entity-user-1", + customerType: "business", + id: "customer-1", + providerSubaccountId: "subaccount-1", + status: VerificationStatus.Pending, + statusExternal: KycAttemptStatus.PENDING, + update: customerUpdate + })) as unknown as typeof ProviderCustomer.findOne; + KycCase.findOne = mock(async () => ({ + providerCaseId: "attempt-1", + statusExternal: KycAttemptStatus.PENDING, + update: mock(async () => undefined) + })) as unknown as typeof KycCase.findOne; + const initiateMock = mock(async () => ({ + attemptId: "attempt-2", + authorizedRepresentativeUrl: "https://avenia.example/representative", + basicCompanyDataUrl: "https://avenia.example/company" + })); + BrlaApiService.getInstance = mock( + () => + ({ + getKybAttemptStatus: mock(async () => { + throw new Error("avenia unavailable"); + }), + initiateKybLevel1: initiateMock + }) as unknown as BrlaApiService + ); + + const res = createResponse(); + await initiateKybLevel1({ query: { subAccountId: "subaccount-1" }, userId: "user-1" } as any, res as any); + + expect(res.statusCode).toBe(httpStatus.OK); + expect(initiateMock).toHaveBeenCalled(); + }); + + it("still rejects re-initiation once Avenia is processing the attempt", async () => { + mockEntityPerProfile(); + ProviderCustomer.findOne = mock(async () => ({ + customerEntityId: "entity-user-1", + customerType: "business", + id: "customer-1", + providerSubaccountId: "subaccount-1", + status: VerificationStatus.InReview, + statusExternal: KycAttemptStatus.PROCESSING + })) as unknown as typeof ProviderCustomer.findOne; + KycCase.findOne = mock(async () => ({ + providerCaseId: "attempt-1", + statusExternal: KycAttemptStatus.PROCESSING + })) as unknown as typeof KycCase.findOne; + const initiateMock = mock(async () => ({ attemptId: "attempt-2" })); + BrlaApiService.getInstance = mock(() => ({ initiateKybLevel1: initiateMock }) as unknown as BrlaApiService); + + const res = createResponse(); + await initiateKybLevel1({ query: { subAccountId: "subaccount-1" }, userId: "user-1" } as any, res as any); + + expect(res.statusCode).toBe(httpStatus.CONFLICT); + expect(initiateMock).not.toHaveBeenCalled(); + }); + + it("rejects an attempt owned by another user without querying Avenia", async () => { + mockEntityPerProfile(); + KycCase.findOne = mock(async () => ({ + customerEntityId: "entity-victim", + providerCustomerId: "customer-1" + })) as unknown as typeof KycCase.findOne; + const providerStatus = mock(async () => ({ attempt: {} })); + BrlaApiService.getInstance = mock( + () => ({ getKybAttemptStatus: providerStatus }) as unknown as BrlaApiService + ); + + const res = createResponse(); + await getKybAttemptStatus({ query: { attemptId: "attempt-1" }, userId: "attacker" } as any, res as any); + + expect(res.statusCode).toBe(httpStatus.FORBIDDEN); + expect(providerStatus).not.toHaveBeenCalled(); + }); + + it("persists an approved provider result and returns only normalized browser fields", async () => { + mockEntityPerProfile(); + const caseUpdate = mock(async () => undefined); + KycCase.findOne = mock(async () => ({ + customerEntityId: "entity-user-1", + providerCustomerId: "customer-1", + update: caseUpdate + })) as unknown as typeof KycCase.findOne; + const customerUpdate = mock(async () => undefined); + ProviderCustomer.findByPk = mock(async () => ({ + customerEntityId: "entity-user-1", + provider: "avenia", + update: customerUpdate + })) as unknown as typeof ProviderCustomer.findByPk; + BrlaApiService.getInstance = mock( + () => + ({ + getKybAttemptStatus: mock(async () => ({ + attempt: { + createdAt: "private", + id: "attempt-1", + levelName: "level-1", + result: KycAttemptResult.APPROVED, + resultMessage: "", + retryable: false, + status: KycAttemptStatus.COMPLETED, + submissionData: { privateCompanyData: true }, + updatedAt: "private" + } + })) + }) as unknown as BrlaApiService + ); + + const res = createResponse(); + await getKybAttemptStatus({ query: { attemptId: "attempt-1" }, userId: "user-1" } as any, res as any); + + expect(res.body).toEqual({ result: KycAttemptResult.APPROVED, status: KycAttemptStatus.COMPLETED }); + expect(customerUpdate).toHaveBeenCalledWith( + expect.objectContaining({ status: VerificationStatus.Approved, statusExternal: KycAttemptStatus.COMPLETED }) + ); + expect(caseUpdate).toHaveBeenCalledWith( + expect.objectContaining({ status: VerificationStatus.Approved, statusExternal: KycAttemptStatus.COMPLETED }) + ); + }); +}); + describe("createSubaccount", () => { - const originalFindByPk = TaxId.findByPk; - const originalCreate = TaxId.create; + const originalProviderFindOne = ProviderCustomer.findOne; + const originalProviderCreate = ProviderCustomer.create; + const originalEntityFindOne = CustomerEntity.findOne; + const originalEntityFindOrCreate = CustomerEntity.findOrCreate; + const originalTaxIdFindByPk = TaxId.findByPk; + const originalKycCaseFindOne = KycCase.findOne; + const originalKycCaseCreate = KycCase.create; const originalGetInstance = BrlaApiService.getInstance; const originalLoggerError = logger.error; beforeEach(() => { logger.error = mock(() => logger) as typeof logger.error; + mockEntityPerProfile(); + // No pre-existing kyc case; case creation is fire-and-forget for these scenarios. + KycCase.findOne = mock(async () => null) as typeof KycCase.findOne; + KycCase.create = mock(async () => ({})) as unknown as typeof KycCase.create; + // Default: no legacy tax_ids row to adopt. + TaxId.findByPk = mock(async () => null) as typeof TaxId.findByPk; }); afterEach(() => { - TaxId.findByPk = originalFindByPk; - TaxId.create = originalCreate; + ProviderCustomer.findOne = originalProviderFindOne; + ProviderCustomer.create = originalProviderCreate; + CustomerEntity.findOne = originalEntityFindOne; + CustomerEntity.findOrCreate = originalEntityFindOrCreate; + TaxId.findByPk = originalTaxIdFindByPk; + KycCase.findOne = originalKycCaseFindOne; + KycCase.create = originalKycCaseCreate; BrlaApiService.getInstance = originalGetInstance; logger.error = originalLoggerError; }); const createAveniaSubaccountMock = mock(async () => ({ id: "new-subaccount" })); + const subaccountInfoMock = mock(async () => ({ accountInfo: { fullName: "", name: "Provider Company Name" } })); function mockBrlaApi() { BrlaApiService.getInstance = mock( () => ({ - createAveniaSubaccount: createAveniaSubaccountMock + createAveniaSubaccount: createAveniaSubaccountMock, + subaccountInfo: subaccountInfoMock }) as unknown as BrlaApiService ); } @@ -201,15 +675,38 @@ describe("createSubaccount", () => { const validBody = { accountType: AveniaAccountType.INDIVIDUAL, name: "Attacker", - quoteId: "quote-1", taxId: "08786985906" }; it("rejects when an existing subaccount belongs to a different Supabase user", async () => { mockBrlaApi(); createAveniaSubaccountMock.mockClear(); + ProviderCustomer.findOne = mock(async () => ({ + customerEntityId: "entity-victim-user", + status: VerificationStatus.Approved + })) as typeof ProviderCustomer.findOne; + + const res = createResponse(); + await createSubaccount( + { + body: validBody, + userId: "attacker-user" + } as any, + res as any + ); + + expect(res.statusCode).toBe(httpStatus.CONFLICT); + expect(res.body).toEqual({ error: "A subaccount already exists for this taxId" }); + expect(createAveniaSubaccountMock).not.toHaveBeenCalled(); + }); + + it("rejects when a quarantined legacy record belongs to a different user", async () => { + mockBrlaApi(); + createAveniaSubaccountMock.mockClear(); + ProviderCustomer.findOne = mock(async () => null) as typeof ProviderCustomer.findOne; TaxId.findByPk = mock(async () => ({ internalStatus: TaxIdInternalStatus.Accepted, + subAccountId: "legacy-sub", userId: "victim-user" })) as typeof TaxId.findByPk; @@ -227,15 +724,19 @@ describe("createSubaccount", () => { expect(createAveniaSubaccountMock).not.toHaveBeenCalled(); }); - it("lets an authenticated caller claim an anonymously-owned existing subaccount record", async () => { + it("lets an authenticated caller claim an anonymously-owned legacy record", async () => { mockBrlaApi(); createAveniaSubaccountMock.mockClear(); - const updateMock = mock(async () => undefined); + ProviderCustomer.findOne = mock(async () => null) as typeof ProviderCustomer.findOne; TaxId.findByPk = mock(async () => ({ + accountType: AveniaAccountType.INDIVIDUAL, internalStatus: TaxIdInternalStatus.Requested, - update: updateMock, + subAccountId: "legacy-sub", userId: null })) as typeof TaxId.findByPk; + const adoptedUpdate = mock(async () => undefined); + const providerCreateMock = mock(async (values: Record) => ({ ...values, update: adoptedUpdate })); + ProviderCustomer.create = providerCreateMock as unknown as typeof ProviderCustomer.create; const res = createResponse(); await createSubaccount( @@ -247,19 +748,28 @@ describe("createSubaccount", () => { ); expect(res.statusCode).toBe(httpStatus.OK); - expect(updateMock).toHaveBeenCalledWith({ userId: "some-user" }); + // The adopted record is owned by the claimer's entity... + expect(providerCreateMock.mock.calls[0]?.[0]).toMatchObject({ customerEntityId: "entity-some-user" }); + // ...and then re-provisioned with the freshly created subaccount. expect(createAveniaSubaccountMock).toHaveBeenCalled(); + expect(adoptedUpdate).toHaveBeenCalledWith({ + companyName: null, + customerType: "individual", + providerSubaccountId: "new-subaccount", + status: VerificationStatus.InReview, + statusExternal: null + }); }); it("allows an authenticated user to (re)create their own subaccount", async () => { mockBrlaApi(); createAveniaSubaccountMock.mockClear(); const updateMock = mock(async () => undefined); - TaxId.findByPk = mock(async () => ({ - internalStatus: TaxIdInternalStatus.Accepted, - update: updateMock, - userId: "same-user" - })) as typeof TaxId.findByPk; + ProviderCustomer.findOne = mock(async () => ({ + customerEntityId: "entity-same-user", + status: VerificationStatus.Approved, + update: updateMock + })) as typeof ProviderCustomer.findOne; const res = createResponse(); await createSubaccount( @@ -279,9 +789,9 @@ describe("createSubaccount", () => { it("allows creation when no existing subaccount record exists", async () => { mockBrlaApi(); createAveniaSubaccountMock.mockClear(); - const createTaxIdMock = mock(async () => undefined); - TaxId.findByPk = mock(async () => null) as typeof TaxId.findByPk; - TaxId.create = createTaxIdMock as unknown as typeof TaxId.create; + const providerCreateMock = mock(async (values: Record) => ({ ...values })); + ProviderCustomer.findOne = mock(async () => null) as typeof ProviderCustomer.findOne; + ProviderCustomer.create = providerCreateMock as unknown as typeof ProviderCustomer.create; const res = createResponse(); await createSubaccount( @@ -295,18 +805,43 @@ describe("createSubaccount", () => { expect(res.statusCode).toBe(httpStatus.OK); expect(res.body).toEqual({ subAccountId: "new-subaccount" }); expect(createAveniaSubaccountMock).toHaveBeenCalled(); - expect(createTaxIdMock).toHaveBeenCalled(); + expect(providerCreateMock).toHaveBeenCalled(); }); - it("allows overwrite when the existing record is only in Consulted state", async () => { + it("persists the submitted company name for a business account", async () => { + mockBrlaApi(); + const providerCreateMock = mock(async (values: Record) => ({ ...values })); + ProviderCustomer.findOne = mock(async () => null) as typeof ProviderCustomer.findOne; + ProviderCustomer.create = providerCreateMock as unknown as typeof ProviderCustomer.create; + + const res = createResponse(); + await createSubaccount( + { + body: { accountType: AveniaAccountType.COMPANY, name: " Acme Ltda ", taxId: "11222333000181" }, + userId: "business-user" + } as any, + res as any + ); + + expect(res.statusCode).toBe(httpStatus.OK); + expect(providerCreateMock.mock.calls[0]?.[0]).toMatchObject({ + companyName: "Provider Company Name", + customerType: "business", + // No KYB attempt exists yet at subaccount creation — pending keeps the flow resumable. + status: VerificationStatus.Pending + }); + expect(subaccountInfoMock).toHaveBeenCalledWith("new-subaccount"); + }); + + it("rejects overwrite when a started record belongs to another entity", async () => { mockBrlaApi(); createAveniaSubaccountMock.mockClear(); const updateMock = mock(async () => undefined); - TaxId.findByPk = mock(async () => ({ - internalStatus: TaxIdInternalStatus.Consulted, - update: updateMock, - userId: "victim-user" - })) as typeof TaxId.findByPk; + ProviderCustomer.findOne = mock(async () => ({ + customerEntityId: "entity-victim-user", + status: VerificationStatus.Started, + update: updateMock + })) as typeof ProviderCustomer.findOne; const res = createResponse(); await createSubaccount( @@ -317,8 +852,8 @@ describe("createSubaccount", () => { res as any ); - expect(res.statusCode).toBe(httpStatus.OK); - expect(createAveniaSubaccountMock).toHaveBeenCalled(); - expect(updateMock).toHaveBeenCalled(); + expect(res.statusCode).toBe(httpStatus.CONFLICT); + expect(createAveniaSubaccountMock).not.toHaveBeenCalled(); + expect(updateMock).not.toHaveBeenCalled(); }); }); diff --git a/apps/api/src/api/controllers/brla.controller.ts b/apps/api/src/api/controllers/brla.controller.ts index 209d757a1..118cbbfb9 100644 --- a/apps/api/src/api/controllers/brla.controller.ts +++ b/apps/api/src/api/controllers/brla.controller.ts @@ -33,54 +33,43 @@ import { } from "@vortexfi/shared"; import { Request, Response } from "express"; import httpStatus from "http-status"; -import { Op } from "sequelize"; import logger from "../../config/logger"; +import KycCase from "../../models/kycCase.model"; +import ProviderCustomer, { VerificationStatus } from "../../models/providerCustomer.model"; import TaxId, { TaxIdInternalStatus } from "../../models/taxId.model"; import { APIError } from "../errors/api-error"; import { getEffectiveUserId } from "../middlewares/effectiveUser"; +import { + accountTypeToCustomerType, + customerTypeToAccountType, + findAveniaCustomerBySubaccountId, + findAveniaCustomerByTaxId, + hashTaxReference, + hydrateAveniaCompanyName, + updateAveniaKycOutcome, + upsertAveniaKycCase +} from "../services/avenia/avenia-customer.service"; import { resolveAveniaAccountForUser } from "../services/avenia-account"; - -// Helper functions for TaxId updates - -async function updateTaxIdToAccepted(taxId: string, quoteId?: string, sessionId?: string): Promise { - const normalized = normalizeTaxId(taxId); - await TaxId.update( - { - finalQuoteId: quoteId, - finalSessionId: sessionId ?? null, - finalTimestamp: new Date(), - internalStatus: TaxIdInternalStatus.Accepted - }, - { - where: { - internalStatus: TaxIdInternalStatus.Requested, - taxId: normalized - } - } - ); -} - -async function updateTaxIdToRejected(taxId: string, quoteId?: string, sessionId?: string): Promise { - const normalized = normalizeTaxId(taxId); - await TaxId.update( - { - finalQuoteId: quoteId, - finalSessionId: sessionId ?? null, - finalTimestamp: new Date(), - internalStatus: TaxIdInternalStatus.Rejected - }, - { - where: { - internalStatus: TaxIdInternalStatus.Requested, - taxId: normalized - } - } - ); -} +import { getOrCreateCustomerEntityForProfile } from "../services/customer-entity.service"; // map from subaccountId → last interaction timestamp. Used for fetching the last relevant kyc event. const _lastInteractionMap = new Map(); +function legacyAveniaStatus(status: TaxIdInternalStatus | null): VerificationStatus { + switch (status) { + case TaxIdInternalStatus.Accepted: + return VerificationStatus.Approved; + case TaxIdInternalStatus.Rejected: + return VerificationStatus.Rejected; + case TaxIdInternalStatus.Requested: + return VerificationStatus.InReview; + case TaxIdInternalStatus.Consulted: + return VerificationStatus.Started; + default: + return VerificationStatus.Pending; + } +} + // Maps webhook failure reasons to standardized enum values function mapKycFailureReason(webhookReason: string | undefined): KycFailureReason { if (!webhookReason) { @@ -160,31 +149,27 @@ export const getAveniaUser = async ( } const brlaApiService = BrlaApiService.getInstance(); - let taxIdRecord: TaxId | null; + let record: ProviderCustomer | null; if (taxId) { - const normalized = normalizeTaxId(taxId); - taxIdRecord = await TaxId.findOne({ - where: { - internalStatus: { [Op.ne]: TaxIdInternalStatus.Consulted }, - taxId: normalized - } - }); + record = await findAveniaCustomerByTaxId(taxId); - if (!taxIdRecord) { + // Consulted records are analytics artifacts without a subaccount, not usable accounts. + if (!record || record.status === VerificationStatus.Started || !record.providerSubaccountId) { res.status(httpStatus.NOT_FOUND).json({ error: "Subaccount not found" }); return; } - // TaxId must be owned by the effective user. - if (taxIdRecord.userId !== effectiveUserId) { + // The account must be owned by the effective user's customer entity. + const entity = await getOrCreateCustomerEntityForProfile(effectiveUserId); + if (record.customerEntityId !== entity.id) { res.status(httpStatus.FORBIDDEN).json({ error: "This tax ID is not linked to your user profile and cannot be used." }); return; } } else { try { const resolved = await resolveAveniaAccountForUser(effectiveUserId); - taxIdRecord = resolved.taxIdRecord; + record = resolved.providerCustomer; } catch (error) { if (error instanceof APIError) { res.status(error.status ?? httpStatus.BAD_REQUEST).json({ error: error.message }); @@ -194,7 +179,8 @@ export const getAveniaUser = async ( } } - const accountInfo = await brlaApiService.subaccountInfo(taxIdRecord.subAccountId); + const subAccountId = record.providerSubaccountId ?? ""; + const accountInfo = await brlaApiService.subaccountInfo(subAccountId); if (!accountInfo) { res.status(httpStatus.NOT_FOUND).json({ error: "Subaccount info not found" }); return; @@ -205,7 +191,7 @@ export const getAveniaUser = async ( evmAddress: accountInfo.wallets.find(w => w.chain === "EVM")?.walletAddress ?? "", identityStatus: accountInfo.accountInfo.identityStatus, kycLevel, - subAccountId: taxIdRecord.subAccountId + subAccountId }); return; } catch (error) { @@ -226,7 +212,7 @@ export const recordInitialKycAttempt = async ( res: Response | BrlaErrorResponse> ): Promise => { try { - const { taxId, quoteId, sessionId } = req.body; + const { taxId } = req.body; const effectiveUserId = getEffectiveUserId(req); if (!taxId) { @@ -234,13 +220,11 @@ export const recordInitialKycAttempt = async ( return; } - const taxIdRecord = await TaxId.findOne({ - where: { - taxId: normalizeTaxId(taxId) - } - }); + const existing = await findAveniaCustomerByTaxId(taxId); - if (!taxIdRecord) { + // provider_customers rows always have an owner, so anonymous callers cannot persist a + // Consulted marker (the route requires auth in practice). + if (!existing && effectiveUserId) { const accountType = isValidCnpj(taxId) ? AveniaAccountType.COMPANY : isValidCpf(taxId) @@ -249,15 +233,18 @@ export const recordInitialKycAttempt = async ( // Create the entry only if a valid taxId is provided. Otherwise we ignore the request. if (accountType) { - await TaxId.create({ - accountType, - initialQuoteId: quoteId, - initialSessionId: sessionId ?? null, - internalStatus: TaxIdInternalStatus.Consulted, - subAccountId: "", - taxId, - userId: effectiveUserId ?? null + const entity = await getOrCreateCustomerEntityForProfile(effectiveUserId); + const record = await ProviderCustomer.create({ + country: "BR", + customerEntityId: entity.id, + customerType: accountTypeToCustomerType(accountType), + provider: "avenia", + rail: "brl", + status: VerificationStatus.Started, + taxReference: normalizeTaxId(taxId), + taxReferenceHash: hashTaxReference(taxId) }); + await upsertAveniaKycCase(record, VerificationStatus.Started); } } @@ -288,26 +275,27 @@ export const getAveniaUserRemainingLimit = async ( return; } - let taxIdRecord: TaxId | null; + let record: ProviderCustomer | null; if (taxId) { - taxIdRecord = await TaxId.findByPk(normalizeTaxId(taxId)); - if (!taxIdRecord) { + record = await findAveniaCustomerByTaxId(taxId); + if (!record) { throw new APIError({ message: "taxId does not match existing records", status: httpStatus.BAD_REQUEST }); } - // TaxId must be owned by the effective user. The legacy partner-key + // The account must be owned by the effective user. The legacy partner-key // exemption that allowed reading any taxId has been removed. - if (taxIdRecord.userId !== effectiveUserId) { + const entity = await getOrCreateCustomerEntityForProfile(effectiveUserId); + if (record.customerEntityId !== entity.id) { res.status(httpStatus.FORBIDDEN).json({ error: "This tax ID is not linked to your user profile and cannot be used." }); return; } } else { try { const resolved = await resolveAveniaAccountForUser(effectiveUserId); - taxIdRecord = resolved.taxIdRecord; + record = resolved.providerCustomer; } catch (error) { if (error instanceof APIError) { res.status(error.status ?? httpStatus.BAD_REQUEST).json({ error: error.message }); @@ -318,7 +306,7 @@ export const getAveniaUserRemainingLimit = async ( } const brlaApiService = BrlaApiService.getInstance(); - const limitsData = await brlaApiService.getSubaccountUsedLimit(taxIdRecord.subAccountId); + const limitsData = await brlaApiService.getSubaccountUsedLimit(record.providerSubaccountId ?? ""); if (!limitsData || !limitsData.limitInfo || !limitsData.limitInfo.limits) { res.status(httpStatus.NOT_FOUND).json({ error: "Limits not found" }); @@ -330,7 +318,7 @@ export const getAveniaUserRemainingLimit = async ( if (!brlLimits) { // Our current assumption is that BRL limits won't exist for an account without a KYC. // But to be safe, we check the status and return a proper status. - const accountInfo = await brlaApiService.subaccountInfo(taxIdRecord.subAccountId); + const accountInfo = await brlaApiService.subaccountInfo(record.providerSubaccountId ?? ""); if (!accountInfo || accountInfo.accountInfo.identityStatus !== "CONFIRMED") { res.status(httpStatus.BAD_REQUEST).json({ error: "KYC invalid" }); return; @@ -359,7 +347,7 @@ export const createSubaccount = async ( res: Response ): Promise => { try { - const { name, taxId, accountType: requestAccountType, quoteId, sessionId } = req.body; + const { name, taxId, accountType: requestAccountType } = req.body; const effectiveUserId = getEffectiveUserId(req); // Reject callers that do not resolve to a user (anonymous requests @@ -378,48 +366,87 @@ export const createSubaccount = async ( // Use the accountType from the request if provided, otherwise determine from taxId const accountType = requestAccountType || (isCnpj ? AveniaAccountType.COMPANY : AveniaAccountType.INDIVIDUAL); + const entity = await getOrCreateCustomerEntityForProfile(effectiveUserId, accountTypeToCustomerType(accountType)); + // Ownership check BEFORE calling the BRLA API to avoid creating a stranded subaccount // on every conflict and to prevent account-takeover via subAccountId overwrite. - const existingTaxId = await TaxId.findByPk(normalizedTaxId); - if (existingTaxId && existingTaxId.internalStatus !== TaxIdInternalStatus.Consulted) { - const ownedByAnotherUser = existingTaxId.userId !== null && existingTaxId.userId !== effectiveUserId; - if (ownedByAnotherUser) { - res.status(httpStatus.CONFLICT).json({ - error: "A subaccount already exists for this taxId" + let existing = await findAveniaCustomerByTaxId(normalizedTaxId); + if (existing && existing.customerEntityId !== entity.id) { + res.status(httpStatus.CONFLICT).json({ + error: "A subaccount already exists for this taxId" + }); + return; + } + + // Legacy adoption: quarantined rows in the tax_ids backup (created before the + // provider_customers cutover, possibly ownerless) are claimable exactly like the + // pre-cutover flow allowed — owned-by-another rejects, anonymous rows are claimed + // by the authenticated caller. One-time per row; the backup itself is never written. + if (!existing) { + const legacy = await TaxId.findByPk(normalizedTaxId); + if (legacy && legacy.internalStatus !== TaxIdInternalStatus.Consulted) { + if (legacy.userId !== null && legacy.userId !== effectiveUserId) { + res.status(httpStatus.CONFLICT).json({ + error: "A subaccount already exists for this taxId" + }); + return; + } + existing = await ProviderCustomer.create({ + country: "BR", + customerEntityId: entity.id, + customerType: accountTypeToCustomerType(legacy.accountType), + provider: "avenia", + providerSubaccountId: legacy.subAccountId || null, + rail: "brl", + status: legacyAveniaStatus(legacy.internalStatus), + taxReference: normalizedTaxId, + taxReferenceHash: hashTaxReference(normalizedTaxId) }); - return; - } - // Allow authenticated users to claim anonymous records by updating userId - if (existingTaxId.userId === null) { - await existingTaxId.update({ userId: effectiveUserId }); } } const brlaApiService = BrlaApiService.getInstance(); const { id } = await brlaApiService.createAveniaSubaccount(accountType, name); + let companyName: string | null = null; + if (accountType === AveniaAccountType.COMPANY) { + companyName = name.trim(); + try { + const account = await brlaApiService.subaccountInfo(id); + companyName = account?.accountInfo.name?.trim() || account?.accountInfo.fullName?.trim() || companyName; + } catch { + // The accepted request name remains usable if the follow-up provider read is temporarily unavailable. + } + } - if (existingTaxId) { - await existingTaxId.update({ - accountType, - internalStatus: TaxIdInternalStatus.Requested, - requestedDate: new Date(), - subAccountId: id + // A company has no verification attempt yet at this point — the hosted KYB links are issued in + // a follow-up call — so it starts pending (resumable). Individuals keep the legacy Requested + // semantics (in_review until the outcome poll decides). + const initialStatus = accountType === AveniaAccountType.COMPANY ? VerificationStatus.Pending : VerificationStatus.InReview; + if (existing) { + await existing.update({ + companyName, + customerType: accountTypeToCustomerType(accountType), + providerSubaccountId: id, + status: initialStatus, + statusExternal: null }); } else { // The entry should have been created the very first a new cpf/cnpj is consulted. // We leave this as is for now to avoid breaking changes. - - await TaxId.create({ - accountType, - initialQuoteId: quoteId, - initialSessionId: sessionId ?? null, - internalStatus: TaxIdInternalStatus.Requested, - requestedDate: new Date(), - subAccountId: id, - taxId: normalizedTaxId, - userId: effectiveUserId + existing = await ProviderCustomer.create({ + companyName, + country: "BR", + customerEntityId: entity.id, + customerType: accountTypeToCustomerType(accountType), + provider: "avenia", + providerSubaccountId: id, + rail: "brl", + status: initialStatus, + taxReference: normalizedTaxId, + taxReferenceHash: hashTaxReference(normalizedTaxId) }); } + await upsertAveniaKycCase(existing, initialStatus, null); res.status(httpStatus.OK).json({ subAccountId: id }); } catch (error) { @@ -433,24 +460,41 @@ export const fetchSubaccountKycStatus = async ( res: Response ): Promise => { try { - const { taxId, quoteId, sessionId } = req.query; + const { taxId } = req.query; if (!taxId) { res.status(httpStatus.BAD_REQUEST).json({ error: "Missing taxId" }); return; } - const taxIdRecord = await TaxId.findByPk(normalizeTaxId(taxId)); - if (!taxIdRecord) { + const record = await findAveniaCustomerByTaxId(taxId); + if (!record) { res.status(httpStatus.NOT_FOUND).json({ error: "Subaccount not found" }); return; } + // Ownership: this endpoint both reads KYC state and drives status transitions, so it + // must not be usable against another user's account. + const effectiveUserId = getEffectiveUserId(req); + if (!effectiveUserId) { + res.status(httpStatus.BAD_REQUEST).json({ error: "This endpoint requires authentication." }); + return; + } + const entity = await getOrCreateCustomerEntityForProfile(effectiveUserId); + if (record.customerEntityId !== entity.id) { + res.status(httpStatus.FORBIDDEN).json({ error: "This tax ID is not linked to your user profile and cannot be used." }); + return; + } + + const subAccountId = record.providerSubaccountId ?? ""; + // Backfill `companyName` for business rows created before the field was populated + // (mirrors the onboarding aggregation's lazy hydration so KYC-only callers recover too). + await hydrateAveniaCompanyName(record); const brlaApiService = BrlaApiService.getInstance(); - const kycAttemptStatuses = await brlaApiService.getKycAttempts(taxIdRecord.subAccountId); + const kycAttemptStatuses = await brlaApiService.getKycAttempts(subAccountId); const kycAttemptStatus = kycAttemptStatuses.attempts[0]; // Get the latest attempt if (!kycAttemptStatus) { - const accountInfo = await brlaApiService.subaccountInfo(taxIdRecord.subAccountId); + const accountInfo = await brlaApiService.subaccountInfo(subAccountId); if (accountInfo?.accountInfo.identityStatus === "CONFIRMED") { res.status(httpStatus.OK).json({ level: "KYC_1", @@ -460,19 +504,30 @@ export const fetchSubaccountKycStatus = async ( }); // Also try updating in case we missed the attempt - await updateTaxIdToAccepted(taxId, quoteId, sessionId); + await updateAveniaKycOutcome(taxId, VerificationStatus.Approved, accountInfo.accountInfo.identityStatus); + return; } + await record.update({ status: VerificationStatus.Pending, statusExternal: null }); + await upsertAveniaKycCase(record, VerificationStatus.Pending, null); res.status(httpStatus.NOT_FOUND).json({ error: "KYC attempt not found" }); return; } // Update our internal status based on the KYC result. if (kycAttemptStatus.result === KycAttemptResult.APPROVED) { - await updateTaxIdToAccepted(taxId, quoteId, sessionId); + await updateAveniaKycOutcome(taxId, VerificationStatus.Approved, kycAttemptStatus.status); } if (kycAttemptStatus.result === KycAttemptResult.REJECTED) { - await updateTaxIdToRejected(taxId, quoteId, sessionId); + await updateAveniaKycOutcome(taxId, VerificationStatus.Rejected, kycAttemptStatus.status); + } + // No result yet: mirror the in-flight attempt. This includes a `rejected` account whose + // owner retries — the fresh attempt puts it back in review so the outcome poll can decide. + if (!kycAttemptStatus.result && record.status !== VerificationStatus.Approved) { + const status = + kycAttemptStatus.status === KycAttemptStatus.EXPIRED ? VerificationStatus.Pending : VerificationStatus.InReview; + await record.update({ status, statusExternal: kycAttemptStatus.status }); + await upsertAveniaKycCase(record, status, kycAttemptStatus.status); } res.status(httpStatus.OK).json({ @@ -532,18 +587,30 @@ export const getSelfieLivenessUrl = async ( return; } - const taxIdRecord = await TaxId.findByPk(normalizeTaxId(taxId)); - if (!taxIdRecord) { + const record = await findAveniaCustomerByTaxId(taxId); + if (!record) { res.status(httpStatus.BAD_REQUEST).json({ error: "Ramp disabled" }); return; } + // Ownership: liveness URLs act on the account's KYC flow. + const effectiveUserId = getEffectiveUserId(req); + if (!effectiveUserId) { + res.status(httpStatus.BAD_REQUEST).json({ error: "This endpoint requires authentication." }); + return; + } + const entity = await getOrCreateCustomerEntityForProfile(effectiveUserId); + if (record.customerEntityId !== entity.id) { + res.status(httpStatus.FORBIDDEN).json({ error: "This tax ID is not linked to your user profile and cannot be used." }); + return; + } + const brlaApiService = BrlaApiService.getInstance(); const selfieUrl = await brlaApiService.getDocumentUploadUrls( AveniaDocumentType.SELFIE_FROM_LIVENESS, false, - taxIdRecord.subAccountId + record.providerSubaccountId ?? "" ); res.status(httpStatus.OK).json({ @@ -590,30 +657,32 @@ export const getUploadUrls = async ( return; } - const taxIdRecord = await TaxId.findByPk(normalizeTaxId(taxId)); - if (!taxIdRecord) { + const record = await findAveniaCustomerByTaxId(taxId); + if (!record) { // Invalid state. Cannot happen since we create the subaccount first for every tax. res.status(httpStatus.BAD_REQUEST).json({ error: "Ramp disabled" }); return; } - if (!req.userId || taxIdRecord.userId !== req.userId) { + if (!req.userId) { + res.status(httpStatus.FORBIDDEN).json({ error: "This tax ID is not linked to your user profile and cannot be used." }); + return; + } + const entity = await getOrCreateCustomerEntityForProfile(req.userId, "business"); + if (record.customerEntityId !== entity.id) { res.status(httpStatus.FORBIDDEN).json({ error: "This tax ID is not linked to your user profile and cannot be used." }); return; } + const subAccountId = record.providerSubaccountId ?? ""; const brlaApiService = BrlaApiService.getInstance(); - const selfieUrl = await brlaApiService.getDocumentUploadUrls( - AveniaDocumentType.SELFIE_FROM_LIVENESS, - false, - taxIdRecord.subAccountId - ); + const selfieUrl = await brlaApiService.getDocumentUploadUrls(AveniaDocumentType.SELFIE_FROM_LIVENESS, false, subAccountId); // assume RG is double sided, CNH is not. const isDoubleSided = documentType === AveniaDocumentType.ID ? true : false; - const idUrls = await brlaApiService.getDocumentUploadUrls(documentType, isDoubleSided, taxIdRecord.subAccountId); + const idUrls = await brlaApiService.getDocumentUploadUrls(documentType, isDoubleSided, subAccountId); res.status(httpStatus.OK).json({ idUpload: { @@ -647,13 +716,18 @@ export const newKyc = async ( return; } - const taxIdRecord = await TaxId.findOne({ where: { subAccountId } }); - if (!taxIdRecord) { + const record = await findAveniaCustomerBySubaccountId(subAccountId); + if (!record) { res.status(httpStatus.NOT_FOUND).json({ error: "Subaccount not found" }); return; } - if (!req.userId || taxIdRecord.userId !== req.userId) { + if (!req.userId) { + res.status(httpStatus.FORBIDDEN).json({ error: "This tax ID is not linked to your user profile and cannot be used." }); + return; + } + const entity = await getOrCreateCustomerEntityForProfile(req.userId); + if (record.customerEntityId !== entity.id) { res.status(httpStatus.FORBIDDEN).json({ error: "This tax ID is not linked to your user profile and cannot be used." }); return; } @@ -689,26 +763,78 @@ export const initiateKybLevel1 = async ( return; } - const taxIdRecord = await TaxId.findOne({ where: { subAccountId } }); - if (!taxIdRecord) { + const record = await findAveniaCustomerBySubaccountId(subAccountId); + if (!record) { res.status(httpStatus.NOT_FOUND).json({ error: "Subaccount not found" }); return; } - if (!req.userId || taxIdRecord.userId !== req.userId) { + if (!req.userId) { + res.status(httpStatus.FORBIDDEN).json({ error: "This tax ID is not linked to your user profile and cannot be used." }); + return; + } + const entity = await getOrCreateCustomerEntityForProfile(req.userId); + if (record.customerEntityId !== entity.id) { res.status(httpStatus.FORBIDDEN).json({ error: "This tax ID is not linked to your user profile and cannot be used." }); return; } - if (taxIdRecord.accountType !== AveniaAccountType.COMPANY) { + const accountType = customerTypeToAccountType(record.customerType); + if (accountType !== AveniaAccountType.COMPANY) { res.status(httpStatus.BAD_REQUEST).json({ - error: "KYB Level 1 is only available for COMPANY accounts. This account is registered as " + taxIdRecord.accountType + error: "KYB Level 1 is only available for COMPANY accounts. This account is registered as " + accountType }); return; } + if (record.status === VerificationStatus.Approved) { + res.status(httpStatus.CONFLICT).json({ error: "This company is already approved" }); + return; + } + + const existingKybCase = await KycCase.findOne({ + where: { providerCustomerId: record.id, type: "kyb" } + }); + // A PENDING attempt means the user never completed Avenia's hosted steps. The hosted URLs are + // not stored, so re-initiation is the only way to surface them again — allow it and rebind the + // case to the fresh attempt. Only an attempt Avenia is processing (or has decided) blocks. + if ( + existingKybCase?.providerCaseId && + record.status !== VerificationStatus.Rejected && + existingKybCase.statusExternal !== KycAttemptStatus.EXPIRED && + existingKybCase.statusExternal !== KycAttemptStatus.PENDING + ) { + res.status(httpStatus.CONFLICT).json({ error: "A KYB attempt is already in progress" }); + return; + } + const brlaApiService = BrlaApiService.getInstance(); + + // The stored status can lag (the hosted steps may have just been finished in another tab): + // probe the live attempt before re-initiating so a processing/approved attempt is not + // orphaned by rebinding the case to a fresh one. A rejected decision stays re-initiable + // (that is the retry path), and a failing probe must not lock the user out of resuming. + if (existingKybCase?.providerCaseId) { + try { + const { attempt } = await brlaApiService.getKybAttemptStatus(existingKybCase.providerCaseId); + const decidedRejected = attempt.status === KycAttemptStatus.COMPLETED && attempt.result === KycAttemptResult.REJECTED; + const resumable = + attempt.status === KycAttemptStatus.PENDING || attempt.status === KycAttemptStatus.EXPIRED || decidedRejected; + if (!resumable) { + res.status(httpStatus.CONFLICT).json({ error: "A KYB attempt is already in progress" }); + return; + } + } catch { + // Re-initiation is the only path back to the hosted steps; keep it available if the probe fails. + } + } + const response = await brlaApiService.initiateKybLevel1(subAccountId); + // The attempt starts PENDING at Avenia — nothing is submitted until the user finishes the hosted + // steps — so our status stays pending (dashboard keeps offering Continue). in_review is set only + // once Avenia reports PROCESSING. + await record.update({ status: VerificationStatus.Pending, statusExternal: KycAttemptStatus.PENDING }); + await upsertAveniaKycCase(record, VerificationStatus.Pending, KycAttemptStatus.PENDING, response.attemptId); res.status(httpStatus.OK).json(response); } catch (error) { @@ -736,10 +862,78 @@ export const getKybAttemptStatus = async ( return; } + const effectiveUserId = getEffectiveUserId(req); + if (!effectiveUserId) { + res.status(httpStatus.BAD_REQUEST).json({ error: "This endpoint requires authentication." }); + return; + } + + const kycCase = await KycCase.findOne({ + where: { provider: "avenia", providerCaseId: attemptId, type: "kyb" } + }); + if (!kycCase) { + res.status(httpStatus.NOT_FOUND).json({ error: "KYB attempt not found" }); + return; + } + + const entity = await getOrCreateCustomerEntityForProfile(effectiveUserId, "business"); + if (kycCase.customerEntityId !== entity.id) { + res.status(httpStatus.FORBIDDEN).json({ error: "This KYB attempt is not linked to your user profile." }); + return; + } + + const record = kycCase.providerCustomerId ? await ProviderCustomer.findByPk(kycCase.providerCustomerId) : null; + if (!record || record.customerEntityId !== entity.id || record.provider !== "avenia") { + res.status(httpStatus.NOT_FOUND).json({ error: "KYB account not found" }); + return; + } + + if (record.status === VerificationStatus.Approved) { + res.status(httpStatus.OK).json({ result: KycAttemptResult.APPROVED, status: KycAttemptStatus.COMPLETED }); + return; + } + const brlaApiService = BrlaApiService.getInstance(); const response = await brlaApiService.getKybAttemptStatus(attemptId); + const attempt = response.attempt; + if (attempt.id !== attemptId) { + throw new APIError({ message: "Avenia returned a mismatched KYB attempt", status: httpStatus.BAD_GATEWAY }); + } + + const approved = attempt.status === KycAttemptStatus.COMPLETED && attempt.result === KycAttemptResult.APPROVED; + const rejected = + attempt.status === KycAttemptStatus.EXPIRED || + (attempt.status === KycAttemptStatus.COMPLETED && attempt.result === KycAttemptResult.REJECTED); + const normalizedStatus = approved + ? VerificationStatus.Approved + : rejected + ? VerificationStatus.Rejected + : attempt.status === KycAttemptStatus.PROCESSING + ? VerificationStatus.InReview + : VerificationStatus.Pending; + const failureReason = rejected ? mapKycFailureReason(attempt.resultMessage) : undefined; + const lifecycle = { + ...(approved ? { approvedAt: new Date(), rejectedAt: null } : {}), + ...(rejected ? { approvedAt: null, rejectedAt: new Date() } : {}) + }; + + await record.update({ + lastFailureReasons: failureReason ? [failureReason] : [], + status: normalizedStatus, + statusExternal: attempt.status + }); + await kycCase.update({ + failureReasons: failureReason ? [failureReason] : [], + status: normalizedStatus, + statusExternal: attempt.status, + ...lifecycle + }); - res.status(httpStatus.OK).json(response); + res.status(httpStatus.OK).json({ + ...(failureReason ? { failureReason } : {}), + ...(attempt.result ? { result: attempt.result } : {}), + status: attempt.status + }); } catch (error) { handleApiError(error, res, "getKybAttemptStatus"); } diff --git a/apps/api/src/api/controllers/monerium.controller.ts b/apps/api/src/api/controllers/monerium.controller.ts new file mode 100644 index 000000000..64f39307a --- /dev/null +++ b/apps/api/src/api/controllers/monerium.controller.ts @@ -0,0 +1,64 @@ +import { NextFunction, Request, Response } from "express"; +import httpStatus from "http-status"; +import { APIError } from "../errors/api-error"; +import { completeMoneriumOAuth, getMoneriumStatus, startMoneriumOAuth } from "../services/monerium/monerium.service"; + +type CustomerType = "individual" | "business"; + +function customerType(value: unknown): CustomerType { + if (value !== "individual" && value !== "business") { + throw new APIError({ message: "customerType must be individual or business", status: httpStatus.BAD_REQUEST }); + } + return value; +} + +function requiredString(value: unknown, name: string): string { + if (typeof value !== "string" || value.length === 0 || value.length > 2048) { + throw new APIError({ message: `${name} is required`, status: httpStatus.BAD_REQUEST }); + } + return value; +} + +function authenticatedUser(req: Request): { email: string; userId: string } { + if (!req.userId || !req.userEmail) { + throw new APIError({ message: "Authenticated user identity is incomplete", status: httpStatus.UNAUTHORIZED }); + } + return { email: req.userEmail, userId: req.userId }; +} + +export async function start(req: Request, res: Response, next: NextFunction): Promise { + try { + const user = authenticatedUser(req); + const body = (req.body ?? {}) as Record; + if ( + body.email !== undefined && + (typeof body.email !== "string" || body.email.trim().toLowerCase() !== user.email.toLowerCase()) + ) { + throw new APIError({ message: "email must match the authenticated user", status: httpStatus.BAD_REQUEST }); + } + res.status(httpStatus.OK).json(await startMoneriumOAuth(user.userId, user.email, customerType(body.customerType))); + } catch (error) { + next(error); + } +} + +export async function complete(req: Request, res: Response, next: NextFunction): Promise { + try { + const user = authenticatedUser(req); + const body = (req.body ?? {}) as Record; + res + .status(httpStatus.OK) + .json(await completeMoneriumOAuth(user.userId, requiredString(body.code, "code"), requiredString(body.state, "state"))); + } catch (error) { + next(error); + } +} + +export async function status(req: Request, res: Response, next: NextFunction): Promise { + try { + const user = authenticatedUser(req); + res.status(httpStatus.OK).json(await getMoneriumStatus(user.userId, customerType(req.query.customerType))); + } catch (error) { + next(error); + } +} diff --git a/apps/api/src/api/controllers/mykobo.controller.ts b/apps/api/src/api/controllers/mykobo.controller.ts index 0812d0457..88bcc81ce 100644 --- a/apps/api/src/api/controllers/mykobo.controller.ts +++ b/apps/api/src/api/controllers/mykobo.controller.ts @@ -3,7 +3,11 @@ import { Request, Response } from "express"; import httpStatus from "http-status"; import { isAddress } from "viem"; import logger from "../../config/logger"; -import { upsertMykoboCustomerFromProfile } from "../services/mykobo/mykobo-customer.service"; +import { + markMykoboCustomerPending, + markMykoboCustomerStarted, + upsertMykoboCustomerFromProfile +} from "../services/mykobo/mykobo-customer.service"; const PROFILE_TEXT_FIELDS = [ "first_name", @@ -48,7 +52,7 @@ export const getProfileController = async (req: Request, res: Response): Promise const { email, memo } = req.query; const userEmail = req.userEmail; - if (!userEmail) { + if (!userEmail || !req.userId) { res.status(httpStatus.UNAUTHORIZED).json({ error: "Authenticated user email missing" }); return; } @@ -83,8 +87,8 @@ export const getProfileController = async (req: Request, res: Response): Promise }; export const createProfileController = async (req: Request, res: Response): Promise => { - const userEmail = req.userEmail; - if (!userEmail) { + const { userId, userEmail } = req; + if (!userId || !userEmail) { res.status(httpStatus.UNAUTHORIZED).json({ error: "Authenticated user email missing" }); return; } @@ -117,10 +121,19 @@ export const createProfileController = async (req: Request, res: Response): Prom } } + await markMykoboCustomerStarted(userId, userEmail); const { profile } = await MykoboApiService.getInstance().createProfile(formData); + await upsertMykoboCustomerFromProfile(userId, userEmail, profile); res.status(httpStatus.CREATED).json({ profile: toFrontendProfile(profile) }); } catch (error) { if (error instanceof MykoboApiError) { + if (req.userId && userEmail) { + try { + await markMykoboCustomerPending(req.userId, userEmail); + } catch (mirrorError) { + logger.error("Failed to mark Mykobo profile creation as pending:", mirrorError); + } + } logger.warn(`Mykobo /profiles upstream error: status=${error.status}`); res.status(error.status).json({ error: "Mykobo profile creation failed" }); return; diff --git a/apps/api/src/api/controllers/notifications.controller.ts b/apps/api/src/api/controllers/notifications.controller.ts new file mode 100644 index 000000000..8be520a6f --- /dev/null +++ b/apps/api/src/api/controllers/notifications.controller.ts @@ -0,0 +1,134 @@ +import { Request, Response } from "express"; +import httpStatus from "http-status"; +import { Op } from "sequelize"; +import logger from "../../config/logger"; +import Notification from "../../models/notification.model"; +import { getOrCreateNotificationPreferences } from "../services/notifications/notification.service"; + +function sendError(res: Response, status: number, code: string, message: string): void { + res.status(status).json({ error: { code, message, status } }); +} + +function requireUserId(req: Request, res: Response): string | null { + if (!req.userId) { + sendError(res, httpStatus.UNAUTHORIZED, "AUTHENTICATION_REQUIRED", "Authentication required"); + return null; + } + return req.userId; +} + +const DEFAULT_LIMIT = 20; +const MAX_LIMIT = 100; + +export async function listNotifications(req: Request, res: Response): Promise { + const userId = requireUserId(req, res); + if (!userId) return; + + const rawLimit = Number(req.query.limit ?? DEFAULT_LIMIT); + const limit = Number.isFinite(rawLimit) ? Math.min(Math.max(Math.trunc(rawLimit), 1), MAX_LIMIT) : DEFAULT_LIMIT; + const before = typeof req.query.before === "string" ? new Date(req.query.before) : null; + if (before && Number.isNaN(before.getTime())) { + sendError(res, httpStatus.BAD_REQUEST, "INVALID_BEFORE", "before must be a valid ISO-8601 date"); + return; + } + + try { + const [notifications, unreadCount] = await Promise.all([ + Notification.findAll({ + limit, + order: [["createdAt", "DESC"]], + where: { profileId: userId, ...(before ? { createdAt: { [Op.lt]: before } } : {}) } + }), + Notification.count({ where: { profileId: userId, readAt: null } }) + ]); + + res.status(httpStatus.OK).json({ + notifications: notifications.map(notification => ({ + body: notification.body, + createdAt: notification.createdAt, + id: notification.id, + metadata: notification.metadata, + readAt: notification.readAt, + title: notification.title, + type: notification.type + })), + unreadCount + }); + } catch (error) { + logger.error("Error listing notifications:", error); + sendError(res, httpStatus.INTERNAL_SERVER_ERROR, "INTERNAL_SERVER_ERROR", "Failed to list notifications"); + } +} + +export async function markNotificationRead(req: Request<{ id: string }>, res: Response): Promise { + const userId = requireUserId(req, res); + if (!userId) return; + + try { + const notification = await Notification.findOne({ where: { id: req.params.id, profileId: userId } }); + if (!notification) { + sendError(res, httpStatus.NOT_FOUND, "NOTIFICATION_NOT_FOUND", "Notification not found"); + return; + } + if (!notification.readAt) { + await notification.update({ readAt: new Date() }); + } + res.status(httpStatus.NO_CONTENT).send(); + } catch (error) { + logger.error("Error marking notification read:", error); + sendError(res, httpStatus.INTERNAL_SERVER_ERROR, "INTERNAL_SERVER_ERROR", "Failed to mark notification read"); + } +} + +export async function markAllNotificationsRead(req: Request, res: Response): Promise { + const userId = requireUserId(req, res); + if (!userId) return; + + try { + await Notification.update({ readAt: new Date() }, { where: { profileId: userId, readAt: null } }); + res.status(httpStatus.NO_CONTENT).send(); + } catch (error) { + logger.error("Error marking all notifications read:", error); + sendError(res, httpStatus.INTERNAL_SERVER_ERROR, "INTERNAL_SERVER_ERROR", "Failed to mark notifications read"); + } +} + +export async function getNotificationPreferences(req: Request, res: Response): Promise { + const userId = requireUserId(req, res); + if (!userId) return; + + try { + const preferences = await getOrCreateNotificationPreferences(userId); + res.status(httpStatus.OK).json({ emailEnabled: preferences.emailEnabled, prefs: preferences.prefs }); + } catch (error) { + logger.error("Error reading notification preferences:", error); + sendError(res, httpStatus.INTERNAL_SERVER_ERROR, "INTERNAL_SERVER_ERROR", "Failed to read preferences"); + } +} + +export async function updateNotificationPreferences(req: Request, res: Response): Promise { + const userId = requireUserId(req, res); + if (!userId) return; + + const { emailEnabled, prefs } = (req.body ?? {}) as { emailEnabled?: unknown; prefs?: unknown }; + if (emailEnabled !== undefined && typeof emailEnabled !== "boolean") { + sendError(res, httpStatus.BAD_REQUEST, "INVALID_EMAIL_ENABLED", "emailEnabled must be a boolean"); + return; + } + if (prefs !== undefined && (typeof prefs !== "object" || prefs === null || Array.isArray(prefs))) { + sendError(res, httpStatus.BAD_REQUEST, "INVALID_PREFS", "prefs must be an object"); + return; + } + + try { + const preferences = await getOrCreateNotificationPreferences(userId); + await preferences.update({ + ...(emailEnabled !== undefined ? { emailEnabled } : {}), + ...(prefs !== undefined ? { prefs: prefs as Record } : {}) + }); + res.status(httpStatus.OK).json({ emailEnabled: preferences.emailEnabled, prefs: preferences.prefs }); + } catch (error) { + logger.error("Error updating notification preferences:", error); + sendError(res, httpStatus.INTERNAL_SERVER_ERROR, "INTERNAL_SERVER_ERROR", "Failed to update preferences"); + } +} diff --git a/apps/api/src/api/controllers/onboarding.controller.ts b/apps/api/src/api/controllers/onboarding.controller.ts new file mode 100644 index 000000000..d3b089860 --- /dev/null +++ b/apps/api/src/api/controllers/onboarding.controller.ts @@ -0,0 +1,318 @@ +import { BrlaApiService, KycAttemptResult, KycAttemptStatus } from "@vortexfi/shared"; +import { Request, Response } from "express"; +import httpStatus from "http-status"; +import logger from "../../config/logger"; +import CustomerEntity from "../../models/customerEntity.model"; +import KycCase from "../../models/kycCase.model"; +import ProviderCustomer, { VerificationStatus } from "../../models/providerCustomer.model"; +import User from "../../models/user.model"; +import { APIError } from "../errors/api-error"; +import { refreshAlfredpayCustomerStatus } from "../services/alfredpay/alfredpay-customer.service"; +import { hydrateAveniaCompanyName } from "../services/avenia/avenia-customer.service"; +import { selectActiveCustomerEntity } from "../services/customer-entity.service"; +import { getMoneriumStatus, MONERIUM_REAUTHENTICATION_REQUIRED } from "../services/monerium/monerium.service"; + +// Provider status refreshes piggyback on the dashboard's 15s status poll; cap them per customer so +// polling (and multiple open tabs) doesn't hammer the providers. Marking at check time also dedupes +// concurrent polls. In-memory on purpose: per instance, the cap just multiplies by instance count. +const PROVIDER_REFRESH_TTL_MS = 60_000; +const lastProviderRefreshAt = new Map(); + +function shouldRefreshProviderStatus(customerId: string): boolean { + const now = Date.now(); + const last = lastProviderRefreshAt.get(customerId); + if (last !== undefined && now - last < PROVIDER_REFRESH_TTL_MS) { + return false; + } + if (lastProviderRefreshAt.size > 10_000) { + for (const [id, at] of lastProviderRefreshAt) { + if (now - at >= PROVIDER_REFRESH_TTL_MS) lastProviderRefreshAt.delete(id); + } + } + lastProviderRefreshAt.set(customerId, now); + return true; +} + +/** + * GET /v1/onboarding/status — aggregated onboarding view for the authenticated profile + * (plan D5), read directly from `provider_customers` + `kyc_cases`. + */ +export async function getOnboardingStatus(req: Request, res: Response): Promise { + const userId = req.userId; + if (!userId) { + res.status(httpStatus.UNAUTHORIZED).json({ + error: { code: "AUTHENTICATION_REQUIRED", message: "Authentication required", status: httpStatus.UNAUTHORIZED } + }); + return; + } + + try { + const [profile, entities] = await Promise.all([ + User.findByPk(userId, { attributes: ["activeCustomerEntityId"] }), + CustomerEntity.findAll({ where: { profileId: userId } }) + ]); + const activeEntityId = entities.some(entity => entity.id === profile?.activeCustomerEntityId) + ? (profile?.activeCustomerEntityId ?? null) + : null; + const entityIds = entities.map(entity => entity.id); + + const providerCustomers = entityIds.length + ? await ProviderCustomer.findAll({ order: [["updatedAt", "DESC"]], where: { customerEntityId: entityIds } }) + : []; + const kycCases = entityIds.length ? await KycCase.findAll({ where: { customerEntityId: entityIds } }) : []; + const kycCasesByProviderCustomer = new Map(); + for (const kycCase of kycCases) { + if (kycCase.providerCustomerId) { + kycCasesByProviderCustomer.set(kycCase.providerCustomerId, kycCase); + } + } + const providerErrors = new Map(); + + await Promise.all( + providerCustomers + .filter(customer => customer.provider === "monerium" && customer.status !== VerificationStatus.Rejected) + .map(async customer => { + try { + const refreshed = await getMoneriumStatus(userId, customer.customerType); + customer.set( + "status", + refreshed.status === "APPROVED" + ? VerificationStatus.Approved + : refreshed.status === "REJECTED" + ? VerificationStatus.Rejected + : ["authorization_started", "created", "incomplete"].includes(refreshed.statusExternal.toLowerCase()) + ? VerificationStatus.Started + : VerificationStatus.InReview + ); + customer.set("statusExternal", refreshed.statusExternal); + } catch (error) { + if (error instanceof APIError && error.type === MONERIUM_REAUTHENTICATION_REQUIRED) { + providerErrors.set(customer.id, { + code: MONERIUM_REAUTHENTICATION_REQUIRED, + message: error.message + }); + } + // Status aggregation remains available if Monerium is unavailable or in-memory credentials were lost. + } + }) + ); + + await Promise.all( + providerCustomers + .filter( + customer => + customer.provider === "avenia" && + customer.customerType === "business" && + !customer.companyName?.trim() && + customer.providerSubaccountId + ) + .map(async customer => hydrateAveniaCompanyName(customer)) + ); + + await Promise.all( + providerCustomers + .filter( + customer => + customer.provider === "avenia" && + customer.customerType === "business" && + customer.status !== VerificationStatus.Approved && + customer.status !== VerificationStatus.Rejected && + !!kycCasesByProviderCustomer.get(customer.id)?.providerCaseId && + shouldRefreshProviderStatus(customer.id) + ) + .map(async customer => { + const kycCase = kycCasesByProviderCustomer.get(customer.id); + if (!kycCase?.providerCaseId) return; + try { + const { attempt } = await BrlaApiService.getInstance().getKybAttemptStatus(kycCase.providerCaseId); + const approved = attempt.status === KycAttemptStatus.COMPLETED && attempt.result === KycAttemptResult.APPROVED; + const rejected = + attempt.status === KycAttemptStatus.EXPIRED || + (attempt.status === KycAttemptStatus.COMPLETED && attempt.result === KycAttemptResult.REJECTED); + // A PENDING attempt is one the user never finished (hosted steps not completed) — keep it + // pending so the dashboard offers Continue; in_review only once Avenia is PROCESSING. + const status = approved + ? VerificationStatus.Approved + : rejected + ? VerificationStatus.Rejected + : attempt.status === KycAttemptStatus.PENDING + ? VerificationStatus.Pending + : VerificationStatus.InReview; + const lifecycle = { + ...(approved ? { approvedAt: new Date(), rejectedAt: null } : {}), + ...(rejected ? { approvedAt: null, rejectedAt: new Date() } : {}) + }; + await Promise.all([ + customer.update({ status, statusExternal: attempt.status }), + kycCase.update({ status, statusExternal: attempt.status, ...lifecycle }) + ]); + } catch { + // Status aggregation remains available while Avenia is temporarily unavailable. + } + }) + ); + + // Avenia individual KYC: refresh from the latest attempt so an approval/rejection that lands + // after the wizard closed is reflected here — nothing else polls Avenia for individuals. + await Promise.all( + providerCustomers + .filter( + customer => + customer.provider === "avenia" && + customer.customerType === "individual" && + customer.status !== VerificationStatus.Approved && + customer.status !== VerificationStatus.Rejected && + !!customer.providerSubaccountId && + shouldRefreshProviderStatus(customer.id) + ) + .map(async customer => { + const kycCase = kycCasesByProviderCustomer.get(customer.id); + try { + const { attempts } = await BrlaApiService.getInstance().getKycAttempts(customer.providerSubaccountId as string); + const attempt = attempts[0]; + if (!attempt) return; + const approved = attempt.status === KycAttemptStatus.COMPLETED && attempt.result === KycAttemptResult.APPROVED; + const rejected = attempt.status === KycAttemptStatus.COMPLETED && attempt.result === KycAttemptResult.REJECTED; + // A PENDING or EXPIRED attempt is one the user never finished (livecheck not completed) — + // not a rejection: keep it pending so the dashboard offers Continue, mirroring + // fetchSubaccountKycStatus. Only an Avenia decision is terminal. + const status = approved + ? VerificationStatus.Approved + : rejected + ? VerificationStatus.Rejected + : attempt.status === KycAttemptStatus.PENDING || attempt.status === KycAttemptStatus.EXPIRED + ? VerificationStatus.Pending + : VerificationStatus.InReview; + const lifecycle = { + ...(approved ? { approvedAt: new Date(), rejectedAt: null } : {}), + ...(rejected ? { approvedAt: null, rejectedAt: new Date() } : {}) + }; + await Promise.all([ + customer.update({ status, statusExternal: attempt.status }), + kycCase?.update({ status, statusExternal: attempt.status, ...lifecycle }) + ]); + } catch { + // Status aggregation remains available while Avenia is temporarily unavailable. + } + }) + ); + + // Alfredpay: refresh non-terminal accounts against the provider so an outcome that lands after + // the KYC wizard closed is reflected here — the machine only polls Alfredpay while it is open. + await Promise.all( + providerCustomers + .filter( + customer => + customer.provider === "alfredpay" && + customer.status !== VerificationStatus.Approved && + customer.status !== VerificationStatus.Rejected && + shouldRefreshProviderStatus(customer.id) + ) + .map(customer => refreshAlfredpayCustomerStatus(customer)) + ); + + // Alfredpay status refresh may update or create a case through a separate model instance. + // Re-read cases so the nested response agrees with the freshly updated account state. + const refreshedKycCases = entityIds.length ? await KycCase.findAll({ where: { customerEntityId: entityIds } }) : []; + kycCasesByProviderCustomer.clear(); + for (const kycCase of refreshedKycCases) { + if (kycCase.providerCustomerId) { + kycCasesByProviderCustomer.set(kycCase.providerCustomerId, kycCase); + } + } + + res.status(httpStatus.OK).json({ + activeEntityId, + entities: entities.map(entity => { + const accounts = providerCustomers.filter(customer => customer.customerEntityId === entity.id); + return { + accounts: accounts.map(customer => { + const kycCase = kycCasesByProviderCustomer.get(customer.id) ?? null; + return { + companyName: customer.customerType === "business" ? customer.companyName : null, + country: customer.country, + customerType: customer.customerType, + error: providerErrors.get(customer.id) ?? null, + id: customer.id, + kycCase: kycCase + ? { + approvedAt: kycCase.approvedAt, + failureReasons: kycCase.failureReasons, + level: kycCase.level, + rejectedAt: kycCase.rejectedAt, + status: kycCase.status, + statusExternal: kycCase.statusExternal, + submittedAt: kycCase.submittedAt, + type: kycCase.type + } + : null, + provider: customer.provider, + rail: customer.rail, + state: customer.status, + status: customer.status, + statusExternal: customer.statusExternal, + // Business tax id (CNPJ) only — lets the dashboard resume a pending company flow + // without re-asking data the owner already supplied. Individual CPFs stay private. + taxReference: customer.customerType === "business" ? customer.taxReference : null + }; + }), + id: entity.id, + status: entity.status, + type: entity.type + }; + }), + selectionRequired: !activeEntityId + }); + } catch (error) { + logger.error("Error aggregating onboarding status:", error); + res.status(httpStatus.INTERNAL_SERVER_ERROR).json({ + error: { + code: "INTERNAL_SERVER_ERROR", + message: "Failed to read onboarding status", + status: httpStatus.INTERNAL_SERVER_ERROR + } + }); + } +} + +export async function putActiveEntity(req: Request, res: Response): Promise { + if (!req.userId) { + res.status(httpStatus.UNAUTHORIZED).json({ + error: { code: "AUTHENTICATION_REQUIRED", message: "Authentication required", status: httpStatus.UNAUTHORIZED } + }); + return; + } + + const type = req.body?.type; + if (type !== "individual" && type !== "business") { + res.status(httpStatus.BAD_REQUEST).json({ + error: { + code: "INVALID_ACTIVE_ENTITY_TYPE", + message: "type must be individual or business", + status: httpStatus.BAD_REQUEST + } + }); + return; + } + + try { + const entity = await selectActiveCustomerEntity(req.userId, type); + res.status(httpStatus.OK).json({ activeEntityId: entity.id, type: entity.type }); + } catch (error) { + if (error instanceof APIError) { + const status = error.status ?? httpStatus.INTERNAL_SERVER_ERROR; + res.status(status).json({ + error: { code: error.type ?? "ACTIVE_ENTITY_SELECTION_FAILED", message: error.message, status } + }); + return; + } + logger.error("Error selecting active customer entity:", error); + res.status(httpStatus.INTERNAL_SERVER_ERROR).json({ + error: { + code: "INTERNAL_SERVER_ERROR", + message: "Failed to select active customer entity", + status: httpStatus.INTERNAL_SERVER_ERROR + } + }); + } +} diff --git a/apps/api/src/api/controllers/recipients.controller.ts b/apps/api/src/api/controllers/recipients.controller.ts new file mode 100644 index 000000000..f0e964328 --- /dev/null +++ b/apps/api/src/api/controllers/recipients.controller.ts @@ -0,0 +1,554 @@ +import { + CORRIDOR_CAPABILITIES, + type CorridorCapability, + type CorridorCountry, + type CorridorCustomerType +} from "@vortexfi/shared"; +import { Request, Response } from "express"; +import httpStatus from "http-status"; +import { Op } from "sequelize"; +import sequelize from "../../config/database"; +import logger from "../../config/logger"; +import CustomerEntity from "../../models/customerEntity.model"; +import ProviderCustomer, { VerificationStatus } from "../../models/providerCustomer.model"; +import RecipientInvitation, { type RecipientInviteeType } from "../../models/recipientInvitation.model"; +import RecipientPayoutReference from "../../models/recipientPayoutReference.model"; +import SenderRecipient, { type SenderRecipientStatus } from "../../models/senderRecipient.model"; +import { getOrCreateCustomerEntityForProfile } from "../services/customer-entity.service"; +import { emitNotification } from "../services/notifications/notification.service"; +import { + canonicalizeEmail, + generateInviteToken, + hashInviteToken, + inviteExpiryDate +} from "../services/recipients/recipient-invite.service"; +import { + getTransferEligibility, + isProviderApproved, + providerForRail +} from "../services/recipients/transfer-eligibility.service"; + +const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +function sendError(res: Response, status: number, code: string, message: string): void { + res.status(status).json({ error: { code, message, status } }); +} + +function requireUserId(req: Request, res: Response): string | null { + if (!req.userId) { + sendError(res, httpStatus.UNAUTHORIZED, "AUTHENTICATION_REQUIRED", "Authentication required"); + return null; + } + return req.userId; +} + +interface CreateInviteBody { + country?: string; + rail?: string; + payoutCurrency?: string; + alias?: string; + inviteeEmail?: string; + inviteeType?: string; +} + +export async function createInvite(req: Request, res: Response): Promise { + const userId = requireUserId(req, res); + if (!userId) return; + + const { country, rail, payoutCurrency, alias, inviteeEmail, inviteeType } = (req.body ?? {}) as CreateInviteBody; + + if (!country || country.length > 4 || !rail || rail.length > 8 || !payoutCurrency || payoutCurrency.length > 8) { + sendError( + res, + httpStatus.BAD_REQUEST, + "INVALID_INVITE_CORRIDOR", + "country (ISO code), rail and payoutCurrency are required" + ); + return; + } + if (inviteeType !== undefined && inviteeType !== "individual" && inviteeType !== "business") { + sendError(res, httpStatus.BAD_REQUEST, "INVALID_INVITEE_TYPE", "inviteeType must be 'individual' or 'business'"); + return; + } + if (alias !== undefined && (typeof alias !== "string" || alias.length > 100)) { + sendError(res, httpStatus.BAD_REQUEST, "INVALID_ALIAS", "alias must be a string of at most 100 characters"); + return; + } + // The dashboard filters by the shared capability matrix; enforce the same rules here so a raw + // API call cannot create an invite for an unknown corridor or a combination the corridor's + // provider cannot onboard (e.g. Alfredpay has no AR company KYB). + const corridor: CorridorCapability | undefined = CORRIDOR_CAPABILITIES[country.toUpperCase() as CorridorCountry]; + if (!corridor || corridor.rail !== rail.toLowerCase()) { + sendError(res, httpStatus.BAD_REQUEST, "INVALID_INVITE_CORRIDOR", "Unknown corridor"); + return; + } + const effectiveInviteeType = (inviteeType ?? "individual") as CorridorCustomerType; + if (!corridor.customerTypes.includes(effectiveInviteeType)) { + sendError( + res, + httpStatus.BAD_REQUEST, + "UNSUPPORTED_INVITEE_TYPE", + `The ${country.toUpperCase()} corridor cannot onboard ${effectiveInviteeType} recipients` + ); + return; + } + + try { + // Invites unlock once any of the sender's corridors is approved (the dashboard rule) — + // enforce it here too. Approvals are persisted on provider_customers by every provider. + const senderEntityIds = (await CustomerEntity.findAll({ attributes: ["id"], where: { profileId: userId } })).map( + entity => entity.id + ); + const approvedOnboardings = senderEntityIds.length + ? await ProviderCustomer.count({ + where: { customerEntityId: senderEntityIds, status: VerificationStatus.Approved } + }) + : 0; + if (!approvedOnboardings) { + sendError( + res, + httpStatus.FORBIDDEN, + "NO_APPROVED_CORRIDOR", + "Recipient invites unlock once one of your corridors is approved" + ); + return; + } + + const senderEntity = await getOrCreateCustomerEntityForProfile(userId); + const token = generateInviteToken(); + + const invitation = await RecipientInvitation.create({ + alias: alias?.trim() || null, + country: country.toUpperCase(), + createdByProfileId: userId, + expiresAt: inviteExpiryDate(), + inviteeEmail: inviteeEmail ?? null, + inviteeEmailCanonical: inviteeEmail ? canonicalizeEmail(inviteeEmail) : null, + inviteeType: (inviteeType ?? "individual") as RecipientInviteeType, + payoutCurrency: payoutCurrency.toLowerCase(), + rail: rail.toLowerCase(), + senderCustomerEntityId: senderEntity.id, + // The raw token is kept while the invite is pending so the sender can re-copy the + // link; redemption looks up by hash only, and acceptance clears the raw token. + token, + tokenHash: hashInviteToken(token) + }); + + res.status(httpStatus.CREATED).json({ + alias: invitation.alias, + country: invitation.country, + createdAt: invitation.createdAt, + expiresAt: invitation.expiresAt, + id: invitation.id, + inviteeEmail: invitation.inviteeEmail, + inviteeType: invitation.inviteeType, + payoutCurrency: invitation.payoutCurrency, + rail: invitation.rail, + status: invitation.status, + token + }); + } catch (error) { + logger.error("Error creating recipient invite:", error); + sendError(res, httpStatus.INTERNAL_SERVER_ERROR, "INTERNAL_SERVER_ERROR", "Failed to create invite"); + } +} + +export async function acceptInvite(req: Request<{ token: string }>, res: Response): Promise { + const userId = requireUserId(req, res); + if (!userId) return; + + try { + const invitation = await RecipientInvitation.findOne({ where: { tokenHash: hashInviteToken(req.params.token) } }); + if (!invitation) { + sendError(res, httpStatus.NOT_FOUND, "INVITE_NOT_FOUND", "Invite not found"); + return; + } + // Re-entry: the recipient who accepted may reopen their link to resume onboarding — accepting + // is not a one-shot that burns the link. Anyone else still bounces off an accepted invite. + const isReEntry = invitation.status === "accepted" && invitation.acceptedByProfileId === userId; + + if (invitation.status === "accepted" && !isReEntry) { + sendError(res, httpStatus.CONFLICT, "INVITE_ALREADY_ACCEPTED", "Invite has already been accepted"); + return; + } + if (invitation.status === "revoked") { + sendError(res, httpStatus.GONE, "INVITE_REVOKED", "Invite has been revoked"); + return; + } + // Expiry closes a *pending* invite only. Once accepted, the relationship exists and KYC may run + // for days — an expiry passing mid-onboarding must not lock the recipient out of their own link. + if (invitation.status === "expired" || (!isReEntry && invitation.expiresAt && invitation.expiresAt < new Date())) { + if (invitation.status !== "expired") { + await invitation.update({ status: "expired", token: null }); + } + sendError(res, httpStatus.GONE, "INVITE_EXPIRED", "Invite has expired"); + return; + } + + // Token-bound redemption (plan D1): when the invite recorded an email, the redeeming + // account must additionally match it. + if (invitation.inviteeEmailCanonical && canonicalizeEmail(req.userEmail ?? "") !== invitation.inviteeEmailCanonical) { + sendError(res, httpStatus.FORBIDDEN, "INVITE_EMAIL_MISMATCH", "This invite is bound to a different email address"); + return; + } + + const senderEntity = await CustomerEntity.findByPk(invitation.senderCustomerEntityId); + if (!senderEntity) { + sendError(res, httpStatus.GONE, "INVITE_SENDER_GONE", "The sender of this invite no longer exists"); + return; + } + if (senderEntity.profileId === userId) { + sendError(res, httpStatus.CONFLICT, "CANNOT_ACCEPT_OWN_INVITE", "You cannot accept your own invite"); + return; + } + + const relationship = await sequelize.transaction(async transaction => { + // Re-read under a row lock and re-check acceptance: the status checks above ran on an + // unlocked read, so two users redeeming the same token concurrently could otherwise + // both pass them and each create an active relationship from one invite. + const lockedInvitation = await RecipientInvitation.findByPk(invitation.id, { + lock: transaction.LOCK.UPDATE, + transaction + }); + if (!lockedInvitation || lockedInvitation.status === "revoked") { + return "invite_gone" as const; + } + if (lockedInvitation.status === "accepted" && lockedInvitation.acceptedByProfileId !== userId) { + return "already_accepted" as const; + } + // The unlocked expiry check above can race the list sweep (which also writes `expired`); + // re-check under the lock so an invite the system already declared expired is never accepted. + if (lockedInvitation.status === "expired") { + return "expired" as const; + } + + // Business invitees onboard as a business entity (KYB); individuals reuse the + // profile's default entity. + const [recipientEntity] = await CustomerEntity.findOrCreate({ + defaults: { profileId: userId, status: "active", type: invitation.inviteeType }, + transaction, + where: { profileId: userId, type: invitation.inviteeType } + }); + + // Recomputed under the lock: a double-submit by the accepting user may have turned a + // pending invite into a re-entry between the unlocked pre-check and this transaction. + const reEntry = lockedInvitation.status === "accepted" && lockedInvitation.acceptedByProfileId === userId; + + const [row, created] = await SenderRecipient.findOrCreate({ + defaults: { + invitationId: invitation.id, + recipientCustomerEntityId: recipientEntity.id, + relationshipStatus: "active", + senderCustomerEntityId: invitation.senderCustomerEntityId + }, + transaction, + where: { + recipientCustomerEntityId: recipientEntity.id, + senderCustomerEntityId: invitation.senderCustomerEntityId + } + }); + + if (!created) { + if (row.relationshipStatus === "blocked") { + // The sender blocked this recipient; a new invite acceptance must not undo that. + return "blocked" as const; + } + // Re-entry reads the relationship, it does not revive it: the sender may have archived + // this recipient, and reopening the link must not silently undo that. + if (!reEntry) { + await row.update({ disabledAt: null, invitationId: invitation.id, relationshipStatus: "active" }, { transaction }); + } + } + + if (!reEntry) { + // Clearing the raw token bounds its at-rest exposure to pending invites; re-copy + // is only offered pre-acceptance and the recipient already holds the link. + await lockedInvitation.update( + { acceptedAt: new Date(), acceptedByProfileId: userId, status: "accepted", token: null }, + { transaction } + ); + } + return { reEntry, row }; + }); + + if (relationship === "invite_gone") { + sendError(res, httpStatus.GONE, "INVITE_REVOKED", "Invite has been revoked"); + return; + } + if (relationship === "already_accepted") { + sendError(res, httpStatus.CONFLICT, "INVITE_ALREADY_ACCEPTED", "Invite has already been accepted"); + return; + } + if (relationship === "expired") { + sendError(res, httpStatus.GONE, "INVITE_EXPIRED", "Invite has expired"); + return; + } + if (relationship === "blocked") { + sendError(res, httpStatus.CONFLICT, "RELATIONSHIP_BLOCKED", "The sender has blocked this relationship"); + return; + } + + const effectiveReEntry = relationship.reEntry; + + // Only a first acceptance is news to the sender; re-entry is the recipient resuming onboarding. + if (senderEntity.profileId && !effectiveReEntry) { + await emitNotification(senderEntity.profileId, { + customerEntityId: senderEntity.id, + metadata: { invitationId: invitation.id, senderRecipientId: relationship.row.id }, + title: "Your recipient invite was accepted", + type: "recipient_invite_accepted" + }); + } + + res.status(effectiveReEntry ? httpStatus.OK : httpStatus.CREATED).json({ + id: relationship.row.id, + invitation: { + country: invitation.country, + id: invitation.id, + inviteeType: invitation.inviteeType, + payoutCurrency: invitation.payoutCurrency, + rail: invitation.rail + }, + relationshipStatus: relationship.row.relationshipStatus + }); + } catch (error) { + logger.error("Error accepting recipient invite:", error); + sendError(res, httpStatus.INTERNAL_SERVER_ERROR, "INTERNAL_SERVER_ERROR", "Failed to accept invite"); + } +} + +export async function listRecipients(req: Request, res: Response): Promise { + const userId = requireUserId(req, res); + if (!userId) return; + + try { + const senderEntity = await getOrCreateCustomerEntityForProfile(userId); + + const relationships = await SenderRecipient.findAll({ + include: [ + { as: "recipient", model: CustomerEntity }, + { as: "invitation", model: RecipientInvitation }, + { as: "payoutReferences", model: RecipientPayoutReference } + ], + order: [["createdAt", "DESC"]], + // Archived relationships are hidden from the sender's list (archive = remove). + where: { relationshipStatus: { [Op.ne]: "archived" }, senderCustomerEntityId: senderEntity.id } + }); + + const recipients = await Promise.all( + relationships.map(async row => { + const invitation = row.get("invitation") as RecipientInvitation | null; + const recipient = row.get("recipient") as CustomerEntity | null; + const payoutReferences = (row.get("payoutReferences") as RecipientPayoutReference[] | undefined) ?? []; + + // Corridor-scoped onboarding summary for the list view; the eligibility + // endpoint remains the authoritative gate. + let onboardingStatus: "approved" | "pending" | "unknown" = "unknown"; + if (invitation && recipient) { + const provider = providerForRail(invitation.rail); + const providerCustomer = await ProviderCustomer.findOne({ + order: [["updatedAt", "DESC"]], + where: { + customerEntityId: recipient.id, + customerType: invitation.inviteeType, + provider, + ...(provider === "alfredpay" ? { country: invitation.country } : {}) + } + }); + onboardingStatus = providerCustomer + ? isProviderApproved(providerCustomer.status) + ? "approved" + : "pending" + : "pending"; + } + + return { + createdAt: row.createdAt, + id: row.id, + invitation: invitation + ? { + alias: invitation.alias, + country: invitation.country, + id: invitation.id, + inviteeEmail: invitation.inviteeEmail, + inviteeType: invitation.inviteeType, + payoutCurrency: invitation.payoutCurrency, + rail: invitation.rail + } + : null, + nickname: row.nickname, + onboardingStatus, + payoutReferences: payoutReferences.map(ref => ({ + currency: ref.currency, + id: ref.id, + instrumentType: ref.instrumentType, + maskedDisplayLabel: ref.maskedDisplayLabel, + rail: ref.rail, + status: ref.status + })), + recipientType: recipient?.type ?? "individual", + relationshipStatus: row.relationshipStatus + }; + }) + ); + + const now = new Date(); + await RecipientInvitation.update( + { status: "expired", token: null }, + { + where: { + expiresAt: { [Op.lt]: now }, + senderCustomerEntityId: senderEntity.id, + status: "pending" + } + } + ); + + // Expired rows stay visible (the sweep above cleared their token) so the sender sees why an + // invite stopped working and can re-invite or remove it; accepted rows surface as relationships. + const pendingInvitations = await RecipientInvitation.findAll({ + order: [["createdAt", "DESC"]], + where: { + archivedAt: null, + senderCustomerEntityId: senderEntity.id, + status: ["pending", "expired"] + } + }); + + res.status(httpStatus.OK).json({ + pendingInvitations: pendingInvitations.map(invitation => ({ + alias: invitation.alias, + country: invitation.country, + createdAt: invitation.createdAt, + expiresAt: invitation.expiresAt, + id: invitation.id, + inviteeEmail: invitation.inviteeEmail, + inviteeType: invitation.inviteeType, + isExpired: invitation.status === "expired" || Boolean(invitation.expiresAt && invitation.expiresAt < now), + payoutCurrency: invitation.payoutCurrency, + rail: invitation.rail, + // Raw token for sender re-copy; null for invites created before it was retained. + token: invitation.token + })), + recipients + }); + } catch (error) { + logger.error("Error listing recipients:", error); + sendError(res, httpStatus.INTERNAL_SERVER_ERROR, "INTERNAL_SERVER_ERROR", "Failed to list recipients"); + } +} + +interface UpdateRecipientBody { + nickname?: string; + status?: string; +} + +const PATCHABLE_STATUSES: SenderRecipientStatus[] = ["active", "blocked", "archived"]; + +export async function updateRecipient(req: Request<{ id: string }>, res: Response): Promise { + const userId = requireUserId(req, res); + if (!userId) return; + + const { nickname, status } = (req.body ?? {}) as UpdateRecipientBody; + + if (nickname !== undefined && (typeof nickname !== "string" || nickname.length > 100)) { + sendError(res, httpStatus.BAD_REQUEST, "INVALID_NICKNAME", "nickname must be a string of at most 100 characters"); + return; + } + if (status !== undefined && !PATCHABLE_STATUSES.includes(status as SenderRecipientStatus)) { + sendError(res, httpStatus.BAD_REQUEST, "INVALID_STATUS", "status must be one of: active, blocked, archived"); + return; + } + + try { + const senderEntity = await getOrCreateCustomerEntityForProfile(userId); + const relationship = await SenderRecipient.findOne({ + where: { id: req.params.id, senderCustomerEntityId: senderEntity.id } + }); + if (!relationship) { + sendError(res, httpStatus.NOT_FOUND, "RECIPIENT_NOT_FOUND", "Recipient not found"); + return; + } + + await relationship.update({ + ...(nickname !== undefined ? { nickname: nickname || null } : {}), + ...(status !== undefined + ? { + disabledAt: status === "active" ? null : new Date(), + relationshipStatus: status as SenderRecipientStatus + } + : {}) + }); + + res.status(httpStatus.OK).json({ + id: relationship.id, + nickname: relationship.nickname, + relationshipStatus: relationship.relationshipStatus + }); + } catch (error) { + logger.error("Error updating recipient:", error); + sendError(res, httpStatus.INTERNAL_SERVER_ERROR, "INTERNAL_SERVER_ERROR", "Failed to update recipient"); + } +} + +interface ArchiveInvitationBody { + archived?: boolean; +} + +// Sender-side list hide only — the invitation keeps its status and the token stays +// redeemable, so an archived invite never blocks the recipient's onboarding. +export async function archiveInvitation(req: Request<{ id: string }>, res: Response): Promise { + const userId = requireUserId(req, res); + if (!userId) return; + + const { archived } = (req.body ?? {}) as ArchiveInvitationBody; + if (typeof archived !== "boolean") { + sendError(res, httpStatus.BAD_REQUEST, "INVALID_ARCHIVED", "archived must be a boolean"); + return; + } + // Sequelize casts :id to uuid in SQL; a malformed id would otherwise surface as a 22P02 500. + if (!UUID_PATTERN.test(req.params.id)) { + sendError(res, httpStatus.NOT_FOUND, "INVITATION_NOT_FOUND", "Invitation not found"); + return; + } + + try { + const senderEntity = await getOrCreateCustomerEntityForProfile(userId); + const invitation = await RecipientInvitation.findOne({ + where: { id: req.params.id, senderCustomerEntityId: senderEntity.id } + }); + if (!invitation) { + sendError(res, httpStatus.NOT_FOUND, "INVITATION_NOT_FOUND", "Invitation not found"); + return; + } + + await invitation.update({ archivedAt: archived ? new Date() : null }); + + res.status(httpStatus.OK).json({ archived: Boolean(invitation.archivedAt), id: invitation.id }); + } catch (error) { + logger.error("Error archiving recipient invitation:", error); + sendError(res, httpStatus.INTERNAL_SERVER_ERROR, "INTERNAL_SERVER_ERROR", "Failed to archive invitation"); + } +} + +export async function getRecipientEligibility(req: Request<{ id: string }>, res: Response): Promise { + const userId = requireUserId(req, res); + if (!userId) return; + + try { + const senderEntity = await getOrCreateCustomerEntityForProfile(userId); + const relationship = await SenderRecipient.findOne({ + where: { id: req.params.id, senderCustomerEntityId: senderEntity.id } + }); + if (!relationship) { + sendError(res, httpStatus.NOT_FOUND, "RECIPIENT_NOT_FOUND", "Recipient not found"); + return; + } + + const eligibility = await getTransferEligibility(relationship); + res.status(httpStatus.OK).json(eligibility); + } catch (error) { + logger.error("Error computing recipient eligibility:", error); + sendError(res, httpStatus.INTERNAL_SERVER_ERROR, "INTERNAL_SERVER_ERROR", "Failed to compute eligibility"); + } +} diff --git a/apps/api/src/api/controllers/userApiKeys.controller.test.ts b/apps/api/src/api/controllers/userApiKeys.controller.test.ts index 4269157f0..40856e743 100644 --- a/apps/api/src/api/controllers/userApiKeys.controller.test.ts +++ b/apps/api/src/api/controllers/userApiKeys.controller.test.ts @@ -75,8 +75,8 @@ describe("revokeUserApiKey", () => { } const expectedPairUpdates = [ - { changes: { isActive: false }, id: "secret-key-id" }, - { changes: { isActive: false }, id: "public-key-id" } + { changes: { isActive: false, revokedAt: expect.any(Date) }, id: "secret-key-id" }, + { changes: { isActive: false, revokedAt: expect.any(Date) }, id: "public-key-id" } ]; it("revokes default-named public and secret keys as one pair via pairedKeyId", async () => { diff --git a/apps/api/src/api/controllers/userApiKeys.controller.ts b/apps/api/src/api/controllers/userApiKeys.controller.ts index 7b6d1fae8..49405b03f 100644 --- a/apps/api/src/api/controllers/userApiKeys.controller.ts +++ b/apps/api/src/api/controllers/userApiKeys.controller.ts @@ -91,6 +91,7 @@ export async function createUserApiKey(req: Request, res: Response): Promise, res: Res } if (!otherKeyId) { - await primaryKey.update({ isActive: false }); + await primaryKey.update({ isActive: false, revokedAt: new Date() }); res.status(httpStatus.NO_CONTENT).send(); return; } @@ -302,7 +304,8 @@ export async function revokeUserApiKey(req: Request<{ keyId: string }>, res: Res return; } - await Promise.all([primaryKey.update({ isActive: false }), pairedKey.update({ isActive: false })]); + const revokedAt = new Date(); + await Promise.all([primaryKey.update({ isActive: false, revokedAt }), pairedKey.update({ isActive: false, revokedAt })]); res.status(httpStatus.NO_CONTENT).send(); } catch (error) { logger.error("Error revoking user API key:", error); diff --git a/apps/api/src/api/middlewares/apiKeyAuth.helpers.test.ts b/apps/api/src/api/middlewares/apiKeyAuth.helpers.test.ts index 56d8e4fe8..e2571a14c 100644 --- a/apps/api/src/api/middlewares/apiKeyAuth.helpers.test.ts +++ b/apps/api/src/api/middlewares/apiKeyAuth.helpers.test.ts @@ -17,8 +17,9 @@ const originalPartnerFindOne = Partner.findOne; function createSecretKeyRecord({ userId = null, - partnerName = "TestPartner" -}: { userId?: string | null; partnerName?: string | null } = {}): ApiKey & { raw: string } { + partnerId = "partner-id", + partnerName = null +}: { userId?: string | null; partnerId?: string | null; partnerName?: string | null } = {}): ApiKey & { raw: string } { const secret = generateApiKey("secret", "test"); const secretHash = bcrypt.hashSync(secret, 4); const record = Object.assign(new ApiKey(), { @@ -27,6 +28,7 @@ function createSecretKeyRecord({ keyHash: secretHash, keyPrefix: getKeyPrefix(secret), keyType: "secret" as const, + partnerId, partnerName, raw: secret, userId @@ -45,7 +47,7 @@ describe("validateSecretApiKey - apiKeyUserId propagation", () => { }); it("returns apiKeyId and apiKeyUserId with a partner for partner-scoped keys", async () => { - const key = createSecretKeyRecord({userId: "user-bound", partnerName: "TestPartner"}); + const key = createSecretKeyRecord({userId: "user-bound", partnerId: "partner-id"}); ApiKey.findAll = mock( async () => [key as unknown as ApiKey] ) as typeof ApiKey.findAll; @@ -63,7 +65,7 @@ describe("validateSecretApiKey - apiKeyUserId propagation", () => { }); it("returns apiKeyUserId = null for an unlinked partner-scoped key", async () => { - const key = createSecretKeyRecord({userId: null, partnerName: "TestPartner"}); + const key = createSecretKeyRecord({userId: null, partnerId: "partner-id"}); ApiKey.findAll = mock( async () => [key as unknown as ApiKey] ) as typeof ApiKey.findAll; @@ -77,8 +79,8 @@ describe("validateSecretApiKey - apiKeyUserId propagation", () => { expect(result?.partner).not.toBeNull(); }); - it("returns partner=null for a user-scoped key (no partnerName, userId set)", async () => { - const key = createSecretKeyRecord({userId: "user-scoped", partnerName: null}); + it("returns partner=null for a user-scoped key (no partnerId, userId set)", async () => { + const key = createSecretKeyRecord({userId: "user-scoped", partnerId: null}); ApiKey.findAll = mock( async () => [key as unknown as ApiKey] ) as typeof ApiKey.findAll; @@ -94,8 +96,20 @@ describe("validateSecretApiKey - apiKeyUserId propagation", () => { expect(Partner.findOne).toHaveBeenCalledTimes(0); }); - it("returns null for a key with no partnerName and no userId (unusable)", async () => { - const key = createSecretKeyRecord({userId: null, partnerName: null}); + it("returns null for a key with no partnerId and no userId (unusable)", async () => { + const key = createSecretKeyRecord({userId: null, partnerId: null}); + ApiKey.findAll = mock( + async () => [key as unknown as ApiKey] + ) as typeof ApiKey.findAll; + + const result = await validateSecretApiKey(key.raw); + expect(result).toBeNull(); + }); + + it("rejects an orphaned partner key instead of degrading it into a user-scoped key", async () => { + // Deleting a partner row sets partner_id NULL (FK ON DELETE SET NULL) but keeps + // partner_name — such a key is revoked, even when it carries a linked user. + const key = createSecretKeyRecord({partnerId: null, partnerName: "DeletedPartner", userId: "user-bound"}); ApiKey.findAll = mock( async () => [key as unknown as ApiKey] ) as typeof ApiKey.findAll; diff --git a/apps/api/src/api/middlewares/apiKeyAuth.helpers.ts b/apps/api/src/api/middlewares/apiKeyAuth.helpers.ts index d799bf97b..cb1f58f70 100644 --- a/apps/api/src/api/middlewares/apiKeyAuth.helpers.ts +++ b/apps/api/src/api/middlewares/apiKeyAuth.helpers.ts @@ -11,8 +11,8 @@ export interface AuthenticatedPartner { /** * Validation result for a secret API key. `partner` may be null for user-scoped - * keys (created via the self-serve API key endpoints) which may have no - * `partner_name` binding; in that case the request authenticates purely as + * keys (created via the self-serve API key endpoints) which have no + * `partner_id` binding; in that case the request authenticates purely as * the linked user via `apiKeyUserId`. */ export interface ValidatedSecretKey { @@ -122,7 +122,24 @@ export async function validatePublicApiKey(apiKey: string): Promise s } }; +/** + * Alfredpay refuses to finalize a KYB submission missing any of these (`110002 "Invalid field(s)"`), + * and it only says so at `sendKybSubmission` — after the documents have been uploaded. Rejecting the + * incomplete payload here keeps that failure at the point of entry, and keeps the contract enforced + * for callers that are not our own KYB wizard. Mirrors GET …/penny/kybRequirements?country=, + * including its two `requiredIf` branches. + */ +const validateKybQuestionnaire = (body: SubmitKybInformationRequest): string | null => { + const requiredText: Array = [ + "walletAddresses", + "sourceOfFunds", + "businessActivities", + "accountPurpose" + ]; + for (const field of requiredText) { + const value = body[field]; + if (typeof value !== "string" || !value.trim()) return `${field} is required`; + } + + const requiredBooleans: Array = [ + "transmitsCustomerFunds", + "operatesInSanctionedCountries", + "isRegulatedBusiness" + ]; + for (const field of requiredBooleans) { + if (typeof body[field] !== "boolean") return `${field} is required`; + } + + for (const field of ["expectedMonthlyVolumeUsd", "expectedMonthlyTransactions"] as const) { + const value = body[field]; + if (typeof value !== "number" || !Number.isFinite(value) || value < 0) return `${field} must be a non-negative number`; + } + if (!Number.isInteger(body.expectedMonthlyTransactions)) return "expectedMonthlyTransactions must be a whole number"; + + if (body.transmitsCustomerFunds && typeof body.conductsComplianceScreening !== "boolean") { + return "conductsComplianceScreening is required when transmitting customer funds"; + } + if (body.conductsComplianceScreening && !body.complianceScreeningDescription?.trim()) { + return "complianceScreeningDescription is required when conducting compliance screening"; + } + return null; +}; + +export const validateKybSubmission: RequestHandler = (req, res, next) => { + const error = validateKybQuestionnaire(req.body as SubmitKybInformationRequest); + if (error) { + next(new APIError({ errors: [{ message: error }], message: error, status: httpStatus.BAD_REQUEST })); + return; + } + next(); +}; + export const validateKycSubmission: RequestHandler = (req, res, next) => { const body = req.body as SubmitKycInformationRequest; const validator = countryValidators[body.country]; diff --git a/apps/api/src/api/routes/v1/alfredpay.route.ts b/apps/api/src/api/routes/v1/alfredpay.route.ts index 5ff0abb55..2a04cd0c6 100644 --- a/apps/api/src/api/routes/v1/alfredpay.route.ts +++ b/apps/api/src/api/routes/v1/alfredpay.route.ts @@ -4,7 +4,7 @@ import { AlfredpayController } from "../../controllers/alfredpay.controller"; import { validateResultCountry } from "../../middlewares/alfredpay.middleware"; import { requirePartnerOrUserAuth } from "../../middlewares/dualAuth"; import { requireAuth } from "../../middlewares/supabaseAuth"; -import { validateKycSubmission } from "../../middlewares/validators"; +import { validateKybSubmission, validateKycSubmission } from "../../middlewares/validators"; const router = Router(); const upload = multer({ limits: { fileSize: 5 * 1024 * 1024 }, storage: multer.memoryStorage() }); @@ -31,7 +31,13 @@ router.post("/submitKycFile", requireAuth, upload.single("file"), validateResult router.post("/sendKycSubmission", requireAuth, validateResultCountry, AlfredpayController.sendKycSubmission); // Business API-based KYB -router.post("/submitKybInformation", requireAuth, validateResultCountry, AlfredpayController.submitKybInformation); +router.post( + "/submitKybInformation", + requireAuth, + validateResultCountry, + validateKybSubmission, + AlfredpayController.submitKybInformation +); router.post("/submitKybFile", requireAuth, upload.single("file"), validateResultCountry, AlfredpayController.submitKybFile); router.get("/findKybCustomerAndBusiness", requireAuth, validateResultCountry, AlfredpayController.findKybCustomerAndBusiness); router.post( diff --git a/apps/api/src/api/routes/v1/index.ts b/apps/api/src/api/routes/v1/index.ts index 2d5c50d17..91f21bc09 100644 --- a/apps/api/src/api/routes/v1/index.ts +++ b/apps/api/src/api/routes/v1/index.ts @@ -15,13 +15,17 @@ import emailRoutes from "./email.route"; import fiatRoutes from "./fiat.route"; import maintenanceRoutes from "./maintenance.route"; import metricsRoutes from "./metrics.route"; +import moneriumRoutes from "./monerium.route"; import mykoboRoutes from "./mykobo.route"; +import notificationsRoutes from "./notifications.route"; +import onboardingRoutes from "./onboarding.route"; import paymentMethodsRoutes from "./payment-methods.route"; import priceRoutes from "./price.route"; import publicKeyRoutes from "./public-key.route"; import quoteRoutes from "./quote.route"; import rampRoutes from "./ramp.route"; import ratingRoutes from "./rating.route"; +import recipientsRoutes from "./recipients.route"; import sessionRoutes from "./session.route"; import siweRoutes from "./siwe.route"; import storageRoutes from "./storage.route"; @@ -152,6 +156,11 @@ router.use("/alfredpay", alfredpayRoutes); */ router.use("/mykobo", mykoboRoutes); +/** + * Server-side Monerium OAuth and KYC/KYB status synchronization. + */ +router.use("/monerium", moneriumRoutes); + /** * POST v1/webhook * DELETE v1/webhook @@ -168,6 +177,31 @@ router.use("/public-key", publicKeyRoutes); */ router.use("/metrics", metricsRoutes); +/** + * Recipient invites, relationships and transfer eligibility for authenticated senders. + * POST /v1/recipients/invite + * POST /v1/recipients/invite/:token/accept + * GET /v1/recipients + * PATCH /v1/recipients/:id + * GET /v1/recipients/:id/eligibility + */ +router.use("/recipients", recipientsRoutes); + +/** + * In-app notification feed and preferences for authenticated users. + * GET /v1/notifications + * POST /v1/notifications/:id/read + * POST /v1/notifications/read-all + * GET/PUT /v1/notifications/preferences + */ +router.use("/notifications", notificationsRoutes); + +/** + * Aggregated onboarding status over provider accounts + KYC cases. + * GET /v1/onboarding/status + */ +router.use("/onboarding", onboardingRoutes); + /** * Self-serve API key management for authenticated Supabase users. * Keys created here are user-scoped (no partner binding) and authenticate diff --git a/apps/api/src/api/routes/v1/monerium.route.ts b/apps/api/src/api/routes/v1/monerium.route.ts new file mode 100644 index 000000000..bbdc6b86f --- /dev/null +++ b/apps/api/src/api/routes/v1/monerium.route.ts @@ -0,0 +1,12 @@ +import { Router } from "express"; +import * as moneriumController from "../../controllers/monerium.controller"; +import { requireAuth } from "../../middlewares/supabaseAuth"; + +const router: Router = Router({ mergeParams: true }); + +router.use(requireAuth); +router.post("/oauth/start", moneriumController.start); +router.post("/oauth/complete", moneriumController.complete); +router.get("/status", moneriumController.status); + +export default router; diff --git a/apps/api/src/api/routes/v1/notifications.route.ts b/apps/api/src/api/routes/v1/notifications.route.ts new file mode 100644 index 000000000..31d8f97e8 --- /dev/null +++ b/apps/api/src/api/routes/v1/notifications.route.ts @@ -0,0 +1,41 @@ +import { Request, Response, Router } from "express"; +import { + getNotificationPreferences, + listNotifications, + markAllNotificationsRead, + markNotificationRead, + updateNotificationPreferences +} from "../../controllers/notifications.controller"; +import { requireAuth } from "../../middlewares/supabaseAuth"; + +const router: Router = Router({ mergeParams: true }); + +router.use(requireAuth); + +/** + * GET /v1/notifications?limit=&before= + * Newest-first feed plus the unread count. + */ +router.get("/", listNotifications as unknown as (req: Request, res: Response) => void); + +/** + * GET /v1/notifications/preferences + */ +router.get("/preferences", getNotificationPreferences as unknown as (req: Request, res: Response) => void); + +/** + * PUT /v1/notifications/preferences + */ +router.put("/preferences", updateNotificationPreferences as unknown as (req: Request, res: Response) => void); + +/** + * POST /v1/notifications/read-all + */ +router.post("/read-all", markAllNotificationsRead as unknown as (req: Request, res: Response) => void); + +/** + * POST /v1/notifications/:id/read + */ +router.post("/:id/read", markNotificationRead as unknown as (req: Request<{ id: string }>, res: Response) => void); + +export default router; diff --git a/apps/api/src/api/routes/v1/onboarding.route.ts b/apps/api/src/api/routes/v1/onboarding.route.ts new file mode 100644 index 000000000..4719c20b7 --- /dev/null +++ b/apps/api/src/api/routes/v1/onboarding.route.ts @@ -0,0 +1,16 @@ +import { Request, Response, Router } from "express"; +import { getOnboardingStatus, putActiveEntity } from "../../controllers/onboarding.controller"; +import { requireAuth } from "../../middlewares/supabaseAuth"; + +const router: Router = Router({ mergeParams: true }); + +router.use(requireAuth); + +/** + * GET /v1/onboarding/status + * Aggregated per-entity provider/KYC onboarding status for the authenticated profile. + */ +router.get("/status", getOnboardingStatus as unknown as (req: Request, res: Response) => void); +router.put("/active-entity", putActiveEntity as unknown as (req: Request, res: Response) => void); + +export default router; diff --git a/apps/api/src/api/routes/v1/recipients.route.ts b/apps/api/src/api/routes/v1/recipients.route.ts new file mode 100644 index 000000000..c561a896e --- /dev/null +++ b/apps/api/src/api/routes/v1/recipients.route.ts @@ -0,0 +1,53 @@ +import { Request, Response, Router } from "express"; +import { + acceptInvite, + archiveInvitation, + createInvite, + getRecipientEligibility, + listRecipients, + updateRecipient +} from "../../controllers/recipients.controller"; +import { requireAuth } from "../../middlewares/supabaseAuth"; + +const router: Router = Router({ mergeParams: true }); + +router.use(requireAuth); + +/** + * POST /v1/recipients/invite + * Create a recipient invite for the authenticated sender; returns the raw link token once. + */ +router.post("/invite", createInvite as unknown as (req: Request, res: Response) => void); + +/** + * POST /v1/recipients/invite/:token/accept + * Recipient (authenticated) redeems the link token; creates the sender↔recipient relationship. + */ +router.post("/invite/:token/accept", acceptInvite as unknown as (req: Request<{ token: string }>, res: Response) => void); + +/** + * GET /v1/recipients + * List the sender's recipients (relationship + onboarding status) and pending invitations. + */ +router.get("/", listRecipients as unknown as (req: Request, res: Response) => void); + +/** + * PATCH /v1/recipients/invitations/:id + * Archive/unarchive a pending invitation — a sender-side list hide, not a revocation: + * the token stays redeemable. + */ +router.patch("/invitations/:id", archiveInvitation as unknown as (req: Request<{ id: string }>, res: Response) => void); + +/** + * PATCH /v1/recipients/:id + * Update nickname or relationship status (active | blocked | archived). + */ +router.patch("/:id", updateRecipient as unknown as (req: Request<{ id: string }>, res: Response) => void); + +/** + * GET /v1/recipients/:id/eligibility + * Transfer gate: { canCreateTransfer, blockingReasonCode? }. + */ +router.get("/:id/eligibility", getRecipientEligibility as unknown as (req: Request<{ id: string }>, res: Response) => void); + +export default router; diff --git a/apps/api/src/api/services/alfredpay/alfredpay-customer.service.ts b/apps/api/src/api/services/alfredpay/alfredpay-customer.service.ts new file mode 100644 index 000000000..dd7019a20 --- /dev/null +++ b/apps/api/src/api/services/alfredpay/alfredpay-customer.service.ts @@ -0,0 +1,315 @@ +import { + AlfredPayCountry, + AlfredPayStatus, + AlfredpayApiService, + AlfredpayCustomerType, + AlfredpayKycStatus +} from "@vortexfi/shared"; +import logger from "../../../config/logger"; +import KycCase from "../../../models/kycCase.model"; +import ProviderCustomer, { ProviderCustomerType, VerificationStatus } from "../../../models/providerCustomer.model"; +import { getOrCreateCustomerEntityForProfile } from "../customer-entity.service"; + +export function alfredpayTypeToCustomerType(type: AlfredpayCustomerType): ProviderCustomerType { + return type === AlfredpayCustomerType.BUSINESS ? "business" : "individual"; +} + +export function customerTypeToAlfredpayType(customerType: ProviderCustomerType): AlfredpayCustomerType { + return customerType === "business" ? AlfredpayCustomerType.BUSINESS : AlfredpayCustomerType.INDIVIDUAL; +} + +const COUNTRY_RAIL: Record = { + AR: "ars", + BO: "bob", + BR: "brl", + CL: "clp", + CN: "cny", + CO: "cop", + DO: "dop", + HK: "hkd", + MX: "mxn", + PE: "pen", + US: "usd" +}; + +/** + * Legacy-shaped view over an alfredpay `provider_customers` row: exposes the pre-cutover + * alfredpay_customers field names so the controller's status machine reads verbatim, and + * mirrors status transitions into the account's kyc_case. + */ +export interface AlfredpayCustomerView { + alfredPayId: string; + country: AlfredPayCountry; + type: AlfredpayCustomerType; + status: AlfredPayStatus; + lastFailureReasons: string[] | null; + createdAt: Date; + updatedAt: Date; + update( + changes: Partial<{ + status: AlfredPayStatus; + verificationStatus: VerificationStatus; + statusExternal: string | null; + lastFailureReasons: string[]; + // Latest Alfredpay submission id — persisted on the kyc_case (providerCaseId), not on + // provider_customers, so a pending submission can be resumed/updated after the wizard closes. + providerCaseId: string; + }> + ): Promise; +} + +function toVerificationStatus(status: AlfredPayStatus): VerificationStatus { + switch (status) { + case AlfredPayStatus.Success: + return VerificationStatus.Approved; + case AlfredPayStatus.Failed: + return VerificationStatus.Rejected; + case AlfredPayStatus.UserCompleted: + case AlfredPayStatus.Verifying: + return VerificationStatus.InReview; + case AlfredPayStatus.Consulted: + case AlfredPayStatus.LinkOpened: + case AlfredPayStatus.UpdateRequired: + return VerificationStatus.Started; + default: + return VerificationStatus.Pending; + } +} + +/** + * Alfredpay reports status casing inconsistently (the sandbox KYB status endpoint returns + * lowercase "pending"); normalize to the uppercase enum vocabulary before comparing or storing. + */ +export function normalizeAlfredpayProviderStatus(status: string): AlfredpayKycStatus { + return status?.toUpperCase() as AlfredpayKycStatus; +} + +/** + * Maps a decisive Alfredpay status string — a fresh submission status or a stored `statusExternal` — + * to our AlfredPayStatus. KYC and KYB share this vocabulary. CREATED and anything not yet decided + * return null so callers can leave a stored status untouched. Mirrors AlfredpayController.mapKycStatus. + */ +function providerStatusToAlfredPayStatus(status: string | null): AlfredPayStatus | null { + switch (status?.toUpperCase()) { + case "IN_REVIEW": + return AlfredPayStatus.Verifying; + case "FAILED": + return AlfredPayStatus.Failed; + case "COMPLETED": + return AlfredPayStatus.Success; + case "UPDATE_REQUIRED": + return AlfredPayStatus.UpdateRequired; + default: + return null; + } +} + +function toAlfredPayStatus(record: ProviderCustomer): AlfredPayStatus { + const decided = providerStatusToAlfredPayStatus(record.statusExternal); + if (decided) return decided; + if (record.statusExternal?.toUpperCase() === "CREATED") return AlfredPayStatus.Consulted; + if (record.status === VerificationStatus.Approved) return AlfredPayStatus.Success; + if (record.status === VerificationStatus.Rejected) return AlfredPayStatus.Failed; + if (record.status === VerificationStatus.InReview) return AlfredPayStatus.UserCompleted; + if (record.status === VerificationStatus.Started) return AlfredPayStatus.Consulted; + return AlfredPayStatus.Consulted; +} + +async function syncKycCase(record: ProviderCustomer, providerCaseId?: string): Promise { + const lifecycle = { + ...(record.status === VerificationStatus.Approved ? { approvedAt: new Date(), rejectedAt: null } : {}), + ...(record.status === VerificationStatus.Rejected ? { approvedAt: null, rejectedAt: new Date() } : {}) + }; + const existing = await KycCase.findOne({ where: { providerCustomerId: record.id } }); + if (existing) { + await existing.update({ + failureReasons: record.lastFailureReasons ?? [], + status: record.status, + statusExternal: record.statusExternal, + ...(providerCaseId ? { providerCaseId } : {}), + ...lifecycle + }); + return; + } + await KycCase.create({ + customerEntityId: record.customerEntityId, + failureReasons: record.lastFailureReasons ?? [], + level: "level_1", + provider: "alfredpay", + providerCaseId: providerCaseId ?? null, + providerCustomerId: record.id, + status: record.status, + statusExternal: record.statusExternal, + type: record.customerType === "business" ? "kyb" : "kyc", + ...lifecycle + }); +} + +function toView(record: ProviderCustomer): AlfredpayCustomerView { + return { + alfredPayId: record.providerCustomerId ?? "", + country: record.country as AlfredPayCountry, + createdAt: record.createdAt, + lastFailureReasons: record.lastFailureReasons, + status: toAlfredPayStatus(record), + type: customerTypeToAlfredpayType(record.customerType), + async update(changes) { + await record.update({ + ...(changes.verificationStatus !== undefined + ? { status: changes.verificationStatus } + : changes.status !== undefined + ? { status: toVerificationStatus(changes.status) } + : {}), + ...(changes.statusExternal !== undefined ? { statusExternal: changes.statusExternal } : {}), + ...(changes.lastFailureReasons !== undefined ? { lastFailureReasons: changes.lastFailureReasons } : {}) + }); + this.status = changes.status ?? toAlfredPayStatus(record); + this.lastFailureReasons = record.lastFailureReasons; + if ( + changes.status !== undefined || + changes.verificationStatus !== undefined || + changes.statusExternal !== undefined || + changes.providerCaseId !== undefined + ) { + await syncKycCase(record, changes.providerCaseId); + } + }, + updatedAt: record.updatedAt + }; +} + +/** + * Latest alfredpay account for (user, country[, type]) — reproduces the legacy + * updatedAt-DESC tie-break across a user's individual/business rows. + */ +export async function findAlfredpayCustomer( + userId: string, + country: AlfredPayCountry, + type?: AlfredpayCustomerType +): Promise { + const entity = await getOrCreateCustomerEntityForProfile(userId, type ? alfredpayTypeToCustomerType(type) : undefined); + const record = await ProviderCustomer.findOne({ + order: [["updatedAt", "DESC"]], + where: { + country, + customerEntityId: entity.id, + provider: "alfredpay", + ...(type ? { customerType: alfredpayTypeToCustomerType(type) } : {}) + } + }); + return record ? toView(record) : null; +} + +/** + * Latest Alfredpay KYB submission id for a business customer. Reconciles the locally persisted id + * with the provider's last-submission and business-details responses, while retaining the local id + * as a recovery fallback whenever provider discovery yields no ids — an empty last-submission + * response is not authoritative (the sandbox answers `{}` even while a submission exists). + */ +export async function resolveAlfredpayKybSubmissionId(alfredPayId: string): Promise { + const service = AlfredpayApiService.getInstance(); + const customer = await ProviderCustomer.findOne({ + where: { provider: "alfredpay", providerCustomerId: alfredPayId } + }); + const persistedSubmissionId = customer + ? ( + await KycCase.findOne({ + order: [["updatedAt", "DESC"]], + where: { providerCustomerId: customer.id } + }) + )?.providerCaseId + : null; + const providerSubmissionIds: string[] = []; + + try { + const lastSubmission = await service.getLastKybSubmission(alfredPayId); + if (lastSubmission?.submissionId) { + providerSubmissionIds.push(lastSubmission.submissionId); + if (!persistedSubmissionId || persistedSubmissionId === lastSubmission.submissionId) { + return lastSubmission.submissionId; + } + } + } catch { + // Fall through to the details lookup. + } + try { + const details = await service.getKybBusinessDetails(alfredPayId); + providerSubmissionIds.push(...details.flatMap(business => (business.submissionId ? [business.submissionId] : []))); + } catch { + // Use the successfully returned provider ids, or the persisted id if discovery yielded none. + } + + if (persistedSubmissionId && providerSubmissionIds.includes(persistedSubmissionId)) { + return persistedSubmissionId; + } + return providerSubmissionIds[0] ?? persistedSubmissionId ?? undefined; +} + +/** + * Refreshes a stored Alfredpay account against the provider so an outcome that lands after the KYC + * wizard was closed (e.g. the provider approving an already-submitted customer) is reflected by the + * onboarding-status aggregator without the user reopening the flow. Best-effort: a provider failure + * or a customer with no submission yet leaves the stored status untouched so aggregation keeps + * serving. The linked kyc_case is synced through the shared view. + */ +export async function refreshAlfredpayCustomerStatus(record: ProviderCustomer): Promise { + const view = toView(record); + const isBusiness = record.customerType === "business"; + try { + const service = AlfredpayApiService.getInstance(); + const submissionId = isBusiness + ? await resolveAlfredpayKybSubmissionId(view.alfredPayId) + : (await service.getLastKycSubmission(view.alfredPayId))?.submissionId; + if (!submissionId) { + return; + } + const statusResponse = isBusiness + ? await service.getKybStatus(view.alfredPayId, submissionId) + : await service.getKycStatus(view.alfredPayId, submissionId); + const providerStatus = normalizeAlfredpayProviderStatus(statusResponse.status); + const mapped = providerStatusToAlfredPayStatus(providerStatus); + if (!mapped) { + // PENDING = a submission that exists but was never finalized (or carried invalid data). It is + // not a rejection: surface it as our Pending so the dashboard offers Continue, and keep the + // submission id so the retry updates it in place (Alfredpay refuses a fresh POST meanwhile). + if (providerStatus === AlfredpayKycStatus.PENDING) { + await view.update({ + providerCaseId: submissionId, + statusExternal: providerStatus, + verificationStatus: VerificationStatus.Pending + }); + } + return; + } + await view.update({ + providerCaseId: submissionId, + status: mapped, + statusExternal: providerStatus, + ...(mapped === AlfredPayStatus.Failed && statusResponse.metadata?.failureReason + ? { lastFailureReasons: [statusResponse.metadata.failureReason] } + : {}) + }); + } catch (error) { + // Keep the stored status if the provider is unavailable or has no submission yet. + logger.info(`Skipping Alfredpay status refresh for customer ${record.id}: ${error}`); + } +} + +export async function createAlfredpayCustomer( + userId: string, + values: { alfredPayId: string; country: AlfredPayCountry; status: AlfredPayStatus; type: AlfredpayCustomerType } +): Promise { + const customerType = alfredpayTypeToCustomerType(values.type); + const entity = await getOrCreateCustomerEntityForProfile(userId, customerType); + const record = await ProviderCustomer.create({ + country: values.country, + customerEntityId: entity.id, + customerType, + provider: "alfredpay", + providerCustomerId: values.alfredPayId, + rail: COUNTRY_RAIL[values.country] ?? null, + status: toVerificationStatus(values.status) + }); + await syncKycCase(record); + return toView(record); +} 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 new file mode 100644 index 000000000..389bc346e --- /dev/null +++ b/apps/api/src/api/services/alfredpay/alfredpay-limits.service.test.ts @@ -0,0 +1,66 @@ +import { afterAll, describe, expect, test } from "bun:test"; +import { + AlfredpayApiService, + type AlfredpayConfigPair, + AlfredpayCustomerType, + FiatToken, + type GetAllConfigsResponse, + RampDirection +} from "@vortexfi/shared"; +import { AlfredpayLimitsService } from "./alfredpay-limits.service"; + +/** + * The live /allConfigs listing contains junk rows: `decimals` null or "", even a null + * `fromCurrency` (observed 2026-07-14). Regression test: such rows must be skipped, not + * indexed — Number(null) is 0, and a customer-specific null-decimals row would otherwise + * override the valid wildcard row and shrink the raw limits by 10^decimals. + */ + +function pair(overrides: Partial): AlfredpayConfigPair { + return { + businessId: null, + createdAt: "2026-01-01T00:00:00Z", + decimals: "2", + fromCurrency: "MXN", + id: "pair", + maxQuantity: "170799.99", + minQuantity: "50.00", + toCurrency: "USDC", + typeCustomer: null, + updatedAt: "2026-01-01T00:00:00Z", + ...overrides + }; +} + +const configsResponse: GetAllConfigsResponse = { + supportedPairs: [ + // The only trustworthy row: wildcard MXN -> USDC with explicit decimals. + pair({ id: "valid-wildcard" }), + // Junk rows as served live; the INDIVIDUAL one would take precedence if indexed. + pair({ decimals: null, id: "junk-individual", typeCustomer: AlfredpayCustomerType.INDIVIDUAL }), + pair({ decimals: null, id: "junk-wildcard" }), + pair({ decimals: "", id: "junk-empty-decimals" }), + pair({ decimals: null, fromCurrency: null, id: "junk-null-currency" }), + // Oversized decimals would make Big(10).pow throw and abort the whole refresh. + pair({ decimals: "999999999999999999999999", id: "junk-huge-decimals", typeCustomer: AlfredpayCustomerType.INDIVIDUAL }) + ] +}; + +const originalGetInstance = AlfredpayApiService.getInstance; +afterAll(() => { + AlfredpayApiService.getInstance = originalGetInstance; +}); + +describe("AlfredpayLimitsService.refresh", () => { + test("indexes only rows with digit-string decimals; junk rows never shadow valid ones", async () => { + AlfredpayApiService.getInstance = () => + ({ getAllConfigs: async () => configsResponse }) as unknown as AlfredpayApiService; + + const service = new (AlfredpayLimitsService as unknown as { new (): AlfredpayLimitsService })(); + await (service as unknown as { refresh(): Promise }).refresh(); + + // From the valid wildcard row: 50.00 / 170799.99 scaled by 10^2 — not 10^0. + const limits = service.getLimits(FiatToken.MXN, "USDC", AlfredpayCustomerType.INDIVIDUAL, RampDirection.BUY); + expect(limits).toEqual({ maxRaw: "17079999", minRaw: "5000" }); + }); +}); 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 ab9931583..e952b0ce7 100644 --- a/apps/api/src/api/services/alfredpay/alfredpay-limits.service.ts +++ b/apps/api/src/api/services/alfredpay/alfredpay-limits.service.ts @@ -118,8 +118,13 @@ 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); - if (!Number.isFinite(decimals)) return; const axes = this.deriveAxes(pair); if (!axes) return; @@ -142,6 +147,7 @@ export class AlfredpayLimitsService { } private deriveAxes(pair: AlfredpayConfigPair): DerivedAxes | null { + if (!pair.fromCurrency) return null; const fromFiat = ALFREDPAY_FIATS[pair.fromCurrency]; const toFiat = ALFREDPAY_FIATS[pair.toCurrency]; diff --git a/apps/api/src/api/services/alfredpay/alfredpay.helpers.ts b/apps/api/src/api/services/alfredpay/alfredpay.helpers.ts index 7a241ccae..a88a270b1 100644 --- a/apps/api/src/api/services/alfredpay/alfredpay.helpers.ts +++ b/apps/api/src/api/services/alfredpay/alfredpay.helpers.ts @@ -11,9 +11,10 @@ import { } from "@vortexfi/shared"; import Big from "big.js"; import { Op } from "sequelize"; -import AlfredPayCustomer from "../../../models/alfredPayCustomer.model"; +import ProviderCustomer from "../../../models/providerCustomer.model"; import QuoteTicket from "../../../models/quoteTicket.model"; import RampState from "../../../models/rampState.model"; +import { getOrCreateCustomerEntityForProfile } from "../customer-entity.service"; import { multiplyByPowerOfTen } from "../pendulum/helpers"; import { AlfredpayLimitsService } from "./alfredpay-limits.service"; @@ -39,8 +40,13 @@ export async function lookupAlfredpayCustomerType(userId: string | undefined, fi if (!userId) return AlfredpayCustomerType.INDIVIDUAL; const country = alfredpayCountryForFiat(fiat); if (!country) return AlfredpayCustomerType.INDIVIDUAL; - const customer = await AlfredPayCustomer.findOne({ order: [["type", "ASC"]], where: { country, userId } }); - return customer?.type === AlfredpayCustomerType.BUSINESS ? AlfredpayCustomerType.BUSINESS : AlfredpayCustomerType.INDIVIDUAL; + const entity = await getOrCreateCustomerEntityForProfile(userId); + // customer_type ASC keeps the legacy type-ASC precedence ('business' < 'individual'). + const customer = await ProviderCustomer.findOne({ + order: [["customerType", "ASC"]], + where: { country, customerEntityId: entity.id, provider: "alfredpay" } + }); + return customer?.customerType === "business" ? AlfredpayCustomerType.BUSINESS : AlfredpayCustomerType.INDIVIDUAL; } /** diff --git a/apps/api/src/api/services/avenia-account.ts b/apps/api/src/api/services/avenia-account.ts index 491120817..898edacc5 100644 --- a/apps/api/src/api/services/avenia-account.ts +++ b/apps/api/src/api/services/avenia-account.ts @@ -1,13 +1,15 @@ import { AveniaAccountType, normalizeTaxId } from "@vortexfi/shared"; import httpStatus from "http-status"; -import TaxId, { TaxIdInternalStatus } from "../../models/taxId.model"; +import ProviderCustomer, { VerificationStatus } from "../../models/providerCustomer.model"; import { APIError } from "../errors/api-error"; +import { customerTypeToAccountType } from "./avenia/avenia-customer.service"; +import { getOrCreateCustomerEntityForProfile } from "./customer-entity.service"; export interface ResolvedAveniaAccount { taxId: string; subAccountId: string; accountType: AveniaAccountType; - taxIdRecord: TaxId; + providerCustomer: ProviderCustomer; } /** @@ -15,10 +17,12 @@ export interface ResolvedAveniaAccount { * are not considered ramp-execution ready; they are reserved for KYC flows. */ export async function resolveAveniaAccountForUser(userId: string): Promise { - const candidates = await TaxId.findAll({ + const entity = await getOrCreateCustomerEntityForProfile(userId); + const candidates = await ProviderCustomer.findAll({ where: { - internalStatus: TaxIdInternalStatus.Accepted, - userId + customerEntityId: entity.id, + provider: "avenia", + status: VerificationStatus.Approved } }); @@ -36,8 +40,8 @@ export async function resolveAveniaAccountForUser(userId: string): Promise { + return ProviderCustomer.findOne({ + where: { provider: "avenia", taxReferenceHash: hashTaxReference(taxId) } + }); +} + +export async function findAveniaCustomerBySubaccountId(subAccountId: string): Promise { + return ProviderCustomer.findOne({ + where: { provider: "avenia", providerSubaccountId: subAccountId } + }); +} + +/** + * Keeps the single kyc_case per Avenia account in sync with the account status (the + * migration backfilled exactly one case per provider account; runtime transitions update + * it in the same code path — these are two new tables, not a legacy dual-write). + */ +export async function upsertAveniaKycCase( + record: ProviderCustomer, + status: VerificationStatus, + statusExternal: string | null = record.statusExternal, + providerCaseId?: string +): Promise { + const lifecycle = { + ...(status === VerificationStatus.InReview ? { submittedAt: new Date() } : {}), + ...(status === VerificationStatus.Approved ? { approvedAt: new Date(), rejectedAt: null } : {}), + ...(status === VerificationStatus.Rejected ? { approvedAt: null, rejectedAt: new Date() } : {}) + }; + + const existing = await KycCase.findOne({ where: { providerCustomerId: record.id } }); + if (existing) { + await existing.update({ ...(providerCaseId ? { providerCaseId } : {}), status, statusExternal, ...lifecycle }); + return; + } + await KycCase.create({ + customerEntityId: record.customerEntityId, + level: "level_1", + provider: "avenia", + providerCaseId, + providerCustomerId: record.id, + status, + statusExternal, + type: record.customerType === "business" ? "kyb" : "kyc", + ...lifecycle + }); +} + +/** + * Poll-driven KYC outcome transition: flips an account to Approved/Rejected based on the + * latest provider attempt (the only mechanism that makes a subaccount ramp-ready). Approval + * is terminal — an Approved account is never downgraded by a stale attempt read — but a + * `rejected` account follows a successful retried attempt to Approved (the legacy + * `WHERE internal_status = 'Requested'` guard left it stuck in `Rejected`, so the user's + * approved KYC never became ramp-ready). Repeated polls of an unchanged outcome no-op. + */ +export async function updateAveniaKycOutcome( + taxId: string, + outcome: VerificationStatus.Approved | VerificationStatus.Rejected, + statusExternal: string +): Promise { + const record = await findAveniaCustomerByTaxId(taxId); + if (!record || record.status === VerificationStatus.Approved) { + return; + } + if (record.status === outcome && record.statusExternal === statusExternal) { + return; + } + await record.update({ status: outcome, statusExternal }); + await upsertAveniaKycCase(record, outcome, statusExternal); +} + +/** + * Best-effort hydration of `company_name` for business Avenia accounts whose row was + * created before the field was backfilled (or whose provider read was unavailable at + * creation). Idempotent: a no-op once a non-empty name is stored, and never runs for + * individual accounts. Swallows provider failures so callers can keep serving status. + */ +export async function hydrateAveniaCompanyName(customer: ProviderCustomer): Promise { + if (customer.provider !== "avenia" || customer.customerType !== "business") { + return; + } + if (customer.companyName?.trim() || !customer.providerSubaccountId) { + return; + } + try { + const account = await BrlaApiService.getInstance().subaccountInfo(customer.providerSubaccountId); + const companyName = account?.accountInfo.name?.trim() || account?.accountInfo.fullName?.trim(); + if (companyName) { + await customer.update({ companyName }); + } + } catch (error) { + logger.warn("hydrateAveniaCompanyName: Avenia subaccountInfo unavailable, skipping backfill:", error); + } +} diff --git a/apps/api/src/api/services/customer-entity.service.ts b/apps/api/src/api/services/customer-entity.service.ts new file mode 100644 index 000000000..328ee8ce3 --- /dev/null +++ b/apps/api/src/api/services/customer-entity.service.ts @@ -0,0 +1,117 @@ +import httpStatus from "http-status"; +import { Transaction } from "sequelize"; +import sequelize from "../../config/database"; +import type { CustomerEntityType } from "../../models/customerEntity.model"; +import CustomerEntity from "../../models/customerEntity.model"; +import User from "../../models/user.model"; +import { APIError } from "../errors/api-error"; + +/** + * Resolves the customer entity owned by a profile, creating the default `individual` + * entity on first touch. Migration 038 backfilled one entity per existing profile and + * verify-otp creates one for new sign-ups, but users with pre-existing sessions never + * re-verify — every entity-scoped read path must tolerate that via this lazy fallback. + */ +export async function getOrCreateCustomerEntityForProfile( + profileId: string, + type?: CustomerEntityType +): Promise { + const profile = await User.findByPk(profileId); + if (profile?.activeCustomerEntityId) { + const activeEntity = await CustomerEntity.findOne({ where: { id: profile.activeCustomerEntityId, profileId } }); + if (!activeEntity) { + throw new APIError({ + isPublic: true, + message: "The active customer entity is not owned by this profile", + status: httpStatus.CONFLICT, + type: "ACTIVE_ENTITY_OWNERSHIP_MISMATCH" + }); + } + if (!type || activeEntity.type === type) { + return activeEntity; + } + } + + if (!type) { + // Without an explicit active-entity selection, type-less callers get the profile's + // default entity. Once a profile has both an individual and a business entity, + // resolution must not depend on row order — the oldest entity is the one created at + // sign-up (or by the 038 backfill). + const existing = await CustomerEntity.findOne({ + order: [ + ["createdAt", "ASC"], + ["id", "ASC"] + ], + where: { profileId } + }); + if (existing) { + return existing; + } + } + + const [entity] = await CustomerEntity.findOrCreate({ + defaults: { + profileId, + status: "active", + type: type ?? "individual" + }, + where: { profileId, type: type ?? "individual" } + }); + return entity; +} + +export async function selectActiveCustomerEntity(profileId: string, type: CustomerEntityType): Promise { + return sequelize.transaction(async transaction => { + const profile = await User.findByPk(profileId, { lock: Transaction.LOCK.UPDATE, transaction }); + if (!profile) { + throw new APIError({ isPublic: true, message: "Profile not found", status: httpStatus.NOT_FOUND }); + } + + if (profile.activeCustomerEntityId) { + const selected = await CustomerEntity.findOne({ + transaction, + where: { id: profile.activeCustomerEntityId, profileId } + }); + if (!selected) { + throw new APIError({ + isPublic: true, + message: "The active customer entity is not owned by this profile", + status: httpStatus.CONFLICT, + type: "ACTIVE_ENTITY_OWNERSHIP_MISMATCH" + }); + } + if (selected.type !== type) { + throw new APIError({ + isPublic: true, + message: "The active customer entity selection cannot be changed", + status: httpStatus.CONFLICT, + type: "ACTIVE_ENTITY_IMMUTABLE" + }); + } + return selected; + } + + const matches = await CustomerEntity.findAll({ transaction, where: { profileId, status: "active", type } }); + if (matches.length > 1) { + throw new APIError({ + isPublic: true, + message: "Multiple customer entities match this account type", + status: httpStatus.CONFLICT, + type: "ACTIVE_ENTITY_AMBIGUOUS" + }); + } + + const selected = + matches[0] ?? + (await CustomerEntity.create( + { + profileId, + status: "active", + type + }, + { transaction } + )); + await profile.update({ activeCustomerEntityId: selected.id }, { transaction }); + return selected; + }); +} diff --git a/apps/api/src/api/services/monerium/monerium.service.test.ts b/apps/api/src/api/services/monerium/monerium.service.test.ts new file mode 100644 index 000000000..c66880b41 --- /dev/null +++ b/apps/api/src/api/services/monerium/monerium.service.test.ts @@ -0,0 +1,375 @@ +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, mock } from "bun:test"; +// Load this shared consumer before the module mocks below; Bun does not unregister mock.module +// replacements, and the API suite may import transfer eligibility after this file. +import "../recipients/transfer-eligibility.service"; +// Value copies taken before the mock.module calls below; restored in afterAll because bun +// module mocks are process-wide and would poison later test files (e.g. integration tests +// that need the real sequelize instance and models). +import * as databaseNamespace from "../../../config/database"; +import * as kycCaseNamespace from "../../../models/kycCase.model"; +import * as providerCustomerNamespace from "../../../models/providerCustomer.model"; +import * as customerEntityNamespace from "../customer-entity.service"; + +const databaseReal = { ...databaseNamespace }; +const kycCaseReal = { ...kycCaseNamespace }; +const providerCustomerReal = { ...providerCustomerNamespace }; +const customerEntityReal = { ...customerEntityNamespace }; + +const customerUpdate = mock(async (_values: unknown, _options: unknown) => undefined); +type MockProviderCustomerRow = { + id: string; + providerCustomerId?: string | null; + status?: string; + update: typeof customerUpdate; +}; +const providerFindOrCreate = mock( + async (_options: unknown): Promise> => [ + { id: "provider-customer-id", update: customerUpdate } + ] +); +const providerFindOne = mock(async (_options: unknown) => null as null | Record); +const kycFindOne = mock(async (_options: unknown) => null); +const kycCreate = mock(async (_values: unknown, _options: unknown) => undefined); + +mock.module("../../../config/database", () => ({ + default: { close: async () => undefined, transaction: async (callback: (transaction: object) => Promise) => callback({}) } +})); +mock.module("../../../models/providerCustomer.model", () => ({ + VerificationStatus: { + Approved: "approved", + InReview: "in_review", + Pending: "pending", + Rejected: "rejected", + Started: "started" + }, + default: { findOne: providerFindOne, findOrCreate: providerFindOrCreate } +})); +mock.module("../../../models/kycCase.model", () => ({ + default: { create: kycCreate, findOne: kycFindOne } +})); +mock.module("../customer-entity.service", () => ({ + getOrCreateCustomerEntityForProfile: async (userId: string) => ({ + id: `entity-${userId}`, + type: userId.startsWith("business-") ? "business" : "individual" + }) +})); + +let service: typeof import("./monerium.service"); +let controller: typeof import("../../controllers/monerium.controller"); +let cache: typeof import("../index").cache; +let config: typeof import("../../../config/vars").config; +const originalFetch = globalThis.fetch; + +function jsonResponse(value: unknown): Response { + return new Response(JSON.stringify(value), { headers: { "Content-Type": "application/json" }, status: 200 }); +} + +beforeAll(async () => { + service = await import("./monerium.service"); + controller = await import("../../controllers/monerium.controller"); + ({ cache } = await import("../index")); + ({ config } = await import("../../../config/vars")); +}); + +beforeEach(() => { + cache.flushAll(); + service.resetMoneriumMemoryForTests(); + config.monerium.apiUrl = "https://api.monerium.test"; + config.monerium.clientId = "client-id"; + config.monerium.redirectUri = "https://dashboard.test/monerium/callback"; + customerUpdate.mockClear(); + providerFindOrCreate.mockClear(); + providerFindOne.mockClear(); + kycFindOne.mockClear(); + kycCreate.mockClear(); +}); + +afterEach(() => { + globalThis.fetch = originalFetch; +}); + +afterAll(() => { + mock.module("../../../config/database", () => ({ ...databaseReal })); + mock.module("../../../models/kycCase.model", () => ({ ...kycCaseReal })); + mock.module("../../../models/providerCustomer.model", () => ({ ...providerCustomerReal })); + mock.module("../customer-entity.service", () => ({ ...customerEntityReal })); + mock.restore(); +}); + +describe("Monerium OAuth", () => { + it("rejects a supplied email that differs from the canonical authenticated email", async () => { + const next = mock((_error: unknown) => undefined); + await controller.start( + { + body: { customerType: "individual", email: "attacker@example.com" }, + userEmail: "owner@example.com", + userId: "owner" + } as never, + {} as never, + next as never + ); + + expect(next).toHaveBeenCalledTimes(1); + expect(next.mock.calls[0]?.[0]).toMatchObject({ status: 400 }); + }); + + it("records pending onboarding when authorization starts", async () => { + await service.startMoneriumOAuth("owner", "owner@example.com", "individual"); + + expect(providerFindOrCreate).toHaveBeenCalledTimes(1); + const options = providerFindOrCreate.mock.calls[0]?.[0] as { defaults: Record }; + expect(options.defaults).toMatchObject({ + customerType: "individual", + provider: "monerium", + providerCustomerId: null, + rail: "eur", + status: "started", + statusExternal: "authorization_started" + }); + expect(customerUpdate).toHaveBeenCalledWith( + { status: "started", statusExternal: "authorization_started" }, + expect.any(Object) + ); + }); + + it("preserves review status when reauthorizing an already-bound profile", async () => { + providerFindOrCreate.mockResolvedValueOnce([ + { + id: "provider-customer-id", + providerCustomerId: "profile-a", + status: "in_review", + update: customerUpdate + }, + false + ]); + + await service.startMoneriumOAuth("owner", "owner@example.com", "individual"); + + expect(customerUpdate).not.toHaveBeenCalled(); + }); + + it("generates PKCE server-side, binds ownership, consumes state once, and mirrors the selected profile", async () => { + const requests: Array<{ init?: RequestInit; url: string }> = []; + globalThis.fetch = mock(async (input: string | URL | Request, init?: RequestInit) => { + const url = String(input); + requests.push({ init, url }); + if (url.endsWith("/auth/token")) { + return jsonResponse({ access_token: "access-token", expires_in: 3600, refresh_token: "refresh-token" }); + } + if (url.endsWith("/auth/context")) { + return jsonResponse({ + defaultProfile: "profile-a", + email: "owner@example.com", + profiles: [ + { id: "profile-z", kind: "personal" }, + { id: "profile-a", kind: "personal" }, + { id: "corporate-a", kind: "corporate" } + ], + userId: "monerium-user-a" + }); + } + return jsonResponse({ id: "profile-a", kind: "personal", state: "approved" }); + }) as unknown as typeof fetch; + + const { authorizationUrl } = await service.startMoneriumOAuth("owner", "owner@example.com", "individual"); + const authorization = new URL(authorizationUrl); + expect(authorization.origin).toBe("https://api.monerium.test"); + expect(authorization.pathname).toBe("/auth"); + expect(authorization.searchParams.get("code_challenge_method")).toBe("S256"); + expect(authorization.searchParams.get("redirect_uri")).toBe(config.monerium.redirectUri); + expect(authorization.searchParams.get("email")).toBe("owner@example.com"); + const state = authorization.searchParams.get("state") as string; + + await expect(service.completeMoneriumOAuth("other", "stolen-code", state)).rejects.toMatchObject({ status: 403 }); + expect(requests).toHaveLength(0); + + const result = await service.completeMoneriumOAuth("owner", "authorization-code", state); + expect(result).toEqual({ + customerType: "individual", + profileId: "profile-a", + status: "APPROVED", + statusExternal: "approved" + }); + + const tokenBody = requests[0]?.init?.body as URLSearchParams; + expect(service.createPkceChallenge(tokenBody.get("code_verifier") as string)).toBe( + authorization.searchParams.get("code_challenge") as string + ); + expect(tokenBody.get("code")).toBe("authorization-code"); + expect(tokenBody.get("redirect_uri")).toBe(config.monerium.redirectUri); + expect(providerFindOrCreate).toHaveBeenCalledTimes(2); + const providerOptions = providerFindOrCreate.mock.calls[1]?.[0] as { defaults: Record }; + expect(providerOptions.defaults).toMatchObject({ + customerType: "individual", + provider: "monerium", + providerCustomerId: "profile-a", + rail: "eur", + status: "approved", + statusExternal: "approved" + }); + expect(kycCreate.mock.calls[0]?.[0]).toMatchObject({ + provider: "monerium", + providerCaseId: "profile-a", + status: "approved", + statusExternal: "approved", + type: "kyc" + }); + expect(JSON.stringify(providerFindOrCreate.mock.calls)).not.toContain("access-token"); + expect(JSON.stringify(kycCreate.mock.calls)).not.toContain("refresh-token"); + + await expect(service.completeMoneriumOAuth("owner", "authorization-code", state)).rejects.toMatchObject({ status: 400 }); + expect(requests).toHaveLength(3); + }); + + it("rejects a different Monerium account before changing the bound profile", async () => { + globalThis.fetch = mock(async (input: string | URL | Request) => { + const url = String(input); + if (url.endsWith("/auth/token")) { + return jsonResponse({ access_token: "access-token", expires_in: 3600, refresh_token: "refresh-token" }); + } + if (url.endsWith("/auth/context")) { + return jsonResponse({ + defaultProfile: "profile-attacker", + email: "attacker@example.com", + profiles: [{ id: "profile-attacker", kind: "personal" }], + userId: "monerium-user-attacker" + }); + } + return jsonResponse({ id: "profile-attacker", kind: "personal", state: "approved" }); + }) as unknown as typeof fetch; + + const { authorizationUrl } = await service.startMoneriumOAuth("owner", "owner@example.com", "individual"); + customerUpdate.mockClear(); + const state = new URL(authorizationUrl).searchParams.get("state") as string; + + await expect(service.completeMoneriumOAuth("owner", "authorization-code", state)).rejects.toMatchObject({ status: 409 }); + expect(customerUpdate).not.toHaveBeenCalled(); + }); + + it("rejects replacement of an existing Monerium profile binding", async () => { + globalThis.fetch = mock(async (input: string | URL | Request) => { + const url = String(input); + if (url.endsWith("/auth/token")) { + return jsonResponse({ access_token: "access-token", expires_in: 3600, refresh_token: "refresh-token" }); + } + if (url.endsWith("/auth/context")) { + return jsonResponse({ + defaultProfile: "profile-b", + email: "owner@example.com", + profiles: [{ id: "profile-b", kind: "personal" }], + userId: "monerium-user-a" + }); + } + return jsonResponse({ id: "profile-b", kind: "personal", state: "approved" }); + }) as unknown as typeof fetch; + providerFindOrCreate.mockResolvedValueOnce([ + { id: "provider-customer-id", providerCustomerId: "profile-a", status: "in_review", update: customerUpdate }, + false + ]); + + const { authorizationUrl } = await service.startMoneriumOAuth("owner", "owner@example.com", "individual"); + providerFindOrCreate.mockResolvedValueOnce([ + { id: "provider-customer-id", providerCustomerId: "profile-a", status: "in_review", update: customerUpdate }, + false + ]); + const state = new URL(authorizationUrl).searchParams.get("state") as string; + + await expect(service.completeMoneriumOAuth("owner", "authorization-code", state)).rejects.toMatchObject({ status: 409 }); + }); + + it("rejects an expired or missing transaction before token exchange", async () => { + const fetchMock = mock(async () => jsonResponse({})); + globalThis.fetch = fetchMock as unknown as typeof fetch; + const { authorizationUrl } = await service.startMoneriumOAuth("business-owner", "owner@example.com", "business"); + const state = new URL(authorizationUrl).searchParams.get("state") as string; + cache.flushAll(); + + await expect(service.completeMoneriumOAuth("business-owner", "authorization-code", state)).rejects.toMatchObject({ status: 400 }); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("refreshes an expired access token and retains the rotated refresh token server-side", async () => { + const tokenBodies: URLSearchParams[] = []; + const authorizationHeaders: string[] = []; + globalThis.fetch = mock(async (input: string | URL | Request, init?: RequestInit) => { + const url = String(input); + if (url.endsWith("/auth/token")) { + tokenBodies.push(init?.body as URLSearchParams); + return tokenBodies.length === 1 + ? jsonResponse({ access_token: "old-access", expires_in: 1, refresh_token: "old-refresh" }) + : jsonResponse({ access_token: "new-access", expires_in: 3600, refresh_token: "rotated-refresh" }); + } + authorizationHeaders.push((init?.headers as Record).Authorization); + if (url.endsWith("/auth/context")) { + return jsonResponse({ + email: "owner@example.com", + profiles: [{ id: "profile-a", kind: "personal" }], + userId: "monerium-user-a" + }); + } + return jsonResponse({ id: "profile-a", kind: "personal", state: "pending" }); + }) as unknown as typeof fetch; + + const { authorizationUrl } = await service.startMoneriumOAuth("owner", "owner@example.com", "individual"); + const state = new URL(authorizationUrl).searchParams.get("state") as string; + await service.completeMoneriumOAuth("owner", "authorization-code", state); + const result = await service.getMoneriumStatus("owner", "individual"); + + expect(tokenBodies[1]?.get("grant_type")).toBe("refresh_token"); + expect(tokenBodies[1]?.get("refresh_token")).toBe("old-refresh"); + expect(authorizationHeaders.slice(-2)).toEqual(["Bearer new-access", "Bearer new-access"]); + expect(result).toEqual({ + customerType: "individual", + profileId: "profile-a", + status: "PENDING", + statusExternal: "pending" + }); + expect(JSON.stringify(result)).not.toContain("rotated-refresh"); + }); + + it("returns the custom reauthentication error when credentials are unavailable", async () => { + await expect(service.getMoneriumStatus("owner", "individual")).rejects.toMatchObject({ + status: 404, + type: service.MONERIUM_REAUTHENTICATION_REQUIRED + }); + }); + + it("returns a persisted terminal status after in-memory credentials are lost", async () => { + providerFindOne.mockResolvedValueOnce({ + providerCustomerId: "profile-approved", + status: "approved", + statusExternal: "approved" + }); + + await expect(service.getMoneriumStatus("owner", "individual")).resolves.toEqual({ + customerType: "individual", + profileId: "profile-approved", + status: "APPROVED", + statusExternal: "approved" + }); + }); + + it("rejects a customer type that differs from the authenticated entity", async () => { + await expect(service.startMoneriumOAuth("owner", "owner@example.com", "business")).rejects.toMatchObject({ status: 400 }); + await expect(service.getMoneriumStatus("owner", "business")).rejects.toMatchObject({ status: 400 }); + }); +}); + +describe("Monerium profile normalization", () => { + it("maps only terminal provider states and keeps all others pending", () => { + expect(service.mapMoneriumProfileState("approved")).toBe("APPROVED"); + expect(service.mapMoneriumProfileState("REJECTED")).toBe("REJECTED"); + expect(service.mapMoneriumProfileState("submitted")).toBe("PENDING"); + }); + + it("selects the matching default profile and rejects ambiguous profiles without one", () => { + const profiles = [ + { id: "personal-z", kind: "personal" }, + { id: "corporate-a", kind: "corporate" }, + { id: "personal-a", kind: "personal" } + ]; + expect(service.selectMoneriumProfile(profiles, "individual", "personal-a")).toEqual({ id: "personal-a", kind: "personal" }); + expect(service.selectMoneriumProfile(profiles, "business")).toEqual({ id: "corporate-a", kind: "corporate" }); + expect(() => service.selectMoneriumProfile(profiles, "individual")).toThrow("Multiple personal Monerium profiles found"); + }); +}); diff --git a/apps/api/src/api/services/monerium/monerium.service.ts b/apps/api/src/api/services/monerium/monerium.service.ts new file mode 100644 index 000000000..6c6751263 --- /dev/null +++ b/apps/api/src/api/services/monerium/monerium.service.ts @@ -0,0 +1,437 @@ +import crypto from "crypto"; +import httpStatus from "http-status"; +import NodeCache from "node-cache"; +import sequelize from "../../../config/database"; +import { config } from "../../../config/vars"; +import KycCase from "../../../models/kycCase.model"; +import ProviderCustomer, { + MoneriumStatus, + ProviderCustomerType, + VerificationStatus +} from "../../../models/providerCustomer.model"; +import { APIError } from "../../errors/api-error"; +import { getOrCreateCustomerEntityForProfile } from "../customer-entity.service"; +import { cache } from "../index"; + +const OAUTH_TRANSACTION_TTL_SECONDS = 10 * 60; +const FETCH_TIMEOUT_MS = 10_000; +export const MONERIUM_REAUTHENTICATION_REQUIRED = "MONERIUM_REAUTHENTICATION_REQUIRED"; +const TOKEN_EXPIRY_SKEW_MS = 30_000; +const CREDENTIAL_TTL_SECONDS = 24 * 60 * 60; +const API_V2_ACCEPT = "application/vnd.monerium.api-v2+json"; + +interface OAuthTransaction { + customerEntityId: string; + customerType: ProviderCustomerType; + expectedEmail: string; + redirectUri: string; + userId: string; + verifier: string; +} + +interface MoneriumCredentials { + accessToken: string; + expiresAt: number; + refreshToken: string; +} + +interface TokenResponse { + access_token?: unknown; + expires_in?: unknown; + refresh_token?: unknown; +} + +interface ProfileReference { + id?: unknown; + kind?: unknown; +} + +interface MoneriumContext { + defaultProfile?: unknown; + email?: unknown; + profiles?: unknown; + userId?: unknown; +} + +export interface MoneriumProfile { + id: string; + kind: "personal" | "corporate"; + state: string; +} + +export interface MoneriumStatusResponse { + customerType: ProviderCustomerType; + profileId: string; + status: MoneriumStatus; + statusExternal: string; +} + +const refreshes = new Map>(); +const credentialCache = new NodeCache({ checkperiod: 600, maxKeys: 5000, stdTTL: CREDENTIAL_TTL_SECONDS, useClones: false }); + +function base64Url(value: Buffer): string { + return value.toString("base64url"); +} + +export function createPkceChallenge(verifier: string): string { + return base64Url(crypto.createHash("sha256").update(verifier, "ascii").digest()); +} + +function stateCacheKey(state: string): string { + return `monerium:oauth:${crypto.createHash("sha256").update(state, "utf8").digest("hex")}`; +} + +function credentialsCacheKey(customerEntityId: string, customerType: ProviderCustomerType): string { + return `monerium:credentials:${customerEntityId}:${customerType}`; +} + +function providerKind(customerType: ProviderCustomerType): MoneriumProfile["kind"] { + return customerType === "business" ? "corporate" : "personal"; +} + +export function mapMoneriumProfileState(state: string): MoneriumStatus { + switch (state.trim().toLowerCase()) { + case "approved": + return "APPROVED"; + case "rejected": + return "REJECTED"; + default: + return "PENDING"; + } +} + +function toVerificationStatus(status: MoneriumStatus, statusExternal: string): VerificationStatus { + if (status === "APPROVED") return VerificationStatus.Approved; + if (status === "REJECTED") return VerificationStatus.Rejected; + if (["authorization_started", "created", "incomplete"].includes(statusExternal.trim().toLowerCase())) { + return VerificationStatus.Started; + } + return VerificationStatus.InReview; +} + +export function selectMoneriumProfile( + profiles: unknown, + customerType: ProviderCustomerType, + defaultProfile?: unknown +): { id: string; kind: string } { + if (!Array.isArray(profiles)) { + throw upstreamError("Monerium context did not contain profiles"); + } + + const expectedKind = providerKind(customerType); + const matches = (profiles as ProfileReference[]) + .filter(profile => typeof profile.id === "string" && profile.kind === expectedKind) + .map(profile => ({ id: profile.id as string, kind: profile.kind as string })); + + if (matches.length === 0) { + throw new APIError({ message: `No ${expectedKind} Monerium profile found`, status: httpStatus.NOT_FOUND }); + } + const selectedDefault = matches.find(profile => profile.id === defaultProfile); + if (selectedDefault) return selectedDefault; + if (matches.length > 1) { + throw new APIError({ message: `Multiple ${expectedKind} Monerium profiles found`, status: httpStatus.CONFLICT }); + } + return matches[0]; +} + +function upstreamError(_internalMessage: string): APIError { + return new APIError({ message: "Monerium request failed", status: httpStatus.BAD_GATEWAY }); +} + +async function fetchJson(url: string, init: RequestInit): Promise { + let response: Response; + try { + response = await fetch(url, { ...init, signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) }); + } catch { + throw upstreamError("Monerium request timed out or failed"); + } + if (!response.ok) { + throw upstreamError(`Monerium returned HTTP ${response.status}`); + } + try { + return await response.json(); + } catch { + throw upstreamError("Monerium returned invalid JSON"); + } +} + +function parseTokenResponse(value: unknown): MoneriumCredentials { + const token = value as TokenResponse; + if ( + !token || + typeof token.access_token !== "string" || + typeof token.refresh_token !== "string" || + typeof token.expires_in !== "number" || + !Number.isFinite(token.expires_in) || + token.expires_in <= 0 + ) { + throw upstreamError("Monerium returned an invalid token response"); + } + return { + accessToken: token.access_token, + expiresAt: Date.now() + token.expires_in * 1000, + refreshToken: token.refresh_token + }; +} + +async function requestToken(params: URLSearchParams): Promise { + const response = await fetchJson(`${config.monerium.apiUrl}/auth/token`, { + body: params, + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + method: "POST" + }); + return parseTokenResponse(response); +} + +async function getValidCredentials(customerEntityId: string, customerType: ProviderCustomerType): Promise { + const key = credentialsCacheKey(customerEntityId, customerType); + const credentials = credentialCache.get(key); + if (!credentials) { + throw new APIError({ + message: "Monerium reauthentication is required", + status: httpStatus.NOT_FOUND, + type: MONERIUM_REAUTHENTICATION_REQUIRED + }); + } + if (credentials.expiresAt - TOKEN_EXPIRY_SKEW_MS > Date.now()) { + credentialCache.ttl(key, CREDENTIAL_TTL_SECONDS); + return credentials; + } + + const inFlight = refreshes.get(key); + if (inFlight) return inFlight; + + const refresh = requestToken( + new URLSearchParams({ + client_id: config.monerium.clientId, + grant_type: "refresh_token", + refresh_token: credentials.refreshToken + }) + ).then(rotated => { + credentialCache.set(key, rotated); + return rotated; + }); + refreshes.set(key, refresh); + try { + return await refresh; + } finally { + refreshes.delete(key); + } +} + +async function readProfile( + credentials: MoneriumCredentials, + customerType: ProviderCustomerType +): Promise<{ context: MoneriumContext & { email: string; userId: string }; profile: MoneriumProfile }> { + const headers = { Accept: API_V2_ACCEPT, Authorization: `Bearer ${credentials.accessToken}` }; + const context = (await fetchJson(`${config.monerium.apiUrl}/auth/context`, { headers, method: "GET" })) as MoneriumContext; + if (typeof context.email !== "string" || typeof context.userId !== "string") { + throw upstreamError("Monerium context did not contain an authenticated identity"); + } + const selected = selectMoneriumProfile(context.profiles, customerType, context.defaultProfile); + const rawProfile = (await fetchJson(`${config.monerium.apiUrl}/profiles/${encodeURIComponent(selected.id)}`, { + headers, + method: "GET" + })) as Record; + + if (rawProfile.id !== selected.id || rawProfile.kind !== selected.kind || typeof rawProfile.state !== "string") { + throw upstreamError("Monerium returned an invalid profile"); + } + return { + context: context as MoneriumContext & { email: string; userId: string }, + profile: rawProfile as unknown as MoneriumProfile + }; +} + +async function mirrorProfile( + customerEntityId: string, + customerType: ProviderCustomerType, + profile: MoneriumProfile +): Promise { + const status = mapMoneriumProfileState(profile.state); + const verificationStatus = toVerificationStatus(status, profile.state); + await sequelize.transaction(async transaction => { + const [customer] = await ProviderCustomer.findOrCreate({ + defaults: { + customerEntityId, + customerType, + provider: "monerium", + providerCustomerId: profile.id, + rail: "eur", + status: verificationStatus, + statusExternal: profile.state + }, + transaction, + where: { customerEntityId, customerType, provider: "monerium", rail: "eur" } + }); + if (customer.providerCustomerId && customer.providerCustomerId !== profile.id) { + throw new APIError({ message: "Monerium profile does not match the existing account", status: httpStatus.CONFLICT }); + } + await customer.update( + { providerCustomerId: profile.id, status: verificationStatus, statusExternal: profile.state }, + { transaction } + ); + + const existingCase = await KycCase.findOne({ transaction, where: { providerCustomerId: customer.id } }); + if (existingCase) { + await existingCase.update( + { + approvedAt: verificationStatus === VerificationStatus.Approved ? (existingCase.approvedAt ?? new Date()) : null, + providerCaseId: profile.id, + rejectedAt: verificationStatus === VerificationStatus.Rejected ? (existingCase.rejectedAt ?? new Date()) : null, + status: verificationStatus, + statusExternal: profile.state + }, + { transaction } + ); + } else { + await KycCase.create( + { + customerEntityId, + provider: "monerium", + providerCaseId: profile.id, + providerCustomerId: customer.id, + status: verificationStatus, + statusExternal: profile.state, + submittedAt: new Date(), + type: customerType === "business" ? "kyb" : "kyc", + ...(verificationStatus === VerificationStatus.Approved ? { approvedAt: new Date() } : {}), + ...(verificationStatus === VerificationStatus.Rejected ? { rejectedAt: new Date() } : {}) + }, + { transaction } + ); + } + }); + return { customerType, profileId: profile.id, status, statusExternal: profile.state }; +} + +export async function startMoneriumOAuth( + userId: string, + email: string, + customerType: ProviderCustomerType +): Promise<{ authorizationUrl: string }> { + if (!config.monerium.clientId) { + throw new APIError({ message: "Monerium OAuth is not configured", status: httpStatus.SERVICE_UNAVAILABLE }); + } + const entity = await getOrCreateCustomerEntityForProfile(userId, customerType); + if (entity.type !== customerType) { + throw new APIError({ message: "customerType does not match the authenticated entity", status: httpStatus.BAD_REQUEST }); + } + await sequelize.transaction(async transaction => { + const [customer, created] = await ProviderCustomer.findOrCreate({ + defaults: { + customerEntityId: entity.id, + customerType, + provider: "monerium", + providerCustomerId: null, + rail: "eur", + status: VerificationStatus.Started, + statusExternal: "authorization_started" + }, + transaction, + where: { customerEntityId: entity.id, customerType, provider: "monerium", rail: "eur" } + }); + if (!created && !customer.providerCustomerId && customer.status !== VerificationStatus.Approved) { + await customer.update({ status: VerificationStatus.Started, statusExternal: "authorization_started" }, { transaction }); + } + }); + const state = base64Url(crypto.randomBytes(32)); + const verifier = base64Url(crypto.randomBytes(64)); + const transaction: OAuthTransaction = { + customerEntityId: entity.id, + customerType, + expectedEmail: email.trim().toLowerCase(), + redirectUri: config.monerium.redirectUri, + userId, + verifier + }; + cache.set(stateCacheKey(state), transaction, OAUTH_TRANSACTION_TTL_SECONDS); + + const url = new URL("/auth", config.monerium.apiUrl); + url.searchParams.set("client_id", config.monerium.clientId); + url.searchParams.set("code_challenge", createPkceChallenge(verifier)); + url.searchParams.set("code_challenge_method", "S256"); + url.searchParams.set("redirect_uri", transaction.redirectUri); + url.searchParams.set("state", state); + url.searchParams.set("email", email); + return { authorizationUrl: url.toString() }; +} + +function readOAuthTransaction(state: string): OAuthTransaction { + const key = stateCacheKey(state); + const pending = cache.get(key); + if (!pending) { + throw new APIError({ message: "Invalid or expired OAuth state", status: httpStatus.BAD_REQUEST }); + } + return pending; +} + +function consumeOAuthTransaction(state: string, userId: string, customerEntityId: string): OAuthTransaction { + const key = stateCacheKey(state); + const pending = readOAuthTransaction(state); + if (pending.userId !== userId || pending.customerEntityId !== customerEntityId) { + throw new APIError({ message: "OAuth transaction does not belong to this user", status: httpStatus.FORBIDDEN }); + } + const consumed = cache.take(key); + if (!consumed) { + throw new APIError({ message: "OAuth state has already been used", status: httpStatus.BAD_REQUEST }); + } + return consumed; +} + +export async function completeMoneriumOAuth(userId: string, code: string, state: string): Promise { + const transaction = readOAuthTransaction(state); + const entity = await getOrCreateCustomerEntityForProfile(userId, transaction.customerType); + const pending = consumeOAuthTransaction(state, userId, entity.id); + if (entity.type !== pending.customerType) { + throw new APIError({ message: "customerType does not match the authenticated entity", status: httpStatus.BAD_REQUEST }); + } + const credentials = await requestToken( + new URLSearchParams({ + client_id: config.monerium.clientId, + code, + code_verifier: pending.verifier, + grant_type: "authorization_code", + redirect_uri: pending.redirectUri + }) + ); + const { context, profile } = await readProfile(credentials, pending.customerType); + if (context.email.trim().toLowerCase() !== pending.expectedEmail) { + throw new APIError({ message: "Monerium account does not match the authenticated user", status: httpStatus.CONFLICT }); + } + const result = await mirrorProfile(entity.id, pending.customerType, profile); + credentialCache.set(credentialsCacheKey(entity.id, pending.customerType), credentials); + return result; +} + +export async function getMoneriumStatus(userId: string, customerType: ProviderCustomerType): Promise { + const entity = await getOrCreateCustomerEntityForProfile(userId, customerType); + if (entity.type !== customerType) { + throw new APIError({ message: "customerType does not match the authenticated entity", status: httpStatus.BAD_REQUEST }); + } + if (!credentialCache.has(credentialsCacheKey(entity.id, customerType))) { + const persisted = await ProviderCustomer.findOne({ + where: { customerEntityId: entity.id, customerType, provider: "monerium", rail: "eur" } + }); + if ( + persisted?.providerCustomerId && + persisted.statusExternal && + (persisted.status === VerificationStatus.Approved || persisted.status === VerificationStatus.Rejected) + ) { + return { + customerType, + profileId: persisted.providerCustomerId, + status: mapMoneriumProfileState(persisted.statusExternal), + statusExternal: persisted.statusExternal + }; + } + } + const credentials = await getValidCredentials(entity.id, customerType); + const { profile } = await readProfile(credentials, customerType); + return mirrorProfile(entity.id, customerType, profile); +} + +export function resetMoneriumMemoryForTests(): void { + credentialCache.flushAll(); + refreshes.clear(); +} diff --git a/apps/api/src/api/services/mykobo/mykobo-customer.service.test.ts b/apps/api/src/api/services/mykobo/mykobo-customer.service.test.ts index 1349de708..2b1a5c5a0 100644 --- a/apps/api/src/api/services/mykobo/mykobo-customer.service.test.ts +++ b/apps/api/src/api/services/mykobo/mykobo-customer.service.test.ts @@ -1,9 +1,15 @@ import { afterEach, describe, expect, it, mock } from "bun:test"; -import { MykoboApiService, MykoboCustomerStatus } from "@vortexfi/shared"; -import MykoboCustomer from "../../../models/mykoboCustomer.model"; +import { MykoboApiService } from "@vortexfi/shared"; +import CustomerEntity from "../../../models/customerEntity.model"; +import KycCase from "../../../models/kycCase.model"; +import ProviderCustomer, { VerificationStatus } from "../../../models/providerCustomer.model"; import User from "../../../models/user.model"; import { APIError } from "../../errors/api-error"; -import { resolveMykoboCustomerForUser } from "./mykobo-customer.service"; +import { + markMykoboCustomerPending, + markMykoboCustomerStarted, + resolveMykoboCustomerForUser +} from "./mykobo-customer.service"; const PROFILE_EMAIL = "user@example.com"; @@ -12,13 +18,21 @@ function stub({ profileEmail, reviewStatus }: { profileEmail: string | null; rev profileEmail ? { email: profileEmail, id: "user-1" } : null ) as unknown as typeof User.findByPk; + CustomerEntity.findOrCreate = mock(async () => [{ id: "entity-1" }, false]) as unknown as typeof CustomerEntity.findOrCreate; + const customer = { - status: MykoboCustomerStatus.CONSULTED, - update: mock(async (changes: { status: MykoboCustomerStatus }) => { + customerEntityId: "entity-1", + id: "customer-1", + status: VerificationStatus.Pending, + statusExternal: null as string | null, + update: mock(async (changes: { status: VerificationStatus; statusExternal: string | null }) => { customer.status = changes.status; + customer.statusExternal = changes.statusExternal; }) }; - MykoboCustomer.findOne = mock(async () => customer) as unknown as typeof MykoboCustomer.findOne; + ProviderCustomer.findOne = mock(async () => customer) as unknown as typeof ProviderCustomer.findOne; + KycCase.findOne = mock(async () => null) as unknown as typeof KycCase.findOne; + KycCase.create = mock(async () => ({})) as unknown as typeof KycCase.create; MykoboApiService.getInstance = mock(() => ({ getProfileByEmail: async () => ({ @@ -31,14 +45,20 @@ function stub({ profileEmail, reviewStatus }: { profileEmail: string | null; rev describe("resolveMykoboCustomerForUser", () => { const originals = { - customerFindOne: MykoboCustomer.findOne, + customerFindOne: ProviderCustomer.findOne, + entityFindOrCreate: CustomerEntity.findOrCreate, getInstance: MykoboApiService.getInstance, + kycCreate: KycCase.create, + kycFindOne: KycCase.findOne, userFindByPk: User.findByPk }; afterEach(() => { User.findByPk = originals.userFindByPk; - MykoboCustomer.findOne = originals.customerFindOne; + ProviderCustomer.findOne = originals.customerFindOne; + CustomerEntity.findOrCreate = originals.entityFindOrCreate; + KycCase.create = originals.kycCreate; + KycCase.findOne = originals.kycFindOne; MykoboApiService.getInstance = originals.getInstance; }); @@ -62,4 +82,14 @@ describe("resolveMykoboCustomerForUser", () => { stub({ profileEmail: PROFILE_EMAIL, reviewStatus: "pending" }); await expect(resolveMykoboCustomerForUser("user-1")).rejects.toBeInstanceOf(APIError); }); + + it("distinguishes profile submission from missing provider data", async () => { + const customer = stub({ profileEmail: PROFILE_EMAIL, reviewStatus: "pending" }); + + await markMykoboCustomerStarted("user-1", PROFILE_EMAIL); + expect(customer.status).toBe(VerificationStatus.Started); + + await markMykoboCustomerPending("user-1", PROFILE_EMAIL); + expect(customer.status).toBe(VerificationStatus.Pending); + }); }); diff --git a/apps/api/src/api/services/mykobo/mykobo-customer.service.ts b/apps/api/src/api/services/mykobo/mykobo-customer.service.ts index 3cced82cd..d58ef74d8 100644 --- a/apps/api/src/api/services/mykobo/mykobo-customer.service.ts +++ b/apps/api/src/api/services/mykobo/mykobo-customer.service.ts @@ -1,36 +1,93 @@ import { MykoboApiError, MykoboApiService, MykoboCustomerStatus, MykoboProfile, mapMykoboReviewStatus } from "@vortexfi/shared"; import httpStatus from "http-status"; import logger from "../../../config/logger"; -import MykoboCustomer from "../../../models/mykoboCustomer.model"; +import KycCase from "../../../models/kycCase.model"; +import ProviderCustomer, { VerificationStatus } from "../../../models/providerCustomer.model"; import User from "../../../models/user.model"; import { APIError } from "../../errors/api-error"; +import { getOrCreateCustomerEntityForProfile } from "../customer-entity.service"; interface UpsertArgs { userId: string; email: string; - status: MykoboCustomerStatus; + status: VerificationStatus; statusExternal: string | null; } +function toVerificationStatus(status: MykoboCustomerStatus): VerificationStatus { + switch (status) { + case MykoboCustomerStatus.APPROVED: + return VerificationStatus.Approved; + case MykoboCustomerStatus.REJECTED: + return VerificationStatus.Rejected; + case MykoboCustomerStatus.PENDING: + return VerificationStatus.InReview; + default: + return VerificationStatus.Pending; + } +} + +async function syncKycCase(record: ProviderCustomer): Promise { + const values = { + status: record.status, + statusExternal: record.statusExternal, + ...(record.status === VerificationStatus.Approved ? { approvedAt: new Date(), rejectedAt: null } : {}), + ...(record.status === VerificationStatus.Rejected ? { approvedAt: null, rejectedAt: new Date() } : {}) + }; + const existing = await KycCase.findOne({ where: { providerCustomerId: record.id } }); + if (existing) { + await existing.update(values); + return; + } + await KycCase.create({ + customerEntityId: record.customerEntityId, + provider: "mykobo", + providerCustomerId: record.id, + type: "kyc", + ...values + }); +} + async function upsertMykoboCustomer({ userId, email, status, statusExternal }: UpsertArgs): Promise { - const existing = await MykoboCustomer.findOne({ where: { userId } }); + const entity = await getOrCreateCustomerEntityForProfile(userId, "individual"); + const existing = await ProviderCustomer.findOne({ + where: { customerEntityId: entity.id, provider: "mykobo" } + }); if (existing) { - await existing.update({ email, status, statusExternal }); + // The Mykobo-side durable key is the email; keep the mirror in sync with the profile. + await existing.update({ providerCustomerId: email, status, statusExternal }); + await syncKycCase(existing); return; } - await MykoboCustomer.create({ email, status, statusExternal, userId }); + const record = await ProviderCustomer.create({ + customerEntityId: entity.id, + provider: "mykobo", + providerCustomerId: email, + rail: "eur", + status, + statusExternal + }); + await syncKycCase(record); } export async function upsertMykoboCustomerFromProfile(userId: string, email: string, profile: MykoboProfile): Promise { const reviewStatus = profile.kyc_status?.review_status ?? null; await upsertMykoboCustomer({ email, - status: mapMykoboReviewStatus(reviewStatus), + status: toVerificationStatus(mapMykoboReviewStatus(reviewStatus)), statusExternal: reviewStatus, userId }); } +export async function markMykoboCustomerStarted(userId: string, email: string): Promise { + await upsertMykoboCustomer({ email, status: VerificationStatus.Started, statusExternal: null, userId }); +} + +export async function markMykoboCustomerPending(userId: string, email: string): Promise { + await upsertMykoboCustomer({ email, status: VerificationStatus.Pending, statusExternal: null, userId }); +} + export interface ResolvedMykoboCustomer { email: string; } @@ -66,8 +123,11 @@ export async function resolveMykoboCustomerForUser(userId: string, providedEmail // Refresh the KYC mirror from the live Mykobo profile, then gate on an approved customer. await syncMykoboCustomerKyc(userId, email); - const customer = await MykoboCustomer.findOne({ where: { userId } }); - if (!customer || customer.status !== MykoboCustomerStatus.APPROVED) { + const entity = await getOrCreateCustomerEntityForProfile(userId, "individual"); + const customer = await ProviderCustomer.findOne({ + where: { customerEntityId: entity.id, provider: "mykobo" } + }); + if (!customer || customer.status !== VerificationStatus.Approved) { throw new APIError({ message: "Mykobo KYC is not approved for this user. Complete Mykobo KYC before requesting an EUR ramp.", status: httpStatus.BAD_REQUEST @@ -85,7 +145,7 @@ export async function syncMykoboCustomerKyc(userId: string, email: string): Prom if (error instanceof MykoboApiError && error.status === 404) { await upsertMykoboCustomer({ email, - status: MykoboCustomerStatus.CONSULTED, + status: VerificationStatus.Pending, statusExternal: null, userId }); diff --git a/apps/api/src/api/services/notifications/notification.service.ts b/apps/api/src/api/services/notifications/notification.service.ts new file mode 100644 index 000000000..40d34faf0 --- /dev/null +++ b/apps/api/src/api/services/notifications/notification.service.ts @@ -0,0 +1,43 @@ +import logger from "../../../config/logger"; +import Notification from "../../../models/notification.model"; +import NotificationPreference from "../../../models/notificationPreference.model"; + +export interface NotificationEvent { + type: string; + title: string; + body?: string; + metadata?: Record; + customerEntityId?: string; +} + +/** + * Writes an in-app notification for a profile. Email dispatch (plan D7 — Supabase + * SMTP/edge function) is not wired yet: when the transport lands it hooks in here, + * gated on the profile's `email_enabled` preference. Never throws — a failed + * notification must not fail the business operation that triggered it. + */ +export async function emitNotification(profileId: string, event: NotificationEvent): Promise { + try { + const notification = await Notification.create({ + body: event.body ?? null, + customerEntityId: event.customerEntityId ?? null, + metadata: event.metadata ?? {}, + profileId, + title: event.title, + type: event.type + }); + return notification; + } catch (error) { + logger.error(`Failed to emit notification '${event.type}' for profile ${profileId}:`, error); + return null; + } +} + +/** The profile's preferences row, created with defaults on first read. */ +export async function getOrCreateNotificationPreferences(profileId: string): Promise { + const [preferences] = await NotificationPreference.findOrCreate({ + defaults: { emailEnabled: true, prefs: {}, profileId }, + where: { profileId } + }); + return preferences; +} diff --git a/apps/api/src/api/services/partners/partner-pricing.service.ts b/apps/api/src/api/services/partners/partner-pricing.service.ts new file mode 100644 index 000000000..7189d5eb2 --- /dev/null +++ b/apps/api/src/api/services/partners/partner-pricing.service.ts @@ -0,0 +1,79 @@ +import { RampCurrency, RampDirection } from "@vortexfi/shared"; +import Partner from "../../../models/partner.model"; +import PartnerPricingConfig from "../../../models/partnerPricingConfig.model"; + +/** + * A partner's identity merged with its pricing config for one ramp direction — the same + * shape the pre-split partners row exposed, so pricing call sites stay unchanged. + */ +export interface PartnerWithPricing { + id: string; + name: string; + displayName: string; + logoUrl: string | null; + rampType: RampDirection; + markupType: "absolute" | "relative" | "none"; + markupValue: number; + markupCurrency: RampCurrency; + vortexFeeType: "absolute" | "relative" | "none"; + vortexFeeValue: number; + targetDiscount: number; + maxSubsidy: number; + minDynamicDifference: number; + maxDynamicDifference: number; + payoutAddressSubstrate: string | null; + payoutAddressEvm: string | null; +} + +/** + * Resolves an active partner (by id or unique name) together with its active pricing + * config for the given direction. Returns null when either the partner or the + * direction's config is missing/inactive — the exact semantics of the pre-split + * `Partner.findOne({ name|id, isActive, rampType })`. + */ +export async function findPartnerWithPricing( + ref: { id?: string; name?: string }, + rampType: RampDirection +): Promise { + const partner = await Partner.findOne({ + where: { + ...(ref.id ? { id: ref.id } : { name: ref.name }), + isActive: true + } + }); + if (!partner) { + return null; + } + + const config = await PartnerPricingConfig.findOne({ + where: { + isActive: true, + partnerId: partner.id, + rampType + } + }); + if (!config) { + return null; + } + + return { + displayName: partner.displayName, + id: partner.id, + logoUrl: partner.logoUrl, + // Nullable in the DB but only read when markupType !== "none" (same contract the + // pre-split partners row had). + markupCurrency: config.markupCurrency as RampCurrency, + markupType: config.markupType, + markupValue: config.markupValue, + maxDynamicDifference: config.maxDynamicDifference, + maxSubsidy: config.maxSubsidy, + minDynamicDifference: config.minDynamicDifference, + name: partner.name, + payoutAddressEvm: config.payoutAddressEvm, + payoutAddressSubstrate: config.payoutAddressSubstrate, + rampType, + targetDiscount: config.targetDiscount, + vortexFeeType: config.vortexFeeType, + vortexFeeValue: config.vortexFeeValue + }; +} diff --git a/apps/api/src/api/services/phases/handlers/brla-onramp-mint-handler.ts b/apps/api/src/api/services/phases/handlers/brla-onramp-mint-handler.ts index 86eeb9db1..2acd46860 100644 --- a/apps/api/src/api/services/phases/handlers/brla-onramp-mint-handler.ts +++ b/apps/api/src/api/services/phases/handlers/brla-onramp-mint-handler.ts @@ -20,8 +20,8 @@ import httpStatus from "http-status"; import logger from "../../../../config/logger"; import QuoteTicket from "../../../../models/quoteTicket.model"; import RampState from "../../../../models/rampState.model"; -import TaxId from "../../../../models/taxId.model"; import { APIError } from "../../../errors/api-error"; +import { findAveniaCustomerByTaxId } from "../../avenia/avenia-customer.service"; import { BasePhaseHandler } from "../base-phase-handler"; import { syncAveniaOnHoldState } from "../helpers/brla-onramp-hold"; import { StateMetadata } from "../meta-state-types"; @@ -68,13 +68,14 @@ export class BrlaOnrampMintHandler extends BasePhaseHandler { throw new Error("Missing 'aveniaTransfer' in quote metadata"); } - const taxIdRecord = await TaxId.findByPk(state.state.taxId); - if (!taxIdRecord) { + const aveniaCustomer = await findAveniaCustomerByTaxId(state.state.taxId); + if (!aveniaCustomer) { throw new APIError({ message: "Subaccount not found", status: httpStatus.BAD_REQUEST }); } + const aveniaSubAccountId = aveniaCustomer.providerSubaccountId ?? ""; const tokenDetails = evmTokenConfig[Networks.Base][EvmToken.BRLA]; if (!tokenDetails) { @@ -124,7 +125,7 @@ export class BrlaOnrampMintHandler extends BasePhaseHandler { } }), brlaApiService, - taxIdRecord.subAccountId + aveniaSubAccountId ); if (!ticketFound) { logger.warn( @@ -134,7 +135,7 @@ export class BrlaOnrampMintHandler extends BasePhaseHandler { } // Check internal balance of Avenia subaccount - const { balances } = await brlaApiService.getAccountBalance(taxIdRecord.subAccountId); + const { balances } = await brlaApiService.getAccountBalance(aveniaSubAccountId); if (!balances || balances.BRLA === undefined || balances.BRLA === null) { return false; } @@ -168,7 +169,7 @@ export class BrlaOnrampMintHandler extends BasePhaseHandler { outputCurrency: BrlaCurrency.BRLA, outputPaymentMethod: AveniaPaymentMethod.BASE, outputThirdParty: false, - subAccountId: taxIdRecord.subAccountId + subAccountId: aveniaSubAccountId }); logger.info("BrlaOnrampMintHandler: Created Avenia pay-out quote for mint transfer."); @@ -190,7 +191,7 @@ export class BrlaOnrampMintHandler extends BasePhaseHandler { walletChain: AveniaPaymentMethod.BASE } }, - taxIdRecord.subAccountId + aveniaSubAccountId ); logger.info( diff --git a/apps/api/src/api/services/phases/handlers/brla-payout-base-handler.ts b/apps/api/src/api/services/phases/handlers/brla-payout-base-handler.ts index 24a2905f9..9e25a384b 100644 --- a/apps/api/src/api/services/phases/handlers/brla-payout-base-handler.ts +++ b/apps/api/src/api/services/phases/handlers/brla-payout-base-handler.ts @@ -11,8 +11,8 @@ import Big from "big.js"; import logger from "../../../../config/logger"; import QuoteTicket from "../../../../models/quoteTicket.model"; import RampState from "../../../../models/rampState.model"; -import TaxId from "../../../../models/taxId.model"; import { PhaseError } from "../../../errors/phase-error"; +import { findAveniaCustomerByTaxId } from "../../avenia/avenia-customer.service"; import { BasePhaseHandler } from "../base-phase-handler"; import { StateMetadata } from "../meta-state-types"; import { ensurePresignedTransferFunded } from "./helpers"; @@ -41,10 +41,11 @@ export class BrlaPayoutOnBasePhaseHandler extends BasePhaseHandler { const outputAmount = quote.outputAmount; const outputCurrency = quote.outputCurrency; - const taxIdRecord = await TaxId.findByPk(taxId); - if (!taxIdRecord) { + const aveniaCustomer = await findAveniaCustomerByTaxId(taxId); + if (!aveniaCustomer) { throw new Error("BrlaPayoutOnBasePhaseHandler: SubaccountId must exist at this stage. This is a bug."); } + const aveniaSubAccountId = aveniaCustomer.providerSubaccountId ?? ""; if (!isFiatTokenEnum(outputCurrency)) { throw new Error("BrlaPayoutOnBasePhaseHandler: Invalid token type."); @@ -60,7 +61,7 @@ export class BrlaPayoutOnBasePhaseHandler extends BasePhaseHandler { // We need to check for existing ticket, recovery scenario if (payOutTicketId) { - await this.checkTicketStatusPaid({ subAccountId: taxIdRecord.subAccountId, ticketId: payOutTicketId }); + await this.checkTicketStatusPaid({ subAccountId: aveniaSubAccountId, ticketId: payOutTicketId }); return this.transitionToNextPhase(state, "complete"); } @@ -75,7 +76,7 @@ export class BrlaPayoutOnBasePhaseHandler extends BasePhaseHandler { while (Date.now() - startTime < timeout) { try { - const balanceResponse = await brlaApiService.getAccountBalance(taxIdRecord.subAccountId); + const balanceResponse = await brlaApiService.getAccountBalance(aveniaSubAccountId); if (balanceResponse && balanceResponse.balances && balanceResponse.balances.BRLA !== undefined) { if (new Big(balanceResponse.balances.BRLA).gte(Big(amountForPayout).round(2, 0))) { // compare with rounded down amount. @@ -107,7 +108,7 @@ export class BrlaPayoutOnBasePhaseHandler extends BasePhaseHandler { try { const amount = new Big(outputAmount); - const subaccount = await brlaApiService.subaccountInfo(taxIdRecord.subAccountId); + const subaccount = await brlaApiService.subaccountInfo(aveniaSubAccountId); if (!subaccount) { throw new Error("BrlaPayoutOnBasePhaseHandler: Subaccount must exist."); } @@ -117,7 +118,7 @@ export class BrlaPayoutOnBasePhaseHandler extends BasePhaseHandler { const payOutQuote = await brlaApiService.createPayOutQuote({ outputAmount: amountForQuote.toString(), outputThirdParty: false, - subAccountId: taxIdRecord.subAccountId + subAccountId: aveniaSubAccountId }); const payOutTicketParams: PixOutputTicketPayload = { @@ -129,7 +130,7 @@ export class BrlaPayoutOnBasePhaseHandler extends BasePhaseHandler { pixKey: pixDestination } }; - const { id: payOutTicketId } = await brlaApiService.createPixOutputTicket(payOutTicketParams, taxIdRecord.subAccountId); + const { id: payOutTicketId } = await brlaApiService.createPixOutputTicket(payOutTicketParams, aveniaSubAccountId); logger.debug("Debug: payOutTicketId", payOutTicketId); // Update the state with the transaction hashes await state.update({ @@ -139,7 +140,7 @@ export class BrlaPayoutOnBasePhaseHandler extends BasePhaseHandler { } }); - await this.checkTicketStatusPaid({ subAccountId: taxIdRecord.subAccountId, ticketId: payOutTicketId }); + await this.checkTicketStatusPaid({ subAccountId: aveniaSubAccountId, ticketId: payOutTicketId }); return this.transitionToNextPhase(state, "complete"); } catch (e) { logger.error("Error in brlaPayoutOnBase", e); diff --git a/apps/api/src/api/services/phases/handlers/fund-ephemeral-handler.ts b/apps/api/src/api/services/phases/handlers/fund-ephemeral-handler.ts index 8cc7737aa..280953291 100644 --- a/apps/api/src/api/services/phases/handlers/fund-ephemeral-handler.ts +++ b/apps/api/src/api/services/phases/handlers/fund-ephemeral-handler.ts @@ -124,16 +124,23 @@ export class FundEphemeralPhaseHandler extends BasePhaseHandler { return; } - const hasSquidApproveBlueprint = state.unsignedTxs.some(tx => tx.phase === "squidRouterApprove"); - if (!hasSquidApproveBlueprint) return; - - await verifyUserSubmittedTxByHash({ - fromNetwork, - hash: state.state.squidRouterApproveHash as `0x${string}` | undefined, - label: "User squidRouter approve", - presignedPhase: "squidRouterApprove", - state - }); + const hasSquidSwapBlueprint = state.unsignedTxs.some(tx => tx.phase === "squidRouterSwap"); + if (!hasSquidSwapBlueprint) return; + + // The approve hash is optional: users whose wallet already holds a sufficient allowance + // for the squid router skip the approve tx entirely and only broadcast the swap. When a + // hash IS reported we still verify it against the blueprint; the swap hash — the tx that + // actually delivers tokens to our ephemeral — is always required. + const approveHash = state.state.squidRouterApproveHash as `0x${string}` | undefined; + if (approveHash) { + await verifyUserSubmittedTxByHash({ + fromNetwork, + hash: approveHash, + label: "User squidRouter approve", + presignedPhase: "squidRouterApprove", + state + }); + } await verifyUserSubmittedTxByHash({ fromNetwork, hash: state.state.squidRouterSwapHash as `0x${string}` | undefined, diff --git a/apps/api/src/api/services/phases/handlers/squid-router-pay-phase-handler.test.ts b/apps/api/src/api/services/phases/handlers/squid-router-pay-phase-handler.test.ts new file mode 100644 index 000000000..9c2389b33 --- /dev/null +++ b/apps/api/src/api/services/phases/handlers/squid-router-pay-phase-handler.test.ts @@ -0,0 +1,236 @@ +// eslint-disable-next-line import/no-unresolved +import { afterAll, beforeEach, describe, expect, it, mock } from "bun:test"; +// Captured before mock.module so afterAll can restore the real package — +// bun module mocks are process-wide and would poison later test files. +import * as sharedNamespace from "@vortexfi/shared"; +import * as rampServiceNamespace from "../../ramp/ramp.service"; +import * as evmFundingNamespace from "../evm-funding"; + +// Value copies taken before mock.module runs — the namespaces themselves are +// live bindings that would reflect the mocks once installed. +const sharedReal = { ...sharedNamespace }; +const rampServiceReal = { ...rampServiceNamespace }; +const evmFundingReal = { ...evmFundingNamespace }; + +const Networks = { + AssetHub: "assethub", + Base: "base", + Moonbeam: "moonbeam", + Polygon: "polygon" +} as const; + +const FiatToken = { + BRL: "BRL", + EURC: "EUR" +} as const; + +const RampDirection = { + BUY: "BUY", + SELL: "SELL" +} as const; + +const SWAP_HASH = "0x31365ff4337000801303097a0494fd97ecc1661ea84fedee801f01825b236f49"; +const EVM_EPHEMERAL_ADDRESS = "0x1111111111111111111111111111111111111111"; +const FUNDER_ADDRESS = "0x2222222222222222222222222222222222222222"; + +// Queue of axelarscan statuses returned per polling iteration; refilled per test. +let axelarStatusQueue: unknown[] = []; +const getStatusAxelarScan = mock(async () => { + if (axelarStatusQueue.length > 1) { + return axelarStatusQueue.shift(); + } + return axelarStatusQueue[0]; +}); +const getStatus = mock(async () => ({ + id: "", + isGMPTransaction: true, + routeStatus: [], + squidTransactionStatus: "", + status: "ongoing" +})); +const recoverAxelarStuckConfirm = mock(async () => "AXELAR_TX_HASH"); +// Never settles on its own; the real implementation rejects on abort, but these +// tests always resolve via the bridge path of Promise.any. +const checkEvmBalanceForToken = mock(() => new Promise(() => undefined)); + +mock.module("@vortexfi/shared", () => ({ + ...sharedReal, + checkEvmBalanceForToken, + EvmClientManager: { + getInstance: () => ({ + getClient: () => ({}), + getWalletClient: () => ({ account: { address: FUNDER_ADDRESS } }) + }) + }, + FiatToken, + getNetworkId: (network: string) => { + if (network === Networks.Base) return 8453; + if (network === Networks.Polygon) return 137; + if (network === Networks.Moonbeam) return 1284; + return undefined; + }, + getOnChainTokenDetails: () => ({ + decimals: 6, + erc20AddressSourceChain: "0x3333333333333333333333333333333333333333", + isNative: false + }), + getStatus, + getStatusAxelarScan, + isAlfredpayToken: () => false, + Networks, + RampDirection, + recoverAxelarStuckConfirm +})); + +mock.module("../evm-funding", () => ({ + getEvmFundingAccount: () => ({ address: FUNDER_ADDRESS }) +})); + +mock.module("../../ramp/ramp.service", () => ({ + default: { + appendErrorLog: mock(async () => undefined) + } +})); + +const { default: QuoteTicket } = await import("../../../../models/quoteTicket.model"); +const { SquidRouterPayPhaseHandler } = await import("./squid-router-pay-phase-handler"); + +const realQuoteTicketFindByPk = QuoteTicket.findByPk; + +afterAll(() => { + mock.module("@vortexfi/shared", () => ({ ...sharedReal })); + mock.module("../evm-funding", () => ({ ...evmFundingReal })); + mock.module("../../ramp/ramp.service", () => ({ ...rampServiceReal })); + QuoteTicket.findByPk = realQuoteTicketFindByPk; +}); + +let quote: { + inputCurrency: string; + outputCurrency: string; + to: string; +}; + +QuoteTicket.findByPk = mock(async () => quote as any) as typeof QuoteTicket.findByPk; + +function makeState(stateOverrides: Record = {}) { + const state = { + currentPhase: "squidRouterPay", + errorLogs: [], + get() { + const { get: _get, update: _update, ...data } = this; + return data; + }, + id: "ramp-1", + phaseHistory: [], + quoteId: "quote-1", + state: { + evmEphemeralAddress: EVM_EPHEMERAL_ADDRESS, + squidRouterPayTxHash: "0xpay", + squidRouterSwapHash: SWAP_HASH, + ...stateOverrides + }, + to: Networks.Base, + type: RampDirection.BUY, + async update(updateData: Record) { + Object.assign(this, updateData); + return this; + } + }; + return state as any; +} + +function makeHandler() { + const handler = new SquidRouterPayPhaseHandler(); + // Shrink the real 60s/10s waits so the polling loop runs in test time. + (handler as any).initialDelayMs = 10; + (handler as any).pollIntervalMs = 10; + return handler; +} + +const STUCK_CONFIRM_STATUS = { + call: { chain: "base" }, + confirm_failed: true, + id: `${SWAP_HASH}_55_172`, + is_insufficient_fee: false, + status: "called" +}; + +const EXECUTED_STATUS = { + id: `${SWAP_HASH}_55_172`, + is_insufficient_fee: false, + status: "executed" +}; + +describe("SquidRouterPayPhaseHandler", () => { + beforeEach(() => { + axelarStatusQueue = []; + getStatus.mockClear(); + getStatusAxelarScan.mockClear(); + recoverAxelarStuckConfirm.mockClear(); + checkEvmBalanceForToken.mockClear(); + quote = { + inputCurrency: FiatToken.BRL, + outputCurrency: "USDC", + to: Networks.Base + }; + }); + + it("recovers a stuck confirm and records the attempt timestamp", async () => { + axelarStatusQueue = [STUCK_CONFIRM_STATUS, EXECUTED_STATUS]; + + const state = makeState(); + const updatedState = await makeHandler().execute(state); + + expect(recoverAxelarStuckConfirm).toHaveBeenCalledTimes(1); + expect(recoverAxelarStuckConfirm).toHaveBeenCalledWith(SWAP_HASH, "base", undefined); + expect(state.state.axelarConfirmRecoveryAt).toBeString(); + expect(updatedState.currentPhase).toBe("finalSettlementSubsidy"); + }); + + it("respects the cooldown and does not re-broadcast a recent recovery attempt", async () => { + axelarStatusQueue = [STUCK_CONFIRM_STATUS, STUCK_CONFIRM_STATUS, EXECUTED_STATUS]; + + const state = makeState({ axelarConfirmRecoveryAt: new Date().toISOString() }); + await makeHandler().execute(state); + + expect(recoverAxelarStuckConfirm).not.toHaveBeenCalled(); + }); + + it("does not attempt recovery while the confirm poll has not failed", async () => { + axelarStatusQueue = [ + { ...STUCK_CONFIRM_STATUS, confirm_failed: false }, + EXECUTED_STATUS + ]; + + const state = makeState(); + await makeHandler().execute(state); + + expect(recoverAxelarStuckConfirm).not.toHaveBeenCalled(); + }); + + it("stops polling when the processor aborts the execution", async () => { + // Regression test for the retry storm: abandoned executions must unwind on abort + // instead of polling the status APIs forever. + axelarStatusQueue = [STUCK_CONFIRM_STATUS]; + quote = { + inputCurrency: FiatToken.EURC, + outputCurrency: "USDC", + to: Networks.AssetHub + }; + + const abortController = new AbortController(); + const state = makeState({ axelarConfirmRecoveryAt: new Date().toISOString() }); + + const execution = makeHandler().execute(state, abortController.signal); + // Let the loop run a few iterations before aborting. + await new Promise(resolve => setTimeout(resolve, 100)); + abortController.abort(new Error("Phase execution timed out")); + + await expect(execution).rejects.toThrow(); + expect(getStatus.mock.calls.length).toBeGreaterThan(0); + + const callsAtAbort = getStatus.mock.calls.length; + await new Promise(resolve => setTimeout(resolve, 150)); + expect(getStatus.mock.calls.length).toBe(callsAtAbort); + }); +}); diff --git a/apps/api/src/api/services/phases/handlers/squid-router-pay-phase-handler.timeout.test.ts b/apps/api/src/api/services/phases/handlers/squid-router-pay-phase-handler.timeout.test.ts new file mode 100644 index 000000000..cef1033d6 --- /dev/null +++ b/apps/api/src/api/services/phases/handlers/squid-router-pay-phase-handler.timeout.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from "bun:test"; +import QuoteTicket from "../../../../models/quoteTicket.model"; +import RampState from "../../../../models/rampState.model"; +import { RecoverablePhaseError } from "../../../errors/phase-error"; +import { SquidRouterPayPhaseHandler } from "./squid-router-pay-phase-handler"; + +type BridgeStatusChecker = { + checkBridgeStatus(state: RampState, swapHash: string, quote: QuoteTicket, timeoutMs?: number): Promise; +}; + +describe("SquidRouterPayPhaseHandler bridge polling timeout", () => { + it("rejects with a recoverable error when its deadline expires", async () => { + const handler = Object.create(SquidRouterPayPhaseHandler.prototype) as BridgeStatusChecker; + const state = { state: {} } as RampState; + const quote = {} as QuoteTicket; + + const result = handler.checkBridgeStatus(state, "0xswap", quote, 0); + + await expect(result).rejects.toBeInstanceOf(RecoverablePhaseError); + await expect(result).rejects.toThrow("Bridge status check timed out after 0ms"); + }); +}); diff --git a/apps/api/src/api/services/phases/handlers/squid-router-pay-phase-handler.ts b/apps/api/src/api/services/phases/handlers/squid-router-pay-phase-handler.ts index 3973a004c..1cd675ec4 100644 --- a/apps/api/src/api/services/phases/handlers/squid-router-pay-phase-handler.ts +++ b/apps/api/src/api/services/phases/handlers/squid-router-pay-phase-handler.ts @@ -17,7 +17,9 @@ import { OnChainToken, RampDirection, RampPhase, - SquidRouterPayResponse + recoverAxelarStuckConfirm, + SquidRouterPayResponse, + sleep } from "@vortexfi/shared"; import Big from "big.js"; import { createWalletClient, encodeFunctionData, Hash, PublicClient } from "viem"; @@ -29,18 +31,16 @@ import RampState from "../../../../models/rampState.model"; import { SubsidyToken } from "../../../../models/subsidy.model"; import { BasePhaseHandler } from "../base-phase-handler"; import { getEvmFundingAccount } from "../evm-funding"; +import { getSquidRouterPayTimeoutMs } from "../phase-processor-config"; const AXELAR_POLLING_INTERVAL_MS = 10000; // 10 seconds const SQUIDROUTER_INITIAL_DELAY_MS = 60000; // 60 seconds const AXL_GAS_SERVICE_EVM = "0x2d5d7d31F671F86C782533cc367F14109a082712"; const BALANCE_POLLING_TIME_MS = 10000; -// NOTE: This timeout is intentionally longer (15 minutes) than the 3–5 minute balance -// checks in other handlers. For SquidRouter/Axelar bridge flows we wait for cross-chain -// settlement and gas payment on the destination chain, which can legitimately take longer -// under network congestion or bridge delays. Reducing this timeout risks premature failure -// of otherwise successful bridge operations. -const EVM_BALANCE_CHECK_TIMEOUT_MS = 15 * 60 * 1000; // 15 minutes const DEFAULT_SQUIDROUTER_GAS_ESTIMATE = "1600000"; // Estimate used to calculate part of the gas fee for SquidRouter transactions. +// Minimum time between Axelar stuck-confirm recovery broadcasts for the same ramp. A new +// validator poll needs a few minutes to complete, so re-broadcasting sooner is pure noise. +const AXELAR_CONFIRM_RECOVERY_COOLDOWN_MS = 10 * 60 * 1000; /** * Handler for the squidRouter pay phase. Checks the status of the Axelar bridge and pays on native GLMR fee. */ @@ -51,6 +51,9 @@ export class SquidRouterPayPhaseHandler extends BasePhaseHandler { private moonbeamWalletClient: ReturnType; private polygonWalletClient: ReturnType; private baseWalletClient: ReturnType; + // Instance fields (not module constants) so tests can shrink the waits. + private initialDelayMs = SQUIDROUTER_INITIAL_DELAY_MS; + private pollIntervalMs = AXELAR_POLLING_INTERVAL_MS; constructor() { super(); @@ -77,7 +80,7 @@ export class SquidRouterPayPhaseHandler extends BasePhaseHandler { * @param state The current ramp state * @returns The updated ramp state */ - protected async executePhase(state: RampState): Promise { + protected async executePhase(state: RampState, signal?: AbortSignal): Promise { const quote = await QuoteTicket.findByPk(state.quoteId); if (!quote) { throw new Error("Quote not found for the given state"); @@ -98,7 +101,7 @@ export class SquidRouterPayPhaseHandler extends BasePhaseHandler { } // Enter check status loop - await this.checkStatus(state, bridgeCallHash, quote); + await this.checkStatus(state, bridgeCallHash, quote, signal); if (state.to === Networks.AssetHub) { return this.transitionToNextPhase(state, "moonbeamToPendulum"); @@ -117,13 +120,15 @@ export class SquidRouterPayPhaseHandler extends BasePhaseHandler { * If the bridge reports success, we consider it a success. * Only if both fail (timeout) we throw. */ - private async checkStatus(state: RampState, swapHash: string, quote: QuoteTicket): Promise { + private async checkStatus(state: RampState, swapHash: string, quote: QuoteTicket, signal?: AbortSignal): Promise { + const pollingTimeoutMs = getSquidRouterPayTimeoutMs(); + // If the destination is not an EVM network, skip the EVM balance optimization and rely on bridge status only. if (quote.to === Networks.AssetHub) { logger.info("SquidRouterPayPhaseHandler: Destination network is non-EVM; skipping EVM balance check optimization.", { toNetwork: quote.to }); - await this.checkBridgeStatus(state, swapHash, quote); + await this.checkBridgeStatus(state, swapHash, quote, pollingTimeoutMs, signal); return; } @@ -141,7 +146,8 @@ export class SquidRouterPayPhaseHandler extends BasePhaseHandler { chain: toChain, intervalMs: BALANCE_POLLING_TIME_MS, ownerAddress: ephemeralAddress, - timeoutMs: EVM_BALANCE_CHECK_TIMEOUT_MS, + signal, + timeoutMs: pollingTimeoutMs, tokenDetails: outTokenDetails }); } else { @@ -156,7 +162,7 @@ export class SquidRouterPayPhaseHandler extends BasePhaseHandler { } // Wrap both promises to prevent unhandled rejections after one succeeds - const bridgeCheckPromise = this.checkBridgeStatus(state, swapHash, quote).catch(err => { + const bridgeCheckPromise = this.checkBridgeStatus(state, swapHash, quote, pollingTimeoutMs, signal).catch(err => { // Re-throw to preserve the error for Promise.any throw err; }); @@ -179,7 +185,7 @@ export class SquidRouterPayPhaseHandler extends BasePhaseHandler { if (balanceError instanceof BalanceCheckError) { if (balanceError.type === BalanceCheckErrorType.Timeout) { - errorMessage += ` Balance check timed out after ${EVM_BALANCE_CHECK_TIMEOUT_MS}ms.`; + errorMessage += ` Balance check timed out after ${pollingTimeoutMs}ms.`; } else if (balanceError.type === BalanceCheckErrorType.ReadFailure) { errorMessage += ` Balance check read failure (unexpected infrastructure issue): ${balanceError.message}.`; } @@ -189,7 +195,7 @@ export class SquidRouterPayPhaseHandler extends BasePhaseHandler { errorMessage += ` Bridge check error: ${bridgeError instanceof Error ? bridgeError.message : String(bridgeError)}.`; } - throw new Error(errorMessage); + throw this.createRecoverableError(errorMessage); } throw error; } @@ -199,13 +205,27 @@ export class SquidRouterPayPhaseHandler extends BasePhaseHandler { * Gets the status of the Axelar bridge * @param txHash The swap (bridgeCall) transaction hash */ - private async checkBridgeStatus(state: RampState, swapHash: string, quote: QuoteTicket): Promise { + private async checkBridgeStatus( + state: RampState, + swapHash: string, + quote: QuoteTicket, + timeoutMs = getSquidRouterPayTimeoutMs(), + signal?: AbortSignal + ): Promise { let isExecuted = false; let payTxHash: string | undefined = state.state.squidRouterPayTxHash; + const timeoutAt = Date.now() + timeoutMs; - await new Promise(resolve => setTimeout(resolve, SQUIDROUTER_INITIAL_DELAY_MS)); + // The signal-aware sleeps make abandoned executions unwind when the processor + // times out this phase; without them every timed-out execution left an immortal + // polling loop behind, and they piled up against the SquidRouter rate limit. + await sleep(Math.min(this.initialDelayMs ?? SQUIDROUTER_INITIAL_DELAY_MS, timeoutMs), signal); while (!isExecuted) { + if (Date.now() >= timeoutAt) { + throw this.createRecoverableError(`SquidRouterPayPhaseHandler: Bridge status check timed out after ${timeoutMs}ms`); + } + try { const squidRouterStatus = await this.getSquidrouterStatus(swapHash, state, quote); @@ -256,6 +276,8 @@ export class SquidRouterPayPhaseHandler extends BasePhaseHandler { await state.update({ state: { ...state.state, squidRouterPayTxHash: payTxHash } }); + } else if (axelarScanStatus.status === "called" && axelarScanStatus.confirm_failed) { + await this.maybeRecoverStuckConfirm(state, swapHash, axelarScanStatus.call?.chain, signal); } } else { logger.info("SquidRouterPayPhaseHandler: Same-chain transaction detected. Skipping Axelar check."); @@ -266,7 +288,53 @@ export class SquidRouterPayPhaseHandler extends BasePhaseHandler { ); } - await new Promise(resolve => setTimeout(resolve, AXELAR_POLLING_INTERVAL_MS)); + await sleep(this.pollIntervalMs, signal); + } + } + + /** + * Axelar's relayer does not retry a failed validator confirmation poll, so a transfer + * whose poll failed stays in status "called" forever. Ask Axelar's recovery signing + * service for a new ConfirmGatewayTx and broadcast it, which restarts the poll. + * Attempts are rate-limited via a timestamp persisted in the ramp state, and failures + * are swallowed so the status loop keeps polling and retries after the cooldown. + */ + private async maybeRecoverStuckConfirm( + state: RampState, + swapHash: string, + sourceChain: string | undefined, + signal?: AbortSignal + ): Promise { + // An unparseable persisted timestamp yields NaN; treat it as "never attempted" so + // the comparison below stays well-defined (NaN comparisons are always false). + const parsedLastAttempt = state.state.axelarConfirmRecoveryAt ? new Date(state.state.axelarConfirmRecoveryAt).getTime() : 0; + const lastAttempt = Number.isFinite(parsedLastAttempt) ? parsedLastAttempt : 0; + if (Date.now() - lastAttempt < AXELAR_CONFIRM_RECOVERY_COOLDOWN_MS) { + return; + } + + if (!sourceChain) { + logger.warn( + `SquidRouterPayPhaseHandler: Confirm poll failed for ${swapHash} but Axelar status has no source chain; cannot attempt recovery.` + ); + return; + } + + // Persist the attempt timestamp before broadcasting so a failing relayer is not + // hammered on every 10s poll iteration. + await state.update({ + state: { ...state.state, axelarConfirmRecoveryAt: new Date().toISOString() } + }); + + try { + const axelarTxHash = await recoverAxelarStuckConfirm(swapHash, sourceChain, signal); + logger.info( + `SquidRouterPayPhaseHandler: Confirm poll failed for ${swapHash}; broadcast recovery ConfirmGatewayTx ${axelarTxHash} on Axelar.` + ); + } catch (error) { + logger.warn( + `SquidRouterPayPhaseHandler: Axelar stuck-confirm recovery attempt failed for ${swapHash}: ${error instanceof Error ? error.message : String(error)}` + ); } } diff --git a/apps/api/src/api/services/phases/meta-state-types.ts b/apps/api/src/api/services/phases/meta-state-types.ts index a1b81f1e5..8b5d2fa0f 100644 --- a/apps/api/src/api/services/phases/meta-state-types.ts +++ b/apps/api/src/api/services/phases/meta-state-types.ts @@ -33,6 +33,9 @@ export interface StateMetadata { squidRouterApproveHash: string; squidRouterSwapHash: string; squidRouterPayTxHash: string; + // Timestamp of the last Axelar stuck-confirm recovery attempt, persisted so + // retried phase executions respect the cooldown instead of re-broadcasting. + axelarConfirmRecoveryAt?: string; unhandledPaymentAlertSent: boolean; depositQrCode: string | undefined; // Set to true once update-time validation gate passes (all presigned txs valid + complete, diff --git a/apps/api/src/api/services/phases/phase-processor-config.test.ts b/apps/api/src/api/services/phases/phase-processor-config.test.ts new file mode 100644 index 000000000..fde9b5d14 --- /dev/null +++ b/apps/api/src/api/services/phases/phase-processor-config.test.ts @@ -0,0 +1,21 @@ +import { afterEach, describe, expect, it } from "bun:test"; +import { getPhaseProcessorMaxExecutionTimeMs, getSquidRouterPayTimeoutMs } from "./phase-processor-config"; + +const originalProcessorTimeout = process.env.PHASE_PROCESSOR_MAX_EXECUTION_TIME_MS; + +describe("phase processor timeout configuration", () => { + afterEach(() => { + if (originalProcessorTimeout === undefined) { + delete process.env.PHASE_PROCESSOR_MAX_EXECUTION_TIME_MS; + } else { + process.env.PHASE_PROCESSOR_MAX_EXECUTION_TIME_MS = originalProcessorTimeout; + } + }); + + it("limits SquidRouterPay polling to 80% of the processor timeout", () => { + process.env.PHASE_PROCESSOR_MAX_EXECUTION_TIME_MS = "1000"; + + expect(getPhaseProcessorMaxExecutionTimeMs()).toBe(1000); + expect(getSquidRouterPayTimeoutMs()).toBe(800); + }); +}); diff --git a/apps/api/src/api/services/phases/phase-processor-config.ts b/apps/api/src/api/services/phases/phase-processor-config.ts new file mode 100644 index 000000000..ee4a6a2ed --- /dev/null +++ b/apps/api/src/api/services/phases/phase-processor-config.ts @@ -0,0 +1,16 @@ +function positiveIntFromEnv(value: string | undefined, fallback: number): number { + const parsed = value ? parseInt(value, 10) : Number.NaN; + return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback; +} + +export function getPhaseProcessorMaxExecutionTimeMs(): number { + return positiveIntFromEnv(process.env.PHASE_PROCESSOR_MAX_EXECUTION_TIME_MS, 600000); +} + +export function getSquidRouterPayTimeoutMs(): number { + return Math.floor(getPhaseProcessorMaxExecutionTimeMs() * 0.8); +} + +export function getPhaseProcessorRetryDelayMs(): number { + return positiveIntFromEnv(process.env.PHASE_PROCESSOR_RETRY_DELAY_MS, 30000); +} diff --git a/apps/api/src/api/services/phases/phase-processor.cancellation.integration.test.ts b/apps/api/src/api/services/phases/phase-processor.cancellation.integration.test.ts index 53f2c1f22..0456ab0dd 100644 --- a/apps/api/src/api/services/phases/phase-processor.cancellation.integration.test.ts +++ b/apps/api/src/api/services/phases/phase-processor.cancellation.integration.test.ts @@ -29,10 +29,13 @@ const TEST_PHASE = "nablaSwap"; describe("PhaseProcessor execution cancellation", () => { let polls = 0; const receivedSignals: (AbortSignal | undefined)[] = []; + const observedLockTimes: number[] = []; const hangingHandler: PhaseHandler = { - execute: async (_state: RampState, signal?: AbortSignal) => { + execute: async (state: RampState, signal?: AbortSignal) => { receivedSignals.push(signal); + const persistedState = await RampState.findByPk(state.id); + observedLockTimes.push(new Date(persistedState?.processingLock.lockedAt as unknown as string).getTime()); // Poll forever, like a phase waiting for a payment that never arrives. await waitUntilTrue( async () => { @@ -86,6 +89,8 @@ describe("PhaseProcessor execution cancellation", () => { expect(receivedSignals.length).toBeGreaterThanOrEqual(2); expect(receivedSignals.every(signal => signal instanceof AbortSignal)).toBe(true); expect(receivedSignals.every(signal => signal?.aborted)).toBe(true); + expect(observedLockTimes.length).toBe(receivedSignals.length); + expect(observedLockTimes.at(-1)).toBeGreaterThan(observedLockTimes[0]); // Regression: without cancellation the abandoned loops keep polling forever. const pollsAfterGivingUp = polls; diff --git a/apps/api/src/api/services/phases/phase-processor.ts b/apps/api/src/api/services/phases/phase-processor.ts index 37b791c75..315f1241c 100644 --- a/apps/api/src/api/services/phases/phase-processor.ts +++ b/apps/api/src/api/services/phases/phase-processor.ts @@ -5,15 +5,9 @@ import { config } from "../../../config/vars"; import RampState from "../../../models/rampState.model"; import { APIError } from "../../errors/api-error"; import { PhaseError, RecoverablePhaseError } from "../../errors/phase-error"; +import { getPhaseProcessorMaxExecutionTimeMs, getPhaseProcessorRetryDelayMs } from "./phase-processor-config"; import phaseRegistry from "./phase-registry"; -// A malformed env override must fall back to the default: parseInt would yield NaN -// and setTimeout(..., NaN) fires immediately, which would time out every phase instantly. -function positiveIntFromEnv(value: string | undefined, fallback: number): number { - const parsed = value ? parseInt(value, 10) : Number.NaN; - return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback; -} - /** * Process phases for a ramping process */ @@ -22,9 +16,9 @@ export class PhaseProcessor { private retriesMap = new Map(); private readonly MAX_RETRIES = 8; // Overridable so tests don't wait 10 minutes for the execution timeout. - private readonly MAX_EXECUTION_TIME_MS = positiveIntFromEnv(process.env.PHASE_PROCESSOR_MAX_EXECUTION_TIME_MS, 600000); // 10 minutes + private readonly MAX_EXECUTION_TIME_MS = getPhaseProcessorMaxExecutionTimeMs(); // 10 minutes // Overridable so tests don't wait 30s between recoverable-error retries. - private readonly DEFAULT_RETRY_DELAY_MS = positiveIntFromEnv(process.env.PHASE_PROCESSOR_RETRY_DELAY_MS, 30000); + private readonly DEFAULT_RETRY_DELAY_MS = getPhaseProcessorRetryDelayMs(); private lockedRamps = new Set(); /** @@ -137,6 +131,21 @@ export class PhaseProcessor { } } + /** + * Keep the lock fresh while a ramp advances through phases and retries. + */ + private async refreshLock(state: RampState): Promise { + await RampState.update( + { + processingLock: { + locked: true, + lockedAt: new Date() + } + }, + { where: { id: state.id } } + ); + } + /** * Check if the lock has expired. We do this to avoid stale locks that can happen when the service crashes or restarts. * @param state The current ramp state @@ -172,6 +181,10 @@ export class PhaseProcessor { */ private async processPhase(state: RampState): Promise { try { + // The lock is acquired once for the whole processing chain. Refresh it before + // every phase attempt so long phases and retries are not mistaken for crashed work. + await this.refreshLock(state); + const { currentPhase } = state; logger.info(`Processing phase ${currentPhase} for ramp ${state.id}`); diff --git a/apps/api/src/api/services/priceFeed.schemas.ts b/apps/api/src/api/services/priceFeed.schemas.ts new file mode 100644 index 000000000..68fbd56db --- /dev/null +++ b/apps/api/src/api/services/priceFeed.schemas.ts @@ -0,0 +1,19 @@ +import { z } from "zod"; + +/** + * External API contract schema for the CoinGecko price feed consumed by + * PriceFeedService (see docs/features/contract-tests.md). + * + * GET /simple/price returns `{ [tokenId]: { [vsCurrency]: number } }`; + * getCryptoPrice reads exactly `data[tokenId][vsCurrency]` and treats a missing + * token or currency key as an error, so the consumed contract is: every entry + * present is an object of numeric prices. Which keys must be present depends on + * the request — the contract test asserts presence for the ids it requested. + * + * The Nabla AMM and DIA oracle rates the service also serves are read from + * Pendulum chain state, not HTTP — out of scope per the no-chain-fidelity + * non-goal of the contract-test PRD. + */ +export const coingeckoSimplePriceResponseSchema = z.record(z.string(), z.record(z.string(), z.number())) satisfies z.ZodType< + Record> +>; diff --git a/apps/api/src/api/services/quote/alfredpay-customer.ts b/apps/api/src/api/services/quote/alfredpay-customer.ts index 9767d3b3d..768f3441b 100644 --- a/apps/api/src/api/services/quote/alfredpay-customer.ts +++ b/apps/api/src/api/services/quote/alfredpay-customer.ts @@ -1,7 +1,8 @@ -import { AlfredPayCountry, AlfredPayStatus, FiatToken, isAlfredpayToken } from "@vortexfi/shared"; +import { AlfredPayCountry, FiatToken, isAlfredpayToken } from "@vortexfi/shared"; import httpStatus from "http-status"; -import AlfredPayCustomer from "../../../models/alfredPayCustomer.model"; +import ProviderCustomer, { VerificationStatus } from "../../../models/providerCustomer.model"; import { APIError } from "../../errors/api-error"; +import { getOrCreateCustomerEntityForProfile } from "../customer-entity.service"; const fiatToCountry: Partial> = { [FiatToken.USD]: AlfredPayCountry.US, @@ -36,15 +37,16 @@ export async function resolveAlfredpayQuoteCustomerId(fiatCurrency: string, user return ALFREDPAY_ANONYMOUS_CUSTOMER_ID; } - const customer = await AlfredPayCustomer.findOne({ - where: { country, userId } + const entity = await getOrCreateCustomerEntityForProfile(userId); + const customer = await ProviderCustomer.findOne({ + where: { country, customerEntityId: entity.id, provider: "alfredpay" } }); - if (!customer || customer.status !== AlfredPayStatus.Success) { + if (!customer || customer.status !== VerificationStatus.Approved) { return ALFREDPAY_ANONYMOUS_CUSTOMER_ID; } - return customer.alfredPayId; + return customer.providerCustomerId ?? ALFREDPAY_ANONYMOUS_CUSTOMER_ID; } /** @@ -71,8 +73,9 @@ export async function resolveAlfredpayCustomerId(fiatCurrency: string, userId: s }); } - const customer = await AlfredPayCustomer.findOne({ - where: { country, userId } + const entity = await getOrCreateCustomerEntityForProfile(userId); + const customer = await ProviderCustomer.findOne({ + where: { country, customerEntityId: entity.id, provider: "alfredpay" } }); if (!customer) { @@ -82,12 +85,12 @@ export async function resolveAlfredpayCustomerId(fiatCurrency: string, userId: s }); } - if (customer.status !== AlfredPayStatus.Success) { + if (customer.status !== VerificationStatus.Approved) { throw new APIError({ message: `Alfredpay KYC status is ${customer.status}. Complete Alfredpay KYC before registering a ramp.`, status: httpStatus.BAD_REQUEST }); } - return customer.alfredPayId; + return customer.providerCustomerId ?? ""; } diff --git a/apps/api/src/api/services/quote/core/partner-resolution.test.ts b/apps/api/src/api/services/quote/core/partner-resolution.test.ts index 04d880341..5ea77898c 100644 --- a/apps/api/src/api/services/quote/core/partner-resolution.test.ts +++ b/apps/api/src/api/services/quote/core/partner-resolution.test.ts @@ -1,6 +1,7 @@ import {afterEach, describe, expect, it, mock} from "bun:test"; import {EPaymentMethod, EvmToken, FiatToken, Networks, RampDirection} from "@vortexfi/shared"; import Partner from "../../../../models/partner.model"; +import PartnerPricingConfig from "../../../../models/partnerPricingConfig.model"; import ProfilePartnerAssignment from "../../../../models/profilePartnerAssignment.model"; import {resolveQuotePartner} from "./partner-resolution"; @@ -14,12 +15,37 @@ const baseRequest = { to: Networks.Base }; +function stubPartner(id: string, name: string) { + return { displayName: name, id, isActive: true, logoUrl: null, name }; +} + +function stubConfig(partnerId: string, rampType: RampDirection) { + return { + isActive: true, + markupCurrency: FiatToken.EURC, + markupType: "none", + markupValue: 0, + maxDynamicDifference: 0, + maxSubsidy: 0, + minDynamicDifference: 0, + partnerId, + payoutAddressEvm: null, + payoutAddressSubstrate: null, + rampType, + targetDiscount: 0, + vortexFeeType: "none", + vortexFeeValue: 0 + }; +} + describe("resolveQuotePartner", () => { const originalPartnerFindOne = Partner.findOne; + const originalConfigFindOne = PartnerPricingConfig.findOne; const originalAssignmentFindOne = ProfilePartnerAssignment.findOne; afterEach(() => { Partner.findOne = originalPartnerFindOne; + PartnerPricingConfig.findOne = originalConfigFindOne; ProfilePartnerAssignment.findOne = originalAssignmentFindOne; }); @@ -27,13 +53,19 @@ describe("resolveQuotePartner", () => { let assignmentLookupCount = 0; Partner.findOne = mock(async ({ where }: { where: { name?: string } }) => { if (where.name === "ExplicitPartner") { - return { id: "explicit-buy-id", name: "ExplicitPartner" }; + return stubPartner("explicit-id", "ExplicitPartner"); } return null; }) as typeof Partner.findOne; + PartnerPricingConfig.findOne = mock(async ({ where }: { where: { partnerId?: string; rampType?: RampDirection } }) => { + if (where.partnerId === "explicit-id" && where.rampType === RampDirection.BUY) { + return stubConfig("explicit-id", RampDirection.BUY); + } + return null; + }) as typeof PartnerPricingConfig.findOne; ProfilePartnerAssignment.findOne = mock(async () => { assignmentLookupCount++; - return { buyPartnerId: "assigned-buy-id", sellPartnerId: "assigned-sell-id" }; + return { partnerId: "assigned-id" }; }) as typeof ProfilePartnerAssignment.findOne; const result = await resolveQuotePartner({ @@ -44,21 +76,23 @@ describe("resolveQuotePartner", () => { }); expect(result.source).toBe("request"); - expect(result.pricingPartnerId).toBe("explicit-buy-id"); - expect(result.ownerPartnerId).toBe("explicit-buy-id"); + expect(result.pricingPartnerId).toBe("explicit-id"); + expect(result.ownerPartnerId).toBe("explicit-id"); expect(assignmentLookupCount).toBe(0); }); it("keeps public-key partner quotes partner-owned for compatibility", async () => { Partner.findOne = mock(async ({ where }: { where: { name?: string } }) => { if (where.name === "PublicKeyPartner") { - return { id: "public-key-buy-id", name: "PublicKeyPartner" }; + return stubPartner("public-key-id", "PublicKeyPartner"); } return null; }) as typeof Partner.findOne; + PartnerPricingConfig.findOne = mock(async () => + stubConfig("public-key-id", RampDirection.BUY) + ) as typeof PartnerPricingConfig.findOne; ProfilePartnerAssignment.findOne = mock(async () => ({ - buyPartnerId: "assigned-buy-id", - sellPartnerId: "assigned-sell-id" + partnerId: "assigned-id" })) as typeof ProfilePartnerAssignment.findOne; const result = await resolveQuotePartner({ @@ -68,21 +102,26 @@ describe("resolveQuotePartner", () => { }); expect(result.source).toBe("publicKey"); - expect(result.pricingPartnerId).toBe("public-key-buy-id"); - expect(result.ownerPartnerId).toBe("public-key-buy-id"); + expect(result.pricingPartnerId).toBe("public-key-id"); + expect(result.ownerPartnerId).toBe("public-key-id"); }); - it("applies the buy profile assignment as pricing-only for authenticated buy users", async () => { + it("applies the profile assignment as pricing-only for authenticated buy users", async () => { ProfilePartnerAssignment.findOne = mock(async () => ({ - buyPartnerId: "assigned-buy-id", - sellPartnerId: "assigned-sell-id" + partnerId: "assigned-id" })) as typeof ProfilePartnerAssignment.findOne; - Partner.findOne = mock(async ({ where }: { where: { id?: string; rampType?: RampDirection } }) => { - if (where.id === "assigned-buy-id" && where.rampType === RampDirection.BUY) { - return { id: "assigned-buy-id", name: "AssignedPartner" }; + Partner.findOne = mock(async ({ where }: { where: { id?: string } }) => { + if (where.id === "assigned-id") { + return stubPartner("assigned-id", "AssignedPartner"); } return null; }) as typeof Partner.findOne; + PartnerPricingConfig.findOne = mock(async ({ where }: { where: { partnerId?: string; rampType?: RampDirection } }) => { + if (where.partnerId === "assigned-id" && where.rampType === RampDirection.BUY) { + return stubConfig("assigned-id", RampDirection.BUY); + } + return null; + }) as typeof PartnerPricingConfig.findOne; const result = await resolveQuotePartner({ ...baseRequest, @@ -90,21 +129,26 @@ describe("resolveQuotePartner", () => { }); expect(result.source).toBe("profileAssignment"); - expect(result.pricingPartnerId).toBe("assigned-buy-id"); + expect(result.pricingPartnerId).toBe("assigned-id"); expect(result.ownerPartnerId).toBeNull(); }); - it("applies the sell profile assignment as pricing-only for authenticated sell users", async () => { + it("resolves the sell-direction pricing config for sell users", async () => { ProfilePartnerAssignment.findOne = mock(async () => ({ - buyPartnerId: "assigned-buy-id", - sellPartnerId: "assigned-sell-id" + partnerId: "assigned-id" })) as typeof ProfilePartnerAssignment.findOne; - Partner.findOne = mock(async ({ where }: { where: { id?: string; rampType?: RampDirection } }) => { - if (where.id === "assigned-sell-id" && where.rampType === RampDirection.SELL) { - return { id: "assigned-sell-id", name: "AssignedPartner" }; + Partner.findOne = mock(async ({ where }: { where: { id?: string } }) => { + if (where.id === "assigned-id") { + return stubPartner("assigned-id", "AssignedPartner"); } return null; }) as typeof Partner.findOne; + PartnerPricingConfig.findOne = mock(async ({ where }: { where: { partnerId?: string; rampType?: RampDirection } }) => { + if (where.partnerId === "assigned-id" && where.rampType === RampDirection.SELL) { + return stubConfig("assigned-id", RampDirection.SELL); + } + return null; + }) as typeof PartnerPricingConfig.findOne; const result = await resolveQuotePartner({ ...baseRequest, @@ -113,16 +157,17 @@ describe("resolveQuotePartner", () => { }); expect(result.source).toBe("profileAssignment"); - expect(result.pricingPartnerId).toBe("assigned-sell-id"); + expect(result.pricingPartnerId).toBe("assigned-id"); expect(result.ownerPartnerId).toBeNull(); + expect(result.partner?.rampType).toBe(RampDirection.SELL); }); - it("falls back to default pricing when no partner id is assigned for the ramp type", async () => { + it("falls back to default pricing when the assignment has no partner id", async () => { ProfilePartnerAssignment.findOne = mock(async () => ({ - buyPartnerId: null, - sellPartnerId: "assigned-sell-id" + partnerId: null })) as typeof ProfilePartnerAssignment.findOne; - Partner.findOne = mock(async () => ({ id: "unexpected" })) as typeof Partner.findOne; + Partner.findOne = mock(async () => stubPartner("unexpected", "unexpected")) as typeof Partner.findOne; + PartnerPricingConfig.findOne = mock(async () => null) as typeof PartnerPricingConfig.findOne; const result = await resolveQuotePartner({ ...baseRequest, @@ -138,7 +183,7 @@ describe("resolveQuotePartner", () => { let assignmentLookupCount = 0; ProfilePartnerAssignment.findOne = mock(async () => { assignmentLookupCount++; - return { buyPartnerId: "assigned-buy-id", sellPartnerId: "assigned-sell-id" }; + return { partnerId: "assigned-id" }; }) as typeof ProfilePartnerAssignment.findOne; Partner.findOne = mock(async () => null) as typeof Partner.findOne; diff --git a/apps/api/src/api/services/quote/core/partner-resolution.ts b/apps/api/src/api/services/quote/core/partner-resolution.ts index 89b36c971..ea63a96d4 100644 --- a/apps/api/src/api/services/quote/core/partner-resolution.ts +++ b/apps/api/src/api/services/quote/core/partner-resolution.ts @@ -1,8 +1,8 @@ import { CreateQuoteRequest, RampDirection } from "@vortexfi/shared"; import { Op } from "sequelize"; import logger from "../../../../config/logger"; -import Partner from "../../../../models/partner.model"; import ProfilePartnerAssignment from "../../../../models/profilePartnerAssignment.model"; +import { findPartnerWithPricing, PartnerWithPricing } from "../../partners/partner-pricing.service"; import type { PartnerPricingSource } from "./types"; type QuotePartnerResolutionRequest = CreateQuoteRequest & { @@ -12,7 +12,7 @@ type QuotePartnerResolutionRequest = CreateQuoteRequest & { export interface ResolvedQuotePartner { ownerPartnerId: string | null; - partner: Partner | null; + partner: PartnerWithPricing | null; pricingPartnerId: string | null; source: PartnerPricingSource; } @@ -23,15 +23,9 @@ async function findPartnerForRamp( partnerRef: string, rampType: RampDirection, source: PartnerPricingSource -): Promise { +): Promise { const isUuid = source === "request" && UUID_PATTERN.test(partnerRef); - const partner = await Partner.findOne({ - where: { - ...(isUuid ? { id: partnerRef } : { name: partnerRef }), - isActive: true, - rampType - } - }); + const partner = await findPartnerWithPricing(isUuid ? { id: partnerRef } : { name: partnerRef }, rampType); if (!partner) { logger.warn( @@ -42,14 +36,8 @@ async function findPartnerForRamp( return partner; } -async function findPartnerByIdForRamp(partnerId: string, rampType: RampDirection): Promise { - const partner = await Partner.findOne({ - where: { - id: partnerId, - isActive: true, - rampType - } - }); +async function findPartnerByIdForRamp(partnerId: string, rampType: RampDirection): Promise { + const partner = await findPartnerWithPricing({ id: partnerId }, rampType); if (!partner) { logger.warn( @@ -60,7 +48,7 @@ async function findPartnerByIdForRamp(partnerId: string, rampType: RampDirection return partner; } -async function findAssignedPartnerId(userId: string, rampType: RampDirection, now: Date): Promise { +async function findAssignedPartnerId(userId: string, now: Date): Promise { const assignment = await ProfilePartnerAssignment.findOne({ order: [["createdAt", "DESC"]], where: { @@ -70,11 +58,7 @@ async function findAssignedPartnerId(userId: string, rampType: RampDirection, no } }); - if (!assignment) { - return null; - } - - return rampType === RampDirection.BUY ? assignment.buyPartnerId : assignment.sellPartnerId; + return assignment?.partnerId ?? null; } export async function resolveQuotePartner( @@ -102,7 +86,7 @@ export async function resolveQuotePartner( } if (request.userId) { - const assignedPartnerId = await findAssignedPartnerId(request.userId, request.rampType, now); + const assignedPartnerId = await findAssignedPartnerId(request.userId, now); if (assignedPartnerId) { const partner = await findPartnerByIdForRamp(assignedPartnerId, request.rampType); return { diff --git a/apps/api/src/api/services/quote/core/quote-fees.ts b/apps/api/src/api/services/quote/core/quote-fees.ts index 473caf5be..5794cad6b 100644 --- a/apps/api/src/api/services/quote/core/quote-fees.ts +++ b/apps/api/src/api/services/quote/core/quote-fees.ts @@ -3,8 +3,8 @@ import Big from "big.js"; import httpStatus from "http-status"; import logger from "../../../../config/logger"; import Anchor from "../../../../models/anchor.model"; -import Partner from "../../../../models/partner.model"; import { APIError } from "../../../errors/api-error"; +import { findPartnerWithPricing } from "../../partners/partner-pricing.service"; import { priceFeedService } from "../../priceFeed.service"; import { getTargetFiatCurrency, validateChainSupport } from "./helpers"; @@ -14,7 +14,7 @@ export interface CalculateFeeComponentsRequest { rampType: RampDirection; from: DestinationType; to: DestinationType; - partnerName?: string; + partnerId?: string; inputCurrency: RampCurrency; outputCurrency: RampCurrency; } @@ -83,88 +83,72 @@ async function calculatePartnerAndVortexFees( let totalPartnerMarkupInFeeCurrency = new Big(0); let totalVortexFeeInFeeCurrency = new Big(0); - // 1. Fetch and process partner-specific configurations if partnerName is provided + // 1. Fetch and process the partner's pricing config if a partner id is provided if (partnerId) { - // Query all records where name matches partnerName AND rampType matches rampType - const partnerRecords = await Partner.findAll({ - where: { - id: partnerId, - isActive: true, - rampType: rampType - } - }); + const pricing = await findPartnerWithPricing({ id: partnerId }, rampType); - if (partnerRecords.length > 0) { + if (pricing) { let hasApplicableFees = false; - for (const record of partnerRecords) { - // Partner markup fee - if (record.markupType !== "none") { - const markupFeeComponent = await calculateFeeComponent( - record.markupValue, - record.markupType as "absolute" | "relative", - inputAmount, - inputCurrency, - record.markupCurrency - ); - - const markupFeeComponentInFeeCurrency = await priceFeedService.convertCurrency( - markupFeeComponent.toString(), - record.markupCurrency, - feeCurrency - ); - totalPartnerMarkupInFeeCurrency = totalPartnerMarkupInFeeCurrency.plus(markupFeeComponentInFeeCurrency); - - if (markupFeeComponent.gt(0)) { - hasApplicableFees = true; - } + // Partner markup fee + if (pricing.markupType !== "none") { + const markupFeeComponent = await calculateFeeComponent( + pricing.markupValue, + pricing.markupType, + inputAmount, + inputCurrency, + pricing.markupCurrency + ); + + const markupFeeComponentInFeeCurrency = await priceFeedService.convertCurrency( + markupFeeComponent.toString(), + pricing.markupCurrency, + feeCurrency + ); + totalPartnerMarkupInFeeCurrency = totalPartnerMarkupInFeeCurrency.plus(markupFeeComponentInFeeCurrency); + + if (markupFeeComponent.gt(0)) { + hasApplicableFees = true; } + } + + // Vortex Fee Component from this partner's pricing config + if (pricing.vortexFeeType !== "none") { + const vortexFeeComponent = await calculateFeeComponent( + pricing.vortexFeeValue, + pricing.vortexFeeType, + inputAmount, + inputCurrency, + pricing.markupCurrency + ); + + const vortexFeeComponentInFeeCurrency = await priceFeedService.convertCurrency( + vortexFeeComponent.toString(), + pricing.markupCurrency, + feeCurrency + ); + totalVortexFeeInFeeCurrency = totalVortexFeeInFeeCurrency.plus(vortexFeeComponentInFeeCurrency); - // Vortex Fee Component from this partner record - if (record.vortexFeeType !== "none") { - const vortexFeeComponent = await calculateFeeComponent( - record.vortexFeeValue, - record.vortexFeeType as "absolute" | "relative", - inputAmount, - inputCurrency, - record.markupCurrency - ); - - const vortexFeeComponentInFeeCurrency = await priceFeedService.convertCurrency( - vortexFeeComponent.toString(), - record.markupCurrency, - feeCurrency - ); - totalVortexFeeInFeeCurrency = totalVortexFeeInFeeCurrency.plus(vortexFeeComponentInFeeCurrency); - - if (vortexFeeComponent.gt(0)) { - hasApplicableFees = true; - } + if (vortexFeeComponent.gt(0)) { + hasApplicableFees = true; } } // Log warning if partner found but no applicable custom fees if (!hasApplicableFees) { - logger.warn(`Partner with name '${partnerId}' found, but no active markup defined. Proceeding with default fees.`); + logger.warn(`Partner '${partnerId}' found, but no active markup defined. Proceeding with default fees.`); } } else { - // No specific partner records found, will use default Vortex fee below - logger.warn(`No fee configuration found for partner with name '${partnerId}'. Proceeding with default fees.`); + // No pricing config found, will use default Vortex fee below + logger.warn(`No fee configuration found for partner '${partnerId}'. Proceeding with default fees.`); } } // 2. If no partner was provided initially, use default Vortex fees if (!partnerId) { - // Query all vortex records for this ramp type - const vortexFoundationPartners = await Partner.findAll({ - where: { - isActive: true, - name: "vortex", - rampType: rampType - } - }); + const vortexPricing = await findPartnerWithPricing({ name: "vortex" }, rampType); - if (vortexFoundationPartners.length === 0) { + if (!vortexPricing) { logger.error(`Vortex partner configuration not found for ${rampType}-ramp in database.`); throw new APIError({ message: "Internal configuration error [VF]", @@ -172,24 +156,21 @@ async function calculatePartnerAndVortexFees( }); } - // Process each vortex record and accumulate fees - for (const vortexFoundationPartner of vortexFoundationPartners) { - if (vortexFoundationPartner.markupType !== "none") { - const vortexFeeComponent = await calculateFeeComponent( - vortexFoundationPartner.markupValue, - vortexFoundationPartner.markupType as "absolute" | "relative", - inputAmount, - inputCurrency, - vortexFoundationPartner.markupCurrency - ); + if (vortexPricing.markupType !== "none") { + const vortexFeeComponent = await calculateFeeComponent( + vortexPricing.markupValue, + vortexPricing.markupType, + inputAmount, + inputCurrency, + vortexPricing.markupCurrency + ); - const vortexFeeComponentInFeeCurrency = await priceFeedService.convertCurrency( - vortexFeeComponent.toString(), - vortexFoundationPartner.markupCurrency, - feeCurrency - ); - totalVortexFeeInFeeCurrency = totalVortexFeeInFeeCurrency.plus(vortexFeeComponentInFeeCurrency); - } + const vortexFeeComponentInFeeCurrency = await priceFeedService.convertCurrency( + vortexFeeComponent.toString(), + vortexPricing.markupCurrency, + feeCurrency + ); + totalVortexFeeInFeeCurrency = totalVortexFeeInFeeCurrency.plus(vortexFeeComponentInFeeCurrency); } } @@ -339,7 +320,7 @@ export async function calculateFeeComponents(request: CalculateFeeComponentsRequ const { partnerMarkupFee, vortexFee } = await calculatePartnerAndVortexFees( request.inputAmount, request.rampType, - request.partnerName, + request.partnerId, request.inputCurrency, feeCurrency ); diff --git a/apps/api/src/api/services/quote/engines/discount/helpers.test.ts b/apps/api/src/api/services/quote/engines/discount/helpers.test.ts index 4ed4c4397..66c844db0 100644 --- a/apps/api/src/api/services/quote/engines/discount/helpers.test.ts +++ b/apps/api/src/api/services/quote/engines/discount/helpers.test.ts @@ -1,6 +1,8 @@ -import { describe, expect, it } from "bun:test"; +import { afterEach, describe, expect, it, spyOn } from "bun:test"; import Big from "big.js"; -import { calculateExpectedOutput, calculateSubsidyAmount } from "./helpers"; +import { priceFeedService } from "../../../priceFeed.service"; +import { QuoteContext } from "../../core/types"; +import { calculateExpectedOutput, calculateSubsidyAmount, getUsdDenominatedInputAmount } from "./helpers"; describe("calculateSubsidyAmount", () => { it("returns 0 when actual output meets expected output", () => { @@ -53,3 +55,84 @@ describe("calculateExpectedOutput with negative targetDiscount (rate floor)", () expect(expectedOutput.toString()).toBe("102"); }); }); + +describe("getUsdDenominatedInputAmount", () => { + const brlToUsdRate = new Big("0.19475713784910216959"); + const eurToUsdRate = new Big("1.16"); + let rateSpy: ReturnType | undefined; + + afterEach(() => { + rateSpy?.mockRestore(); + rateSpy = undefined; + }); + + const makeCtx = (inputCurrency: string, inputAmount: string, bridgedUsdcAmount?: string) => + ({ + evmToEvm: bridgedUsdcAmount ? { outputAmountDecimal: new Big(bridgedUsdcAmount) } : undefined, + request: { inputAmount, inputCurrency } + }) as unknown as QuoteContext; + + it("returns the request amount unchanged for USD-like inputs", async () => { + const usd = await getUsdDenominatedInputAmount(makeCtx("USDC", "1000")); + expect(usd.toString()).toBe("1000"); + }); + + it("values a BRLA input at the BRL-USD oracle rate", async () => { + rateSpy = spyOn(priceFeedService, "getFiatToUsdExchangeRate").mockResolvedValue(brlToUsdRate); + + const usd = await getUsdDenominatedInputAmount(makeCtx("BRLA", "1000")); + expect(usd.toFixed(6)).toBe("194.757138"); + }); + + it("regression: a 1000 BRLA offramp to BRL targets ~1000 BRL, not inputAmount x inverted rate", async () => { + rateSpy = spyOn(priceFeedService, "getFiatToUsdExchangeRate").mockResolvedValue(brlToUsdRate); + + // Before the fix the engine passed the raw 1000 (BRLA) into calculateExpectedOutput as if + // it were USD, yielding an expected output of ~5134 BRL and a massively inflated subsidy. + const usd = await getUsdDenominatedInputAmount(makeCtx("BRLA", "1000")); + const { expectedOutput } = calculateExpectedOutput(usd.toString(), brlToUsdRate, 0, true, null); + expect(expectedOutput.toFixed(4)).toBe("1000.0000"); + }); + + it("values a EURC input at the EUR-USD oracle rate", async () => { + rateSpy = spyOn(priceFeedService, "getFiatToUsdExchangeRate").mockResolvedValue(eurToUsdRate); + + const usd = await getUsdDenominatedInputAmount(makeCtx("EURC", "1000")); + expect(usd.toFixed(2)).toBe("1160.00"); + }); + + it("regression: a 1000 EURC offramp to EUR targets ~1000 EUR, not a zero-subsidy undershoot", async () => { + rateSpy = spyOn(priceFeedService, "getFiatToUsdExchangeRate").mockResolvedValue(eurToUsdRate); + + // Before the fix the engine passed the raw 1000 (EURC) into calculateExpectedOutput as if + // it were USD, yielding an expected output of ~862 EUR — below the actual output, so the + // EURC→SEPA subsidy was silently never paid. + const usd = await getUsdDenominatedInputAmount(makeCtx("EURC", "1000")); + const { expectedOutput } = calculateExpectedOutput(usd.toString(), eurToUsdRate, 0, true, null); + expect(expectedOutput.toFixed(4)).toBe("1000.0000"); + }); + + it("falls back to the bridged USDC amount when the fiat-peg rate lookup fails", async () => { + rateSpy = spyOn(priceFeedService, "getFiatToUsdExchangeRate").mockRejectedValue(new Error("feed down")); + + const usd = await getUsdDenominatedInputAmount(makeCtx("BRLA", "1000", "194.757138")); + expect(usd.toString()).toBe("194.757138"); + }); + + it("falls back to the raw input when the fiat-peg rate lookup fails and no bridged amount exists", async () => { + rateSpy = spyOn(priceFeedService, "getFiatToUsdExchangeRate").mockRejectedValue(new Error("feed down")); + + const usd = await getUsdDenominatedInputAmount(makeCtx("BRLA", "1000")); + expect(usd.toString()).toBe("1000"); + }); + + it("falls back to the bridged USDC amount for tokens without a fiat peg", async () => { + const usd = await getUsdDenominatedInputAmount(makeCtx("ETH", "0.5", "1834.201")); + expect(usd.toString()).toBe("1834.201"); + }); + + it("falls back to the request amount when no bridged amount is available", async () => { + const usd = await getUsdDenominatedInputAmount(makeCtx("ETH", "0.5")); + expect(usd.toString()).toBe("0.5"); + }); +}); diff --git a/apps/api/src/api/services/quote/engines/discount/helpers.ts b/apps/api/src/api/services/quote/engines/discount/helpers.ts index c4e56bcc5..f46864a8a 100644 --- a/apps/api/src/api/services/quote/engines/discount/helpers.ts +++ b/apps/api/src/api/services/quote/engines/discount/helpers.ts @@ -1,7 +1,9 @@ -import { RampDirection } from "@vortexfi/shared"; +import { EvmToken, FiatToken, normalizeTokenSymbol, RampDirection } from "@vortexfi/shared"; import Big from "big.js"; +import logger from "../../../../../config/logger"; import { config } from "../../../../../config/vars"; -import Partner from "../../../../../models/partner.model"; +import { findPartnerWithPricing, PartnerWithPricing } from "../../../partners/partner-pricing.service"; +import { priceFeedService } from "../../../priceFeed.service"; import { QuoteContext } from "../../core/types"; import { DiscountComputation } from "./index"; @@ -12,6 +14,8 @@ interface PartnerDiscountState { difference: Big; } +// Keyed per (partner, direction): pre-split the BUY/SELL partner rows had distinct ids and +// got per-direction state isolation for free — the composite key preserves that. const partnerDiscountState = new Map(); function getDeltaD(): Big { @@ -22,10 +26,16 @@ function isWithinStateTimeout(timestamp: Date, now: Date): boolean { return now.getTime() - timestamp.getTime() < config.quote.discountStateTimeoutMinutes * 60 * 1000; } -export type ActivePartner = Pick< - Partner, - "id" | "targetDiscount" | "maxSubsidy" | "minDynamicDifference" | "maxDynamicDifference" | "name" -> | null; +export type ActivePartner = { + id: string; + name: string; + targetDiscount: number; + maxSubsidy: number; + minDynamicDifference: number; + maxDynamicDifference: number; + /** Discount-state map key, scoped per (partner, ramp direction). */ + stateKey: string; +} | null; export interface DiscountSubsidyPayload { actualOutputAmountDecimal: Big; @@ -33,33 +43,92 @@ export interface DiscountSubsidyPayload { expectedOutputAmountDecimal: Big; } +export function toActivePartner(pricing: PartnerWithPricing): ActivePartner { + return { + id: pricing.id, + maxDynamicDifference: pricing.maxDynamicDifference, + maxSubsidy: pricing.maxSubsidy, + minDynamicDifference: pricing.minDynamicDifference, + name: pricing.name, + stateKey: `${pricing.id}:${pricing.rampType}`, + targetDiscount: pricing.targetDiscount + }; +} + +export async function resolveActivePartnerById(partnerId: string, rampType: RampDirection): Promise { + const pricing = await findPartnerWithPricing({ id: partnerId }, rampType); + return pricing ? toActivePartner(pricing) : null; +} + export async function resolveDiscountPartner(ctx: QuoteContext, rampType: RampDirection): Promise { const partnerId = ctx.partner?.id; - const where = { - isActive: true, - rampType - } as const; - if (partnerId) { - const partner = await Partner.findOne({ - where: { - ...where, - id: partnerId - } - }); - + const partner = await resolveActivePartnerById(partnerId, rampType); if (partner) { return partner; } } - return Partner.findOne({ - where: { - ...where, - name: DEFAULT_PARTNER_NAME + const vortexPricing = await findPartnerWithPricing({ name: DEFAULT_PARTNER_NAME }, rampType); + return vortexPricing ? toActivePartner(vortexPricing) : null; +} + +const USD_LIKE_INPUT_CURRENCIES: ReadonlySet = new Set([ + "USD", + EvmToken.USDC, + EvmToken.USDT, + EvmToken.USDCE, + EvmToken.AXLUSDC +]); + +const FIAT_PEG_BY_STABLECOIN: Record = { + [EvmToken.BRLA]: FiatToken.BRL, + [EvmToken.EURC]: FiatToken.EURC +}; + +/** + * Value the offramp request input in USD. The offramp expected-output math multiplies a + * USD amount by the inverted FIAT-USD oracle rate, but request.inputAmount is denominated + * in the input token: USD-like stables pass through unchanged, fiat-pegged stables + * (BRLA, EURC) are valued at their peg's FIAT-USD oracle rate, and any other token falls + * back to the bridged USDC amount when available. + * + * A rate-feed failure while valuing a fiat-pegged stable MUST NOT fail the quote: the + * engine already holds the bridged USDC amount, a good USD-denominated proxy, so we fall + * back to it (or the raw input as a last resort) rather than throwing from discount math. + */ +export async function getUsdDenominatedInputAmount(ctx: QuoteContext): Promise { + const { inputAmount, inputCurrency } = ctx.request; + const normalized = normalizeTokenSymbol(inputCurrency); + + if (USD_LIKE_INPUT_CURRENCIES.has(normalized)) { + return new Big(inputAmount); + } + + const pegFiat = FIAT_PEG_BY_STABLECOIN[normalized]; + if (pegFiat) { + try { + const fiatToUsdRate = await priceFeedService.getFiatToUsdExchangeRate(pegFiat); + return new Big(inputAmount).mul(fiatToUsdRate); + } catch (error) { + const fallback = usdFallbackFromContext(ctx); + logger.warn( + `getUsdDenominatedInputAmount: ${pegFiat}-USD rate lookup failed for ${inputCurrency} input, ` + + `falling back to ${fallback.toString()} USD. Error: ${error instanceof Error ? error.message : error}` + ); + return fallback; } - }); + } + + return usdFallbackFromContext(ctx); +} + +function usdFallbackFromContext(ctx: QuoteContext): Big { + if (ctx.evmToEvm?.outputAmountDecimal) { + return ctx.evmToEvm.outputAmountDecimal; + } + return new Big(ctx.request.inputAmount); } /** @@ -96,19 +165,19 @@ export function getAdjustedDifference(partner?: ActivePartner): Big { return new Big(0); } - const partnerState = partnerDiscountState.get(partner.id); + const partnerState = partnerDiscountState.get(partner.stateKey); const now = new Date(); // Use partner's max caps if available, otherwise fall back to targetDiscount const maxCap = partner.maxDynamicDifference ?? 0; if (!partnerState) { - partnerDiscountState.set(partner.id, { difference: new Big(0), lastQuoteTimestamp: now }); + partnerDiscountState.set(partner.stateKey, { difference: new Big(0), lastQuoteTimestamp: now }); return new Big(0); } if (!partnerState.lastQuoteTimestamp) { - partnerDiscountState.set(partner.id, { difference: partnerState.difference, lastQuoteTimestamp: now }); + partnerDiscountState.set(partner.stateKey, { difference: partnerState.difference, lastQuoteTimestamp: now }); return partnerState.difference; } @@ -117,7 +186,7 @@ export function getAdjustedDifference(partner?: ActivePartner): Big { if (!isYounger) { const updatedDifference = partnerState.difference.plus(getDeltaD()); const clampedDifference = updatedDifference.gt(maxCap) ? Big(maxCap) : updatedDifference; - partnerDiscountState.set(partner.id, { difference: clampedDifference, lastQuoteTimestamp: now }); + partnerDiscountState.set(partner.stateKey, { difference: clampedDifference, lastQuoteTimestamp: now }); return clampedDifference; } else { // Return existing difference @@ -129,7 +198,7 @@ export function handleQuoteConsumptionForDiscountState(partner?: ActivePartner): return; } - const partnerState = partnerDiscountState.get(partner.id); + const partnerState = partnerDiscountState.get(partner.stateKey); const now = new Date(); if (!partnerState || !partnerState.lastQuoteTimestamp) { @@ -145,7 +214,7 @@ export function handleQuoteConsumptionForDiscountState(partner?: ActivePartner): const updatedDifference = partnerState.difference.minus(getDeltaD()); const clampedDifference = updatedDifference.lt(minCap) ? Big(minCap) : updatedDifference; - partnerDiscountState.set(partner.id, { difference: clampedDifference, lastQuoteTimestamp: null }); + partnerDiscountState.set(partner.stateKey, { difference: clampedDifference, lastQuoteTimestamp: null }); } } diff --git a/apps/api/src/api/services/quote/engines/discount/offramp-alfredpay.ts b/apps/api/src/api/services/quote/engines/discount/offramp-alfredpay.ts index b1ac53a0e..ca57d2fca 100644 --- a/apps/api/src/api/services/quote/engines/discount/offramp-alfredpay.ts +++ b/apps/api/src/api/services/quote/engines/discount/offramp-alfredpay.ts @@ -9,7 +9,12 @@ import Big from "big.js"; import { priceFeedService } from "../../../priceFeed.service"; import { QuoteContext } from "../../core/types"; import { BaseDiscountEngine, DiscountComputation } from "."; -import { calculateExpectedOutput, calculateSubsidyAmount, resolveDiscountPartner } from "./helpers"; +import { + calculateExpectedOutput, + calculateSubsidyAmount, + getUsdDenominatedInputAmount, + resolveDiscountPartner +} from "./helpers"; export class OffRampAlfredpayDiscountEngine extends BaseDiscountEngine { readonly config = { @@ -59,11 +64,19 @@ export class OffRampAlfredpayDiscountEngine extends BaseDiscountEngine { const usdToFiatRate = new Big(1).div(effectiveRate); const finalOutput = usdOnPolygon.mul(usdToFiatRate); + // The inverted rate converts USD -> fiat, so a non-USD input (e.g. BRLA) must be valued in USD first. + const inputAmountUsd = await getUsdDenominatedInputAmount(ctx); + if (!inputAmountUsd.eq(inputAmount)) { + ctx.addNote?.( + `OffRampAlfredpayDiscountEngine: valued input ${inputAmount} ${ctx.request.inputCurrency} at ${inputAmountUsd.toFixed(6)} USD for discount calculation` + ); + } + const { expectedOutput: expectedOutputDecimal, adjustedDifference, adjustedTargetDiscount - } = calculateExpectedOutput(inputAmount, effectiveRate, targetDiscount, this.config.isOfframp, partner); + } = calculateExpectedOutput(inputAmountUsd.toString(), effectiveRate, targetDiscount, this.config.isOfframp, partner); const idealSubsidyDecimal = expectedOutputDecimal.gt(finalOutput) ? expectedOutputDecimal.minus(finalOutput) : new Big(0); diff --git a/apps/api/src/api/services/quote/engines/discount/offramp.ts b/apps/api/src/api/services/quote/engines/discount/offramp.ts index aa6e7b547..2d4ccaa40 100644 --- a/apps/api/src/api/services/quote/engines/discount/offramp.ts +++ b/apps/api/src/api/services/quote/engines/discount/offramp.ts @@ -3,7 +3,12 @@ import Big from "big.js"; import logger from "../../../../../config/logger"; import { QuoteContext } from "../../core/types"; import { BaseDiscountEngine, DiscountComputation } from "."; -import { calculateExpectedOutput, calculateSubsidyAmount, resolveDiscountPartner } from "./helpers"; +import { + calculateExpectedOutput, + calculateSubsidyAmount, + getUsdDenominatedInputAmount, + resolveDiscountPartner +} from "./helpers"; export class OffRampDiscountEngine extends BaseDiscountEngine { readonly config = { @@ -32,18 +37,27 @@ export class OffRampDiscountEngine extends BaseDiscountEngine { // biome-ignore lint/style/noNonNullAssertion: Context is validated in validate const oraclePrice = nablaSwap.oraclePrice!; - const { inputAmount, rampType } = ctx.request; + const { inputAmount, inputCurrency, rampType } = ctx.request; const partner = await resolveDiscountPartner(ctx, rampType); const targetDiscount = partner?.targetDiscount ?? 0; const maxSubsidy = partner?.maxSubsidy ?? 0; - // Calculate the oracle-based expected output in BRL. + // The expected-output math multiplies a USD amount by the inverted FIAT-USD oracle rate, + // so a non-USD input (e.g. BRLA) must be valued in USD first. + const inputAmountUsd = await getUsdDenominatedInputAmount(ctx); + if (!inputAmountUsd.eq(inputAmount)) { + ctx.addNote?.( + `OffRampDiscountEngine: valued input ${inputAmount} ${inputCurrency} at ${inputAmountUsd.toFixed(6)} USD for discount calculation` + ); + } + + // Calculate the oracle-based expected output in the target fiat currency. const { expectedOutput: oracleExpectedOutputDecimal, adjustedDifference, adjustedTargetDiscount - } = calculateExpectedOutput(inputAmount, oraclePrice, targetDiscount, this.config.isOfframp, partner); + } = calculateExpectedOutput(inputAmountUsd.toString(), oraclePrice, targetDiscount, this.config.isOfframp, partner); // Account for the anchor fee deducted in the Finalize stage, which reduces the user's received amount. // We need to add it back to the expected output to calculate the subsidy correctly. diff --git a/apps/api/src/api/services/quote/engines/fee/index.ts b/apps/api/src/api/services/quote/engines/fee/index.ts index c46768aa3..600aad36a 100644 --- a/apps/api/src/api/services/quote/engines/fee/index.ts +++ b/apps/api/src/api/services/quote/engines/fee/index.ts @@ -53,7 +53,7 @@ export abstract class BaseFeeEngine implements Stage { inputCurrency: request.inputCurrency, outputAmountOfframp: ctx.nablaSwap?.outputAmountDecimal?.toString() ?? "0", outputCurrency: request.outputCurrency, - partnerName: ctx.partner?.id || undefined, + partnerId: ctx.partner?.id || undefined, rampType: request.rampType, to: request.to }); diff --git a/apps/api/src/api/services/ramp/ramp.service.register-auth.test.ts b/apps/api/src/api/services/ramp/ramp.service.register-auth.test.ts index e19cb9a41..16db74af1 100644 --- a/apps/api/src/api/services/ramp/ramp.service.register-auth.test.ts +++ b/apps/api/src/api/services/ramp/ramp.service.register-auth.test.ts @@ -1,15 +1,18 @@ -import { afterEach, describe, expect, it, mock } from "bun:test"; +import { afterAll, afterEach, describe, expect, it, mock } from "bun:test"; import httpStatus from "http-status"; import type { Transaction } from "sequelize"; import { config } from "../../../config/vars"; import QuoteTicket from "../../../models/quoteTicket.model"; +import RampState from "../../../models/rampState.model"; +import User from "../../../models/user.model"; import { APIError } from "../../errors/api-error"; import { RampService } from "./ramp.service"; // Locks in the user-gating guards at the top of RampService.registerRamp. See // docs/architecture/user-gated-ramp-registration.md. The guards run before any DB write or // signing-account validation, so overriding withTransaction (to skip the real DB) and mocking -// QuoteTicket.findByPk is enough to drive them. +// QuoteTicket.findByPk (plus the User lookup and one-active-ramp lock queries that follow the +// guards) is enough to drive them. class TestRampService extends RampService { protected async withTransaction(callback: (transaction: Transaction) => Promise): Promise { return callback({} as Transaction); @@ -40,11 +43,24 @@ async function expectRegisterError(userId: string | undefined, expectedStatus: n describe("RampService.registerRamp user gating", () => { const originalFindByPk = QuoteTicket.findByPk; + const originalUserFindByPk = User.findByPk; + const originalRampStateUpdate = RampState.update; + const originalRampStateFindOne = RampState.findOne; + + User.findByPk = mock(async () => ({ id: "user-a" })) as unknown as typeof User.findByPk; + RampState.update = mock(async () => [0]) as unknown as typeof RampState.update; + RampState.findOne = mock(async () => null) as unknown as typeof RampState.findOne; afterEach(() => { QuoteTicket.findByPk = originalFindByPk; }); + afterAll(() => { + User.findByPk = originalUserFindByPk; + RampState.update = originalRampStateUpdate; + RampState.findOne = originalRampStateFindOne; + }); + it("lets an authenticated caller claim an anonymous quote (passes the user-gating guards)", async () => { stubQuote({ userId: null }); const service = new TestRampService(); diff --git a/apps/api/src/api/services/ramp/ramp.service.ts b/apps/api/src/api/services/ramp/ramp.service.ts index 1401323a9..ef5daff17 100644 --- a/apps/api/src/api/services/ramp/ramp.service.ts +++ b/apps/api/src/api/services/ramp/ramp.service.ts @@ -46,12 +46,16 @@ import { Op, Transaction, WhereOptions } from "sequelize"; import { isAddress } from "viem"; import logger from "../../../config/logger"; import { config } from "../../../config/vars"; -import Partner from "../../../models/partner.model"; import QuoteTicket from "../../../models/quoteTicket.model"; import RampState, { RampStateAttributes } from "../../../models/rampState.model"; -import TaxId from "../../../models/taxId.model"; +import User from "../../../models/user.model"; import { APIError } from "../../errors/api-error"; -import { ActivePartner, handleQuoteConsumptionForDiscountState } from "../../services/quote/engines/discount/helpers"; +import { + ActivePartner, + handleQuoteConsumptionForDiscountState, + resolveActivePartnerById +} from "../../services/quote/engines/discount/helpers"; +import { findAveniaCustomerByTaxId } from "../avenia/avenia-customer.service"; import { resolveAveniaAccountForRamp } from "../avenia-account"; import { resolveMykoboCustomerForUser } from "../mykobo/mykobo-customer.service"; import { StateMetadata } from "../phases/meta-state-types"; @@ -220,6 +224,42 @@ export class RampService extends BaseRampService { }); } + const user = await User.findByPk(effectiveUserId, { lock: Transaction.LOCK.UPDATE, transaction }); + if (!user) { + throw new APIError({ + message: "Authenticated user profile not found.", + status: httpStatus.BAD_REQUEST + }); + } + + const startDeadline = new Date(Date.now() - RAMP_START_EXPIRATION_TIME_SECONDS * 1000); + await RampState.update( + { currentPhase: "timedOut" }, + { + transaction, + where: { + createdAt: { [Op.lt]: startDeadline }, + currentPhase: "initial", + userId: effectiveUserId + } + } + ); + + const activeRamp = await RampState.findOne({ + attributes: ["id"], + transaction, + where: { + currentPhase: { [Op.notIn]: ["complete", "failed", "timedOut"] }, + userId: effectiveUserId + } + }); + if (activeRamp) { + throw new APIError({ + message: `An active ramp already exists for this user: ${activeRamp.id}`, + status: httpStatus.CONFLICT + }); + } + // Before removing this kill-switch, add a hermetic EUR corridor scenario in // apps/api/src/tests/corridors/ (the Mykobo corridors are currently covered by // RUN_LIVE_TESTS-gated tests only — see docs/testing-strategy.md). @@ -254,7 +294,7 @@ export class RampService extends BaseRampService { const pricingPartnerId = quote.pricingPartnerId ?? quote.partnerId; let partner: ActivePartner = null; if (pricingPartnerId) { - partner = await Partner.findByPk(pricingPartnerId); + partner = await resolveActivePartnerById(pricingPartnerId, quote.rampType); } handleQuoteConsumptionForDiscountState(partner); @@ -752,6 +792,7 @@ export class RampService extends BaseRampService { } return { + currentPhase: ramp.currentPhase, date: ramp.createdAt.toISOString(), externalTxExplorerLink: transactionExplorerLink, externalTxHash: transactionHash, @@ -776,8 +817,7 @@ export class RampService extends BaseRampService { */ private mapPhaseToStatus(phase: RampPhase): TransactionStatus { if (phase === "complete") return TransactionStatus.COMPLETE; - // Don't return 'failed' as status, instead return 'pending' to avoid confusion - // if (phase === "failed" || phase === "timedOut") return TransactionStatus.FAILED; + if (phase === "failed" || phase === "timedOut") return TransactionStatus.FAILED; return TransactionStatus.PENDING; } @@ -901,15 +941,16 @@ export class RampService extends BaseRampService { ): Promise<{ wallets: { evm: string }; brCode: string }> { const brlaApiService = BrlaApiService.getInstance(); - const taxIdRecord = await TaxId.findByPk(normalizeTaxId(taxId)); - if (!taxIdRecord) { + const aveniaCustomer = await findAveniaCustomerByTaxId(taxId); + if (!aveniaCustomer) { throw new APIError({ message: "Subaccount not found", status: httpStatus.BAD_REQUEST }); } - const subAccountData = await brlaApiService.subaccountInfo(taxIdRecord.subAccountId); - const subaccountLimits = await brlaApiService.getSubaccountUsedLimit(taxIdRecord.subAccountId); + const aveniaSubAccountId = aveniaCustomer.providerSubaccountId ?? ""; + const subAccountData = await brlaApiService.subaccountInfo(aveniaSubAccountId); + const subaccountLimits = await brlaApiService.getSubaccountUsedLimit(aveniaSubAccountId); if (!subaccountLimits) { throw new APIError({ message: "Failed to fetch subaccount limits", @@ -986,15 +1027,16 @@ export class RampService extends BaseRampService { ): Promise<{ brCode: string; aveniaTicketId: string }> { const brlaApiService = BrlaApiService.getInstance(); - const taxIdRecord = await TaxId.findByPk(normalizeTaxId(taxId)); - if (!taxIdRecord) { + const aveniaCustomer = await findAveniaCustomerByTaxId(taxId); + if (!aveniaCustomer) { throw new APIError({ message: "Subaccount not found.", status: httpStatus.BAD_REQUEST }); } + const aveniaSubAccountId = aveniaCustomer.providerSubaccountId ?? ""; - const accountLimits = await brlaApiService.getSubaccountUsedLimit(taxIdRecord.subAccountId); + const accountLimits = await brlaApiService.getSubaccountUsedLimit(aveniaSubAccountId); if (!accountLimits) { throw new APIError({ message: "Failed to fetch subaccount limits.", @@ -1012,7 +1054,7 @@ export class RampService extends BaseRampService { outputCurrency: BrlaCurrency.BRLA, outputPaymentMethod: AveniaPaymentMethod.INTERNAL, outputThirdParty: false, - subAccountId: taxIdRecord.subAccountId + subAccountId: aveniaSubAccountId }); const aveniaTicket = await brlaApiService.createPixInputTicket( @@ -1026,7 +1068,7 @@ export class RampService extends BaseRampService { additionalData: generateReferenceLabel(quote) } }, - taxIdRecord.subAccountId + aveniaSubAccountId ); return { aveniaTicketId: aveniaTicket.id, brCode: aveniaTicket.brCode }; diff --git a/apps/api/src/api/services/recipients/recipient-invite.service.ts b/apps/api/src/api/services/recipients/recipient-invite.service.ts new file mode 100644 index 000000000..784e1e599 --- /dev/null +++ b/apps/api/src/api/services/recipients/recipient-invite.service.ts @@ -0,0 +1,26 @@ +import crypto from "crypto"; + +/** Invite links stop being redeemable after this window (spec: recipient-transfers.md). */ +export const INVITE_TTL_MS = 14 * 24 * 60 * 60 * 1000; + +/** + * URL-safe, single-use redemption token. The sha256 hash is the only lookup key; the raw token is + * additionally retained while the invite is pending so the sender can re-copy the link, and is + * cleared on acceptance/expiry (see recipient-transfers.md invariant 1). + */ +export function generateInviteToken(): string { + return crypto.randomBytes(24).toString("base64url"); +} + +export function hashInviteToken(token: string): string { + return crypto.createHash("sha256").update(token, "utf8").digest("hex"); +} + +/** Case/whitespace-insensitive form used to bind an invite to an email at redemption. */ +export function canonicalizeEmail(email: string): string { + return email.trim().toLowerCase(); +} + +export function inviteExpiryDate(now: Date = new Date()): Date { + return new Date(now.getTime() + INVITE_TTL_MS); +} diff --git a/apps/api/src/api/services/recipients/transfer-eligibility.service.test.ts b/apps/api/src/api/services/recipients/transfer-eligibility.service.test.ts new file mode 100644 index 000000000..a3b4c1e24 --- /dev/null +++ b/apps/api/src/api/services/recipients/transfer-eligibility.service.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from "bun:test"; +import { VerificationStatus } from "../../../models/providerCustomer.model"; +import { + isFreshMoneriumApproval, + isProviderApproved, + isProviderInReview, + isProviderRestricted, + providerForRail +} from "./transfer-eligibility.service"; + +function classify(status: string): "approved" | "rejected" | "in_review" | "started" | "pending" { + if (isProviderApproved(status)) return "approved"; + if (isProviderRestricted(status)) return "rejected"; + if (isProviderInReview(status)) return "in_review"; + if (status === VerificationStatus.Started) return "started"; + return "pending"; +} + +describe("provider status classification", () => { + it("uses Monerium as the EUR onboarding provider", () => { + expect(providerForRail("eur")).toBe("monerium"); + }); + + it("fails closed when a mirrored Monerium approval is stale", () => { + const now = Date.UTC(2026, 6, 13, 12, 0, 0); + expect(isFreshMoneriumApproval(new Date(now - 4 * 60 * 1000), now)).toBe(true); + expect(isFreshMoneriumApproval(new Date(now - 6 * 60 * 1000), now)).toBe(false); + }); + + it("classifies terminal approved statuses", () => { + expect(classify(VerificationStatus.Approved)).toBe("approved"); + }); + + it("classifies terminal rejected statuses", () => { + expect(classify(VerificationStatus.Rejected)).toBe("rejected"); + }); + + it("classifies submitted-and-under-review statuses as in_review", () => { + expect(classify(VerificationStatus.InReview)).toBe("in_review"); + }); + + it("distinguishes an initiated flow from missing or stale data", () => { + expect(classify(VerificationStatus.Started)).toBe("started"); + expect(classify(VerificationStatus.Pending)).toBe("pending"); + }); + + it("does not classify in-review statuses as approved or restricted (gate is unchanged)", () => { + for (const status of [VerificationStatus.InReview]) { + expect(isProviderApproved(status)).toBe(false); + expect(isProviderRestricted(status)).toBe(false); + } + }); +}); diff --git a/apps/api/src/api/services/recipients/transfer-eligibility.service.ts b/apps/api/src/api/services/recipients/transfer-eligibility.service.ts new file mode 100644 index 000000000..4b858126c --- /dev/null +++ b/apps/api/src/api/services/recipients/transfer-eligibility.service.ts @@ -0,0 +1,104 @@ +import ProviderCustomer, { type ProviderName, VerificationStatus } from "../../../models/providerCustomer.model"; +import RecipientInvitation from "../../../models/recipientInvitation.model"; +import RecipientPayoutReference from "../../../models/recipientPayoutReference.model"; +import type SenderRecipient from "../../../models/senderRecipient.model"; + +export type BlockingReasonCode = + | "invite_not_accepted" + | "relationship_not_active" + | "recipient_onboarding_pending" + | "provider_payout_reference_unverified" + | "provider_restricted"; + +export interface TransferEligibility { + canCreateTransfer: boolean; + blockingReasonCode?: BlockingReasonCode; +} + +const MONERIUM_APPROVAL_FRESHNESS_MS = 5 * 60 * 1000; + +export function isFreshMoneriumApproval(updatedAt: Date, now = Date.now()): boolean { + return now - updatedAt.getTime() <= MONERIUM_APPROVAL_FRESHNESS_MS; +} + +/** Which provider onboards a recipient for a given payout rail. */ +export function providerForRail(rail: string): ProviderName { + if (rail === "eur") { + return "monerium"; + } + if (rail === "brl") { + return "avenia"; + } + return "alfredpay"; +} + +export function isProviderApproved(status: string): boolean { + return status === VerificationStatus.Approved; +} + +export function isProviderRestricted(status: string): boolean { + return status === VerificationStatus.Rejected; +} + +export function isProviderInReview(status: string): boolean { + return status === VerificationStatus.InReview; +} + +/** + * Gate for creating a transfer to a recipient (plan §7): invite accepted, relationship + * active, recipient onboarded with the corridor's provider, and a verified payout + * reference for the corridor. Returns the first failing check as the blocking reason. + */ +export async function getTransferEligibility(relationship: SenderRecipient): Promise { + if (relationship.relationshipStatus === "invited") { + return { blockingReasonCode: "invite_not_accepted", canCreateTransfer: false }; + } + if (relationship.relationshipStatus !== "active") { + return { blockingReasonCode: "relationship_not_active", canCreateTransfer: false }; + } + + // The corridor comes from the originating invite; without one there is nothing the + // recipient could have onboarded for yet. + const invitation = relationship.invitationId ? await RecipientInvitation.findByPk(relationship.invitationId) : null; + if (!invitation) { + return { blockingReasonCode: "recipient_onboarding_pending", canCreateTransfer: false }; + } + + const provider = providerForRail(invitation.rail); + const providerCustomer = await ProviderCustomer.findOne({ + order: [["updatedAt", "DESC"]], + where: { + customerEntityId: relationship.recipientCustomerEntityId, + customerType: invitation.inviteeType, + provider, + // Alfredpay accounts are per-country; Monerium/Avenia accounts are not. + ...(provider === "alfredpay" ? { country: invitation.country } : {}) + } + }); + + if (!providerCustomer) { + return { blockingReasonCode: "recipient_onboarding_pending", canCreateTransfer: false }; + } + if (isProviderRestricted(providerCustomer.status)) { + return { blockingReasonCode: "provider_restricted", canCreateTransfer: false }; + } + if (!isProviderApproved(providerCustomer.status)) { + return { blockingReasonCode: "recipient_onboarding_pending", canCreateTransfer: false }; + } + if (provider === "monerium" && !isFreshMoneriumApproval(providerCustomer.updatedAt)) { + return { blockingReasonCode: "recipient_onboarding_pending", canCreateTransfer: false }; + } + + const verifiedPayoutReference = await RecipientPayoutReference.findOne({ + where: { + rail: invitation.rail, + senderRecipientId: relationship.id, + status: "verified" + } + }); + if (!verifiedPayoutReference) { + return { blockingReasonCode: "provider_payout_reference_unverified", canCreateTransfer: false }; + } + + return { canCreateTransfer: true }; +} diff --git a/apps/api/src/api/services/transactions/common/feeDistribution.ts b/apps/api/src/api/services/transactions/common/feeDistribution.ts index 57439cf90..377821938 100644 --- a/apps/api/src/api/services/transactions/common/feeDistribution.ts +++ b/apps/api/src/api/services/transactions/common/feeDistribution.ts @@ -19,8 +19,8 @@ import logger from "../../../../config/logger"; import { config } from "../../../../config/vars"; import erc20ABI from "../../../../contracts/ERC20"; import { MULTICALL3_ADDRESS, multicall3ABI } from "../../../../contracts/Multicall3"; -import Partner from "../../../../models/partner.model"; import { QuoteTicketAttributes } from "../../../../models/quoteTicket.model"; +import { findPartnerWithPricing } from "../../partners/partner-pricing.service"; import { multiplyByPowerOfTen } from "../../pendulum/helpers"; import { getZenlinkIdForAsset } from "../../zenlink"; @@ -52,20 +52,18 @@ export async function createSubstrateFeeDistributionTransaction(quote: QuoteTick const partnerMarkupFeeUSD = usdFeeStructure.partnerMarkup; // Get payout addresses - const vortexPartner = await Partner.findOne({ - where: { isActive: true, name: "vortex", rampType: quote.rampType } - }); + const vortexPartner = await findPartnerWithPricing({ name: "vortex" }, quote.rampType); if (!vortexPartner) { logger.error( "FEE DISTRIBUTION FAILED: No active 'vortex' partner found for rampType=" + quote.rampType + - ". A row with name='vortex' and is_active=true MUST exist in the 'partners' table; otherwise no fees can be collected." + ". An active partners row named 'vortex' with an active pricing config for this ramp_type MUST exist; otherwise no fees can be collected." ); throw new Error(`Vortex partner row missing for rampType=${quote.rampType}; cannot build fee distribution transaction.`); } if (!vortexPartner.payoutAddressSubstrate) { logger.error( - "FEE DISTRIBUTION FAILED: 'payout_address_substrate' is not set on the 'vortex' partner row (rampType=" + + "FEE DISTRIBUTION FAILED: 'payout_address_substrate' is not set on the 'vortex' pricing config (rampType=" + quote.rampType + "). This column MUST be set to a Pendulum address; otherwise no substrate fees can be collected." ); @@ -78,10 +76,8 @@ export async function createSubstrateFeeDistributionTransaction(quote: QuoteTick const pricingPartnerId = getQuotePricingPartnerId(quote); let partnerPayoutAddress = null; if (pricingPartnerId) { - const quotePartner = await Partner.findOne({ - where: { id: pricingPartnerId, isActive: true, rampType: quote.rampType } - }); - if (quotePartner && quotePartner.payoutAddressSubstrate) { + const quotePartner = await findPartnerWithPricing({ id: pricingPartnerId }, quote.rampType); + if (quotePartner?.payoutAddressSubstrate) { partnerPayoutAddress = quotePartner.payoutAddressSubstrate; } } @@ -222,14 +218,12 @@ export async function createEvmFeeDistributionTransaction(quote: QuoteTicketAttr const partnerMarkupFeeUSD = usdFeeStructure.partnerMarkup; // Get vortex payout address (EVM) - const vortexPartner = await Partner.findOne({ - where: { isActive: true, name: "vortex", rampType: quote.rampType } - }); + const vortexPartner = await findPartnerWithPricing({ name: "vortex" }, quote.rampType); if (!vortexPartner) { logger.error( "EVM FEE DISTRIBUTION FAILED: No active 'vortex' partner found for rampType=" + quote.rampType + - ". A row with name='vortex' and is_active=true MUST exist in the 'partners' table; otherwise no fees can be collected." + ". An active partners row named 'vortex' with an active pricing config for this ramp_type MUST exist; otherwise no fees can be collected." ); throw new Error( `Vortex partner row missing for rampType=${quote.rampType}; cannot build EVM fee distribution transaction.` @@ -239,7 +233,7 @@ export async function createEvmFeeDistributionTransaction(quote: QuoteTicketAttr const fallback = config.defaults.vortexEvmPayoutAddress; if (!fallback) { logger.error( - "EVM FEE DISTRIBUTION FAILED: 'payout_address_evm' is not set on the 'vortex' partner row (rampType=" + + "EVM FEE DISTRIBUTION FAILED: 'payout_address_evm' is not set on the 'vortex' pricing config (rampType=" + quote.rampType + ") and DEFAULT_VORTEX_EVM_PAYOUT_ADDRESS env var is not configured. Set one to avoid losing fees." ); @@ -248,7 +242,7 @@ export async function createEvmFeeDistributionTransaction(quote: QuoteTicketAttr ); } logger.warn( - `EVM FEE DISTRIBUTION: vortex partner row (rampType=${quote.rampType}) has no payout_address_evm; falling back to DEFAULT_VORTEX_EVM_PAYOUT_ADDRESS=${fallback}.` + `EVM FEE DISTRIBUTION: vortex pricing config (rampType=${quote.rampType}) has no payout_address_evm; falling back to DEFAULT_VORTEX_EVM_PAYOUT_ADDRESS=${fallback}.` ); } const vortexPayoutAddress = vortexPartner.payoutAddressEvm ?? (config.defaults.vortexEvmPayoutAddress as string); @@ -257,9 +251,7 @@ export async function createEvmFeeDistributionTransaction(quote: QuoteTicketAttr const pricingPartnerId = getQuotePricingPartnerId(quote); let partnerPayoutAddressEvm: string | null = null; if (pricingPartnerId) { - const quotePartner = await Partner.findOne({ - where: { id: pricingPartnerId, isActive: true, rampType: quote.rampType } - }); + const quotePartner = await findPartnerWithPricing({ id: pricingPartnerId }, quote.rampType); if (quotePartner?.payoutAddressEvm) { partnerPayoutAddressEvm = quotePartner.payoutAddressEvm; } diff --git a/apps/api/src/api/workers/unhandled-payment.worker.ts b/apps/api/src/api/workers/unhandled-payment.worker.ts index a76a8035b..7e7c721ec 100644 --- a/apps/api/src/api/workers/unhandled-payment.worker.ts +++ b/apps/api/src/api/workers/unhandled-payment.worker.ts @@ -4,7 +4,7 @@ import { Op } from "sequelize"; import logger from "../../config/logger"; import { config } from "../../config/vars"; import RampState from "../../models/rampState.model"; -import TaxId from "../../models/taxId.model"; +import { findAveniaCustomerByTaxId } from "../services/avenia/avenia-customer.service"; import { SlackNotifier } from "../services/slack.service"; const DEFAULT_CRON_TIME = "*/15 * * * *"; @@ -154,14 +154,14 @@ class UnhandledPaymentWorker { for (const taxId in statesByTaxId) { try { - const taxIdRecord = await TaxId.findOne({ where: { taxId: normalizeTaxId(taxId) } }); - if (!taxIdRecord) { - logger.warn(`No TaxId record found for taxId: ${taxId}. Skipping states.`); + const aveniaCustomer = await findAveniaCustomerByTaxId(taxId); + if (!aveniaCustomer || !aveniaCustomer.providerSubaccountId) { + logger.warn(`No Avenia provider account found for taxId: ${taxId}. Skipping states.`); statesByTaxId[taxId].forEach(state => this.processedStateIds.add(state.id)); continue; } - const { subAccountId } = taxIdRecord; + const subAccountId = aveniaCustomer.providerSubaccountId; const tickets = await this.brlaApiService.getAveniaPayinTickets(subAccountId); const aveniaTicketSet = new Set(tickets.filter(ticket => ticket.status === "PAID").map(ticket => ticket.id)); diff --git a/apps/api/src/config/express.ts b/apps/api/src/config/express.ts index 89028bffe..ac15fcb1a 100644 --- a/apps/api/src/config/express.ts +++ b/apps/api/src/config/express.ts @@ -23,6 +23,14 @@ const REQUEST_BODY_LIMIT = "20mb"; */ const app = express(); +// Extra fixed origins for non-production dashboard deployments (comma-separated env +// var, e.g. a staging or preview URL). Resolved once at boot — this stays an explicit +// whitelist per the security spec; wildcards are dropped, never honored. +const dashboardOrigins = (process.env.DASHBOARD_ORIGINS ?? "") + .split(",") + .map(origin => origin.trim()) + .filter(origin => origin.length > 0 && !origin.includes("*")); + // enable CORS - Cross Origin Resource Sharing app.use( cors({ @@ -33,10 +41,15 @@ app.use( methods: "GET,HEAD,PUT,PATCH,POST,DELETE", // Explicitly list allowed headers origin: [ "https://app.vortexfinance.co", + "https://dashboard.vortexfinance.co", "https://metrics.vortexfinance.co", + ...dashboardOrigins, config.env !== "production" ? "https://staging--vortexfi.netlify.app" : null, config.env === "development" ? "http://localhost:5173" : null, config.env === "development" ? "http://127.0.0.1:5173" : null, + // Dashboard dev server (deployed origins come from DASHBOARD_ORIGINS) + config.env === "development" ? "http://localhost:5174" : null, + config.env === "development" ? "http://127.0.0.1:5174" : null, config.env === "development" ? "http://localhost:6006" : null ].filter(Boolean) as string[] }) diff --git a/apps/api/src/config/vars.test.ts b/apps/api/src/config/vars.test.ts index 2e83fc47c..6c5fd710f 100644 --- a/apps/api/src/config/vars.test.ts +++ b/apps/api/src/config/vars.test.ts @@ -8,6 +8,8 @@ const requiredProductionEnv = { ADMIN_SECRET: "test-admin-secret", FLOW_VARIANT: "monerium", METRICS_DASHBOARD_SECRET: "test-metrics-dashboard-secret", + MONERIUM_CLIENT_ID: "test-monerium-client-id", + MONERIUM_REDIRECT_URI: "https://dashboard.example.com/monerium/callback", SUPABASE_ANON_KEY: "test-anon-key", SUPABASE_SERVICE_KEY: "test-service-key", SUPABASE_URL: "https://example.supabase.co", @@ -84,4 +86,26 @@ describe("vars deployment environment validation", () => { expect(result.exitCode).toBe(1); expect(result.stderr).toContain("METRICS_DASHBOARD_SECRET"); }); + + it("requires the exact Monerium callback URI in production", async () => { + const result = await importVarsWithEnv({ + DEPLOYMENT_ENV: "production", + MONERIUM_REDIRECT_URI: "", + NODE_ENV: "production" + }); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain("MONERIUM_REDIRECT_URI"); + }); + + it("requires the Monerium auth-code client ID in production", async () => { + const result = await importVarsWithEnv({ + DEPLOYMENT_ENV: "production", + MONERIUM_CLIENT_ID: "", + NODE_ENV: "production" + }); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain("MONERIUM_CLIENT_ID"); + }); }); diff --git a/apps/api/src/config/vars.ts b/apps/api/src/config/vars.ts index 6512010f2..5b3886069 100644 --- a/apps/api/src/config/vars.ts +++ b/apps/api/src/config/vars.ts @@ -162,13 +162,17 @@ interface Config { mykobo: { feeFallback: MykoboFeeFallback; }; + monerium: { + apiUrl: string; + clientId: string; + redirectUri: string; + }; subscanApiKey: string | undefined; vortexFeePenPercentage: number; secrets: { pendulumFundingSeed: string | undefined; moonbeamExecutorPrivateKey: string | undefined; - clientDomainSecret: string | undefined; webhookPrivateKey: string | undefined; }; @@ -221,6 +225,13 @@ export const config: Config = { }, logs: nodeEnv === "production" ? "combined" : "dev", metricsDashboardSecret: process.env.METRICS_DASHBOARD_SECRET || "", + monerium: { + apiUrl: + process.env.MONERIUM_API_URL || + (process.env.SANDBOX_ENABLED === "true" ? "https://api.monerium.dev" : "https://api.monerium.app"), + clientId: process.env.MONERIUM_CLIENT_ID || "", + redirectUri: process.env.MONERIUM_REDIRECT_URI || "http://localhost:5174/monerium/callback" + }, mykobo: { feeFallback: readMykoboFeeFallback() }, @@ -266,7 +277,6 @@ export const config: Config = { sandboxEnabled: process.env.SANDBOX_ENABLED === "true", secrets: { - clientDomainSecret: process.env.CLIENT_DOMAIN_SECRET, moonbeamExecutorPrivateKey: process.env.MOONBEAM_EXECUTOR_PRIVATE_KEY, pendulumFundingSeed: process.env.PENDULUM_FUNDING_SEED, webhookPrivateKey: process.env.WEBHOOK_PRIVATE_KEY @@ -318,6 +328,8 @@ if (config.env === "production") { if (!config.adminSecret) missing.push("ADMIN_SECRET"); if (!config.metricsDashboardSecret) missing.push("METRICS_DASHBOARD_SECRET"); if (!process.env.FLOW_VARIANT) missing.push("FLOW_VARIANT"); + if (!config.monerium.clientId) missing.push("MONERIUM_CLIENT_ID"); + if (!process.env.MONERIUM_REDIRECT_URI) missing.push("MONERIUM_REDIRECT_URI"); if (missing.length > 0) { throw new Error(`Missing required environment variables in production: ${missing.join(", ")}`); diff --git a/apps/api/src/database/migrations/038-create-customer-entities.ts b/apps/api/src/database/migrations/038-create-customer-entities.ts new file mode 100644 index 000000000..fd0cdd751 --- /dev/null +++ b/apps/api/src/database/migrations/038-create-customer-entities.ts @@ -0,0 +1,88 @@ +import { DataTypes, QueryInterface } from "sequelize"; + +// Creates customer_entities (the legal/compliance customer anchor between profiles and +// provider/KYC tables — see docs/architecture/unified-user-management-schema.md) and +// backfills one 'individual' entity per existing profile. +export async function up(queryInterface: QueryInterface): Promise { + await queryInterface.createTable("customer_entities", { + country: { + allowNull: true, + type: DataTypes.STRING(10) + }, + created_at: { + allowNull: false, + defaultValue: DataTypes.NOW, + type: DataTypes.DATE + }, + id: { + allowNull: false, + defaultValue: DataTypes.UUIDV4, + primaryKey: true, + type: DataTypes.UUID + }, + profile_id: { + allowNull: true, + type: DataTypes.UUID + }, + status: { + allowNull: false, + defaultValue: "active", + type: DataTypes.STRING(20) + }, + type: { + allowNull: false, + defaultValue: "individual", + type: DataTypes.STRING(20) + }, + updated_at: { + allowNull: false, + defaultValue: DataTypes.NOW, + type: DataTypes.DATE + } + }); + + // profile_id is nullable by design: compliance records outlive a deleted profile. + await queryInterface.addConstraint("customer_entities", { + fields: ["profile_id"], + name: "fk_customer_entities_profile_id", + onDelete: "SET NULL", + onUpdate: "CASCADE", + references: { + field: "id", + table: "profiles" + }, + type: "foreign key" + }); + + // VARCHAR + CHECK instead of ENUM for evolvable product states (plan D6). + await queryInterface.sequelize.query( + `ALTER TABLE "customer_entities" ADD CONSTRAINT "chk_customer_entities_type" CHECK (type IN ('individual', 'business'));` + ); + await queryInterface.sequelize.query( + `ALTER TABLE "customer_entities" ADD CONSTRAINT "chk_customer_entities_status" CHECK (status IN ('active', 'archived', 'blocked'));` + ); + + // Non-unique: the schema stays capable of multiple entities per profile even though v1 + // creates exactly one (plan D4). + await queryInterface.addIndex("customer_entities", ["profile_id"], { + name: "idx_customer_entities_profile_id" + }); + + // Supabase grants anon/authenticated ALL on new public tables via ALTER DEFAULT PRIVILEGES; + // RLS with no policies denies PostgREST access while the API's direct connection (table + // owner) is unaffected. Required on every new table holding customer data. + await queryInterface.sequelize.query(`ALTER TABLE "customer_entities" ENABLE ROW LEVEL SECURITY;`); + + // Backfill: one individual/active entity per existing profile. Idempotent via anti-join. + await queryInterface.sequelize.query(` + INSERT INTO customer_entities (id, profile_id, type, status, created_at, updated_at) + SELECT uuid_generate_v4(), p.id, 'individual', 'active', p.created_at, NOW() + FROM profiles p + LEFT JOIN customer_entities ce ON ce.profile_id = p.id + WHERE ce.id IS NULL; + `); +} + +export async function down(queryInterface: QueryInterface): Promise { + await queryInterface.dropTable("customer_entities"); +} diff --git a/apps/api/src/database/migrations/039-split-partners.ts b/apps/api/src/database/migrations/039-split-partners.ts new file mode 100644 index 000000000..61d70879d --- /dev/null +++ b/apps/api/src/database/migrations/039-split-partners.ts @@ -0,0 +1,292 @@ +import { DataTypes, QueryInterface } from "sequelize"; + +// Splits partners (today: one row per (name, ramp_type) with pricing inline) into +// partners (unique name, commercial identity) + partner_pricing_configs (per-direction +// pricing). One-shot data migration, no dual-write: the full pre-split table is snapshotted +// to partners_legacy as the backup, duplicate-name rows are folded into a canonical row per +// name, and every FK that pointed at a folded row is repointed BEFORE the fold (the FKs are +// ON DELETE SET NULL — deleting first would null out quote history). +export async function up(queryInterface: QueryInterface): Promise { + // 1. Backup snapshot of the pre-split table (kept indefinitely, never read by code). + await queryInterface.sequelize.query("CREATE TABLE IF NOT EXISTS partners_legacy AS TABLE partners;"); + + // 2. New pricing table. Columns moved verbatim from partners; VARCHAR + CHECK instead of + // ENUM (plan D6). is_active is per-direction (today a partner can be active for BUY + // and inactive for SELL — that lives on the config now). + await queryInterface.createTable("partner_pricing_configs", { + created_at: { + allowNull: false, + defaultValue: DataTypes.NOW, + type: DataTypes.DATE + }, + id: { + allowNull: false, + defaultValue: DataTypes.UUIDV4, + primaryKey: true, + type: DataTypes.UUID + }, + is_active: { + allowNull: false, + defaultValue: true, + type: DataTypes.BOOLEAN + }, + markup_currency: { + allowNull: true, + type: DataTypes.STRING(30) + }, + markup_type: { + allowNull: false, + defaultValue: "none", + type: DataTypes.STRING(16) + }, + markup_value: { + allowNull: false, + defaultValue: 0, + type: DataTypes.DECIMAL(10, 4) + }, + max_dynamic_difference: { + allowNull: false, + defaultValue: 0, + type: DataTypes.DECIMAL(10, 4) + }, + max_subsidy: { + allowNull: false, + defaultValue: 0, + type: DataTypes.DECIMAL(10, 4) + }, + min_dynamic_difference: { + allowNull: false, + defaultValue: 0, + type: DataTypes.DECIMAL(10, 4) + }, + partner_id: { + allowNull: false, + type: DataTypes.UUID + }, + payout_address_evm: { + allowNull: true, + type: DataTypes.STRING(255) + }, + payout_address_substrate: { + allowNull: true, + type: DataTypes.STRING(255) + }, + ramp_type: { + allowNull: false, + type: DataTypes.STRING(8) + }, + target_discount: { + allowNull: false, + defaultValue: 0, + type: DataTypes.DECIMAL(10, 4) + }, + updated_at: { + allowNull: false, + defaultValue: DataTypes.NOW, + type: DataTypes.DATE + }, + vortex_fee_type: { + allowNull: false, + defaultValue: "none", + type: DataTypes.STRING(16) + }, + vortex_fee_value: { + allowNull: false, + defaultValue: 0, + type: DataTypes.DECIMAL(10, 4) + } + }); + + await queryInterface.addConstraint("partner_pricing_configs", { + fields: ["partner_id"], + name: "fk_partner_pricing_configs_partner_id", + onDelete: "CASCADE", + onUpdate: "CASCADE", + references: { + field: "id", + table: "partners" + }, + type: "foreign key" + }); + await queryInterface.addConstraint("partner_pricing_configs", { + fields: ["partner_id", "ramp_type"], + name: "uniq_partner_pricing_configs_partner_ramp", + type: "unique" + }); + await queryInterface.sequelize.query( + `ALTER TABLE "partner_pricing_configs" ADD CONSTRAINT "chk_partner_pricing_configs_ramp_type" CHECK (ramp_type IN ('BUY', 'SELL'));` + ); + await queryInterface.sequelize.query( + `ALTER TABLE "partner_pricing_configs" ADD CONSTRAINT "chk_partner_pricing_configs_markup_type" CHECK (markup_type IN ('absolute', 'relative', 'none'));` + ); + await queryInterface.sequelize.query( + `ALTER TABLE "partner_pricing_configs" ADD CONSTRAINT "chk_partner_pricing_configs_vortex_fee_type" CHECK (vortex_fee_type IN ('absolute', 'relative', 'none'));` + ); + await queryInterface.sequelize.query(`ALTER TABLE "partner_pricing_configs" ENABLE ROW LEVEL SECURITY;`); + + // 3. One pricing config per legacy (name, ramp_type) row, attached to the canonical + // partner for that name (earliest created row; deterministic id tie-break). Where the + // same (name, ramp_type) has duplicate rows (nothing prevents it today), prefer the + // active row, then the most recently updated — mirroring runtime findOne semantics. + await queryInterface.sequelize.query(` + WITH canon AS ( + SELECT DISTINCT ON (name) id, name + FROM partners + ORDER BY name, created_at ASC, id ASC + ), + source AS ( + SELECT DISTINCT ON (p.name, p.ramp_type) p.* + FROM partners p + ORDER BY p.name, p.ramp_type, p.is_active DESC, p.updated_at DESC + ) + INSERT INTO partner_pricing_configs ( + id, partner_id, ramp_type, markup_type, markup_value, markup_currency, + vortex_fee_type, vortex_fee_value, target_discount, max_subsidy, + min_dynamic_difference, max_dynamic_difference, + payout_address_substrate, payout_address_evm, is_active, created_at, updated_at + ) + SELECT + uuid_generate_v4(), c.id, s.ramp_type::text, s.markup_type::text, s.markup_value, s.markup_currency, + s.vortex_fee_type::text, s.vortex_fee_value, s.target_discount, s.max_subsidy, + s.min_dynamic_difference, s.max_dynamic_difference, + s.payout_address_substrate, s.payout_address_evm, s.is_active, s.created_at, s.updated_at + FROM source s + JOIN canon c ON c.name = s.name + ON CONFLICT (partner_id, ramp_type) DO NOTHING; + `); + + // 4. Repoint FKs from folded rows to the canonical row — BEFORE deleting the folded rows. + await queryInterface.sequelize.query(` + WITH canon AS ( + SELECT DISTINCT ON (name) id, name FROM partners ORDER BY name, created_at ASC, id ASC + ), + mapping AS ( + SELECT p.id AS old_id, c.id AS new_id + FROM partners p JOIN canon c ON c.name = p.name + WHERE p.id <> c.id + ) + UPDATE quote_tickets q SET partner_id = m.new_id FROM mapping m WHERE q.partner_id = m.old_id; + `); + await queryInterface.sequelize.query(` + WITH canon AS ( + SELECT DISTINCT ON (name) id, name FROM partners ORDER BY name, created_at ASC, id ASC + ), + mapping AS ( + SELECT p.id AS old_id, c.id AS new_id + FROM partners p JOIN canon c ON c.name = p.name + WHERE p.id <> c.id + ) + UPDATE quote_tickets q SET pricing_partner_id = m.new_id FROM mapping m WHERE q.pricing_partner_id = m.old_id; + `); + await queryInterface.sequelize.query(` + WITH canon AS ( + SELECT DISTINCT ON (name) id, name FROM partners ORDER BY name, created_at ASC, id ASC + ), + mapping AS ( + SELECT p.id AS old_id, c.id AS new_id + FROM partners p JOIN canon c ON c.name = p.name + WHERE p.id <> c.id + ) + UPDATE profile_partner_assignments a SET buy_partner_id = m.new_id FROM mapping m WHERE a.buy_partner_id = m.old_id; + `); + await queryInterface.sequelize.query(` + WITH canon AS ( + SELECT DISTINCT ON (name) id, name FROM partners ORDER BY name, created_at ASC, id ASC + ), + mapping AS ( + SELECT p.id AS old_id, c.id AS new_id + FROM partners p JOIN canon c ON c.name = p.name + WHERE p.id <> c.id + ) + UPDATE profile_partner_assignments a SET sell_partner_id = m.new_id FROM mapping m WHERE a.sell_partner_id = m.old_id; + `); + + // 5. Collapse buy/sell_partner_id on assignments into a single partner_id (they were + // always the two direction-rows of the SAME partner_name). Legacy columns stay as + // unread backup. + await queryInterface.addColumn("profile_partner_assignments", "partner_id", { + allowNull: true, + onDelete: "SET NULL", + onUpdate: "CASCADE", + references: { + key: "id", + model: "partners" + }, + type: DataTypes.UUID + }); + await queryInterface.addIndex("profile_partner_assignments", ["partner_id"], { + name: "idx_profile_partner_assignments_partner_id" + }); + await queryInterface.sequelize.query(` + WITH canon AS ( + SELECT DISTINCT ON (name) id, name FROM partners ORDER BY name, created_at ASC, id ASC + ) + UPDATE profile_partner_assignments a SET partner_id = c.id + FROM canon c + WHERE c.name = a.partner_name AND a.partner_id IS NULL; + `); + + // 6. Canonical partner is active iff any direction is active (per-direction activity now + // lives on the configs). + await queryInterface.sequelize.query(` + WITH canon AS ( + SELECT DISTINCT ON (name) id, name FROM partners ORDER BY name, created_at ASC, id ASC + ) + UPDATE partners p SET is_active = EXISTS ( + SELECT 1 FROM partner_pricing_configs cfg WHERE cfg.partner_id = p.id AND cfg.is_active + ) + FROM canon c WHERE c.id = p.id; + `); + + // 7. Fold: delete the non-canonical duplicate rows (their pricing lives in the configs, + // their full original state in partners_legacy). + await queryInterface.sequelize.query(` + WITH canon AS ( + SELECT DISTINCT ON (name) id, name FROM partners ORDER BY name, created_at ASC, id ASC + ) + DELETE FROM partners p USING canon c WHERE p.name = c.name AND p.id <> c.id; + `); + + // 8. Safety net: the migrator swallows ALL bulkInsert errors, so environments exist where + // migration 004 is recorded as applied but the vortex seed rows are missing — and fee + // distribution hard-fails without them. Recreate partner + both configs idempotently + // (seed values from 004). + await queryInterface.sequelize.query(` + INSERT INTO partners (id, name, display_name, is_active, ramp_type, created_at, updated_at) + SELECT uuid_generate_v4(), 'vortex', 'Vortex', TRUE, 'BUY', NOW(), NOW() + WHERE NOT EXISTS (SELECT 1 FROM partners WHERE name = 'vortex'); + `); + await queryInterface.sequelize.query(` + INSERT INTO partner_pricing_configs ( + id, partner_id, ramp_type, markup_type, markup_value, markup_currency, + vortex_fee_type, vortex_fee_value, payout_address_substrate, is_active, created_at, updated_at + ) + SELECT uuid_generate_v4(), p.id, d.ramp_type, 'relative', 0.0001, 'USDC', + 'none', 0, '6emGJgvN86YVYj5jENjfoMfEvX5p8hMHJGSYPpbtvHNEHTgy', TRUE, NOW(), NOW() + FROM partners p + CROSS JOIN (VALUES ('BUY'), ('SELL')) AS d(ramp_type) + WHERE p.name = 'vortex' + AND NOT EXISTS ( + SELECT 1 FROM partner_pricing_configs cfg + WHERE cfg.partner_id = p.id AND cfg.ramp_type = d.ramp_type + ); + `); + + // 9. The point of the split: a partner name is now a stable, unique handle. + await queryInterface.sequelize.query(`ALTER TABLE "partners" ADD CONSTRAINT "uniq_partners_name" UNIQUE (name);`); +} + +export async function down(queryInterface: QueryInterface): Promise { + // Best-effort restore: bring back the folded rows from the snapshot; FK repoints on + // quote_tickets/assignments are not reversed (the canonical ids remain valid rows). + await queryInterface.sequelize.query(`ALTER TABLE "partners" DROP CONSTRAINT IF EXISTS "uniq_partners_name";`); + await queryInterface.sequelize.query(` + INSERT INTO partners + SELECT l.* FROM partners_legacy l + WHERE NOT EXISTS (SELECT 1 FROM partners p WHERE p.id = l.id); + `); + await queryInterface.removeIndex("profile_partner_assignments", "idx_profile_partner_assignments_partner_id"); + await queryInterface.removeColumn("profile_partner_assignments", "partner_id"); + await queryInterface.dropTable("partner_pricing_configs"); + await queryInterface.sequelize.query("DROP TABLE IF EXISTS partners_legacy;"); +} diff --git a/apps/api/src/database/migrations/040-create-provider-customers-kyc-cases.ts b/apps/api/src/database/migrations/040-create-provider-customers-kyc-cases.ts new file mode 100644 index 000000000..eb4539458 --- /dev/null +++ b/apps/api/src/database/migrations/040-create-provider-customers-kyc-cases.ts @@ -0,0 +1,370 @@ +import { DataTypes, QueryInterface } from "sequelize"; + +// Creates provider_customers (unified provider/rail account) + kyc_cases (unified KYC/KYB +// attempts) and backfills them from mykobo_customers, alfredpay_customers and the Avenia +// half of tax_ids. One-shot migration, no dual-write; the legacy tables are left untouched +// as backup. Rows without an owner (tax_ids.user_id IS NULL) are NOT migrated — they stay +// legacy-only until claimed through the existing authenticated createSubaccount flow. +// +// Status values are carried VERBATIM per provider (mykobo CONSULTED/PENDING/APPROVED/REJECTED, +// alfredpay CONSULTED/LINK_OPENED/USER_COMPLETED/VERIFYING/FAILED/SUCCESS/UPDATE_REQUIRED, +// avenia Consulted/Requested/Accepted/Rejected) so every existing status comparison keeps +// working; the dashboard aggregator normalizes at read time. UPDATE_REQUIRED is included even +// though the legacy alfredpay enum lacks it — the shared enum has it and writes of it crashed +// against the legacy table (latent bug this schema fixes). +export async function up(queryInterface: QueryInterface): Promise { + await queryInterface.createTable("provider_customers", { + country: { + allowNull: true, + type: DataTypes.STRING(4) + }, + created_at: { + allowNull: false, + defaultValue: DataTypes.NOW, + type: DataTypes.DATE + }, + customer_entity_id: { + allowNull: false, + type: DataTypes.UUID + }, + customer_type: { + allowNull: false, + defaultValue: "individual", + type: DataTypes.STRING(16) + }, + id: { + allowNull: false, + defaultValue: DataTypes.UUIDV4, + primaryKey: true, + type: DataTypes.UUID + }, + last_failure_reasons: { + allowNull: true, + defaultValue: [], + type: DataTypes.JSONB + }, + provider: { + allowNull: false, + type: DataTypes.STRING(16) + }, + provider_customer_id: { + allowNull: true, + type: DataTypes.STRING(255) + }, + provider_subaccount_id: { + allowNull: true, + type: DataTypes.STRING(255) + }, + rail: { + allowNull: true, + type: DataTypes.STRING(8) + }, + status: { + allowNull: false, + type: DataTypes.STRING(32) + }, + status_external: { + allowNull: true, + type: DataTypes.STRING(255) + }, + // Raw (normalized, digits-only) tax reference — avenia only. Deliberate deviation from + // the unified doc's "no raw tax IDs" non-goal: the raw value is the join/aggregation key + // for in-flight ramp state (ramp_states.state.taxId, getPendingBrlVolume) and already + // persists in that JSONB indefinitely; dropping it here would force a dual-key transition + // in fund-flow-critical code. Revisit once legacy ramp state stops carrying raw tax ids. + tax_reference: { + allowNull: true, + type: DataTypes.STRING(32) + }, + tax_reference_hash: { + allowNull: true, + type: DataTypes.STRING(64) + }, + tax_reference_masked: { + allowNull: true, + type: DataTypes.STRING(64) + }, + updated_at: { + allowNull: false, + defaultValue: DataTypes.NOW, + type: DataTypes.DATE + } + }); + + await queryInterface.addConstraint("provider_customers", { + fields: ["customer_entity_id"], + name: "fk_provider_customers_customer_entity_id", + onDelete: "CASCADE", + onUpdate: "CASCADE", + references: { + field: "id", + table: "customer_entities" + }, + type: "foreign key" + }); + + await queryInterface.sequelize.query( + `ALTER TABLE "provider_customers" ADD CONSTRAINT "chk_provider_customers_provider" CHECK (provider IN ('mykobo', 'alfredpay', 'avenia'));` + ); + await queryInterface.sequelize.query( + `ALTER TABLE "provider_customers" ADD CONSTRAINT "chk_provider_customers_customer_type" CHECK (customer_type IN ('individual', 'business'));` + ); + await queryInterface.sequelize.query( + `ALTER TABLE "provider_customers" ADD CONSTRAINT "chk_provider_customers_status" CHECK (status IN ( + 'CONSULTED', 'PENDING', 'APPROVED', 'REJECTED', + 'LINK_OPENED', 'USER_COMPLETED', 'VERIFYING', 'FAILED', 'SUCCESS', 'UPDATE_REQUIRED', + 'Consulted', 'Requested', 'Accepted', 'Rejected' + ));` + ); + + // Partial uniques: the external ids are the durable per-provider keys where they exist + // (alfredpay customer id, mykobo email, avenia subaccount id). The tax hash reproduces the + // legacy "one tax ID globally" dedup guard without storing raw tax IDs. + await queryInterface.sequelize.query( + `CREATE UNIQUE INDEX IF NOT EXISTS "ux_provider_customers_provider_customer" + ON "provider_customers" (provider, provider_customer_id) WHERE provider_customer_id IS NOT NULL;` + ); + await queryInterface.sequelize.query( + `CREATE UNIQUE INDEX IF NOT EXISTS "ux_provider_customers_subaccount" + ON "provider_customers" (provider, provider_subaccount_id) WHERE provider_subaccount_id IS NOT NULL;` + ); + await queryInterface.sequelize.query( + `CREATE UNIQUE INDEX IF NOT EXISTS "ux_provider_customers_tax_hash" + ON "provider_customers" (provider, tax_reference_hash) WHERE tax_reference_hash IS NOT NULL;` + ); + // One account per entity/corridor/type — except avenia, where legacy data legitimately has + // multiple tax-id accounts per user (resolveAveniaAccount errors on >1 Accepted at runtime; + // that behavior is preserved, not turned into a migration failure). + await queryInterface.sequelize.query( + `CREATE UNIQUE INDEX IF NOT EXISTS "ux_provider_customers_entity_corridor" + ON "provider_customers" (provider, customer_entity_id, rail, COALESCE(country, ''), customer_type) + WHERE provider <> 'avenia';` + ); + await queryInterface.addIndex("provider_customers", ["customer_entity_id"], { + name: "idx_provider_customers_customer_entity_id" + }); + await queryInterface.sequelize.query(`ALTER TABLE "provider_customers" ENABLE ROW LEVEL SECURITY;`); + + await queryInterface.createTable("kyc_cases", { + approved_at: { + allowNull: true, + type: DataTypes.DATE + }, + created_at: { + allowNull: false, + defaultValue: DataTypes.NOW, + type: DataTypes.DATE + }, + customer_entity_id: { + allowNull: false, + type: DataTypes.UUID + }, + failure_reasons: { + allowNull: true, + defaultValue: [], + type: DataTypes.JSONB + }, + id: { + allowNull: false, + defaultValue: DataTypes.UUIDV4, + primaryKey: true, + type: DataTypes.UUID + }, + level: { + allowNull: true, + type: DataTypes.STRING(16) + }, + provider: { + allowNull: false, + type: DataTypes.STRING(16) + }, + provider_case_id: { + allowNull: true, + type: DataTypes.STRING(255) + }, + provider_customer_id: { + allowNull: true, + type: DataTypes.UUID + }, + rejected_at: { + allowNull: true, + type: DataTypes.DATE + }, + status: { + allowNull: false, + type: DataTypes.STRING(32) + }, + status_external: { + allowNull: true, + type: DataTypes.STRING(255) + }, + submitted_at: { + allowNull: true, + type: DataTypes.DATE + }, + type: { + allowNull: false, + defaultValue: "kyc", + type: DataTypes.STRING(8) + }, + updated_at: { + allowNull: false, + defaultValue: DataTypes.NOW, + type: DataTypes.DATE + } + }); + + await queryInterface.addConstraint("kyc_cases", { + fields: ["customer_entity_id"], + name: "fk_kyc_cases_customer_entity_id", + onDelete: "CASCADE", + onUpdate: "CASCADE", + references: { + field: "id", + table: "customer_entities" + }, + type: "foreign key" + }); + await queryInterface.addConstraint("kyc_cases", { + fields: ["provider_customer_id"], + name: "fk_kyc_cases_provider_customer_id", + onDelete: "SET NULL", + onUpdate: "CASCADE", + references: { + field: "id", + table: "provider_customers" + }, + type: "foreign key" + }); + await queryInterface.sequelize.query( + `ALTER TABLE "kyc_cases" ADD CONSTRAINT "chk_kyc_cases_type" CHECK (type IN ('kyc', 'kyb'));` + ); + await queryInterface.sequelize.query( + `ALTER TABLE "kyc_cases" ADD CONSTRAINT "chk_kyc_cases_provider" CHECK (provider IN ('mykobo', 'alfredpay', 'avenia'));` + ); + await queryInterface.sequelize.query( + `ALTER TABLE "kyc_cases" ADD CONSTRAINT "chk_kyc_cases_status" CHECK (status IN ( + 'CONSULTED', 'PENDING', 'APPROVED', 'REJECTED', + 'LINK_OPENED', 'USER_COMPLETED', 'VERIFYING', 'FAILED', 'SUCCESS', 'UPDATE_REQUIRED', + 'Consulted', 'Requested', 'Accepted', 'Rejected' + ));` + ); + await queryInterface.addIndex("kyc_cases", ["customer_entity_id"], { + name: "idx_kyc_cases_customer_entity_id" + }); + await queryInterface.addIndex("kyc_cases", ["provider_customer_id"], { + name: "idx_kyc_cases_provider_customer_id" + }); + await queryInterface.sequelize.query(`ALTER TABLE "kyc_cases" ENABLE ROW LEVEL SECURITY;`); + + // --- Backfills (idempotent via NOT EXISTS; timestamps preserved from source rows) --- + + // Mykobo: one row per user (user_id is unique). The provider-side durable key is the + // (last-synced) email → provider_customer_id. Rail is implicitly EUR; no country. + await queryInterface.sequelize.query(` + INSERT INTO provider_customers ( + id, customer_entity_id, provider, rail, country, provider_customer_id, + customer_type, status, status_external, last_failure_reasons, created_at, updated_at + ) + SELECT + uuid_generate_v4(), ce.id, 'mykobo', 'eur', NULL, m.email, + LOWER(m.type::text), m.status::text, m.status_external, + COALESCE(to_jsonb(m.last_failure_reasons), '[]'::jsonb), m.created_at, m.updated_at + FROM mykobo_customers m + JOIN customer_entities ce ON ce.profile_id = m.user_id + WHERE NOT EXISTS ( + SELECT 1 FROM provider_customers pc + WHERE pc.provider = 'mykobo' AND pc.provider_customer_id = m.email + ); + `); + + // AlfredPay: one row per (user, country, type), keeping the most recently updated where + // historical duplicates exist (mirrors the runtime updatedAt-DESC findOne semantics). + await queryInterface.sequelize.query(` + WITH source AS ( + SELECT DISTINCT ON (a.user_id, a.country, a.type) a.* + FROM alfredpay_customers a + ORDER BY a.user_id, a.country, a.type, a.updated_at DESC + ) + INSERT INTO provider_customers ( + id, customer_entity_id, provider, rail, country, provider_customer_id, + customer_type, status, status_external, last_failure_reasons, created_at, updated_at + ) + SELECT + uuid_generate_v4(), ce.id, 'alfredpay', + CASE s.country::text + WHEN 'MX' THEN 'mxn' WHEN 'AR' THEN 'ars' WHEN 'CO' THEN 'cop' WHEN 'US' THEN 'usd' + WHEN 'BR' THEN 'brl' WHEN 'DO' THEN 'dop' WHEN 'CN' THEN 'cny' WHEN 'HK' THEN 'hkd' + WHEN 'CL' THEN 'clp' WHEN 'PE' THEN 'pen' WHEN 'BO' THEN 'bob' + END, + s.country::text, s.alfred_pay_id, + LOWER(s.type::text), s.status::text, s.status_external, + COALESCE(to_jsonb(s.last_failure_reasons), '[]'::jsonb), s.created_at, s.updated_at + FROM source s + JOIN customer_entities ce ON ce.profile_id = s.user_id + WHERE NOT EXISTS ( + SELECT 1 FROM provider_customers pc + WHERE pc.provider = 'alfredpay' AND pc.provider_customer_id = s.alfred_pay_id + ); + `); + + // Avenia (tax_ids): only owned rows migrate (user_id IS NULL rows are quarantined in the + // legacy table per the no-auto-assign rule). The raw tax id is replaced by a sha256 hash + // (runtime lookups hash the taxId from ramp state) + a masked display value. Empty-string + // sub_account_id is the legacy "no subaccount yet" sentinel → NULL. + await queryInterface.sequelize.query(` + INSERT INTO provider_customers ( + id, customer_entity_id, provider, rail, country, provider_subaccount_id, + tax_reference, tax_reference_hash, tax_reference_masked, + customer_type, status, created_at, updated_at + ) + SELECT + uuid_generate_v4(), ce.id, 'avenia', 'brl', 'BR', NULLIF(t.sub_account_id, ''), + t.tax_id, + encode(sha256(convert_to(t.tax_id, 'UTF8')), 'hex'), + repeat('*', GREATEST(length(t.tax_id) - 4, 0)) || right(t.tax_id, 4), + CASE t.account_type::text WHEN 'COMPANY' THEN 'business' ELSE 'individual' END, + COALESCE(t.internal_status::text, 'Consulted'), t.created_at, t.updated_at + FROM tax_ids t + JOIN customer_entities ce ON ce.profile_id = t.user_id + WHERE t.user_id IS NOT NULL + AND NOT EXISTS ( + SELECT 1 FROM provider_customers pc + WHERE pc.provider = 'avenia' + AND pc.tax_reference_hash = encode(sha256(convert_to(t.tax_id, 'UTF8')), 'hex') + ); + `); + + // kyc_cases: one case per migrated provider account mirroring its current verification + // state (type kyb for business customers). Avenia cases carry the workflow timestamps from + // tax_ids (requested_date/final_timestamp); mykobo/alfredpay have no recorded lifecycle + // timestamps to convert. kyc_level_2 is dead in apps/api — superseded with NO data + // conversion. + await queryInterface.sequelize.query(` + INSERT INTO kyc_cases ( + id, customer_entity_id, provider_customer_id, provider, level, type, + status, status_external, failure_reasons, submitted_at, approved_at, rejected_at, + created_at, updated_at + ) + SELECT + uuid_generate_v4(), pc.customer_entity_id, pc.id, pc.provider, 'level_1', + CASE WHEN pc.customer_type = 'business' THEN 'kyb' ELSE 'kyc' END, + pc.status, pc.status_external, COALESCE(pc.last_failure_reasons, '[]'::jsonb), + t.requested_date, + CASE WHEN pc.status = 'Accepted' THEN t.final_timestamp END, + CASE WHEN pc.status = 'Rejected' THEN t.final_timestamp END, + pc.created_at, pc.updated_at + FROM provider_customers pc + LEFT JOIN tax_ids t + ON pc.provider = 'avenia' + AND pc.tax_reference_hash = encode(sha256(convert_to(t.tax_id, 'UTF8')), 'hex') + WHERE NOT EXISTS ( + SELECT 1 FROM kyc_cases k WHERE k.provider_customer_id = pc.id + ); + `); +} + +export async function down(queryInterface: QueryInterface): Promise { + await queryInterface.dropTable("kyc_cases"); + await queryInterface.dropTable("provider_customers"); +} diff --git a/apps/api/src/database/migrations/041-add-partner-id-to-api-keys.ts b/apps/api/src/database/migrations/041-add-partner-id-to-api-keys.ts new file mode 100644 index 000000000..22d0e7e56 --- /dev/null +++ b/apps/api/src/database/migrations/041-add-partner-id-to-api-keys.ts @@ -0,0 +1,48 @@ +import { DataTypes, QueryInterface } from "sequelize"; + +// Adds the partner_id FK to api_keys (backfilled from the partner_name string against the +// now-unique partners.name — depends on 039), plus the target-schema scopes/revoked_at +// columns. partner_name is kept as an unread backup column (no dual-write, not dropped). +// Note: the schema doc's profile_id already exists as api_keys.user_id (migration 034). +export async function up(queryInterface: QueryInterface): Promise { + await queryInterface.addColumn("api_keys", "partner_id", { + allowNull: true, + onDelete: "SET NULL", + onUpdate: "CASCADE", + references: { + key: "id", + model: "partners" + }, + type: DataTypes.UUID + }); + await queryInterface.addColumn("api_keys", "scopes", { + allowNull: true, + type: DataTypes.JSONB + }); + await queryInterface.addColumn("api_keys", "revoked_at", { + allowNull: true, + type: DataTypes.DATE + }); + + await queryInterface.addIndex("api_keys", ["partner_id"], { + name: "idx_api_keys_partner_id" + }); + + // Backfill: names are unique after 039, so the mapping is unambiguous. Keys whose + // partner_name matches no partners row keep partner_id NULL — they were already unusable + // (name lookup found nothing) and stay unusable, same behavior. + await queryInterface.sequelize.query(` + UPDATE api_keys k SET partner_id = p.id + FROM partners p + WHERE k.partner_name IS NOT NULL + AND p.name = k.partner_name + AND k.partner_id IS NULL; + `); +} + +export async function down(queryInterface: QueryInterface): Promise { + await queryInterface.removeIndex("api_keys", "idx_api_keys_partner_id"); + await queryInterface.removeColumn("api_keys", "revoked_at"); + await queryInterface.removeColumn("api_keys", "scopes"); + await queryInterface.removeColumn("api_keys", "partner_id"); +} diff --git a/apps/api/src/database/migrations/042-create-recipient-tables.ts b/apps/api/src/database/migrations/042-create-recipient-tables.ts new file mode 100644 index 000000000..dd5fd256b --- /dev/null +++ b/apps/api/src/database/migrations/042-create-recipient-tables.ts @@ -0,0 +1,334 @@ +import { DataTypes, QueryInterface } from "sequelize"; + +// Recipient product tables (docs/architecture/recipient-transfers-schema.md, refined by the +// plan's D1/D3): recipient_invitations are LINK-based — token_hash is the redemption key and +// invitee_email is optional metadata; recipient_payout_references are thin pointers to +// provider-side instruments (no payout PII stored locally). All net-new, atomic revert. +export async function up(queryInterface: QueryInterface): Promise { + await queryInterface.createTable("recipient_invitations", { + accepted_at: { + allowNull: true, + type: DataTypes.DATE + }, + accepted_by_profile_id: { + allowNull: true, + type: DataTypes.UUID + }, + amount: { + allowNull: true, + type: DataTypes.DECIMAL(38, 18) + }, + country: { + allowNull: false, + type: DataTypes.STRING(4) + }, + created_at: { + allowNull: false, + defaultValue: DataTypes.NOW, + type: DataTypes.DATE + }, + created_by_profile_id: { + allowNull: true, + type: DataTypes.UUID + }, + expires_at: { + allowNull: true, + type: DataTypes.DATE + }, + id: { + allowNull: false, + defaultValue: DataTypes.UUIDV4, + primaryKey: true, + type: DataTypes.UUID + }, + invitee_email: { + allowNull: true, + type: DataTypes.STRING(255) + }, + invitee_email_canonical: { + allowNull: true, + type: DataTypes.STRING(255) + }, + invitee_type: { + allowNull: false, + defaultValue: "individual", + type: DataTypes.STRING(16) + }, + payout_currency: { + allowNull: false, + type: DataTypes.STRING(8) + }, + rail: { + allowNull: false, + type: DataTypes.STRING(8) + }, + revoked_at: { + allowNull: true, + type: DataTypes.DATE + }, + sender_customer_entity_id: { + allowNull: false, + type: DataTypes.UUID + }, + status: { + allowNull: false, + defaultValue: "pending", + type: DataTypes.STRING(16) + }, + token_hash: { + allowNull: false, + type: DataTypes.STRING(128), + unique: true + }, + updated_at: { + allowNull: false, + defaultValue: DataTypes.NOW, + type: DataTypes.DATE + } + }); + + await queryInterface.addConstraint("recipient_invitations", { + fields: ["sender_customer_entity_id"], + name: "fk_recipient_invitations_sender_entity", + onDelete: "CASCADE", + onUpdate: "CASCADE", + references: { + field: "id", + table: "customer_entities" + }, + type: "foreign key" + }); + await queryInterface.addConstraint("recipient_invitations", { + fields: ["created_by_profile_id"], + name: "fk_recipient_invitations_created_by_profile", + onDelete: "SET NULL", + onUpdate: "CASCADE", + references: { + field: "id", + table: "profiles" + }, + type: "foreign key" + }); + await queryInterface.addConstraint("recipient_invitations", { + fields: ["accepted_by_profile_id"], + name: "fk_recipient_invitations_accepted_by_profile", + onDelete: "SET NULL", + onUpdate: "CASCADE", + references: { + field: "id", + table: "profiles" + }, + type: "foreign key" + }); + await queryInterface.sequelize.query( + `ALTER TABLE "recipient_invitations" ADD CONSTRAINT "chk_recipient_invitations_status" CHECK (status IN ('pending', 'accepted', 'expired', 'revoked'));` + ); + await queryInterface.sequelize.query( + `ALTER TABLE "recipient_invitations" ADD CONSTRAINT "chk_recipient_invitations_invitee_type" CHECK (invitee_type IN ('individual', 'business'));` + ); + await queryInterface.addIndex("recipient_invitations", ["sender_customer_entity_id"], { + name: "idx_recipient_invitations_sender_entity" + }); + await queryInterface.sequelize.query(`ALTER TABLE "recipient_invitations" ENABLE ROW LEVEL SECURITY;`); + + await queryInterface.createTable("sender_recipients", { + created_at: { + allowNull: false, + defaultValue: DataTypes.NOW, + type: DataTypes.DATE + }, + disabled_at: { + allowNull: true, + type: DataTypes.DATE + }, + id: { + allowNull: false, + defaultValue: DataTypes.UUIDV4, + primaryKey: true, + type: DataTypes.UUID + }, + invitation_id: { + allowNull: true, + type: DataTypes.UUID + }, + nickname: { + allowNull: true, + type: DataTypes.STRING(100) + }, + recipient_customer_entity_id: { + allowNull: false, + type: DataTypes.UUID + }, + relationship_status: { + allowNull: false, + defaultValue: "invited", + type: DataTypes.STRING(16) + }, + sender_customer_entity_id: { + allowNull: false, + type: DataTypes.UUID + }, + updated_at: { + allowNull: false, + defaultValue: DataTypes.NOW, + type: DataTypes.DATE + } + }); + + await queryInterface.addConstraint("sender_recipients", { + fields: ["sender_customer_entity_id"], + name: "fk_sender_recipients_sender_entity", + onDelete: "CASCADE", + onUpdate: "CASCADE", + references: { + field: "id", + table: "customer_entities" + }, + type: "foreign key" + }); + await queryInterface.addConstraint("sender_recipients", { + fields: ["recipient_customer_entity_id"], + name: "fk_sender_recipients_recipient_entity", + onDelete: "CASCADE", + onUpdate: "CASCADE", + references: { + field: "id", + table: "customer_entities" + }, + type: "foreign key" + }); + await queryInterface.addConstraint("sender_recipients", { + fields: ["invitation_id"], + name: "fk_sender_recipients_invitation", + onDelete: "SET NULL", + onUpdate: "CASCADE", + references: { + field: "id", + table: "recipient_invitations" + }, + type: "foreign key" + }); + await queryInterface.addConstraint("sender_recipients", { + fields: ["sender_customer_entity_id", "recipient_customer_entity_id"], + name: "uniq_sender_recipients_pair", + type: "unique" + }); + await queryInterface.sequelize.query( + `ALTER TABLE "sender_recipients" ADD CONSTRAINT "chk_sender_recipients_status" CHECK (relationship_status IN ('invited', 'active', 'blocked', 'archived'));` + ); + await queryInterface.addIndex("sender_recipients", ["recipient_customer_entity_id"], { + name: "idx_sender_recipients_recipient_entity" + }); + await queryInterface.sequelize.query(`ALTER TABLE "sender_recipients" ENABLE ROW LEVEL SECURITY;`); + + await queryInterface.createTable("recipient_payout_references", { + country: { + allowNull: false, + type: DataTypes.STRING(4) + }, + created_at: { + allowNull: false, + defaultValue: DataTypes.NOW, + type: DataTypes.DATE + }, + currency: { + allowNull: false, + type: DataTypes.STRING(8) + }, + id: { + allowNull: false, + defaultValue: DataTypes.UUIDV4, + primaryKey: true, + type: DataTypes.UUID + }, + instrument_type: { + allowNull: false, + type: DataTypes.STRING(20) + }, + last_provider_sync_at: { + allowNull: true, + type: DataTypes.DATE + }, + masked_display_label: { + allowNull: true, + type: DataTypes.STRING(255) + }, + provider: { + allowNull: false, + type: DataTypes.STRING(16) + }, + provider_instrument_id: { + allowNull: true, + type: DataTypes.STRING(255) + }, + rail: { + allowNull: false, + type: DataTypes.STRING(8) + }, + recipient_customer_entity_id: { + allowNull: false, + type: DataTypes.UUID + }, + sender_recipient_id: { + allowNull: false, + type: DataTypes.UUID + }, + status: { + allowNull: false, + defaultValue: "pending", + type: DataTypes.STRING(16) + }, + updated_at: { + allowNull: false, + defaultValue: DataTypes.NOW, + type: DataTypes.DATE + } + }); + + await queryInterface.addConstraint("recipient_payout_references", { + fields: ["sender_recipient_id"], + name: "fk_recipient_payout_references_sender_recipient", + onDelete: "CASCADE", + onUpdate: "CASCADE", + references: { + field: "id", + table: "sender_recipients" + }, + type: "foreign key" + }); + await queryInterface.addConstraint("recipient_payout_references", { + fields: ["recipient_customer_entity_id"], + name: "fk_recipient_payout_references_recipient_entity", + onDelete: "CASCADE", + onUpdate: "CASCADE", + references: { + field: "id", + table: "customer_entities" + }, + type: "foreign key" + }); + await queryInterface.sequelize.query( + `ALTER TABLE "recipient_payout_references" ADD CONSTRAINT "chk_recipient_payout_references_status" CHECK (status IN ('pending', 'verified', 'rejected', 'disabled'));` + ); + await queryInterface.sequelize.query( + `ALTER TABLE "recipient_payout_references" ADD CONSTRAINT "chk_recipient_payout_references_provider" CHECK (provider IN ('mykobo', 'alfredpay', 'avenia'));` + ); + await queryInterface.sequelize.query( + `ALTER TABLE "recipient_payout_references" ADD CONSTRAINT "chk_recipient_payout_references_instrument_type" CHECK (instrument_type IN ('pix', 'iban', 'clabe', 'ach', 'cbu_cvu', 'account_number'));` + ); + // Single non-disabled reference per relationship/corridor (plan D3). + await queryInterface.sequelize.query( + `CREATE UNIQUE INDEX IF NOT EXISTS "ux_recipient_payout_references_corridor" + ON "recipient_payout_references" (sender_recipient_id, country, rail) WHERE status <> 'disabled';` + ); + await queryInterface.addIndex("recipient_payout_references", ["recipient_customer_entity_id"], { + name: "idx_recipient_payout_references_recipient_entity" + }); + await queryInterface.sequelize.query(`ALTER TABLE "recipient_payout_references" ENABLE ROW LEVEL SECURITY;`); +} + +export async function down(queryInterface: QueryInterface): Promise { + await queryInterface.dropTable("recipient_payout_references"); + await queryInterface.dropTable("sender_recipients"); + await queryInterface.dropTable("recipient_invitations"); +} diff --git a/apps/api/src/database/migrations/043-create-notifications.ts b/apps/api/src/database/migrations/043-create-notifications.ts new file mode 100644 index 000000000..16eb8676b --- /dev/null +++ b/apps/api/src/database/migrations/043-create-notifications.ts @@ -0,0 +1,132 @@ +import { DataTypes, QueryInterface } from "sequelize"; + +// In-app notification feed + per-profile preferences (plan §8 — dashboard need beyond the +// architecture docs). +export async function up(queryInterface: QueryInterface): Promise { + await queryInterface.createTable("notifications", { + body: { + allowNull: true, + type: DataTypes.TEXT + }, + created_at: { + allowNull: false, + defaultValue: DataTypes.NOW, + type: DataTypes.DATE + }, + customer_entity_id: { + allowNull: true, + type: DataTypes.UUID + }, + id: { + allowNull: false, + defaultValue: DataTypes.UUIDV4, + primaryKey: true, + type: DataTypes.UUID + }, + metadata: { + allowNull: true, + defaultValue: {}, + type: DataTypes.JSONB + }, + profile_id: { + allowNull: false, + type: DataTypes.UUID + }, + read_at: { + allowNull: true, + type: DataTypes.DATE + }, + title: { + allowNull: false, + type: DataTypes.STRING(255) + }, + type: { + allowNull: false, + type: DataTypes.STRING(64) + }, + updated_at: { + allowNull: false, + defaultValue: DataTypes.NOW, + type: DataTypes.DATE + } + }); + + await queryInterface.addConstraint("notifications", { + fields: ["profile_id"], + name: "fk_notifications_profile_id", + onDelete: "CASCADE", + onUpdate: "CASCADE", + references: { + field: "id", + table: "profiles" + }, + type: "foreign key" + }); + await queryInterface.addConstraint("notifications", { + fields: ["customer_entity_id"], + name: "fk_notifications_customer_entity_id", + onDelete: "SET NULL", + onUpdate: "CASCADE", + references: { + field: "id", + table: "customer_entities" + }, + type: "foreign key" + }); + await queryInterface.addIndex("notifications", ["profile_id", "created_at"], { + name: "idx_notifications_profile_created" + }); + await queryInterface.sequelize.query(`ALTER TABLE "notifications" ENABLE ROW LEVEL SECURITY;`); + + await queryInterface.createTable("notification_preferences", { + created_at: { + allowNull: false, + defaultValue: DataTypes.NOW, + type: DataTypes.DATE + }, + email_enabled: { + allowNull: false, + defaultValue: true, + type: DataTypes.BOOLEAN + }, + id: { + allowNull: false, + defaultValue: DataTypes.UUIDV4, + primaryKey: true, + type: DataTypes.UUID + }, + prefs: { + allowNull: false, + defaultValue: {}, + type: DataTypes.JSONB + }, + profile_id: { + allowNull: false, + type: DataTypes.UUID, + unique: true + }, + updated_at: { + allowNull: false, + defaultValue: DataTypes.NOW, + type: DataTypes.DATE + } + }); + + await queryInterface.addConstraint("notification_preferences", { + fields: ["profile_id"], + name: "fk_notification_preferences_profile_id", + onDelete: "CASCADE", + onUpdate: "CASCADE", + references: { + field: "id", + table: "profiles" + }, + type: "foreign key" + }); + await queryInterface.sequelize.query(`ALTER TABLE "notification_preferences" ENABLE ROW LEVEL SECURITY;`); +} + +export async function down(queryInterface: QueryInterface): Promise { + await queryInterface.dropTable("notification_preferences"); + await queryInterface.dropTable("notifications"); +} diff --git a/apps/api/src/database/migrations/044-add-monerium-provider.ts b/apps/api/src/database/migrations/044-add-monerium-provider.ts new file mode 100644 index 000000000..9dfc7dd17 --- /dev/null +++ b/apps/api/src/database/migrations/044-add-monerium-provider.ts @@ -0,0 +1,30 @@ +import { QueryInterface } from "sequelize"; + +const PROVIDERS_WITH_MONERIUM = "'mykobo', 'alfredpay', 'avenia', 'monerium'"; +const ORIGINAL_PROVIDERS = "'mykobo', 'alfredpay', 'avenia'"; + +async function replaceProviderConstraints(queryInterface: QueryInterface, providers: string): Promise { + await queryInterface.sequelize.query(` + ALTER TABLE "provider_customers" DROP CONSTRAINT "chk_provider_customers_provider"; + ALTER TABLE "provider_customers" ADD CONSTRAINT "chk_provider_customers_provider" + CHECK (provider IN (${providers})); + ALTER TABLE "kyc_cases" DROP CONSTRAINT "chk_kyc_cases_provider"; + ALTER TABLE "kyc_cases" ADD CONSTRAINT "chk_kyc_cases_provider" + CHECK (provider IN (${providers})); + `); +} + +export async function up(queryInterface: QueryInterface): Promise { + await replaceProviderConstraints(queryInterface, PROVIDERS_WITH_MONERIUM); +} + +export async function down(queryInterface: QueryInterface): Promise { + const [rows] = await queryInterface.sequelize.query( + `SELECT 1 FROM "provider_customers" WHERE provider = 'monerium' + UNION ALL SELECT 1 FROM "kyc_cases" WHERE provider = 'monerium' LIMIT 1;` + ); + if (rows.length > 0) { + throw new Error("Cannot remove Monerium provider constraints while Monerium records exist"); + } + await replaceProviderConstraints(queryInterface, ORIGINAL_PROVIDERS); +} diff --git a/apps/api/src/database/migrations/045-normalize-verification-statuses.ts b/apps/api/src/database/migrations/045-normalize-verification-statuses.ts new file mode 100644 index 000000000..145603df5 --- /dev/null +++ b/apps/api/src/database/migrations/045-normalize-verification-statuses.ts @@ -0,0 +1,91 @@ +import { DataTypes, QueryInterface } from "sequelize"; + +const CANONICAL_STATUSES = "'pending', 'started', 'in_review', 'approved', 'rejected'"; +const LEGACY_STATUSES = + "'CONSULTED', 'PENDING', 'APPROVED', 'REJECTED', 'LINK_OPENED', 'USER_COMPLETED', 'VERIFYING', 'FAILED', 'SUCCESS', 'UPDATE_REQUIRED', 'Consulted', 'Requested', 'Accepted', 'Rejected'"; + +async function dropStatusConstraints(queryInterface: QueryInterface): Promise { + await queryInterface.sequelize.query(` + ALTER TABLE "provider_customers" DROP CONSTRAINT "chk_provider_customers_status"; + ALTER TABLE "kyc_cases" DROP CONSTRAINT "chk_kyc_cases_status"; + `); +} + +async function addStatusConstraints(queryInterface: QueryInterface, statuses: string): Promise { + await queryInterface.sequelize.query(` + ALTER TABLE "provider_customers" ADD CONSTRAINT "chk_provider_customers_status" + CHECK (status IN (${statuses})); + ALTER TABLE "kyc_cases" ADD CONSTRAINT "chk_kyc_cases_status" + CHECK (status IN (${statuses})); + `); +} + +const canonicalStatusSql = `CASE + WHEN status IN ('APPROVED', 'SUCCESS', 'Accepted') THEN 'approved' + WHEN status IN ('REJECTED', 'FAILED', 'Rejected') THEN 'rejected' + WHEN status IN ('USER_COMPLETED', 'VERIFYING', 'Requested') THEN 'in_review' + WHEN provider = 'avenia' AND status = 'Consulted' THEN 'started' + WHEN provider = 'alfredpay' AND status IN ('CONSULTED', 'LINK_OPENED', 'UPDATE_REQUIRED') THEN 'started' + WHEN provider = 'monerium' AND status = 'PENDING' + AND LOWER(COALESCE(status_external, '')) IN ('authorization_started', 'created', 'incomplete') THEN 'started' + WHEN provider = 'monerium' AND status = 'PENDING' AND COALESCE(status_external, '') = '' THEN 'pending' + WHEN status = 'PENDING' THEN 'in_review' + ELSE 'pending' +END`; + +const legacyStatusSql = `CASE provider + WHEN 'avenia' THEN CASE status + WHEN 'approved' THEN 'Accepted' + WHEN 'rejected' THEN 'Rejected' + WHEN 'in_review' THEN 'Requested' + WHEN 'started' THEN 'Consulted' + ELSE 'Consulted' + END + WHEN 'alfredpay' THEN CASE status + WHEN 'approved' THEN 'SUCCESS' + WHEN 'rejected' THEN 'FAILED' + WHEN 'in_review' THEN 'VERIFYING' + WHEN 'started' THEN 'CONSULTED' + ELSE 'CONSULTED' + END + WHEN 'monerium' THEN CASE status + WHEN 'approved' THEN 'APPROVED' + WHEN 'rejected' THEN 'REJECTED' + ELSE 'PENDING' + END + ELSE CASE status + WHEN 'approved' THEN 'APPROVED' + WHEN 'rejected' THEN 'REJECTED' + WHEN 'in_review' THEN 'PENDING' + ELSE 'CONSULTED' + END +END`; + +export async function up(queryInterface: QueryInterface): Promise { + await queryInterface.addColumn("provider_customers", "company_name", { + allowNull: true, + type: DataTypes.STRING(255) + }); + await dropStatusConstraints(queryInterface); + await queryInterface.sequelize.query(`UPDATE "provider_customers" SET status = ${canonicalStatusSql};`); + await queryInterface.sequelize.query(`UPDATE "kyc_cases" SET status = ${canonicalStatusSql};`); + await addStatusConstraints(queryInterface, CANONICAL_STATUSES); + await queryInterface.removeColumn("provider_customers", "tax_reference_masked"); +} + +export async function down(queryInterface: QueryInterface): Promise { + await queryInterface.addColumn("provider_customers", "tax_reference_masked", { + allowNull: true, + type: DataTypes.STRING(64) + }); + await queryInterface.sequelize.query(` + UPDATE "provider_customers" + SET tax_reference_masked = repeat('*', GREATEST(length(tax_reference) - 4, 0)) || right(tax_reference, 4) + WHERE tax_reference IS NOT NULL; + `); + await dropStatusConstraints(queryInterface); + await queryInterface.sequelize.query(`UPDATE "provider_customers" SET status = ${legacyStatusSql};`); + await queryInterface.sequelize.query(`UPDATE "kyc_cases" SET status = ${legacyStatusSql};`); + await addStatusConstraints(queryInterface, LEGACY_STATUSES); + await queryInterface.removeColumn("provider_customers", "company_name"); +} diff --git a/apps/api/src/database/migrations/046-allow-started-verification-status.ts b/apps/api/src/database/migrations/046-allow-started-verification-status.ts new file mode 100644 index 000000000..fbc3960cd --- /dev/null +++ b/apps/api/src/database/migrations/046-allow-started-verification-status.ts @@ -0,0 +1,27 @@ +import { QueryInterface } from "sequelize"; + +const CANONICAL_STATUSES = "'pending', 'started', 'in_review', 'approved', 'rejected'"; +const PREVIOUS_STATUSES = "'pending', 'in_review', 'approved', 'rejected'"; + +async function replaceStatusConstraints(queryInterface: QueryInterface, statuses: string): Promise { + await queryInterface.sequelize.query(` + ALTER TABLE "provider_customers" DROP CONSTRAINT "chk_provider_customers_status"; + ALTER TABLE "provider_customers" ADD CONSTRAINT "chk_provider_customers_status" + CHECK (status IN (${statuses})); + ALTER TABLE "kyc_cases" DROP CONSTRAINT "chk_kyc_cases_status"; + ALTER TABLE "kyc_cases" ADD CONSTRAINT "chk_kyc_cases_status" + CHECK (status IN (${statuses})); + `); +} + +export async function up(queryInterface: QueryInterface): Promise { + await replaceStatusConstraints(queryInterface, CANONICAL_STATUSES); +} + +export async function down(queryInterface: QueryInterface): Promise { + await queryInterface.sequelize.query(` + UPDATE "provider_customers" SET status = 'pending' WHERE status = 'started'; + UPDATE "kyc_cases" SET status = 'pending' WHERE status = 'started'; + `); + await replaceStatusConstraints(queryInterface, PREVIOUS_STATUSES); +} diff --git a/apps/api/src/database/migrations/047-restore-monerium-reauth-status.ts b/apps/api/src/database/migrations/047-restore-monerium-reauth-status.ts new file mode 100644 index 000000000..50f7339cd --- /dev/null +++ b/apps/api/src/database/migrations/047-restore-monerium-reauth-status.ts @@ -0,0 +1,27 @@ +import { QueryInterface } from "sequelize"; + +export async function up(queryInterface: QueryInterface): Promise { + await queryInterface.sequelize.query(` + WITH latest_case AS ( + SELECT DISTINCT ON (provider_customer_id) + provider_customer_id, status, status_external + FROM kyc_cases + WHERE provider = 'monerium' AND provider_customer_id IS NOT NULL + ORDER BY provider_customer_id, updated_at DESC + ) + UPDATE provider_customers pc + SET status = latest_case.status, + status_external = latest_case.status_external, + updated_at = NOW() + FROM latest_case + WHERE pc.id = latest_case.provider_customer_id + AND pc.provider = 'monerium' + AND pc.provider_customer_id IS NOT NULL + AND pc.status = 'started' + AND pc.status_external = 'authorization_started'; + `); +} + +export async function down(_queryInterface: QueryInterface): Promise { + // Restoring stale authorization_started statuses would reintroduce the broken state. +} diff --git a/apps/api/src/database/migrations/048-add-active-customer-entity-to-profiles.ts b/apps/api/src/database/migrations/048-add-active-customer-entity-to-profiles.ts new file mode 100644 index 000000000..927a6872a --- /dev/null +++ b/apps/api/src/database/migrations/048-add-active-customer-entity-to-profiles.ts @@ -0,0 +1,62 @@ +import { DataTypes, QueryInterface } from "sequelize"; + +export async function up(queryInterface: QueryInterface): Promise { + await queryInterface.addColumn("profiles", "active_customer_entity_id", { + allowNull: true, + type: DataTypes.UUID + }); + + await queryInterface.addConstraint("profiles", { + fields: ["active_customer_entity_id"], + name: "fk_profiles_active_customer_entity_id", + onDelete: "SET NULL", + onUpdate: "CASCADE", + references: { + field: "id", + table: "customer_entities" + }, + type: "foreign key" + }); + + await queryInterface.addIndex("profiles", ["active_customer_entity_id"], { + name: "idx_profiles_active_customer_entity_id" + }); + + // Preserve an unambiguous entity that already has provider data. Empty legacy individual + // entities were created automatically and must not prevent an existing user choosing company. + await queryInterface.sequelize.query(` + UPDATE profiles p + SET active_customer_entity_id = candidate.id, + updated_at = NOW() + FROM ( + SELECT profile_id, MIN(id::text)::uuid AS id + FROM customer_entities + WHERE profile_id IS NOT NULL + AND ( + EXISTS (SELECT 1 FROM provider_customers pc WHERE pc.customer_entity_id = customer_entities.id) + OR EXISTS ( + SELECT 1 FROM recipient_invitations ri WHERE ri.sender_customer_entity_id = customer_entities.id + ) + OR EXISTS ( + SELECT 1 FROM sender_recipients sr + WHERE sr.sender_customer_entity_id = customer_entities.id + OR sr.recipient_customer_entity_id = customer_entities.id + ) + OR EXISTS ( + SELECT 1 FROM recipient_payout_references rpr + WHERE rpr.recipient_customer_entity_id = customer_entities.id + ) + ) + GROUP BY profile_id + HAVING COUNT(*) = 1 AND BOOL_AND(status = 'active') + ) candidate + WHERE p.id = candidate.profile_id + AND p.active_customer_entity_id IS NULL; + `); +} + +export async function down(queryInterface: QueryInterface): Promise { + await queryInterface.removeIndex("profiles", "idx_profiles_active_customer_entity_id"); + await queryInterface.removeConstraint("profiles", "fk_profiles_active_customer_entity_id"); + await queryInterface.removeColumn("profiles", "active_customer_entity_id"); +} diff --git a/apps/api/src/database/migrations/049-unique-customer-entities-profile-type.ts b/apps/api/src/database/migrations/049-unique-customer-entities-profile-type.ts new file mode 100644 index 000000000..b52448f63 --- /dev/null +++ b/apps/api/src/database/migrations/049-unique-customer-entities-profile-type.ts @@ -0,0 +1,41 @@ +import { QueryInterface } from "sequelize"; + +// Adds the uniqueness the code already assumes: getOrCreateCustomerEntityForProfile and +// invite acceptance findOrCreate on (profile_id, type) are only race-safe when the database +// enforces one entity per profile and type. Multiple entity *types* per profile stay allowed +// (plan D4 — individual + business); only duplicate rows of the same type are precluded. +// +// Duplicates could only have been created by that findOrCreate race. Unreferenced duplicates +// (keeping the oldest row per group) are removed; a *referenced* duplicate makes the index +// creation fail loudly for manual repair rather than silently merging compliance anchors. +export async function up(queryInterface: QueryInterface): Promise { + await queryInterface.sequelize.query(` + DELETE FROM customer_entities ce + USING customer_entities keeper + WHERE ce.profile_id IS NOT NULL + AND keeper.profile_id = ce.profile_id + AND keeper.type = ce.type + AND (keeper.created_at, keeper.id) < (ce.created_at, ce.id) + AND NOT EXISTS (SELECT 1 FROM provider_customers t WHERE t.customer_entity_id = ce.id) + AND NOT EXISTS (SELECT 1 FROM kyc_cases t WHERE t.customer_entity_id = ce.id) + AND NOT EXISTS (SELECT 1 FROM notifications t WHERE t.customer_entity_id = ce.id) + AND NOT EXISTS (SELECT 1 FROM recipient_invitations t WHERE t.sender_customer_entity_id = ce.id) + AND NOT EXISTS ( + SELECT 1 FROM sender_recipients t + WHERE t.sender_customer_entity_id = ce.id OR t.recipient_customer_entity_id = ce.id + ) + AND NOT EXISTS (SELECT 1 FROM recipient_payout_references t WHERE t.recipient_customer_entity_id = ce.id); + `); + + // Partial: profile_id is nullable by design (compliance records outlive a deleted profile), + // and orphaned rows must not collide with each other. + await queryInterface.sequelize.query(` + CREATE UNIQUE INDEX "uq_customer_entities_profile_id_type" + ON "customer_entities" (profile_id, type) + WHERE profile_id IS NOT NULL; + `); +} + +export async function down(queryInterface: QueryInterface): Promise { + await queryInterface.sequelize.query(`DROP INDEX IF EXISTS "uq_customer_entities_profile_id_type";`); +} diff --git a/apps/api/src/database/migrations/050-recipient-invitations-alias-token-archive.ts b/apps/api/src/database/migrations/050-recipient-invitations-alias-token-archive.ts new file mode 100644 index 000000000..d038dcc6f --- /dev/null +++ b/apps/api/src/database/migrations/050-recipient-invitations-alias-token-archive.ts @@ -0,0 +1,36 @@ +import { DataTypes, QueryInterface } from "sequelize"; + +export async function up(queryInterface: QueryInterface): Promise { + await queryInterface.removeColumn("recipient_invitations", "amount"); + + await queryInterface.addColumn("recipient_invitations", "alias", { + allowNull: true, + type: DataTypes.STRING(100) + }); + + // Raw invite token, retained while the invite is pending so the sender can re-copy the + // link; cleared on acceptance. Redemption still looks up by token_hash only. + await queryInterface.addColumn("recipient_invitations", "token", { + allowNull: true, + type: DataTypes.STRING(64) + }); + + // Sender-side soft hide: archived invitations disappear from the sender's list but the + // token stays redeemable. + await queryInterface.addColumn("recipient_invitations", "archived_at", { + allowNull: true, + type: DataTypes.DATE + }); +} + +export async function down(queryInterface: QueryInterface): Promise { + await queryInterface.removeColumn("recipient_invitations", "archived_at"); + await queryInterface.removeColumn("recipient_invitations", "token"); + await queryInterface.removeColumn("recipient_invitations", "alias"); + + // Data is not restored on revert. + await queryInterface.addColumn("recipient_invitations", "amount", { + allowNull: true, + type: DataTypes.DECIMAL(38, 18) + }); +} diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 38a89a788..3d9c73e53 100755 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -28,7 +28,6 @@ setLogger(logger); // Consider grouping all environment checks into a single function const validateRequiredEnvVars = () => { const requiredVars = { - CLIENT_DOMAIN_SECRET: config.secrets.clientDomainSecret, MOONBEAM_EXECUTOR_PRIVATE_KEY: config.secrets.moonbeamExecutorPrivateKey, PENDULUM_FUNDING_SEED: config.secrets.pendulumFundingSeed }; diff --git a/apps/api/src/models/alfredPayCustomer.model.ts b/apps/api/src/models/alfredPayCustomer.model.ts deleted file mode 100644 index 349cca1e3..000000000 --- a/apps/api/src/models/alfredPayCustomer.model.ts +++ /dev/null @@ -1,127 +0,0 @@ -import { AlfredPayCountry, AlfredPayStatus, AlfredPayType } from "@vortexfi/shared"; -import { DataTypes, Model, Optional } from "sequelize"; -import sequelize from "../config/database"; - -export interface AlfredPayCustomerAttributes { - id: string; // Internal PK - userId: string; // Foreign key to User (profiles.id) - alfredPayId: string; // Alfredpay's user ID - country: AlfredPayCountry; - status: AlfredPayStatus; - statusExternal: string | null; - lastFailureReasons: string[] | null; - type: AlfredPayType; - createdAt: Date; - updatedAt: Date; -} - -type AlfredPayCustomerCreationAttributes = Optional< - AlfredPayCustomerAttributes, - "id" | "createdAt" | "updatedAt" | "statusExternal" | "lastFailureReasons" ->; - -class AlfredPayCustomer - extends Model - implements AlfredPayCustomerAttributes -{ - declare id: string; - declare userId: string; - declare alfredPayId: string; - declare country: AlfredPayCountry; - declare status: AlfredPayStatus; - declare statusExternal: string | null; - declare lastFailureReasons: string[] | null; - declare type: AlfredPayType; - declare createdAt: Date; - declare updatedAt: Date; -} - -AlfredPayCustomer.init( - { - alfredPayId: { - allowNull: false, - comment: "Alfredpay's user ID", - field: "alfred_pay_id", - type: DataTypes.STRING, - unique: true - }, - country: { - allowNull: false, - type: DataTypes.ENUM(...Object.values(AlfredPayCountry)) - }, - createdAt: { - allowNull: false, - defaultValue: DataTypes.NOW, - field: "created_at", - type: DataTypes.DATE - }, - id: { - allowNull: false, - defaultValue: DataTypes.UUIDV4, - primaryKey: true, - type: DataTypes.UUID - }, - lastFailureReasons: { - allowNull: true, - defaultValue: [], - field: "last_failure_reasons", - type: DataTypes.ARRAY(DataTypes.STRING) - }, - status: { - allowNull: false, - defaultValue: AlfredPayStatus.Consulted, - type: DataTypes.ENUM(...Object.values(AlfredPayStatus)) - }, - statusExternal: { - allowNull: true, - comment: "Alfredpay's direct status", - field: "status_external", - type: DataTypes.STRING - }, - type: { - allowNull: false, - defaultValue: AlfredPayType.INDIVIDUAL, - type: DataTypes.ENUM(...Object.values(AlfredPayType)) - }, - updatedAt: { - allowNull: false, - defaultValue: DataTypes.NOW, - field: "updated_at", - type: DataTypes.DATE - }, - userId: { - allowNull: false, - field: "user_id", - onDelete: "CASCADE", - onUpdate: "CASCADE", - references: { - key: "id", - model: "profiles" - }, - type: DataTypes.UUID - } - }, - { - indexes: [ - { - fields: ["user_id"], - name: "idx_alfredpay_customers_user_id" - }, - { - fields: ["alfred_pay_id"], - name: "idx_alfredpay_customers_alfred_pay_id", - unique: true - }, - { - fields: ["email"], - name: "idx_alfredpay_customers_email" - } - ], - modelName: "AlfredPayCustomer", - sequelize, // Following convention - tableName: "alfredpay_customers", - timestamps: true - } -); - -export default AlfredPayCustomer; diff --git a/apps/api/src/models/apiKey.model.ts b/apps/api/src/models/apiKey.model.ts index b59ac1a3e..384f5479a 100644 --- a/apps/api/src/models/apiKey.model.ts +++ b/apps/api/src/models/apiKey.model.ts @@ -5,15 +5,19 @@ import type Partner from "./partner.model"; // Define the attributes of the ApiKey model export interface ApiKeyAttributes { id: string; + /** Legacy backup column — authorization resolves through partnerId; never read this. */ partnerName: string | null; + partnerId: string | null; keyType: "public" | "secret"; keyHash: string | null; keyValue: string | null; keyPrefix: string; name: string | null; + scopes: string[] | null; lastUsedAt: Date | null; expiresAt: Date | null; isActive: boolean; + revokedAt: Date | null; userId: string | null; createdAt: Date; updatedAt: Date; @@ -22,7 +26,18 @@ export interface ApiKeyAttributes { // Define the attributes that can be set during creation type ApiKeyCreationAttributes = Optional< ApiKeyAttributes, - "id" | "keyType" | "name" | "lastUsedAt" | "expiresAt" | "partnerName" | "userId" | "createdAt" | "updatedAt" + | "id" + | "keyType" + | "name" + | "scopes" + | "lastUsedAt" + | "expiresAt" + | "revokedAt" + | "partnerName" + | "partnerId" + | "userId" + | "createdAt" + | "updatedAt" >; // Define the ApiKey model @@ -31,6 +46,8 @@ class ApiKey extends Model implement declare partnerName: string | null; + declare partnerId: string | null; + declare keyType: "public" | "secret"; declare keyHash: string | null; @@ -41,20 +58,24 @@ class ApiKey extends Model implement declare name: string | null; + declare scopes: string[] | null; + declare lastUsedAt: Date | null; declare expiresAt: Date | null; declare isActive: boolean; + declare revokedAt: Date | null; + declare userId: string | null; declare createdAt: Date; declare updatedAt: Date; - // Association helper - partners with this name - declare partners?: Partner[]; + // Association helper + declare partner?: Partner; } // Initialize the model @@ -114,11 +135,31 @@ ApiKey.init( allowNull: true, type: DataTypes.STRING(100) }, + partnerId: { + allowNull: true, + field: "partner_id", + onDelete: "SET NULL", + onUpdate: "CASCADE", + references: { + key: "id", + model: "partners" + }, + type: DataTypes.UUID + }, partnerName: { allowNull: true, field: "partner_name", type: DataTypes.STRING(100) }, + revokedAt: { + allowNull: true, + field: "revoked_at", + type: DataTypes.DATE + }, + scopes: { + allowNull: true, + type: DataTypes.JSONB + }, updatedAt: { allowNull: false, defaultValue: DataTypes.NOW, @@ -143,6 +184,10 @@ ApiKey.init( fields: ["partner_name"], name: "idx_api_keys_partner_name" }, + { + fields: ["partner_id"], + name: "idx_api_keys_partner_id" + }, { fields: ["key_type"], name: "idx_api_keys_key_type" diff --git a/apps/api/src/models/customerEntity.model.ts b/apps/api/src/models/customerEntity.model.ts new file mode 100644 index 000000000..43bc50e60 --- /dev/null +++ b/apps/api/src/models/customerEntity.model.ts @@ -0,0 +1,96 @@ +import { DataTypes, Model, Optional } from "sequelize"; +import sequelize from "../config/database"; + +export type CustomerEntityType = "individual" | "business"; +export type CustomerEntityStatus = "active" | "archived" | "blocked"; + +// The legal/compliance customer — the owner anchor for provider accounts and KYC cases. +// Sits between profiles (login identity) and the provider/KYC tables. +export interface CustomerEntityAttributes { + id: string; + profileId: string | null; + type: CustomerEntityType; + country: string | null; + status: CustomerEntityStatus; + createdAt: Date; + updatedAt: Date; +} + +type CustomerEntityCreationAttributes = Optional< + CustomerEntityAttributes, + "id" | "profileId" | "type" | "country" | "status" | "createdAt" | "updatedAt" +>; + +class CustomerEntity + extends Model + implements CustomerEntityAttributes +{ + declare id: string; + declare profileId: string | null; + declare type: CustomerEntityType; + declare country: string | null; + declare status: CustomerEntityStatus; + declare createdAt: Date; + declare updatedAt: Date; +} + +CustomerEntity.init( + { + country: { + allowNull: true, + type: DataTypes.STRING(10) + }, + createdAt: { + allowNull: false, + defaultValue: DataTypes.NOW, + field: "created_at", + type: DataTypes.DATE + }, + id: { + defaultValue: DataTypes.UUIDV4, + primaryKey: true, + type: DataTypes.UUID + }, + profileId: { + allowNull: true, + field: "profile_id", + onDelete: "SET NULL", + onUpdate: "CASCADE", + references: { + key: "id", + model: "profiles" + }, + type: DataTypes.UUID + }, + status: { + allowNull: false, + defaultValue: "active", + type: DataTypes.STRING(20) + }, + type: { + allowNull: false, + defaultValue: "individual", + type: DataTypes.STRING(20) + }, + updatedAt: { + allowNull: false, + defaultValue: DataTypes.NOW, + field: "updated_at", + type: DataTypes.DATE + } + }, + { + indexes: [ + { + fields: ["profile_id"], + name: "idx_customer_entities_profile_id" + } + ], + modelName: "CustomerEntity", + sequelize, + tableName: "customer_entities", + timestamps: true + } +); + +export default CustomerEntity; diff --git a/apps/api/src/models/index.ts b/apps/api/src/models/index.ts index f869815ae..bd152a569 100644 --- a/apps/api/src/models/index.ts +++ b/apps/api/src/models/index.ts @@ -1,15 +1,21 @@ import sequelize from "../config/database"; -import AlfredPayCustomer from "./alfredPayCustomer.model"; import Anchor from "./anchor.model"; import ApiClientEvent from "./apiClientEvent.model"; import ApiKey from "./apiKey.model"; -import KycLevel2 from "./kycLevel2.model"; +import CustomerEntity from "./customerEntity.model"; +import KycCase from "./kycCase.model"; import MaintenanceSchedule from "./maintenanceSchedule.model"; -import MykoboCustomer from "./mykoboCustomer.model"; +import Notification from "./notification.model"; +import NotificationPreference from "./notificationPreference.model"; import Partner from "./partner.model"; +import PartnerPricingConfig from "./partnerPricingConfig.model"; import ProfilePartnerAssignment from "./profilePartnerAssignment.model"; +import ProviderCustomer from "./providerCustomer.model"; import QuoteTicket from "./quoteTicket.model"; import RampState from "./rampState.model"; +import RecipientInvitation from "./recipientInvitation.model"; +import RecipientPayoutReference from "./recipientPayoutReference.model"; +import SenderRecipient from "./senderRecipient.model"; import Subsidy from "./subsidy.model"; import TaxId from "./taxId.model"; import User from "./user.model"; @@ -32,18 +38,9 @@ QuoteTicket.belongsTo(User, { as: "user", foreignKey: "userId" }); User.hasMany(RampState, { as: "rampStates", foreignKey: "userId" }); RampState.belongsTo(User, { as: "user", foreignKey: "userId" }); -User.hasMany(KycLevel2, { as: "kycRecords", foreignKey: "userId" }); -KycLevel2.belongsTo(User, { as: "user", foreignKey: "userId" }); - User.hasMany(TaxId, { as: "taxIds", foreignKey: "userId" }); TaxId.belongsTo(User, { as: "user", foreignKey: "userId" }); -User.hasMany(AlfredPayCustomer, { as: "alfredPayCustomers", foreignKey: "userId" }); -AlfredPayCustomer.belongsTo(User, { as: "user", foreignKey: "userId" }); - -User.hasOne(MykoboCustomer, { as: "mykoboCustomer", foreignKey: "userId" }); -MykoboCustomer.belongsTo(User, { as: "user", foreignKey: "userId" }); - User.hasMany(ProfilePartnerAssignment, { as: "partnerAssignments", foreignKey: "userId" }); ProfilePartnerAssignment.belongsTo(User, { as: "user", foreignKey: "userId" }); ProfilePartnerAssignment.belongsTo(Partner, { as: "buyPartner", foreignKey: "buyPartnerId" }); @@ -55,19 +52,65 @@ Partner.hasMany(ProfilePartnerAssignment, { as: "sellProfileAssignments", foreig User.hasMany(ApiKey, { as: "apiKeys", foreignKey: "userId" }); ApiKey.belongsTo(User, { as: "user", foreignKey: "userId" }); +// API key ↔ partner attribution (FK replaces the partner_name string) +ApiKey.belongsTo(Partner, { as: "partner", foreignKey: "partnerId" }); +Partner.hasMany(ApiKey, { as: "apiKeys", foreignKey: "partnerId" }); + +// Partner pricing split +Partner.hasMany(PartnerPricingConfig, { as: "pricingConfigs", foreignKey: "partnerId" }); +PartnerPricingConfig.belongsTo(Partner, { as: "partner", foreignKey: "partnerId" }); +ProfilePartnerAssignment.belongsTo(Partner, { as: "partner", foreignKey: "partnerId" }); +Partner.hasMany(ProfilePartnerAssignment, { as: "profileAssignments", foreignKey: "partnerId" }); + +// Customer entity — owner anchor between profiles and provider/KYC records +User.hasMany(CustomerEntity, { as: "customerEntities", foreignKey: "profileId" }); +CustomerEntity.belongsTo(User, { as: "profile", foreignKey: "profileId" }); +User.belongsTo(CustomerEntity, { as: "activeCustomerEntity", foreignKey: "activeCustomerEntityId" }); +CustomerEntity.hasMany(ProviderCustomer, { as: "providerCustomers", foreignKey: "customerEntityId" }); +ProviderCustomer.belongsTo(CustomerEntity, { as: "customerEntity", foreignKey: "customerEntityId" }); +CustomerEntity.hasMany(KycCase, { as: "kycCases", foreignKey: "customerEntityId" }); +KycCase.belongsTo(CustomerEntity, { as: "customerEntity", foreignKey: "customerEntityId" }); +ProviderCustomer.hasMany(KycCase, { as: "kycCases", foreignKey: "providerCustomerId" }); +KycCase.belongsTo(ProviderCustomer, { as: "providerCustomer", foreignKey: "providerCustomerId" }); + +// Recipient graph +CustomerEntity.hasMany(RecipientInvitation, { as: "sentInvitations", foreignKey: "senderCustomerEntityId" }); +RecipientInvitation.belongsTo(CustomerEntity, { as: "sender", foreignKey: "senderCustomerEntityId" }); +CustomerEntity.hasMany(SenderRecipient, { as: "recipients", foreignKey: "senderCustomerEntityId" }); +SenderRecipient.belongsTo(CustomerEntity, { as: "sender", foreignKey: "senderCustomerEntityId" }); +CustomerEntity.hasMany(SenderRecipient, { as: "senders", foreignKey: "recipientCustomerEntityId" }); +SenderRecipient.belongsTo(CustomerEntity, { as: "recipient", foreignKey: "recipientCustomerEntityId" }); +SenderRecipient.belongsTo(RecipientInvitation, { as: "invitation", foreignKey: "invitationId" }); +RecipientInvitation.hasOne(SenderRecipient, { as: "relationship", foreignKey: "invitationId" }); +SenderRecipient.hasMany(RecipientPayoutReference, { as: "payoutReferences", foreignKey: "senderRecipientId" }); +RecipientPayoutReference.belongsTo(SenderRecipient, { as: "senderRecipient", foreignKey: "senderRecipientId" }); +RecipientPayoutReference.belongsTo(CustomerEntity, { as: "recipient", foreignKey: "recipientCustomerEntityId" }); + +// Notifications +User.hasMany(Notification, { as: "notifications", foreignKey: "profileId" }); +Notification.belongsTo(User, { as: "profile", foreignKey: "profileId" }); +User.hasOne(NotificationPreference, { as: "notificationPreference", foreignKey: "profileId" }); +NotificationPreference.belongsTo(User, { as: "profile", foreignKey: "profileId" }); + // Initialize models const models = { - AlfredPayCustomer, Anchor, ApiClientEvent, ApiKey, - KycLevel2, + CustomerEntity, + KycCase, MaintenanceSchedule, - MykoboCustomer, + Notification, + NotificationPreference, Partner, + PartnerPricingConfig, ProfilePartnerAssignment, + ProviderCustomer, QuoteTicket, RampState, + RecipientInvitation, + RecipientPayoutReference, + SenderRecipient, Subsidy, TaxId, User, diff --git a/apps/api/src/models/kycCase.model.ts b/apps/api/src/models/kycCase.model.ts new file mode 100644 index 000000000..14f944ffe --- /dev/null +++ b/apps/api/src/models/kycCase.model.ts @@ -0,0 +1,169 @@ +import { DataTypes, Model, Optional } from "sequelize"; +import sequelize from "../config/database"; +import type { ProviderName, VerificationStatus } from "./providerCustomer.model"; + +export type KycCaseType = "kyc" | "kyb"; + +// Unified KYC/KYB verification attempts, independent of the provider account row. +// Replaces the dead kyc_level_2 table (no data conversion — it had no readers). +export interface KycCaseAttributes { + id: string; + customerEntityId: string; + providerCustomerId: string | null; + provider: ProviderName; + level: string | null; + type: KycCaseType; + status: VerificationStatus; + statusExternal: string | null; + providerCaseId: string | null; + failureReasons: string[] | null; + submittedAt: Date | null; + approvedAt: Date | null; + rejectedAt: Date | null; + createdAt: Date; + updatedAt: Date; +} + +type KycCaseCreationAttributes = Optional< + KycCaseAttributes, + | "id" + | "providerCustomerId" + | "level" + | "type" + | "statusExternal" + | "providerCaseId" + | "failureReasons" + | "submittedAt" + | "approvedAt" + | "rejectedAt" + | "createdAt" + | "updatedAt" +>; + +class KycCase extends Model implements KycCaseAttributes { + declare id: string; + declare customerEntityId: string; + declare providerCustomerId: string | null; + declare provider: ProviderName; + declare level: string | null; + declare type: KycCaseType; + declare status: VerificationStatus; + declare statusExternal: string | null; + declare providerCaseId: string | null; + declare failureReasons: string[] | null; + declare submittedAt: Date | null; + declare approvedAt: Date | null; + declare rejectedAt: Date | null; + declare createdAt: Date; + declare updatedAt: Date; +} + +KycCase.init( + { + approvedAt: { + allowNull: true, + field: "approved_at", + type: DataTypes.DATE + }, + createdAt: { + allowNull: false, + defaultValue: DataTypes.NOW, + field: "created_at", + type: DataTypes.DATE + }, + customerEntityId: { + allowNull: false, + field: "customer_entity_id", + onDelete: "CASCADE", + onUpdate: "CASCADE", + references: { + key: "id", + model: "customer_entities" + }, + type: DataTypes.UUID + }, + failureReasons: { + allowNull: true, + defaultValue: [], + field: "failure_reasons", + type: DataTypes.JSONB + }, + id: { + defaultValue: DataTypes.UUIDV4, + primaryKey: true, + type: DataTypes.UUID + }, + level: { + allowNull: true, + type: DataTypes.STRING(16) + }, + provider: { + allowNull: false, + type: DataTypes.STRING(16) + }, + providerCaseId: { + allowNull: true, + field: "provider_case_id", + type: DataTypes.STRING(255) + }, + providerCustomerId: { + allowNull: true, + field: "provider_customer_id", + onDelete: "SET NULL", + onUpdate: "CASCADE", + references: { + key: "id", + model: "provider_customers" + }, + type: DataTypes.UUID + }, + rejectedAt: { + allowNull: true, + field: "rejected_at", + type: DataTypes.DATE + }, + status: { + allowNull: false, + type: DataTypes.STRING(32) + }, + statusExternal: { + allowNull: true, + field: "status_external", + type: DataTypes.STRING(255) + }, + submittedAt: { + allowNull: true, + field: "submitted_at", + type: DataTypes.DATE + }, + type: { + allowNull: false, + defaultValue: "kyc", + type: DataTypes.STRING(8) + }, + updatedAt: { + allowNull: false, + defaultValue: DataTypes.NOW, + field: "updated_at", + type: DataTypes.DATE + } + }, + { + indexes: [ + { + fields: ["customer_entity_id"], + name: "idx_kyc_cases_customer_entity_id" + }, + { + fields: ["provider_customer_id"], + name: "idx_kyc_cases_provider_customer_id" + } + ], + modelName: "KycCase", + sequelize, + tableName: "kyc_cases", + timestamps: true + } +); + +export default KycCase; diff --git a/apps/api/src/models/kycLevel2.model.ts b/apps/api/src/models/kycLevel2.model.ts deleted file mode 100644 index 8c71e3f56..000000000 --- a/apps/api/src/models/kycLevel2.model.ts +++ /dev/null @@ -1,110 +0,0 @@ -import { DataTypes, Model, Optional } from "sequelize"; -import sequelize from "../config/database"; - -export interface KycLevel2Attributes { - id: string; - userId: string | null; - subaccountId: string; - documentType: "RG" | "CNH"; - uploadData: unknown; - status: "Requested" | "DataCollected" | "BrlaValidating" | "Rejected" | "Accepted" | "Cancelled"; - errorLogs: unknown[]; - createdAt: Date; - updatedAt: Date; -} - -type KycLevel2CreationAttributes = Optional; - -class KycLevel2 extends Model implements KycLevel2Attributes { - declare id: string; - declare userId: string | null; - declare subaccountId: string; - declare documentType: "RG" | "CNH"; - declare uploadData: unknown; - declare status: "Requested" | "DataCollected" | "BrlaValidating" | "Rejected" | "Accepted" | "Cancelled"; - declare errorLogs: unknown[]; - declare createdAt: Date; - declare updatedAt: Date; -} - -KycLevel2.init( - { - createdAt: { - allowNull: false, - defaultValue: DataTypes.NOW, - field: "created_at", - type: DataTypes.DATE - }, - documentType: { - allowNull: false, - field: "document_type", - type: DataTypes.ENUM("RG", "CNH") - }, - errorLogs: { - allowNull: false, - defaultValue: [], - field: "error_logs", - type: DataTypes.JSONB - }, - id: { - defaultValue: DataTypes.UUIDV4, - primaryKey: true, - type: DataTypes.UUID - }, - status: { - allowNull: false, - defaultValue: "Requested", - field: "status", - type: DataTypes.ENUM("Requested", "DataCollected", "BrlaValidating", "Rejected", "Accepted", "Cancelled") - }, - subaccountId: { - allowNull: false, - field: "subaccount_id", - type: DataTypes.STRING - }, - updatedAt: { - allowNull: false, - defaultValue: DataTypes.NOW, - field: "updated_at", - type: DataTypes.DATE - }, - uploadData: { - allowNull: false, - field: "upload_data", - type: DataTypes.JSONB - }, - userId: { - allowNull: true, - field: "user_id", - onDelete: "CASCADE", - onUpdate: "CASCADE", - references: { - key: "id", - model: "profiles" - }, - type: DataTypes.UUID - } - }, - { - indexes: [ - { - fields: ["subaccount_id"], - name: "idx_kyc_level_2_subaccount" - }, - { - fields: ["status"], - name: "idx_kyc_level_2_status" - }, - { - fields: ["user_id"], - name: "idx_kyc_level_2_user_id" - } - ], - modelName: "KycLevel2", - sequelize, - tableName: "kyc_level_2", - timestamps: true - } -); - -export default KycLevel2; diff --git a/apps/api/src/models/mykoboCustomer.model.ts b/apps/api/src/models/mykoboCustomer.model.ts deleted file mode 100644 index 55aee45fc..000000000 --- a/apps/api/src/models/mykoboCustomer.model.ts +++ /dev/null @@ -1,117 +0,0 @@ -import { MykoboCustomerStatus, MykoboCustomerType } from "@vortexfi/shared"; -import { DataTypes, Model, Optional } from "sequelize"; -import sequelize from "../config/database"; - -export interface MykoboCustomerAttributes { - id: string; - userId: string; - email: string; - status: MykoboCustomerStatus; - statusExternal: string | null; - lastFailureReasons: string[] | null; - type: MykoboCustomerType; - createdAt: Date; - updatedAt: Date; -} - -type MykoboCustomerCreationAttributes = Optional< - MykoboCustomerAttributes, - "id" | "createdAt" | "updatedAt" | "statusExternal" | "lastFailureReasons" | "status" | "type" ->; - -class MykoboCustomer - extends Model - implements MykoboCustomerAttributes -{ - declare id: string; - declare userId: string; - declare email: string; - declare status: MykoboCustomerStatus; - declare statusExternal: string | null; - declare lastFailureReasons: string[] | null; - declare type: MykoboCustomerType; - declare createdAt: Date; - declare updatedAt: Date; -} - -MykoboCustomer.init( - { - createdAt: { - allowNull: false, - defaultValue: DataTypes.NOW, - field: "created_at", - type: DataTypes.DATE - }, - email: { - allowNull: false, - type: DataTypes.STRING, - unique: true - }, - id: { - allowNull: false, - defaultValue: DataTypes.UUIDV4, - primaryKey: true, - type: DataTypes.UUID - }, - lastFailureReasons: { - allowNull: true, - defaultValue: [], - field: "last_failure_reasons", - type: DataTypes.ARRAY(DataTypes.STRING) - }, - status: { - allowNull: false, - defaultValue: MykoboCustomerStatus.CONSULTED, - type: DataTypes.ENUM(...Object.values(MykoboCustomerStatus)) - }, - statusExternal: { - allowNull: true, - comment: "Mykobo's raw kyc_status.review_status", - field: "status_external", - type: DataTypes.STRING - }, - type: { - allowNull: false, - defaultValue: MykoboCustomerType.INDIVIDUAL, - type: DataTypes.ENUM(...Object.values(MykoboCustomerType)) - }, - updatedAt: { - allowNull: false, - defaultValue: DataTypes.NOW, - field: "updated_at", - type: DataTypes.DATE - }, - userId: { - allowNull: false, - field: "user_id", - onDelete: "CASCADE", - onUpdate: "CASCADE", - references: { - key: "id", - model: "profiles" - }, - type: DataTypes.UUID, - unique: true - } - }, - { - indexes: [ - { - fields: ["user_id"], - name: "idx_mykobo_customers_user_id", - unique: true - }, - { - fields: ["email"], - name: "idx_mykobo_customers_email", - unique: true - } - ], - modelName: "MykoboCustomer", - sequelize, - tableName: "mykobo_customers", - timestamps: true - } -); - -export default MykoboCustomer; diff --git a/apps/api/src/models/notification.model.ts b/apps/api/src/models/notification.model.ts new file mode 100644 index 000000000..9e75f7596 --- /dev/null +++ b/apps/api/src/models/notification.model.ts @@ -0,0 +1,115 @@ +import { DataTypes, Model, Optional } from "sequelize"; +import sequelize from "../config/database"; + +// In-app notification feed row (plan §8). type is an open vocabulary (e.g. +// onboarding_status_changed, invite_created, ramp_completed). +export interface NotificationAttributes { + id: string; + profileId: string; + customerEntityId: string | null; + type: string; + title: string; + body: string | null; + metadata: Record | null; + readAt: Date | null; + createdAt: Date; + updatedAt: Date; +} + +type NotificationCreationAttributes = Optional< + NotificationAttributes, + "id" | "customerEntityId" | "body" | "metadata" | "readAt" | "createdAt" | "updatedAt" +>; + +class Notification extends Model implements NotificationAttributes { + declare id: string; + declare profileId: string; + declare customerEntityId: string | null; + declare type: string; + declare title: string; + declare body: string | null; + declare metadata: Record | null; + declare readAt: Date | null; + declare createdAt: Date; + declare updatedAt: Date; +} + +Notification.init( + { + body: { + allowNull: true, + type: DataTypes.TEXT + }, + createdAt: { + allowNull: false, + defaultValue: DataTypes.NOW, + field: "created_at", + type: DataTypes.DATE + }, + customerEntityId: { + allowNull: true, + field: "customer_entity_id", + onDelete: "SET NULL", + onUpdate: "CASCADE", + references: { + key: "id", + model: "customer_entities" + }, + type: DataTypes.UUID + }, + id: { + defaultValue: DataTypes.UUIDV4, + primaryKey: true, + type: DataTypes.UUID + }, + metadata: { + allowNull: true, + defaultValue: {}, + type: DataTypes.JSONB + }, + profileId: { + allowNull: false, + field: "profile_id", + onDelete: "CASCADE", + onUpdate: "CASCADE", + references: { + key: "id", + model: "profiles" + }, + type: DataTypes.UUID + }, + readAt: { + allowNull: true, + field: "read_at", + type: DataTypes.DATE + }, + title: { + allowNull: false, + type: DataTypes.STRING(255) + }, + type: { + allowNull: false, + type: DataTypes.STRING(64) + }, + updatedAt: { + allowNull: false, + defaultValue: DataTypes.NOW, + field: "updated_at", + type: DataTypes.DATE + } + }, + { + indexes: [ + { + fields: ["profile_id", "created_at"], + name: "idx_notifications_profile_created" + } + ], + modelName: "Notification", + sequelize, + tableName: "notifications", + timestamps: true + } +); + +export default Notification; diff --git a/apps/api/src/models/notificationPreference.model.ts b/apps/api/src/models/notificationPreference.model.ts new file mode 100644 index 000000000..c20c8986a --- /dev/null +++ b/apps/api/src/models/notificationPreference.model.ts @@ -0,0 +1,83 @@ +import { DataTypes, Model, Optional } from "sequelize"; +import sequelize from "../config/database"; + +// Per-profile notification preferences (plan §8). prefs is an open JSONB bag for +// per-notification-type toggles. +export interface NotificationPreferenceAttributes { + id: string; + profileId: string; + emailEnabled: boolean; + prefs: Record; + createdAt: Date; + updatedAt: Date; +} + +type NotificationPreferenceCreationAttributes = Optional< + NotificationPreferenceAttributes, + "id" | "emailEnabled" | "prefs" | "createdAt" | "updatedAt" +>; + +class NotificationPreference + extends Model + implements NotificationPreferenceAttributes +{ + declare id: string; + declare profileId: string; + declare emailEnabled: boolean; + declare prefs: Record; + declare createdAt: Date; + declare updatedAt: Date; +} + +NotificationPreference.init( + { + createdAt: { + allowNull: false, + defaultValue: DataTypes.NOW, + field: "created_at", + type: DataTypes.DATE + }, + emailEnabled: { + allowNull: false, + defaultValue: true, + field: "email_enabled", + type: DataTypes.BOOLEAN + }, + id: { + defaultValue: DataTypes.UUIDV4, + primaryKey: true, + type: DataTypes.UUID + }, + prefs: { + allowNull: false, + defaultValue: {}, + type: DataTypes.JSONB + }, + profileId: { + allowNull: false, + field: "profile_id", + onDelete: "CASCADE", + onUpdate: "CASCADE", + references: { + key: "id", + model: "profiles" + }, + type: DataTypes.UUID, + unique: true + }, + updatedAt: { + allowNull: false, + defaultValue: DataTypes.NOW, + field: "updated_at", + type: DataTypes.DATE + } + }, + { + modelName: "NotificationPreference", + sequelize, + tableName: "notification_preferences", + timestamps: true + } +); + +export default NotificationPreference; diff --git a/apps/api/src/models/partner.model.ts b/apps/api/src/models/partner.model.ts index 91b03089f..054229424 100644 --- a/apps/api/src/models/partner.model.ts +++ b/apps/api/src/models/partner.model.ts @@ -1,32 +1,21 @@ -import { RampCurrency, RampDirection } from "@vortexfi/shared"; import { DataTypes, Model, Optional } from "sequelize"; import sequelize from "../config/database"; -// Define the attributes of the Partner model +// Commercial identity only — one row per partner, unique name. Pricing lives in +// partner_pricing_configs (per ramp direction), resolved via (partner_id, ramp_type). +// The legacy pricing/ramp_type columns still exist in the DB as unread backup. export interface PartnerAttributes { id: string; // UUID name: string; displayName: string; logoUrl: string | null; - markupType: "absolute" | "relative" | "none"; - markupValue: number; - markupCurrency: RampCurrency; - payoutAddressSubstrate: string | null; - payoutAddressEvm: string | null; - rampType: RampDirection; - vortexFeeType: "absolute" | "relative" | "none"; - vortexFeeValue: number; - targetDiscount: number; - maxSubsidy: number; - minDynamicDifference: number; - maxDynamicDifference: number; isActive: boolean; createdAt: Date; updatedAt: Date; } // Define the attributes that can be set during creation -type PartnerCreationAttributes = Optional; +type PartnerCreationAttributes = Optional; // Define the Partner model class Partner extends Model implements PartnerAttributes { @@ -38,30 +27,6 @@ class Partner extends Model implem declare logoUrl: string | null; - declare markupType: "absolute" | "relative" | "none"; - - declare markupValue: number; - - declare markupCurrency: RampCurrency; - - declare payoutAddressSubstrate: string | null; - - declare payoutAddressEvm: string | null; - - declare rampType: RampDirection; - - declare vortexFeeType: "absolute" | "relative" | "none"; - - declare vortexFeeValue: number; - - declare targetDiscount: number; - - declare maxSubsidy: number; - - declare minDynamicDifference: number; - - declare maxDynamicDifference: number; - declare isActive: boolean; declare createdAt: Date; @@ -99,90 +64,24 @@ Partner.init( field: "logo_url", type: DataTypes.STRING(255) }, - markupCurrency: { - allowNull: true, - field: "markup_currency", - type: DataTypes.STRING(30) - }, - markupType: { - allowNull: false, - defaultValue: "none", - field: "markup_type", - type: DataTypes.ENUM("absolute", "relative", "none") - }, - markupValue: { - allowNull: false, - defaultValue: 0, - field: "markup_value", - type: DataTypes.DECIMAL(10, 4) - }, - maxDynamicDifference: { - allowNull: false, - defaultValue: 0, - field: "max_dynamic_difference", - type: DataTypes.DECIMAL(10, 4) - }, - maxSubsidy: { - allowNull: false, - defaultValue: 0, - field: "max_subsidy", - type: DataTypes.DECIMAL(10, 4) - }, - minDynamicDifference: { - allowNull: false, - defaultValue: 0, - field: "min_dynamic_difference", - type: DataTypes.DECIMAL(10, 4) - }, name: { allowNull: false, - type: DataTypes.STRING(100) - }, - payoutAddressEvm: { - allowNull: true, - field: "payout_address_evm", - type: DataTypes.STRING(255) - }, - payoutAddressSubstrate: { - allowNull: true, - field: "payout_address_substrate", - type: DataTypes.STRING(255) - }, - rampType: { - allowNull: false, - field: "ramp_type", - type: DataTypes.ENUM(RampDirection.BUY, RampDirection.SELL) - }, - targetDiscount: { - allowNull: false, - defaultValue: 0, - field: "target_discount", - type: DataTypes.DECIMAL(10, 4) + type: DataTypes.STRING(100), + unique: true }, updatedAt: { allowNull: false, defaultValue: DataTypes.NOW, field: "updated_at", type: DataTypes.DATE - }, - vortexFeeType: { - allowNull: false, - defaultValue: "none", - field: "vortex_fee_type", - type: DataTypes.ENUM("absolute", "relative", "none") - }, - vortexFeeValue: { - allowNull: false, - defaultValue: 0, - field: "vortex_fee_value", - type: DataTypes.DECIMAL(10, 4) } }, { indexes: [ { - fields: ["name", "ramp_type"], - name: "idx_partners_name_ramp_type" + fields: ["name"], + name: "uniq_partners_name", + unique: true } ], modelName: "Partner", diff --git a/apps/api/src/models/partnerPricingConfig.model.ts b/apps/api/src/models/partnerPricingConfig.model.ts new file mode 100644 index 000000000..5286cac90 --- /dev/null +++ b/apps/api/src/models/partnerPricingConfig.model.ts @@ -0,0 +1,189 @@ +import { RampCurrency, RampDirection } from "@vortexfi/shared"; +import { DataTypes, Model, Optional } from "sequelize"; +import sequelize from "../config/database"; + +// Per-direction pricing for a partner, split out of the partners table (which keeps only the +// commercial identity under a unique name). Resolved by (partner_id, ramp_type). +export interface PartnerPricingConfigAttributes { + id: string; + partnerId: string; + rampType: RampDirection; + markupType: "absolute" | "relative" | "none"; + markupValue: number; + markupCurrency: RampCurrency | null; + vortexFeeType: "absolute" | "relative" | "none"; + vortexFeeValue: number; + targetDiscount: number; + maxSubsidy: number; + minDynamicDifference: number; + maxDynamicDifference: number; + payoutAddressSubstrate: string | null; + payoutAddressEvm: string | null; + isActive: boolean; + createdAt: Date; + updatedAt: Date; +} + +type PartnerPricingConfigCreationAttributes = Optional< + PartnerPricingConfigAttributes, + | "id" + | "markupType" + | "markupValue" + | "markupCurrency" + | "vortexFeeType" + | "vortexFeeValue" + | "targetDiscount" + | "maxSubsidy" + | "minDynamicDifference" + | "maxDynamicDifference" + | "payoutAddressSubstrate" + | "payoutAddressEvm" + | "isActive" + | "createdAt" + | "updatedAt" +>; + +class PartnerPricingConfig + extends Model + implements PartnerPricingConfigAttributes +{ + declare id: string; + declare partnerId: string; + declare rampType: RampDirection; + declare markupType: "absolute" | "relative" | "none"; + declare markupValue: number; + declare markupCurrency: RampCurrency | null; + declare vortexFeeType: "absolute" | "relative" | "none"; + declare vortexFeeValue: number; + declare targetDiscount: number; + declare maxSubsidy: number; + declare minDynamicDifference: number; + declare maxDynamicDifference: number; + declare payoutAddressSubstrate: string | null; + declare payoutAddressEvm: string | null; + declare isActive: boolean; + declare createdAt: Date; + declare updatedAt: Date; +} + +PartnerPricingConfig.init( + { + createdAt: { + allowNull: false, + defaultValue: DataTypes.NOW, + field: "created_at", + type: DataTypes.DATE + }, + id: { + defaultValue: DataTypes.UUIDV4, + primaryKey: true, + type: DataTypes.UUID + }, + isActive: { + allowNull: false, + defaultValue: true, + field: "is_active", + type: DataTypes.BOOLEAN + }, + markupCurrency: { + allowNull: true, + field: "markup_currency", + type: DataTypes.STRING(30) + }, + markupType: { + allowNull: false, + defaultValue: "none", + field: "markup_type", + type: DataTypes.STRING(16) + }, + markupValue: { + allowNull: false, + defaultValue: 0, + field: "markup_value", + type: DataTypes.DECIMAL(10, 4) + }, + maxDynamicDifference: { + allowNull: false, + defaultValue: 0, + field: "max_dynamic_difference", + type: DataTypes.DECIMAL(10, 4) + }, + maxSubsidy: { + allowNull: false, + defaultValue: 0, + field: "max_subsidy", + type: DataTypes.DECIMAL(10, 4) + }, + minDynamicDifference: { + allowNull: false, + defaultValue: 0, + field: "min_dynamic_difference", + type: DataTypes.DECIMAL(10, 4) + }, + partnerId: { + allowNull: false, + field: "partner_id", + onDelete: "CASCADE", + onUpdate: "CASCADE", + references: { + key: "id", + model: "partners" + }, + type: DataTypes.UUID + }, + payoutAddressEvm: { + allowNull: true, + field: "payout_address_evm", + type: DataTypes.STRING(255) + }, + payoutAddressSubstrate: { + allowNull: true, + field: "payout_address_substrate", + type: DataTypes.STRING(255) + }, + rampType: { + allowNull: false, + field: "ramp_type", + type: DataTypes.STRING(8) + }, + targetDiscount: { + allowNull: false, + defaultValue: 0, + field: "target_discount", + type: DataTypes.DECIMAL(10, 4) + }, + updatedAt: { + allowNull: false, + defaultValue: DataTypes.NOW, + field: "updated_at", + type: DataTypes.DATE + }, + vortexFeeType: { + allowNull: false, + defaultValue: "none", + field: "vortex_fee_type", + type: DataTypes.STRING(16) + }, + vortexFeeValue: { + allowNull: false, + defaultValue: 0, + field: "vortex_fee_value", + type: DataTypes.DECIMAL(10, 4) + } + }, + { + indexes: [ + { + fields: ["partner_id", "ramp_type"], + name: "uniq_partner_pricing_configs_partner_ramp", + unique: true + } + ], + modelName: "PartnerPricingConfig", + sequelize, + tableName: "partner_pricing_configs", + timestamps: true + } +); + +export default PartnerPricingConfig; diff --git a/apps/api/src/models/profilePartnerAssignment.model.ts b/apps/api/src/models/profilePartnerAssignment.model.ts index e8ae91799..d4af03075 100644 --- a/apps/api/src/models/profilePartnerAssignment.model.ts +++ b/apps/api/src/models/profilePartnerAssignment.model.ts @@ -5,7 +5,11 @@ export interface ProfilePartnerAssignmentAttributes { id: string; userId: string; partnerName: string; + /** Canonical partner FK — replaces the buy/sell pair (which were the two direction-rows of the same partner). */ + partnerId: string | null; + /** Legacy backup column — unread after the partners split. */ buyPartnerId: string | null; + /** Legacy backup column — unread after the partners split. */ sellPartnerId: string | null; isActive: boolean; expiresAt: Date | null; @@ -15,7 +19,7 @@ export interface ProfilePartnerAssignmentAttributes { type ProfilePartnerAssignmentCreationAttributes = Optional< ProfilePartnerAssignmentAttributes, - "id" | "createdAt" | "updatedAt" | "isActive" | "expiresAt" + "id" | "createdAt" | "updatedAt" | "isActive" | "expiresAt" | "partnerId" | "buyPartnerId" | "sellPartnerId" >; class ProfilePartnerAssignment @@ -28,6 +32,8 @@ class ProfilePartnerAssignment declare partnerName: string; + declare partnerId: string | null; + declare buyPartnerId: string | null; declare sellPartnerId: string | null; @@ -76,6 +82,17 @@ ProfilePartnerAssignment.init( field: "is_active", type: DataTypes.BOOLEAN }, + partnerId: { + allowNull: true, + field: "partner_id", + onDelete: "SET NULL", + onUpdate: "CASCADE", + references: { + key: "id", + model: "partners" + }, + type: DataTypes.UUID + }, partnerName: { allowNull: false, field: "partner_name", @@ -120,6 +137,10 @@ ProfilePartnerAssignment.init( fields: ["partner_name"], name: "idx_profile_partner_assignments_partner_name" }, + { + fields: ["partner_id"], + name: "idx_profile_partner_assignments_partner_id" + }, { fields: ["buy_partner_id"], name: "idx_profile_partner_assignments_buy_partner" diff --git a/apps/api/src/models/providerCustomer.model.ts b/apps/api/src/models/providerCustomer.model.ts new file mode 100644 index 000000000..75d1f1038 --- /dev/null +++ b/apps/api/src/models/providerCustomer.model.ts @@ -0,0 +1,182 @@ +import { DataTypes, Model, Optional } from "sequelize"; +import sequelize from "../config/database"; + +export type ProviderName = "mykobo" | "alfredpay" | "avenia" | "monerium"; +export type ProviderCustomerType = "individual" | "business"; +export type MoneriumStatus = "PENDING" | "APPROVED" | "REJECTED"; + +export enum VerificationStatus { + Pending = "pending", + Started = "started", + InReview = "in_review", + Approved = "approved", + Rejected = "rejected" +} + +// One anchor for every provider/rail account, owned by exactly +// one customer_entity. Folds the legacy mykobo_customers/alfredpay_customers tables and the +// Avenia half of tax_ids. taxReference holds the raw normalized tax id (avenia only — it is +// the join/aggregation key for in-flight ramp state); the sha256 hash backs the uniqueness +// guard and hashed lookups. Masked display values are derived from taxReference at read time. +export interface ProviderCustomerAttributes { + id: string; + customerEntityId: string; + provider: ProviderName; + rail: string | null; + country: string | null; + providerCustomerId: string | null; + providerSubaccountId: string | null; + companyName: string | null; + taxReference: string | null; + taxReferenceHash: string | null; + customerType: ProviderCustomerType; + status: VerificationStatus; + statusExternal: string | null; + lastFailureReasons: string[] | null; + createdAt: Date; + updatedAt: Date; +} + +type ProviderCustomerCreationAttributes = Optional< + ProviderCustomerAttributes, + | "id" + | "rail" + | "country" + | "providerCustomerId" + | "providerSubaccountId" + | "companyName" + | "taxReference" + | "taxReferenceHash" + | "customerType" + | "statusExternal" + | "lastFailureReasons" + | "createdAt" + | "updatedAt" +>; + +class ProviderCustomer + extends Model + implements ProviderCustomerAttributes +{ + declare id: string; + declare customerEntityId: string; + declare provider: ProviderName; + declare rail: string | null; + declare country: string | null; + declare providerCustomerId: string | null; + declare providerSubaccountId: string | null; + declare companyName: string | null; + declare taxReference: string | null; + declare taxReferenceHash: string | null; + declare customerType: ProviderCustomerType; + declare status: VerificationStatus; + declare statusExternal: string | null; + declare lastFailureReasons: string[] | null; + declare createdAt: Date; + declare updatedAt: Date; +} + +ProviderCustomer.init( + { + companyName: { + allowNull: true, + field: "company_name", + type: DataTypes.STRING(255) + }, + country: { + allowNull: true, + type: DataTypes.STRING(4) + }, + createdAt: { + allowNull: false, + defaultValue: DataTypes.NOW, + field: "created_at", + type: DataTypes.DATE + }, + customerEntityId: { + allowNull: false, + field: "customer_entity_id", + onDelete: "CASCADE", + onUpdate: "CASCADE", + references: { + key: "id", + model: "customer_entities" + }, + type: DataTypes.UUID + }, + customerType: { + allowNull: false, + defaultValue: "individual", + field: "customer_type", + type: DataTypes.STRING(16) + }, + id: { + defaultValue: DataTypes.UUIDV4, + primaryKey: true, + type: DataTypes.UUID + }, + lastFailureReasons: { + allowNull: true, + defaultValue: [], + field: "last_failure_reasons", + type: DataTypes.JSONB + }, + provider: { + allowNull: false, + type: DataTypes.STRING(16) + }, + providerCustomerId: { + allowNull: true, + field: "provider_customer_id", + type: DataTypes.STRING(255) + }, + providerSubaccountId: { + allowNull: true, + field: "provider_subaccount_id", + type: DataTypes.STRING(255) + }, + rail: { + allowNull: true, + type: DataTypes.STRING(8) + }, + status: { + allowNull: false, + type: DataTypes.STRING(32) + }, + statusExternal: { + allowNull: true, + field: "status_external", + type: DataTypes.STRING(255) + }, + taxReference: { + allowNull: true, + field: "tax_reference", + type: DataTypes.STRING(32) + }, + taxReferenceHash: { + allowNull: true, + field: "tax_reference_hash", + type: DataTypes.STRING(64) + }, + updatedAt: { + allowNull: false, + defaultValue: DataTypes.NOW, + field: "updated_at", + type: DataTypes.DATE + } + }, + { + indexes: [ + { + fields: ["customer_entity_id"], + name: "idx_provider_customers_customer_entity_id" + } + ], + modelName: "ProviderCustomer", + sequelize, + tableName: "provider_customers", + timestamps: true + } +); + +export default ProviderCustomer; diff --git a/apps/api/src/models/recipientInvitation.model.ts b/apps/api/src/models/recipientInvitation.model.ts new file mode 100644 index 000000000..433bac785 --- /dev/null +++ b/apps/api/src/models/recipientInvitation.model.ts @@ -0,0 +1,213 @@ +import { DataTypes, Model, Optional } from "sequelize"; +import sequelize from "../config/database"; + +export type RecipientInvitationStatus = "pending" | "accepted" | "expired" | "revoked"; +export type RecipientInviteeType = "individual" | "business"; + +// Link-based recipient invite (plan D1): token_hash is the redemption key. The raw token is +// retained while the invite is pending so the sender can re-copy the link, and cleared on +// acceptance; invitee_email is optional metadata, matched at acceptance only if recorded. +export interface RecipientInvitationAttributes { + id: string; + senderCustomerEntityId: string; + createdByProfileId: string | null; + inviteeEmail: string | null; + inviteeEmailCanonical: string | null; + inviteeType: RecipientInviteeType; + country: string; + rail: string; + payoutCurrency: string; + alias: string | null; + status: RecipientInvitationStatus; + tokenHash: string; + token: string | null; + archivedAt: Date | null; + expiresAt: Date | null; + acceptedAt: Date | null; + revokedAt: Date | null; + acceptedByProfileId: string | null; + createdAt: Date; + updatedAt: Date; +} + +type RecipientInvitationCreationAttributes = Optional< + RecipientInvitationAttributes, + | "id" + | "createdByProfileId" + | "inviteeEmail" + | "inviteeEmailCanonical" + | "inviteeType" + | "alias" + | "token" + | "archivedAt" + | "status" + | "expiresAt" + | "acceptedAt" + | "revokedAt" + | "acceptedByProfileId" + | "createdAt" + | "updatedAt" +>; + +class RecipientInvitation + extends Model + implements RecipientInvitationAttributes +{ + declare id: string; + declare senderCustomerEntityId: string; + declare createdByProfileId: string | null; + declare inviteeEmail: string | null; + declare inviteeEmailCanonical: string | null; + declare inviteeType: RecipientInviteeType; + declare country: string; + declare rail: string; + declare payoutCurrency: string; + declare alias: string | null; + declare status: RecipientInvitationStatus; + declare tokenHash: string; + declare token: string | null; + declare archivedAt: Date | null; + declare expiresAt: Date | null; + declare acceptedAt: Date | null; + declare revokedAt: Date | null; + declare acceptedByProfileId: string | null; + declare createdAt: Date; + declare updatedAt: Date; +} + +RecipientInvitation.init( + { + acceptedAt: { + allowNull: true, + field: "accepted_at", + type: DataTypes.DATE + }, + acceptedByProfileId: { + allowNull: true, + field: "accepted_by_profile_id", + onDelete: "SET NULL", + onUpdate: "CASCADE", + references: { + key: "id", + model: "profiles" + }, + type: DataTypes.UUID + }, + alias: { + allowNull: true, + type: DataTypes.STRING(100) + }, + archivedAt: { + allowNull: true, + field: "archived_at", + type: DataTypes.DATE + }, + country: { + allowNull: false, + type: DataTypes.STRING(4) + }, + createdAt: { + allowNull: false, + defaultValue: DataTypes.NOW, + field: "created_at", + type: DataTypes.DATE + }, + createdByProfileId: { + allowNull: true, + field: "created_by_profile_id", + onDelete: "SET NULL", + onUpdate: "CASCADE", + references: { + key: "id", + model: "profiles" + }, + type: DataTypes.UUID + }, + expiresAt: { + allowNull: true, + field: "expires_at", + type: DataTypes.DATE + }, + id: { + defaultValue: DataTypes.UUIDV4, + primaryKey: true, + type: DataTypes.UUID + }, + inviteeEmail: { + allowNull: true, + field: "invitee_email", + type: DataTypes.STRING(255) + }, + inviteeEmailCanonical: { + allowNull: true, + field: "invitee_email_canonical", + type: DataTypes.STRING(255) + }, + inviteeType: { + allowNull: false, + defaultValue: "individual", + field: "invitee_type", + type: DataTypes.STRING(16) + }, + payoutCurrency: { + allowNull: false, + field: "payout_currency", + type: DataTypes.STRING(8) + }, + rail: { + allowNull: false, + type: DataTypes.STRING(8) + }, + revokedAt: { + allowNull: true, + field: "revoked_at", + type: DataTypes.DATE + }, + senderCustomerEntityId: { + allowNull: false, + field: "sender_customer_entity_id", + onDelete: "CASCADE", + onUpdate: "CASCADE", + references: { + key: "id", + model: "customer_entities" + }, + type: DataTypes.UUID + }, + status: { + allowNull: false, + defaultValue: "pending", + type: DataTypes.STRING(16) + }, + token: { + allowNull: true, + type: DataTypes.STRING(64) + }, + tokenHash: { + allowNull: false, + field: "token_hash", + type: DataTypes.STRING(128), + unique: true + }, + updatedAt: { + allowNull: false, + defaultValue: DataTypes.NOW, + field: "updated_at", + type: DataTypes.DATE + } + }, + { + indexes: [ + { + fields: ["sender_customer_entity_id"], + name: "idx_recipient_invitations_sender_entity" + } + ], + modelName: "RecipientInvitation", + sequelize, + tableName: "recipient_invitations", + timestamps: true + } +); + +export default RecipientInvitation; diff --git a/apps/api/src/models/recipientPayoutReference.model.ts b/apps/api/src/models/recipientPayoutReference.model.ts new file mode 100644 index 000000000..4b965738e --- /dev/null +++ b/apps/api/src/models/recipientPayoutReference.model.ts @@ -0,0 +1,150 @@ +import { DataTypes, Model, Optional } from "sequelize"; +import sequelize from "../config/database"; +import type { ProviderName } from "./providerCustomer.model"; + +export type RecipientPayoutReferenceStatus = "pending" | "verified" | "rejected" | "disabled"; +export type PayoutInstrumentType = "pix" | "iban" | "clabe" | "ach" | "cbu_cvu" | "account_number"; + +// Thin pointer to a provider-side payout instrument (plan D3): provider_instrument_id + +// masked label only — no reusable PIX/IBAN/ACH/CLABE/CBU PII is ever stored locally. One +// non-disabled reference per relationship/corridor (partial unique in the migration). +export interface RecipientPayoutReferenceAttributes { + id: string; + senderRecipientId: string; + recipientCustomerEntityId: string; + provider: ProviderName; + country: string; + rail: string; + currency: string; + instrumentType: PayoutInstrumentType; + providerInstrumentId: string | null; + maskedDisplayLabel: string | null; + status: RecipientPayoutReferenceStatus; + lastProviderSyncAt: Date | null; + createdAt: Date; + updatedAt: Date; +} + +type RecipientPayoutReferenceCreationAttributes = Optional< + RecipientPayoutReferenceAttributes, + "id" | "providerInstrumentId" | "maskedDisplayLabel" | "status" | "lastProviderSyncAt" | "createdAt" | "updatedAt" +>; + +class RecipientPayoutReference + extends Model + implements RecipientPayoutReferenceAttributes +{ + declare id: string; + declare senderRecipientId: string; + declare recipientCustomerEntityId: string; + declare provider: ProviderName; + declare country: string; + declare rail: string; + declare currency: string; + declare instrumentType: PayoutInstrumentType; + declare providerInstrumentId: string | null; + declare maskedDisplayLabel: string | null; + declare status: RecipientPayoutReferenceStatus; + declare lastProviderSyncAt: Date | null; + declare createdAt: Date; + declare updatedAt: Date; +} + +RecipientPayoutReference.init( + { + country: { + allowNull: false, + type: DataTypes.STRING(4) + }, + createdAt: { + allowNull: false, + defaultValue: DataTypes.NOW, + field: "created_at", + type: DataTypes.DATE + }, + currency: { + allowNull: false, + type: DataTypes.STRING(8) + }, + id: { + defaultValue: DataTypes.UUIDV4, + primaryKey: true, + type: DataTypes.UUID + }, + instrumentType: { + allowNull: false, + field: "instrument_type", + type: DataTypes.STRING(20) + }, + lastProviderSyncAt: { + allowNull: true, + field: "last_provider_sync_at", + type: DataTypes.DATE + }, + maskedDisplayLabel: { + allowNull: true, + field: "masked_display_label", + type: DataTypes.STRING(255) + }, + provider: { + allowNull: false, + type: DataTypes.STRING(16) + }, + providerInstrumentId: { + allowNull: true, + field: "provider_instrument_id", + type: DataTypes.STRING(255) + }, + rail: { + allowNull: false, + type: DataTypes.STRING(8) + }, + recipientCustomerEntityId: { + allowNull: false, + field: "recipient_customer_entity_id", + onDelete: "CASCADE", + onUpdate: "CASCADE", + references: { + key: "id", + model: "customer_entities" + }, + type: DataTypes.UUID + }, + senderRecipientId: { + allowNull: false, + field: "sender_recipient_id", + onDelete: "CASCADE", + onUpdate: "CASCADE", + references: { + key: "id", + model: "sender_recipients" + }, + type: DataTypes.UUID + }, + status: { + allowNull: false, + defaultValue: "pending", + type: DataTypes.STRING(16) + }, + updatedAt: { + allowNull: false, + defaultValue: DataTypes.NOW, + field: "updated_at", + type: DataTypes.DATE + } + }, + { + indexes: [ + { + fields: ["recipient_customer_entity_id"], + name: "idx_recipient_payout_references_recipient_entity" + } + ], + modelName: "RecipientPayoutReference", + sequelize, + tableName: "recipient_payout_references", + timestamps: true + } +); + +export default RecipientPayoutReference; diff --git a/apps/api/src/models/senderRecipient.model.ts b/apps/api/src/models/senderRecipient.model.ts new file mode 100644 index 000000000..35e7b673e --- /dev/null +++ b/apps/api/src/models/senderRecipient.model.ts @@ -0,0 +1,128 @@ +import { DataTypes, Model, Optional } from "sequelize"; +import sequelize from "../config/database"; + +export type SenderRecipientStatus = "invited" | "active" | "blocked" | "archived"; + +// The sender↔recipient relationship after an invite is accepted. The sender owns this row; +// the recipient owns their own profile/entity/compliance identity, reusable across senders +// (UNIQUE(sender, recipient) — one recipient can be linked to many senders). +export interface SenderRecipientAttributes { + id: string; + senderCustomerEntityId: string; + recipientCustomerEntityId: string; + invitationId: string | null; + relationshipStatus: SenderRecipientStatus; + nickname: string | null; + disabledAt: Date | null; + createdAt: Date; + updatedAt: Date; +} + +type SenderRecipientCreationAttributes = Optional< + SenderRecipientAttributes, + "id" | "invitationId" | "relationshipStatus" | "nickname" | "disabledAt" | "createdAt" | "updatedAt" +>; + +class SenderRecipient + extends Model + implements SenderRecipientAttributes +{ + declare id: string; + declare senderCustomerEntityId: string; + declare recipientCustomerEntityId: string; + declare invitationId: string | null; + declare relationshipStatus: SenderRecipientStatus; + declare nickname: string | null; + declare disabledAt: Date | null; + declare createdAt: Date; + declare updatedAt: Date; +} + +SenderRecipient.init( + { + createdAt: { + allowNull: false, + defaultValue: DataTypes.NOW, + field: "created_at", + type: DataTypes.DATE + }, + disabledAt: { + allowNull: true, + field: "disabled_at", + type: DataTypes.DATE + }, + id: { + defaultValue: DataTypes.UUIDV4, + primaryKey: true, + type: DataTypes.UUID + }, + invitationId: { + allowNull: true, + field: "invitation_id", + onDelete: "SET NULL", + onUpdate: "CASCADE", + references: { + key: "id", + model: "recipient_invitations" + }, + type: DataTypes.UUID + }, + nickname: { + allowNull: true, + type: DataTypes.STRING(100) + }, + recipientCustomerEntityId: { + allowNull: false, + field: "recipient_customer_entity_id", + onDelete: "CASCADE", + onUpdate: "CASCADE", + references: { + key: "id", + model: "customer_entities" + }, + type: DataTypes.UUID + }, + relationshipStatus: { + allowNull: false, + defaultValue: "invited", + field: "relationship_status", + type: DataTypes.STRING(16) + }, + senderCustomerEntityId: { + allowNull: false, + field: "sender_customer_entity_id", + onDelete: "CASCADE", + onUpdate: "CASCADE", + references: { + key: "id", + model: "customer_entities" + }, + type: DataTypes.UUID + }, + updatedAt: { + allowNull: false, + defaultValue: DataTypes.NOW, + field: "updated_at", + type: DataTypes.DATE + } + }, + { + indexes: [ + { + fields: ["sender_customer_entity_id", "recipient_customer_entity_id"], + name: "uniq_sender_recipients_pair", + unique: true + }, + { + fields: ["recipient_customer_entity_id"], + name: "idx_sender_recipients_recipient_entity" + } + ], + modelName: "SenderRecipient", + sequelize, + tableName: "sender_recipients", + timestamps: true + } +); + +export default SenderRecipient; diff --git a/apps/api/src/models/user.model.ts b/apps/api/src/models/user.model.ts index ef971917c..eeb0e2e90 100644 --- a/apps/api/src/models/user.model.ts +++ b/apps/api/src/models/user.model.ts @@ -4,21 +4,34 @@ import sequelize from "../config/database"; export interface UserAttributes { id: string; // UUID from Supabase Auth email: string; + activeCustomerEntityId: string | null; createdAt: Date; updatedAt: Date; } -type UserCreationAttributes = Optional; +type UserCreationAttributes = Optional; class User extends Model implements UserAttributes { declare id: string; declare email: string; + declare activeCustomerEntityId: string | null; declare createdAt: Date; declare updatedAt: Date; } User.init( { + activeCustomerEntityId: { + allowNull: true, + field: "active_customer_entity_id", + onDelete: "SET NULL", + onUpdate: "CASCADE", + references: { + key: "id", + model: "customer_entities" + }, + type: DataTypes.UUID + }, createdAt: { allowNull: false, defaultValue: DataTypes.NOW, diff --git a/apps/api/src/test-utils/factories.ts b/apps/api/src/test-utils/factories.ts index 9508c5dca..6fb981923 100644 --- a/apps/api/src/test-utils/factories.ts +++ b/apps/api/src/test-utils/factories.ts @@ -1,6 +1,5 @@ import { AlfredPayCountry, - AlfredPayStatus, AlfredPayType, AveniaAccountType, type DestinationType, @@ -8,19 +7,22 @@ import { EvmToken, FiatToken, Networks, + normalizeTaxId, RampDirection, type UnsignedTx } from "@vortexfi/shared"; import { generateApiKey, getKeyPrefix, hashApiKey } from "../api/middlewares/apiKeyAuth.helpers"; +import { hashTaxReference } from "../api/services/avenia/avenia-customer.service"; +import { getOrCreateCustomerEntityForProfile } from "../api/services/customer-entity.service"; import type { StateMetadata } from "../api/services/phases/meta-state-types"; import type { QuoteTicketMetadata } from "../api/services/quote/core/types"; import { config } from "../config/vars"; -import AlfredPayCustomer from "../models/alfredPayCustomer.model"; import ApiKey from "../models/apiKey.model"; -import Partner from "../models/partner.model"; +import Partner, { type PartnerAttributes } from "../models/partner.model"; +import PartnerPricingConfig, { type PartnerPricingConfigAttributes } from "../models/partnerPricingConfig.model"; +import ProviderCustomer, { VerificationStatus } from "../models/providerCustomer.model"; import QuoteTicket, { type QuoteTicketAttributes } from "../models/quoteTicket.model"; import RampState, { type RampStateAttributes } from "../models/rampState.model"; -import TaxId, { TaxIdInternalStatus } from "../models/taxId.model"; import User from "../models/user.model"; let sequence = 0; @@ -36,27 +38,48 @@ export async function createTestUser(overrides: Partial<{ id: string; email: str }); } -export async function createTestPartner(overrides: Partial[0]> = {}): Promise { +type TestPartnerOverrides = Partial< + Pick & + Omit +>; + +/** + * Creates (or reuses, when the unique name already exists) a partner row plus a pricing + * config for the given rampType — the post-split equivalent of the old one-row-per-direction + * partner. Pricing overrides land on the config. + */ +export async function createTestPartner(overrides: TestPartnerOverrides = {}): Promise { const seq = nextSeq(); - return Partner.create({ - displayName: `Test Partner ${seq}`, - isActive: true, - logoUrl: null, - markupCurrency: FiatToken.EURC, - markupType: "none", - markupValue: 0, - maxDynamicDifference: 0, - maxSubsidy: 0, - minDynamicDifference: 0, - name: `test-partner-${seq}`, - payoutAddressEvm: null, - payoutAddressSubstrate: null, - rampType: RampDirection.BUY, - targetDiscount: 0, - vortexFeeType: "none", - vortexFeeValue: 0, - ...overrides + const name = overrides.name ?? `test-partner-${seq}`; + + const [partner] = await Partner.findOrCreate({ + defaults: { + displayName: overrides.displayName ?? `Test Partner ${seq}`, + isActive: overrides.isActive ?? true, + logoUrl: overrides.logoUrl ?? null, + name + }, + where: { name } + }); + + await PartnerPricingConfig.create({ + isActive: overrides.isActive ?? true, + markupCurrency: overrides.markupCurrency ?? FiatToken.EURC, + markupType: overrides.markupType ?? "none", + markupValue: overrides.markupValue ?? 0, + maxDynamicDifference: overrides.maxDynamicDifference ?? 0, + maxSubsidy: overrides.maxSubsidy ?? 0, + minDynamicDifference: overrides.minDynamicDifference ?? 0, + partnerId: partner.id, + payoutAddressEvm: overrides.payoutAddressEvm ?? null, + payoutAddressSubstrate: overrides.payoutAddressSubstrate ?? null, + rampType: overrides.rampType ?? RampDirection.BUY, + targetDiscount: overrides.targetDiscount ?? 0, + vortexFeeType: overrides.vortexFeeType ?? "none", + vortexFeeValue: overrides.vortexFeeValue ?? 0 }); + + return partner; } /** @@ -67,6 +90,15 @@ export async function createTestApiKey( options: { partnerName?: string; userId?: string } = {} ): Promise<{ record: ApiKey; plaintextKey: string }> { const plaintextKey = generateApiKey("secret", "test"); + + // Auth resolves partners by FK; translate the name (unique) to the id here so tests can + // keep passing partnerName. + let partnerId: string | null = null; + if (options.partnerName) { + const partner = await Partner.findOne({ where: { name: options.partnerName } }); + partnerId = partner?.id ?? null; + } + const record = await ApiKey.create({ expiresAt: null, isActive: true, @@ -76,6 +108,7 @@ export async function createTestApiKey( keyValue: null, lastUsedAt: null, name: "test key", + partnerId, partnerName: options.partnerName ?? null, userId: options.userId ?? null }); @@ -130,39 +163,56 @@ export async function seedVortexPartners(): Promise { } } +/** Updates a partner's pricing config for one direction (post-split home of payout/fee fields). */ +export async function updatePartnerPricing( + name: string, + rampType: RampDirection, + values: Partial> +): Promise { + const partner = await Partner.findOne({ where: { name } }); + if (!partner) { + throw new Error(`updatePartnerPricing: no partner named '${name}'`); + } + await PartnerPricingConfig.update(values, { where: { partnerId: partner.id, rampType } }); +} + /** An Alfredpay-KYC'd customer linked to a user, as required by MXN/COP/USD/ARS ramp registration. */ export async function createTestAlfredpayCustomer( userId: string, overrides: Partial<{ alfredPayId: string; country: AlfredPayCountry }> = {} -): Promise { +): Promise { const seq = nextSeq(); - return AlfredPayCustomer.create({ - alfredPayId: overrides.alfredPayId ?? `test-alfredpay-customer-${seq}`, + const entity = await getOrCreateCustomerEntityForProfile(userId); + return ProviderCustomer.create({ country: overrides.country ?? AlfredPayCountry.MX, - lastFailureReasons: null, - status: AlfredPayStatus.Success, - statusExternal: null, - type: AlfredPayType.INDIVIDUAL, - userId + customerEntityId: entity.id, + customerType: "individual", + provider: "alfredpay", + providerCustomerId: overrides.alfredPayId ?? `test-alfredpay-customer-${seq}`, + status: VerificationStatus.Approved }); } /** An Avenia-KYC'd tax id linked to a user, as required by BRL ramp registration. */ -export async function createTestTaxId(userId: string, overrides: Partial<{ taxId: string; subAccountId: string }> = {}) { +/** An Accepted Avenia provider account for the user — the post-cutover home of tax_ids rows. */ +export async function createTestTaxId( + userId: string, + overrides: Partial<{ companyName: string; customerType: "individual" | "business"; taxId: string; subAccountId: string }> = {} +) { const seq = nextSeq(); - return TaxId.create({ - accountType: AveniaAccountType.INDIVIDUAL, - finalQuoteId: null, - finalSessionId: null, - finalTimestamp: null, - initialQuoteId: null, - initialSessionId: null, - internalStatus: TaxIdInternalStatus.Accepted, - kycAttempt: null, - requestedDate: new Date(), - subAccountId: overrides.subAccountId ?? "test-subaccount-id", - taxId: overrides.taxId ?? `1234567890${seq}`, - userId + const taxReference = normalizeTaxId(overrides.taxId ?? `1234567890${seq}`); + const entity = await getOrCreateCustomerEntityForProfile(userId); + return ProviderCustomer.create({ + companyName: overrides.customerType === "business" ? (overrides.companyName ?? "Test Company") : null, + country: "BR", + customerEntityId: entity.id, + customerType: overrides.customerType ?? "individual", + provider: "avenia", + providerSubaccountId: overrides.subAccountId ?? "test-subaccount-id", + rail: "brl", + status: VerificationStatus.Approved, + taxReference, + taxReferenceHash: hashTaxReference(taxReference) }); } diff --git a/apps/api/src/test-utils/fake-world/fake-anchors.ts b/apps/api/src/test-utils/fake-world/fake-anchors.ts index 3e6dc9c56..60decc7e3 100644 --- a/apps/api/src/test-utils/fake-world/fake-anchors.ts +++ b/apps/api/src/test-utils/fake-world/fake-anchors.ts @@ -150,7 +150,13 @@ export class FakeBrla { payOutRate = 1; subaccountId = "test-subaccount-id"; subaccountEvmWallet = "0x7ba99e99bc669b3508aff9cc0a898e869459f877"; - /** Internal Avenia subaccount balances served by getAccountBalance; script per test. */ + /** KYC state served in subaccountInfo().accountInfo; production gates ramps on CONFIRMED. */ + identityStatus: "NOT-IDENTIFIED" | "CONFIRMED" = "CONFIRMED"; + /** + * Internal Avenia subaccount balances served by getAccountBalance; script per test. + * Numbers here for ergonomic scripting — the wire (and the fake's response) carries + * decimal strings. + */ accountBalances = { BRLA: 0, USDC: 0, USDM: 0, USDT: 0 }; /** Status reported for every pay-in ticket by getAveniaPayinTickets. */ payinTicketStatus: AveniaTicketStatus = AveniaTicketStatus.PAID; @@ -184,7 +190,7 @@ export class FakeBrla { createPixInputTicket: async () => { const ticket = { brCode: `brcode-${++this.counter}`, - expiration: new Date(Date.now() + 3600_000), + expiration: new Date(Date.now() + 3600_000).toISOString(), id: `pix-in-${this.counter}` }; this.pixInputTickets.push(ticket); @@ -196,7 +202,14 @@ export class FakeBrla { this.onPixOutputTicket?.({ id: ticket.id, walletAddress: payload?.ticketBlockchainOutput?.walletAddress }); return ticket; }, - getAccountBalance: async () => ({ balances: { ...this.accountBalances } }), + getAccountBalance: async () => ({ + balances: { + BRLA: String(this.accountBalances.BRLA), + USDC: String(this.accountBalances.USDC), + USDM: String(this.accountBalances.USDM), + USDT: String(this.accountBalances.USDT) + } + }), getAveniaPayinTickets: async () => this.pixInputTickets.map(ticket => ({ id: ticket.id, status: this.payinTicketStatus })), getAveniaPayoutTicket: async (ticketId: string) => ({ id: ticketId, @@ -219,7 +232,7 @@ export class FakeBrla { } }), subaccountInfo: async () => ({ - accountInfo: {}, + accountInfo: { identityStatus: this.identityStatus }, brCode: "test-brcode", createdAt: new Date().toISOString(), id: this.subaccountId, @@ -368,7 +381,7 @@ export class FakeAlfredpay { toCurrency: request.toCurrency }), quoteId: request.quoteId, - status: AlfredpayOfframpStatus.ON_CHAIN_DEPOSIT_RECEIVED, + status: AlfredpayOfframpStatus.CREATED, toAmount: (Number(request.amount) * this.offrampRate).toString(), toCurrency: request.toCurrency, transactionId, diff --git a/apps/api/src/test-utils/preload.ts b/apps/api/src/test-utils/preload.ts index 480671402..7e67411de 100644 --- a/apps/api/src/test-utils/preload.ts +++ b/apps/api/src/test-utils/preload.ts @@ -47,7 +47,6 @@ if (!process.env.RUN_LIVE_TESTS) { process.env.EVM_FUNDING_PRIVATE_KEY = "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80"; process.env.PENDULUM_FUNDING_SEED = "bottom drive obey lake curtain smoke basket hold race lonely fit walk"; process.env.FUNDING_SECRET = ""; - process.env.CLIENT_DOMAIN_SECRET = ""; // Keep rate limiting out of the way of HTTP-level tests. process.env.RATE_LIMIT_MAX_REQUESTS = "100000"; diff --git a/apps/api/src/tests/alfredpay-kyb-pending.integration.test.ts b/apps/api/src/tests/alfredpay-kyb-pending.integration.test.ts new file mode 100644 index 000000000..561866250 --- /dev/null +++ b/apps/api/src/tests/alfredpay-kyb-pending.integration.test.ts @@ -0,0 +1,556 @@ +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, mock } from "bun:test"; +import { + AlfredPayCountry, + AlfredPayStatus, + AlfredpayApiService, + AlfredpayCustomerType, + AlfredpayKycStatus +} from "@vortexfi/shared"; +import { createAlfredpayCustomer } from "../api/services/alfredpay/alfredpay-customer.service"; +import KycCase from "../models/kycCase.model"; +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"; + +// Regression suite for the stuck-KYB deadlock: a submission left PENDING at Alfredpay (finalize +// failed or data was invalid) kept our status on `started` forever, and re-filing the form POSTed a +// new submission Alfredpay refuses while one is pending. PENDING now maps to our `pending`, the +// latest submission id is persisted on the kyc_case, and re-submitting updates the pending +// submission in place (PUT …/customers/kyb). + +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 authHeaders(token: string): Record { + return { Authorization: `Bearer ${token}`, "Content-Type": "application/json" }; +} + +// Carries the compliance questionnaire because `validateKybSubmission` rejects a submission without +// it — the same 110002 contract Alfredpay enforces at finalize. +const KYB_FORM = { + accountPurpose: "Treasury management", + address: "Calle 1 # 2-3", + businessActivities: "Cross-border payments software", + businessName: "Prueba SAS", + city: "Bogota", + country: "CO", + expectedMonthlyTransactions: 120, + expectedMonthlyVolumeUsd: 50000, + isRegulatedBusiness: false, + operatesInSanctionedCountries: false, + relatedPersons: [ + { + dateOfBirth: "1990-01-01", + email: "rep@example.com", + firstName: "Ana", + lastName: "Rep", + nationalities: ["CO"], + pep: false + } + ], + sourceOfFunds: "Sale of goods/services", + state: "DC", + taxId: "900123456", + transmitsCustomerFunds: false, + walletAddresses: "N/A", + website: "https://prueba.example.com", + zipCode: "110111" +}; + +async function createBusinessCustomer(email: string) { + const user = await createTestUser({ email }); + const token = testUserToken(user.id, email); + await createAlfredpayCustomer(user.id, { + alfredPayId: "ap-kyb-pending", + country: AlfredPayCountry.CO, + status: AlfredPayStatus.Consulted, + type: AlfredpayCustomerType.BUSINESS + }); + return { token, user }; +} + +describe("Alfredpay KYB PENDING submission", () => { + it("surfaces a provider-PENDING submission as pending with the submission id on the kyc case", async () => { + const { token } = await createBusinessCustomer("kyb-pending-status@example.com"); + + AlfredpayApiService.getInstance = mock( + () => + ({ + getKybStatus: mock(async () => ({ status: AlfredpayKycStatus.PENDING })), + getLastKybSubmission: mock(async () => ({ submissionId: "kyb-sub-1" })) + }) as unknown as AlfredpayApiService + ); + + const response = await api.request("/v1/onboarding/status", { headers: authHeaders(token) }); + expect(response.status).toBe(200); + const body = (await response.json()) as { entities: Array<{ accounts: Array<{ provider: string; state: string }> }> }; + const account = body.entities.flatMap(entity => entity.accounts).find(item => item.provider === "alfredpay"); + expect(account?.state).toBe("pending"); + + const customer = await ProviderCustomer.findOne({ where: { providerCustomerId: "ap-kyb-pending" } }); + expect(customer?.status).toBe(VerificationStatus.Pending); + expect(customer?.statusExternal).toBe("PENDING"); + const kycCase = await KycCase.findOne({ where: { providerCustomerId: customer?.id } }); + expect(kycCase?.providerCaseId).toBe("kyb-sub-1"); + }); + + it("updates the pending submission in place instead of POSTing a new one on re-submit", async () => { + const { token } = await createBusinessCustomer("kyb-pending-resubmit@example.com"); + + const updateKybInformation = mock(async (_customerId: string, _submissionId: string, _data: unknown) => undefined); + const submitKybInformation = mock(async () => ({ submissionId: "should-not-be-created" })); + AlfredpayApiService.getInstance = mock( + () => + ({ + getKybStatus: mock(async () => ({ status: AlfredpayKycStatus.PENDING })), + getLastKybSubmission: mock(async () => ({ submissionId: "kyb-sub-1" })), + submitKybInformation, + updateKybInformation + }) as unknown as AlfredpayApiService + ); + + const response = await api.request("/v1/alfredpay/submitKybInformation", { + body: JSON.stringify(KYB_FORM), + headers: authHeaders(token), + method: "POST" + }); + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ submissionId: "kyb-sub-1" }); + + expect(updateKybInformation).toHaveBeenCalledTimes(1); + expect(updateKybInformation.mock.calls[0]).toEqual(["ap-kyb-pending", "kyb-sub-1", KYB_FORM]); + expect(submitKybInformation).not.toHaveBeenCalled(); + + const customer = await ProviderCustomer.findOne({ where: { providerCustomerId: "ap-kyb-pending" } }); + const kycCase = await KycCase.findOne({ where: { providerCustomerId: customer?.id } }); + expect(kycCase?.providerCaseId).toBe("kyb-sub-1"); + }); + + it("resolves the submission id from the KYB details when the last-submission response omits it", async () => { + const { token } = await createBusinessCustomer("kyb-details-fallback@example.com"); + + const updateKybInformation = mock(async (_customerId: string, _submissionId: string, _data: unknown) => undefined); + const submitKybInformation = mock(async () => ({ submissionId: "should-not-be-created" })); + AlfredpayApiService.getInstance = mock( + () => + ({ + getKybBusinessDetails: mock(async () => [{ relatedPersons: [], submissionId: "kyb-sub-1" }]), + getKybStatus: mock(async () => ({ status: AlfredpayKycStatus.PENDING })), + // Sandbox-observed: the dedicated last-submission endpoint answers without a submissionId. + getLastKybSubmission: mock(async () => ({})), + submitKybInformation, + updateKybInformation + }) as unknown as AlfredpayApiService + ); + + const response = await api.request("/v1/alfredpay/submitKybInformation", { + body: JSON.stringify(KYB_FORM), + headers: authHeaders(token), + method: "POST" + }); + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ submissionId: "kyb-sub-1" }); + expect(updateKybInformation).toHaveBeenCalledTimes(1); + expect(submitKybInformation).not.toHaveBeenCalled(); + }); + + it("recovers from 'Customer KYB already exists' by updating the existing submission", async () => { + const { token } = await createBusinessCustomer("kyb-already-exists@example.com"); + + const updateKybInformation = mock(async (_customerId: string, _submissionId: string, _data: unknown) => undefined); + let statusCalls = 0; + AlfredpayApiService.getInstance = mock( + () => + ({ + getKybBusinessDetails: mock(async () => [{ relatedPersons: [], submissionId: "kyb-sub-1" }]), + // The pre-submit status probe fails, so the controller attempts a fresh POST first. + getKybStatus: mock(async () => { + statusCalls += 1; + if (statusCalls === 1) throw new Error("Request failed with status '500'. Error: sandbox hiccup"); + return { status: AlfredpayKycStatus.PENDING }; + }), + getLastKybSubmission: mock(async () => ({})), + submitKybInformation: mock(async () => { + throw new Error( + 'Request failed with status \'400\'. Error: {"errorCode":111405,"errorMessage":"Customer KYB already exists"}' + ); + }), + updateKybInformation + }) as unknown as AlfredpayApiService + ); + + const response = await api.request("/v1/alfredpay/submitKybInformation", { + body: JSON.stringify(KYB_FORM), + headers: authHeaders(token), + method: "POST" + }); + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ submissionId: "kyb-sub-1" }); + expect(updateKybInformation).toHaveBeenCalledTimes(1); + expect(updateKybInformation.mock.calls[0]).toEqual(["ap-kyb-pending", "kyb-sub-1", KYB_FORM]); + + const customer = await ProviderCustomer.findOne({ where: { providerCustomerId: "ap-kyb-pending" } }); + const kycCase = await KycCase.findOne({ where: { providerCustomerId: customer?.id } }); + expect(kycCase?.providerCaseId).toBe("kyb-sub-1"); + }); + + it("does not update an existing KYB unless Alfredpay confirms it is editable", async () => { + const { token } = await createBusinessCustomer("kyb-already-completed@example.com"); + + const updateKybInformation = mock(async (_customerId: string, _submissionId: string, _data: unknown) => undefined); + let statusCalls = 0; + AlfredpayApiService.getInstance = mock( + () => + ({ + getKybStatus: mock(async () => { + statusCalls += 1; + if (statusCalls === 1) throw new Error("Request failed with status '500'. Error: sandbox hiccup"); + return { status: AlfredpayKycStatus.COMPLETED }; + }), + getLastKybSubmission: mock(async () => ({ submissionId: "kyb-sub-1" })), + submitKybInformation: mock(async () => { + throw new Error( + 'Request failed with status \'400\'. Error: {"errorCode":111405,"errorMessage":"Customer KYB already exists"}' + ); + }), + updateKybInformation + }) as unknown as AlfredpayApiService + ); + + const response = await api.request("/v1/alfredpay/submitKybInformation", { + body: JSON.stringify(KYB_FORM), + headers: authHeaders(token), + method: "POST" + }); + + expect(response.status).toBe(409); + expect(updateKybInformation).not.toHaveBeenCalled(); + }); + + it("prefers a persisted submission id when Alfredpay still returns it", async () => { + const { token } = await createBusinessCustomer("kyb-persisted-current@example.com"); + const customer = await ProviderCustomer.findOne({ where: { providerCustomerId: "ap-kyb-pending" } }); + await KycCase.update({ providerCaseId: "kyb-sub-persisted" }, { where: { providerCustomerId: customer?.id } }); + + const updateKybInformation = mock(async (_customerId: string, _submissionId: string, _data: unknown) => undefined); + AlfredpayApiService.getInstance = mock( + () => + ({ + getKybBusinessDetails: mock(async () => [ + { relatedPersons: [], submissionId: "kyb-sub-first" }, + { relatedPersons: [], submissionId: "kyb-sub-persisted" } + ]), + getKybStatus: mock(async () => ({ status: AlfredpayKycStatus.PENDING })), + getLastKybSubmission: mock(async () => ({})), + updateKybInformation + }) as unknown as AlfredpayApiService + ); + + const response = await api.request("/v1/alfredpay/submitKybInformation", { + body: JSON.stringify(KYB_FORM), + headers: authHeaders(token), + method: "POST" + }); + + expect(response.status).toBe(200); + expect(updateKybInformation.mock.calls[0]?.[1]).toBe("kyb-sub-persisted"); + }); + + it("falls back to Alfredpay's first submission when the persisted id is stale", async () => { + const { token } = await createBusinessCustomer("kyb-persisted-stale@example.com"); + const customer = await ProviderCustomer.findOne({ where: { providerCustomerId: "ap-kyb-pending" } }); + await KycCase.update({ providerCaseId: "kyb-sub-stale" }, { where: { providerCustomerId: customer?.id } }); + + const updateKybInformation = mock(async (_customerId: string, _submissionId: string, _data: unknown) => undefined); + AlfredpayApiService.getInstance = mock( + () => + ({ + getKybBusinessDetails: mock(async () => [ + { relatedPersons: [], submissionId: "kyb-sub-first" }, + { relatedPersons: [], submissionId: "kyb-sub-second" } + ]), + getKybStatus: mock(async () => ({ status: AlfredpayKycStatus.PENDING })), + getLastKybSubmission: mock(async () => ({})), + updateKybInformation + }) as unknown as AlfredpayApiService + ); + + const response = await api.request("/v1/alfredpay/submitKybInformation", { + body: JSON.stringify(KYB_FORM), + headers: authHeaders(token), + method: "POST" + }); + + expect(response.status).toBe(200); + expect(updateKybInformation.mock.calls[0]?.[1]).toBe("kyb-sub-first"); + }); + + it("uses the persisted submission id when Alfredpay discovery is unavailable", async () => { + const { token } = await createBusinessCustomer("kyb-persisted-recovery@example.com"); + const customer = await ProviderCustomer.findOne({ where: { providerCustomerId: "ap-kyb-pending" } }); + await KycCase.update({ providerCaseId: "kyb-sub-persisted" }, { where: { providerCustomerId: customer?.id } }); + + const updateKybInformation = mock(async (_customerId: string, _submissionId: string, _data: unknown) => undefined); + AlfredpayApiService.getInstance = mock( + () => + ({ + getKybBusinessDetails: mock(async () => { + throw new Error("details unavailable"); + }), + getKybStatus: mock(async () => ({ status: AlfredpayKycStatus.PENDING })), + getLastKybSubmission: mock(async () => { + throw new Error("last submission unavailable"); + }), + updateKybInformation + }) as unknown as AlfredpayApiService + ); + + const response = await api.request("/v1/alfredpay/submitKybInformation", { + body: JSON.stringify(KYB_FORM), + headers: authHeaders(token), + method: "POST" + }); + + expect(response.status).toBe(200); + expect(updateKybInformation.mock.calls[0]?.[1]).toBe("kyb-sub-persisted"); + }); + + it("surfaces a failing in-place update instead of falling through to a fresh POST", async () => { + const { token } = await createBusinessCustomer("kyb-put-fails@example.com"); + + const submitKybInformation = mock(async (_customerId: string, _data: unknown) => ({ submissionId: "kyb-sub-new" })); + AlfredpayApiService.getInstance = mock( + () => + ({ + getKybStatus: mock(async () => ({ status: AlfredpayKycStatus.PENDING })), + getLastKybSubmission: mock(async () => ({ submissionId: "kyb-sub-1" })), + submitKybInformation, + updateKybInformation: mock(async () => { + throw new Error("invalid related person data"); + }) + }) as unknown as AlfredpayApiService + ); + + const response = await api.request("/v1/alfredpay/submitKybInformation", { + body: JSON.stringify(KYB_FORM), + headers: authHeaders(token), + method: "POST" + }); + + expect(response.status).toBe(500); + expect(((await response.json()) as { error: string }).error).toContain("invalid related person data"); + expect(submitKybInformation).not.toHaveBeenCalled(); + }); + + // Sandbox-observed: the last-submission endpoint can answer `{}` (success without a submissionId) + // even while a submission exists — an empty answer must not discard the persisted recovery id. + it("uses the persisted submission id when last-submission answers empty and the details lookup fails", async () => { + const { token } = await createBusinessCustomer("kyb-persisted-empty-last@example.com"); + const customer = await ProviderCustomer.findOne({ where: { providerCustomerId: "ap-kyb-pending" } }); + await KycCase.update({ providerCaseId: "kyb-sub-persisted" }, { where: { providerCustomerId: customer?.id } }); + + const updateKybInformation = mock(async (_customerId: string, _submissionId: string, _data: unknown) => undefined); + AlfredpayApiService.getInstance = mock( + () => + ({ + getKybBusinessDetails: mock(async () => { + throw new Error("details unavailable"); + }), + getKybStatus: mock(async () => ({ status: AlfredpayKycStatus.PENDING })), + getLastKybSubmission: mock(async () => ({})), + updateKybInformation + }) as unknown as AlfredpayApiService + ); + + const response = await api.request("/v1/alfredpay/submitKybInformation", { + body: JSON.stringify(KYB_FORM), + headers: authHeaders(token), + method: "POST" + }); + + expect(response.status).toBe(200); + expect(updateKybInformation.mock.calls[0]?.[1]).toBe("kyb-sub-persisted"); + }); + + it("getKycStatus flips a stale started row to pending via the details fallback", async () => { + const { token } = await createBusinessCustomer("kyb-status-refresh@example.com"); + + AlfredpayApiService.getInstance = mock( + () => + ({ + getKybBusinessDetails: mock(async () => [{ relatedPersons: [], submissionId: "kyb-sub-1" }]), + getKybStatus: mock(async () => ({ status: AlfredpayKycStatus.PENDING })), + getLastKybSubmission: mock(async () => ({})) + }) as unknown as AlfredpayApiService + ); + + const response = await api.request("/v1/alfredpay/getKycStatus?country=CO&type=BUSINESS", { + headers: authHeaders(token) + }); + expect(response.status).toBe(200); + + const customer = await ProviderCustomer.findOne({ where: { providerCustomerId: "ap-kyb-pending" } }); + expect(customer?.status).toBe(VerificationStatus.Pending); + expect(customer?.statusExternal).toBe("PENDING"); + const kycCase = await KycCase.findOne({ where: { providerCustomerId: customer?.id } }); + expect(kycCase?.providerCaseId).toBe("kyb-sub-1"); + }); + + // Sandbox-observed: the KYB status endpoint reports lowercase "pending". + it("normalizes a lowercase provider status: getKycStatus maps it to pending and stores it uppercased", async () => { + const { token } = await createBusinessCustomer("kyb-lowercase-status@example.com"); + + AlfredpayApiService.getInstance = mock( + () => + ({ + getKybStatus: mock(async () => ({ status: "pending" })), + getLastKybSubmission: mock(async () => ({ submissionId: "kyb-sub-1" })) + }) as unknown as AlfredpayApiService + ); + + const response = await api.request("/v1/alfredpay/getKycStatus?country=CO&type=BUSINESS", { + headers: authHeaders(token) + }); + expect(response.status).toBe(200); + + const customer = await ProviderCustomer.findOne({ where: { providerCustomerId: "ap-kyb-pending" } }); + expect(customer?.status).toBe(VerificationStatus.Pending); + expect(customer?.statusExternal).toBe("PENDING"); + }); + + it("normalizes a lowercase provider status: re-submit updates the pending submission in place", async () => { + const { token } = await createBusinessCustomer("kyb-lowercase-resubmit@example.com"); + + const updateKybInformation = mock(async (_customerId: string, _submissionId: string, _data: unknown) => undefined); + const submitKybInformation = mock(async () => ({ submissionId: "should-not-be-created" })); + AlfredpayApiService.getInstance = mock( + () => + ({ + getKybStatus: mock(async () => ({ status: "pending" })), + getLastKybSubmission: mock(async () => ({ submissionId: "kyb-sub-1" })), + submitKybInformation, + updateKybInformation + }) as unknown as AlfredpayApiService + ); + + const response = await api.request("/v1/alfredpay/submitKybInformation", { + body: JSON.stringify(KYB_FORM), + headers: authHeaders(token), + method: "POST" + }); + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ submissionId: "kyb-sub-1" }); + expect(updateKybInformation).toHaveBeenCalledTimes(1); + expect(submitKybInformation).not.toHaveBeenCalled(); + }); + + it("still creates a fresh submission when none exists", async () => { + const { token } = await createBusinessCustomer("kyb-fresh-submit@example.com"); + + const submitKybInformation = mock(async () => ({ submissionId: "kyb-sub-new" })); + AlfredpayApiService.getInstance = mock( + () => + ({ + getLastKybSubmission: mock(async () => { + throw new Error("Request failed with status '404'. Error: no submission"); + }), + submitKybInformation + }) as unknown as AlfredpayApiService + ); + + const response = await api.request("/v1/alfredpay/submitKybInformation", { + body: JSON.stringify(KYB_FORM), + headers: authHeaders(token), + method: "POST" + }); + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ submissionId: "kyb-sub-new" }); + expect(submitKybInformation).toHaveBeenCalledTimes(1); + + const customer = await ProviderCustomer.findOne({ where: { providerCustomerId: "ap-kyb-pending" } }); + const kycCase = await KycCase.findOne({ where: { providerCustomerId: customer?.id } }); + expect(kycCase?.providerCaseId).toBe("kyb-sub-new"); + }); + + // Alfredpay only reports a missing questionnaire at sendKybSubmission — after every document has + // been uploaded. These keep that contract enforced at the point of entry. + it("rejects a submission missing the compliance questionnaire before reaching Alfredpay", async () => { + const { token } = await createBusinessCustomer("kyb-missing-questionnaire@example.com"); + + const submitKybInformation = mock(async () => ({ submissionId: "should-not-be-created" })); + AlfredpayApiService.getInstance = mock(() => ({ submitKybInformation }) as unknown as AlfredpayApiService); + + const { accountPurpose, ...withoutAccountPurpose } = KYB_FORM; + const response = await api.request("/v1/alfredpay/submitKybInformation", { + body: JSON.stringify(withoutAccountPurpose), + headers: authHeaders(token), + method: "POST" + }); + + expect(response.status).toBe(400); + expect(submitKybInformation).not.toHaveBeenCalled(); + }); + + it("rejects the conditional screening answer when funds are transmitted", async () => { + const { token } = await createBusinessCustomer("kyb-conditional-questionnaire@example.com"); + + const submitKybInformation = mock(async () => ({ submissionId: "should-not-be-created" })); + AlfredpayApiService.getInstance = mock(() => ({ submitKybInformation }) as unknown as AlfredpayApiService); + + const response = await api.request("/v1/alfredpay/submitKybInformation", { + body: JSON.stringify({ ...KYB_FORM, transmitsCustomerFunds: true }), + headers: authHeaders(token), + method: "POST" + }); + + expect(response.status).toBe(400); + expect(submitKybInformation).not.toHaveBeenCalled(); + }); + + // A customer that retried carries several businesses. Without submissionId the caller cannot tell + // which related persons belong to the submission it is filing, and uploads land on a stale person. + it("findKybCustomerAndBusiness keeps the submission id alongside each business's related persons", async () => { + const { token } = await createBusinessCustomer("kyb-related-persons@example.com"); + + AlfredpayApiService.getInstance = mock( + () => + ({ + getKybBusinessDetails: mock(async () => [ + { relatedPersons: [{ idRelatedPerson: "rp-stale" }], submissionId: "kyb-sub-stale" }, + { relatedPersons: [{ idRelatedPerson: "rp-current" }], submissionId: "kyb-sub-current" } + ]) + }) as unknown as AlfredpayApiService + ); + + const response = await api.request("/v1/alfredpay/findKybCustomerAndBusiness?country=CO", { + headers: authHeaders(token) + }); + expect(response.status).toBe(200); + expect(await response.json()).toEqual([ + { relatedPersons: [{ idRelatedPerson: "rp-stale" }], submissionId: "kyb-sub-stale" }, + { relatedPersons: [{ idRelatedPerson: "rp-current" }], submissionId: "kyb-sub-current" } + ]); + }); +}); diff --git a/apps/api/src/tests/contracts/alfredpay.contract.test.ts b/apps/api/src/tests/contracts/alfredpay.contract.test.ts new file mode 100644 index 000000000..2847db259 --- /dev/null +++ b/apps/api/src/tests/contracts/alfredpay.contract.test.ts @@ -0,0 +1,466 @@ +/** + * External API contract: Alfredpay (docs/features/contract-tests.md). + * + * The same consumed-contract schemas run against the fake (hermetic, PR-blocking) + * and against the partner API (live, nightly). Live tests skip cleanly when + * ALFREDPAY_* credentials are absent; the transaction-creating and account-reading + * tests additionally require pre-provisioned sandbox fixtures (see .env.example): + * + * - ALFREDPAY_CONTRACT_CUSTOMER_ID KYC-approved MX sandbox customer + * - ALFREDPAY_CONTRACT_FIAT_ACCOUNT_ID SPEI fiat account of that customer + * - ALFREDPAY_CONTRACT_KYC_SUBMISSION_ID a KYC submission of that customer + * + * Per PRD, at most one transaction per direction is created per run, and nothing + * progresses past the point where a real payment would be required (an onramp + * stays CREATED / awaiting payment). `getQuote` has no production consumers and + * is deliberately uncovered. + * + * KYB: the details read runs with the rest of the live half against a hard-coded sandbox + * business customer (KYB_CUSTOMER_ID). The full company-onboarding sequence is opt-in via + * ALFREDPAY_CONTRACT_RUN_KYB_FLOW=1 because it leaves a business customer behind per run: + * + * RUN_LIVE_TESTS=1 ALFREDPAY_CONTRACT_RUN_KYB_FLOW=1 bun test alfredpay.contract + */ +import { describe, expect, test } from "bun:test"; +import { + AlfredpayApiService, + AlfredpayChain, + alfredpayConfigsResponseSchema, + alfredpayCreateOnrampResponseSchema, + AlfredpayFeeType, + alfredpayFiatAccountsResponseSchema, + AlfredpayCustomerType, + AlfredpayFiatAccountType, + AlfredpayFiatCurrency, + alfredpayKybBusinessDetailsResponseSchema, + AlfredpayKybFileType, + AlfredpayKybRelatedPersonFileType, + alfredpayKycStatusResponseSchema, + alfredpayOfframpTransactionSchema, + AlfredpayOnChainCurrency, + alfredpayOnrampTransactionSchema, + AlfredpayPaymentMethodType, + alfredpayQuoteResponseSchema, + AlfredpayTradeLimitError, + type CreateAlfredpayOnrampQuoteRequest, + type SubmitKybInformationRequest +} from "@vortexfi/shared"; +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 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_KYB_FLOW = !!process.env.ALFREDPAY_CONTRACT_RUN_KYB_FLOW; + +// A sandbox MX company put through the full KYB by the flow test below, so the details read +// exercises a fully populated business: four company documents, both representative documents, and a +// stored `questionnaire`. Its submission is FAILED (see the flow test — the sandbox rejects the +// blank placeholder images), which does not affect this read: details are served regardless of +// verification outcome. Re-create it with ALFREDPAY_CONTRACT_RUN_KYB_FLOW=1 and pin the new id here +// 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=="; + +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" }); +} + +/** + * The name that broke KYB in production. Alfredpay's relate-person upload answers a non-ASCII + * multipart filename with a bare 502 `111301 UNKNOWN_ERROR`, and macOS separates the time from + * AM/PM with U+202F — so every screenshot a user uploads trips it while looking like plain ASCII. + * AlfredpayApiService rewrites the name before sending; driving the flow with this one keeps that + * honest against the real endpoint, since it passes only because of the rewrite. Escaped + * deliberately: the literal character is invisible here. + */ +const MACOS_SCREENSHOT_PNG_NAME = "Screenshot 2026-07-09 at 12.23.56\u202fPM.png"; + +function kybFlowForm(email: string): SubmitKybInformationRequest { + return { + accountPurpose: "Treasury management", + address: "Avenida Paseo de la Reforma 100", + businessActivities: "Cross-border payments software", + businessName: "Vortex Contract Test SA de CV", + city: "Ciudad de Mexico", + country: "MX", + expectedMonthlyTransactions: 120, + expectedMonthlyVolumeUsd: 50000, + // false keeps the run on the unregulated branch, which is what the document set below covers. + isRegulatedBusiness: false, + operatesInSanctionedCountries: false, + relatedPersons: [ + { + dateOfBirth: "1990-01-01", + email, + firstName: "Ana", + lastName: "Rep", + nationalities: ["MX"], + // Not required for MX, but Alfredpay requires it for CO/US. + pep: false + } + ], + sourceOfFunds: "Sale of goods/services", + state: "CDMX", + taxId: "AAA010101AAA", + transmitsCustomerFunds: false, + walletAddresses: "N/A", + website: "https://vortex-contract-test.example.com", + zipCode: "06600" + }; +} + +if (RUN_LIVE && !HAS_CREDS) { + console.warn("[contract:live] Alfredpay live half skipped: ALFREDPAY_API_KEY/ALFREDPAY_API_SECRET not set"); +} + +// Unremarkable placeholder wallet, mirroring the squidrouter suite. +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" }; + +function onrampQuoteRequest(fromAmount: string): CreateAlfredpayOnrampQuoteRequest { + // Mirrors OnRampInitializeAlfredpayEngine: fiat -> USDC minted on Polygon. + return { + chain: AlfredpayChain.MATIC, + fromAmount, + fromCurrency: AlfredpayFiatCurrency.MXN, + metadata: QUOTE_METADATA, + paymentMethodType: AlfredpayPaymentMethodType.BANK, + toCurrency: AlfredpayOnChainCurrency.USDC + }; +} + +describe("Alfredpay external API contract — hermetic (fake)", () => { + function seededFake() { + const fake = new FakeAlfredpay(); + fake.quoteFees = [{ amount: "12.50", currency: "MXN", type: AlfredpayFeeType.PROCESSING_FEE }]; + return fake; + } + + test("fake onramp and offramp quotes satisfy the quote contract", async () => { + const api = seededFake().asService(); + const onrampQuote = await api.createOnrampQuote(onrampQuoteRequest("500")); + expect(() => alfredpayQuoteResponseSchema.parse(onrampQuote)).not.toThrow(); + + const offrampQuote = await api.createOfframpQuote({ + chain: AlfredpayChain.MATIC, + fromAmount: "30", + fromCurrency: AlfredpayOnChainCurrency.USDC, + metadata: QUOTE_METADATA, + paymentMethodType: AlfredpayPaymentMethodType.BANK, + toCurrency: AlfredpayFiatCurrency.MXN + }); + expect(() => alfredpayQuoteResponseSchema.parse(offrampQuote)).not.toThrow(); + }); + + test("fake onramp order and transaction polling satisfy their contracts", async () => { + const fake = seededFake(); + const api = fake.asService(); + const order = await api.createOnramp({ + amount: "500", + chain: AlfredpayChain.MATIC, + customerId: "cust-1", + depositAddress: TEST_ADDRESS, + fromCurrency: AlfredpayFiatCurrency.MXN, + paymentMethodType: AlfredpayPaymentMethodType.BANK, + quoteId: "quote-1", + toCurrency: AlfredpayOnChainCurrency.USDC + }); + expect(() => alfredpayCreateOnrampResponseSchema.parse(order)).not.toThrow(); + + const transaction = await api.getOnrampTransaction(order.transaction.transactionId); + expect(() => alfredpayOnrampTransactionSchema.parse(transaction)).not.toThrow(); + }); + + test("fake offramp order and transaction polling satisfy their contracts", async () => { + const api = seededFake().asService(); + const order = await api.createOfframp({ + amount: "30", + chain: AlfredpayChain.MATIC, + customerId: "cust-1", + fiatAccountId: "fa-1", + fromCurrency: AlfredpayOnChainCurrency.USDC, + originAddress: TEST_ADDRESS, + quoteId: "quote-1", + toCurrency: AlfredpayFiatCurrency.MXN + }); + expect(() => alfredpayOfframpTransactionSchema.parse(order)).not.toThrow(); + + const transaction = await api.getOfframpTransaction(order.transactionId); + expect(() => alfredpayOfframpTransactionSchema.parse(transaction)).not.toThrow(); + }); + + test("fake fiat account listing satisfies the contract", async () => { + const fake = seededFake(); + fake.fiatAccountsByCustomer.set("cust-1", [ + { + accountNumber: "646180157000000004", + accountType: "checking", + customerId: "cust-1", + fiatAccountId: "fa-1", + type: AlfredpayFiatAccountType.SPEI + } + ]); + const accounts = await fake.asService().listFiatAccounts("cust-1"); + expect(() => alfredpayFiatAccountsResponseSchema.parse(accounts)).not.toThrow(); + }); +}); + +describe.skipIf(!RUN_LIVE || !HAS_CREDS)("Alfredpay external API contract — live", () => { + const api = () => AlfredpayApiService.getInstance(); + + test( + "GET /configurations response satisfies the configs contract", + async () => { + const configs = await runLive("alfredpay getAllConfigs", () => api().getAllConfigs()); + if (!configs) return; // inconclusive — see test-utils/contract-support.ts + alfredpayConfigsResponseSchema.parse(configs); + }, + 60_000 + ); + + test( + "POST /quotes responses satisfy the quote contract (both directions)", + async () => { + const onrampQuote = await runLive("alfredpay createOnrampQuote", () => api().createOnrampQuote(onrampQuoteRequest("500"))); + if (onrampQuote) alfredpayQuoteResponseSchema.parse(onrampQuote); + + const offrampQuote = await runLive("alfredpay createOfframpQuote", () => + api().createOfframpQuote({ + chain: AlfredpayChain.MATIC, + fromAmount: "30", + fromCurrency: AlfredpayOnChainCurrency.USDC, + metadata: QUOTE_METADATA, + paymentMethodType: AlfredpayPaymentMethodType.BANK, + toCurrency: AlfredpayFiatCurrency.MXN + }) + ); + if (offrampQuote) alfredpayQuoteResponseSchema.parse(offrampQuote); + }, + 60_000 + ); + + test( + "an absurd quote amount still yields the limit-breach error contract", + async () => { + // The 409 body is consumed inside executeRequest, which turns it into + // AlfredpayTradeLimitError — the parsed error carries the consumed fields. A + // generic Error here would mean the errorCode/errorMetadata shape drifted. + const limitError = await runLive("alfredpay limit breach", async () => { + try { + await api().createOnrampQuote(onrampQuoteRequest("999999999999")); + return null; // no maximum configured for the pair — nothing to assert + } catch (error) { + if (error instanceof AlfredpayTradeLimitError) return error; + throw error; // network/5xx etc. -> inconclusive + } + }); + if (!limitError) return; + expect(limitError.kind).toBe("above"); + expect(limitError.quantity).toMatch(/^\d+(\.\d+)?$/); + expect(limitError.fromCurrency.length).toBeGreaterThan(0); + }, + 60_000 + ); + + test.skipIf(!CUSTOMER_ID)( + "GET /fiatAccounts response satisfies the fiat accounts contract", + async () => { + const accounts = await runLive("alfredpay listFiatAccounts", () => api().listFiatAccounts(CUSTOMER_ID as string)); + if (!accounts) return; + alfredpayFiatAccountsResponseSchema.parse(accounts); + }, + 60_000 + ); + + test.skipIf(!CUSTOMER_ID || !KYC_SUBMISSION_ID)( + "GET /kyc/{submissionId}/status response satisfies the KYC status contract", + async () => { + const status = await runLive("alfredpay getKycStatus", () => + api().getKycStatus(CUSTOMER_ID as string, KYC_SUBMISSION_ID as string) + ); + if (!status) return; + alfredpayKycStatusResponseSchema.parse(status); + }, + 60_000 + ); + + test.skipIf(!CUSTOMER_ID)( + "POST /onramp + GET /onramp/{id} responses satisfy their contracts (stops at awaiting payment)", + async () => { + const quote = await runLive("alfredpay onramp quote (order)", () => + api().createOnrampQuote({ + ...onrampQuoteRequest("500"), + metadata: { businessId: "vortex", customerId: CUSTOMER_ID as string } + }) + ); + if (!quote) return; + + const order = await runLive("alfredpay createOnramp", () => + api().createOnramp({ + amount: quote.fromAmount, + chain: AlfredpayChain.MATIC, + customerId: CUSTOMER_ID as string, + depositAddress: TEST_ADDRESS, + fromCurrency: AlfredpayFiatCurrency.MXN, + paymentMethodType: AlfredpayPaymentMethodType.BANK, + quoteId: quote.quoteId, + toCurrency: AlfredpayOnChainCurrency.USDC + }) + ); + if (!order) return; + alfredpayCreateOnrampResponseSchema.parse(order); + + const transaction = await runLive("alfredpay getOnrampTransaction", () => + api().getOnrampTransaction(order.transaction.transactionId) + ); + if (!transaction) return; + alfredpayOnrampTransactionSchema.parse(transaction); + }, + 120_000 + ); + + test.skipIf(!CUSTOMER_ID || !FIAT_ACCOUNT_ID)( + "POST /offramp + GET /offramp/{id} responses satisfy their contracts (no deposit is made)", + async () => { + const quote = await runLive("alfredpay offramp quote (order)", () => + api().createOfframpQuote({ + chain: AlfredpayChain.MATIC, + fromAmount: "30", + fromCurrency: AlfredpayOnChainCurrency.USDC, + metadata: { businessId: "vortex", customerId: CUSTOMER_ID as string }, + paymentMethodType: AlfredpayPaymentMethodType.BANK, + toCurrency: AlfredpayFiatCurrency.MXN + }) + ); + if (!quote) return; + + const order = await runLive("alfredpay createOfframp", () => + api().createOfframp({ + amount: quote.fromAmount, + chain: AlfredpayChain.MATIC, + customerId: CUSTOMER_ID as string, + fiatAccountId: FIAT_ACCOUNT_ID as string, + fromCurrency: AlfredpayOnChainCurrency.USDC, + originAddress: TEST_ADDRESS, + quoteId: quote.quoteId, + toCurrency: AlfredpayFiatCurrency.MXN + }) + ); + if (!order) return; + alfredpayOfframpTransactionSchema.parse(order); + + const transaction = await runLive("alfredpay getOfframpTransaction", () => api().getOfframpTransaction(order.transactionId)); + if (!transaction) return; + alfredpayOfframpTransactionSchema.parse(transaction); + }, + 120_000 + ); + + /** + * The pair the KYB document uploads depend on: `submissionId` keys the company-document + * uploads, `relatedPersons[].idRelatedPerson` keys the representative's. Both were modelled + * from Alfredpay's docs and asserted only by our own mocks until this test — see the raw + * payload it prints when diagnosing an upload the provider refuses. + */ + test( + "GET /kyb/details response satisfies the KYB business details contract", + async () => { + const details = await runLive("alfredpay getKybBusinessDetails", () => api().getKybBusinessDetails(KYB_CUSTOMER_ID)); + if (!details) return; + console.info(`[contract:live] kyb/details raw payload:\n${JSON.stringify(details, null, 2)}`); + alfredpayKybBusinessDetailsResponseSchema.parse(details); + }, + 60_000 + ); +}); + +/** + * The full MX company KYB sequence the widget and dashboard drive, against the live sandbox: + * create business customer -> submit info -> 3 company documents -> details -> representative's + * document pair -> finalize -> status. + * + * Opt-in (ALFREDPAY_CONTRACT_RUN_KYB_FLOW=1) rather than nightly: unlike the ramp tests, which + * stop before money moves, this leaves a business customer behind on every run. + * + * Deliberately not wrapped in `runLive`: this is a diagnostic probe, so a provider rejection must + * surface as a failure with its body rather than be logged as inconclusive. Each step prints its + * raw response. + * + * "Completes" means every call is accepted and every response shape holds. The submission itself is + * 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", () => { + test( + "a Mexican company KYB completes every step end to end", + async () => { + const api = AlfredpayApiService.getInstance(); + const email = `vortex-kyb-contract-${Date.now()}@example.com`; + + const customer = await api.createCustomer(email, AlfredpayCustomerType.BUSINESS, "MX"); + console.info(`[contract:kyb] createCustomer -> ${JSON.stringify(customer)}`); + const customerId = customer.customerId; + expect(customerId).toBeTruthy(); + + const submission = await api.submitKybInformation(customerId, kybFlowForm(email)); + console.info(`[contract:kyb] submitKybInformation -> ${JSON.stringify(submission)}`); + const submissionId = submission.submissionId; + expect(submissionId).toBeTruthy(); + + for (const fileType of [ + AlfredpayKybFileType.TAX_ID_DOCUMENT, + AlfredpayKybFileType.ARTICLES_INCORPORATION, + AlfredpayKybFileType.PROOF_ADDRESS, + AlfredpayKybFileType.SHAREHOLDER_REGISTRY + ]) { + await api.submitKybFiles(customerId, submissionId, fileType, blankPng()); + console.info(`[contract:kyb] submitKybFiles ${fileType} -> ok`); + } + + const details = await api.getKybBusinessDetails(customerId); + console.info(`[contract:kyb] getKybBusinessDetails -> ${JSON.stringify(details, null, 2)}`); + alfredpayKybBusinessDetailsResponseSchema.parse(details); + + const business = details.find(entry => entry.submissionId === submissionId); + expect(business).toBeDefined(); + const relatedPersonId = business?.relatedPersons[0]?.idRelatedPerson; + expect(relatedPersonId).toBeTruthy(); + + // The step that failed in the dashboard with errorCode 111301 — see MACOS_SCREENSHOT_PNG_NAME. + for (const fileType of [AlfredpayKybRelatedPersonFileType.DOC_FRONT, AlfredpayKybRelatedPersonFileType.DOC_BACK]) { + await api.submitKybRelatedPersonFiles(customerId, relatedPersonId as string, fileType, blankPng(MACOS_SCREENSHOT_PNG_NAME)); + console.info(`[contract:kyb] submitKybRelatedPersonFiles ${fileType} -> ok`); + } + + await api.sendKybSubmission(customerId, submissionId); + console.info("[contract:kyb] sendKybSubmission -> ok"); + + // Alfredpay accepts the submission (IN_REVIEW), then its verification rejects it: the sandbox + // flips this customer to FAILED within ~30s because the uploads are blank 1x1 PNGs rather than + // real identity documents. That is the right outcome for placeholder images, so the contract + // asserted here is that every call is accepted and every response shape holds — not that + // verification approves. Do not "fix" a FAILED status by chasing the payload. + const status = await api.getKybStatus(customerId, submissionId); + console.info(`[contract:kyb] getKybStatus -> ${JSON.stringify(status)}`); + alfredpayKycStatusResponseSchema.parse(status); + }, + 300_000 + ); +}); + +// Not gated on HAS_CREDS: in the nightly (CONTRACT_EXPECT_LIVE=1) missing credentials +// are exactly the rot this must turn into a failure. +test.skipIf(!RUN_LIVE)("live contract coverage actually ran", () => { + assertLiveCoverage(); +}); diff --git a/apps/api/src/tests/contracts/avenia.contract.test.ts b/apps/api/src/tests/contracts/avenia.contract.test.ts new file mode 100644 index 000000000..172777e63 --- /dev/null +++ b/apps/api/src/tests/contracts/avenia.contract.test.ts @@ -0,0 +1,201 @@ +/** + * External API contract: Avenia/BRLA (docs/features/contract-tests.md). + * + * The same consumed-contract schemas run against the fake (hermetic, PR-blocking) + * and against the partner API (live, nightly). Live tests skip cleanly when BRLA_* + * credentials are absent; the subaccount-scoped tests additionally require a + * pre-provisioned, KYC-approved sandbox subaccount (see .env.example): + * + * - AVENIA_CONTRACT_SUBACCOUNT_ID + * + * Per PRD, only one transaction (a PIX pay-in ticket, which expires unpaid) is + * created per run. Payout tickets are covered hermetically only — creating one + * live would move BRLA balance, and reading one needs the id of a real payout. + * `createOnchainSwapQuote`/`createOnchainSwapTicket`/`getMainAccountBalance`/ + * `getAveniaSwapTicket` have no production consumers and are deliberately uncovered. + */ +import { describe, expect, test } from "bun:test"; +import { + aveniaAccountBalanceSchema, + aveniaAccountInfoSchema, + aveniaAccountLimitsSchema, + aveniaPayinTicketsSchema, + AveniaPaymentMethod, + aveniaPayoutTicketSchema, + aveniaPixInputTicketSchema, + aveniaPixKeyDataSchema, + aveniaQuoteResponseSchema, + BlockchainSendMethod, + BrlaApiService, + BrlaCurrency, + type PayInQuoteParams +} from "@vortexfi/shared"; +import { assertLiveCoverage, runLive } from "../../test-utils/contract-support"; +import { FakeBrla } from "../../test-utils/fake-world/fake-anchors"; + +const RUN_LIVE = !!process.env.RUN_LIVE_TESTS; +const HAS_CREDS = !!(process.env.BRLA_API_KEY && process.env.BRLA_PRIVATE_KEY); +const SUBACCOUNT_ID = process.env.AVENIA_CONTRACT_SUBACCOUNT_ID; + +if (RUN_LIVE && !HAS_CREDS) { + console.warn("[contract:live] Avenia live half skipped: BRLA_API_KEY/BRLA_PRIVATE_KEY not set"); +} + +// Mirrors OnRampInitializeAveniaEngine / prepareOnrampBrlTransactions: BRL arrives +// via PIX and lands as BRLA on the (sub)account's internal balance. +function payInQuoteParams(subAccountId?: string): PayInQuoteParams { + return { + inputAmount: "100", + inputCurrency: BrlaCurrency.BRL, + inputPaymentMethod: AveniaPaymentMethod.PIX, + inputThirdParty: false, + outputCurrency: BrlaCurrency.BRLA, + outputPaymentMethod: AveniaPaymentMethod.INTERNAL, + outputThirdParty: false, + ...(subAccountId ? { subAccountId } : {}) + }; +} + +describe("Avenia external API contract — hermetic (fake)", () => { + test("fake pay-in and payout quotes satisfy the quote contract", async () => { + const api = new FakeBrla().asService(); + const payInQuote = await api.createPayInQuote(payInQuoteParams()); + expect(() => aveniaQuoteResponseSchema.parse(payInQuote)).not.toThrow(); + + const payOutQuote = await api.createPayOutQuote({ outputAmount: "50", outputThirdParty: false }); + expect(() => aveniaQuoteResponseSchema.parse(payOutQuote)).not.toThrow(); + }); + + test("fake pix key validation satisfies the contract", async () => { + const pixKeyData = await new FakeBrla().asService().validatePixKey("test-pix-key"); + expect(() => aveniaPixKeyDataSchema.parse(pixKeyData)).not.toThrow(); + }); + + test("fake ticket creation and polling satisfy their contracts", async () => { + const fake = new FakeBrla(); + const api = fake.asService(); + + const pixInTicket = await api.createPixInputTicket( + { + quoteToken: "quote-token", + ticketBlockchainOutput: { beneficiaryWalletId: "00000000-0000-0000-0000-000000000000" } + }, + fake.subaccountId + ); + expect(() => aveniaPixInputTicketSchema.parse(pixInTicket)).not.toThrow(); + + const payinTickets = await api.getAveniaPayinTickets(fake.subaccountId); + expect(() => aveniaPayinTicketsSchema.parse(payinTickets)).not.toThrow(); + + const payoutTicket = await api.getAveniaPayoutTicket("pix-out-1", fake.subaccountId); + expect(() => aveniaPayoutTicketSchema.parse(payoutTicket)).not.toThrow(); + }); + + test("fake account limits, balances and info satisfy their contracts", async () => { + const fake = new FakeBrla(); + const api = fake.asService(); + + const limits = await api.getSubaccountUsedLimit(fake.subaccountId); + expect(() => aveniaAccountLimitsSchema.parse(limits)).not.toThrow(); + + const balances = await api.getAccountBalance(fake.subaccountId); + expect(() => aveniaAccountBalanceSchema.parse(balances)).not.toThrow(); + + const info = await api.subaccountInfo(fake.subaccountId); + expect(() => aveniaAccountInfoSchema.parse(info)).not.toThrow(); + }); +}); + +describe.skipIf(!RUN_LIVE || !HAS_CREDS)("Avenia external API contract — live", () => { + const api = () => BrlaApiService.getInstance(); + + test( + "GET /quote/fixed-rate responses satisfy the quote contract (pay-in, transfer, payout)", + async () => { + const payInQuote = await runLive("avenia createPayInQuote", () => api().createPayInQuote(payInQuoteParams())); + if (payInQuote) aveniaQuoteResponseSchema.parse(payInQuote); + + // Second production pay-in shape: internal BRLA moved to Moonbeam via permit. + const transferQuote = await runLive("avenia createPayInQuote (moonbeam)", () => + api().createPayInQuote({ + blockchainSendMethod: BlockchainSendMethod.PERMIT, + inputAmount: "100", + inputCurrency: BrlaCurrency.BRLA, + inputPaymentMethod: AveniaPaymentMethod.INTERNAL, + inputThirdParty: false, + outputCurrency: BrlaCurrency.BRLA, + outputPaymentMethod: AveniaPaymentMethod.MOONBEAM, + outputThirdParty: false + }) + ); + if (transferQuote) aveniaQuoteResponseSchema.parse(transferQuote); + + const payOutQuote = await runLive("avenia createPayOutQuote", () => + api().createPayOutQuote({ outputAmount: "50", outputThirdParty: false }) + ); + if (payOutQuote) aveniaQuoteResponseSchema.parse(payOutQuote); + }, + 60_000 + ); + + test.skipIf(!SUBACCOUNT_ID)( + "GET /account/limits, /balances and /account-info responses satisfy their contracts", + async () => { + const limits = await runLive("avenia getSubaccountUsedLimit", () => api().getSubaccountUsedLimit(SUBACCOUNT_ID as string)); + if (limits) aveniaAccountLimitsSchema.parse(limits); + + const balances = await runLive("avenia getAccountBalance", () => api().getAccountBalance(SUBACCOUNT_ID as string)); + if (balances) aveniaAccountBalanceSchema.parse(balances); + + const info = await runLive("avenia subaccountInfo", () => api().subaccountInfo(SUBACCOUNT_ID as string)); + if (info) { + aveniaAccountInfoSchema.parse(info); + // Piggy-back pix-key validation on the subaccount's own key — no separate fixture. + if (info.pixKey) { + const pixKeyData = await runLive("avenia validatePixKey", () => api().validatePixKey(info.pixKey)); + if (pixKeyData) aveniaPixKeyDataSchema.parse(pixKeyData); + } else { + console.warn("[contract:live] avenia validatePixKey skipped: subaccount has no pixKey"); + } + } + }, + 60_000 + ); + + test.skipIf(!SUBACCOUNT_ID)( + "POST /account/tickets (PIX pay-in) + ticket listing satisfy their contracts (ticket expires unpaid)", + async () => { + const quote = await runLive("avenia pay-in quote (ticket)", () => + api().createPayInQuote(payInQuoteParams(SUBACCOUNT_ID)) + ); + if (!quote) return; + + const ticket = await runLive("avenia createPixInputTicket", () => + api().createPixInputTicket( + { + quoteToken: quote.quoteToken, + ticketBlockchainOutput: { beneficiaryWalletId: "00000000-0000-0000-0000-000000000000" }, + ticketBrlPixInput: { additionalData: "contract-test" } + }, + SUBACCOUNT_ID as string + ) + ); + if (!ticket) return; + aveniaPixInputTicketSchema.parse(ticket); + + const payinTickets = await runLive("avenia getAveniaPayinTickets", () => api().getAveniaPayinTickets(SUBACCOUNT_ID as string)); + if (!payinTickets) return; + aveniaPayinTicketsSchema.parse(payinTickets); + // The envelope/discriminator path is only exercised live through the real client; + // our just-created ticket disappearing from the list would mean it drifted. + expect(payinTickets.map(t => t.id)).toContain(ticket.id); + }, + 120_000 + ); +}); + +// Not gated on HAS_CREDS: in the nightly (CONTRACT_EXPECT_LIVE=1) missing credentials +// are exactly the rot this must turn into a failure. +test.skipIf(!RUN_LIVE)("live contract coverage actually ran", () => { + assertLiveCoverage(); +}); diff --git a/apps/api/src/tests/contracts/pricefeeds.contract.test.ts b/apps/api/src/tests/contracts/pricefeeds.contract.test.ts new file mode 100644 index 000000000..e302aa749 --- /dev/null +++ b/apps/api/src/tests/contracts/pricefeeds.contract.test.ts @@ -0,0 +1,80 @@ +/** + * External API contract: CoinGecko price feed (docs/features/contract-tests.md). + * + * Unlike the anchor fakes, FakePrices patches PriceFeedService's methods *above* + * the HTTP seam (it never produces wire JSON), so the verified-fake half is + * fixture-based here: the hermetic tests pin the schema's accept/reject behavior, + * and drift detection comes entirely from the live half (plus, later, warn-only + * production parsing per Milestone 5). + * + * The live half mirrors getCryptoPrice's request construction: the ids/currencies + * requested are the ones production actually asks for ("usd-coin" as the USD proxy + * for the CoinGecko fiat fallback with vs_currencies mxn/cop/ars, and tokenIdMap + * entries like "ethereum" priced in usd). No credentials are strictly required: + * with COINGECKO_API_KEY set it uses the configured (pro) base URL like production, + * otherwise it falls back to the keyless public API, which serves the same shape. + */ +import { describe, expect, test } from "bun:test"; +import { coingeckoSimplePriceResponseSchema } from "../../api/services/priceFeed.schemas"; +import { assertLiveCoverage, runLive } from "../../test-utils/contract-support"; + +const RUN_LIVE = !!process.env.RUN_LIVE_TESTS; + +const REQUESTED_IDS = ["usd-coin", "ethereum"]; +const REQUESTED_CURRENCIES = ["usd", "mxn", "cop", "ars"]; + +describe("CoinGecko external API contract — hermetic (fixtures)", () => { + test("accepts the consumed simple/price shape including unknown keys", () => { + const body = { + ethereum: { last_updated_at: 1751882400, usd: 2500.12 }, + "usd-coin": { ars: 1000, cop: 4000, mxn: 17.2, usd: 1.0 } + }; + expect(() => coingeckoSimplePriceResponseSchema.parse(body)).not.toThrow(); + }); + + test("rejects non-numeric prices", () => { + expect(() => coingeckoSimplePriceResponseSchema.parse({ "usd-coin": { usd: "1.0" } })).toThrow(); + expect(() => coingeckoSimplePriceResponseSchema.parse({ "usd-coin": null })).toThrow(); + }); +}); + +describe.skipIf(!RUN_LIVE)("CoinGecko external API contract — live", () => { + test( + "GET /simple/price response satisfies the consumed contract", + async () => { + const { config } = await import("../../config/vars"); + const apiKey = config.priceProviders.coingecko.apiKey; + const baseUrl = apiKey ? config.priceProviders.coingecko.baseUrl : "https://api.coingecko.com/api/v3"; + + const body = await runLive("coingecko simple/price", async () => { + const url = new URL(`${baseUrl}/simple/price`); + url.searchParams.append("ids", REQUESTED_IDS.join(",")); + url.searchParams.append("vs_currencies", REQUESTED_CURRENCIES.join(",")); + const headers: HeadersInit = { Accept: "application/json" }; + if (apiKey) headers["x-cg-pro-api-key"] = apiKey; + const response = await fetch(url.toString(), { headers }); + if (!response.ok) { + throw new Error(`CoinGecko API error: ${response.status} ${await response.text()}`); + } + return (await response.json()) as unknown; + }); + if (!body) return; // inconclusive — see test-utils/contract-support.ts + + const parsed = coingeckoSimplePriceResponseSchema.parse(body); + // getCryptoPrice throws when a requested id/currency key is absent — presence + // of what was asked for is part of the consumed contract. + for (const id of REQUESTED_IDS) { + expect(parsed[id]).toBeDefined(); + expect(parsed[id]?.usd).toBeGreaterThan(0); + } + for (const currency of REQUESTED_CURRENCIES) { + expect(parsed["usd-coin"]?.[currency]).toBeGreaterThan(0); + } + }, + 60_000 + ); +}); + +test.skipIf(!RUN_LIVE)("live contract coverage actually ran", () => { + assertLiveCoverage(); +}); diff --git a/apps/api/src/tests/corridors/brl-offramp-crosschain.scenario.test.ts b/apps/api/src/tests/corridors/brl-offramp-crosschain.scenario.test.ts index 69a6368fd..427566678 100644 --- a/apps/api/src/tests/corridors/brl-offramp-crosschain.scenario.test.ts +++ b/apps/api/src/tests/corridors/brl-offramp-crosschain.scenario.test.ts @@ -14,9 +14,8 @@ import { generatePrivateKey, privateKeyToAccount, type PrivateKeyAccount } from import phaseProcessor from "../../api/services/phases/phase-processor"; import QuoteTicket from "../../models/quoteTicket.model"; import RampState from "../../models/rampState.model"; -import Partner from "../../models/partner.model"; import { resetTestDatabase, setupTestDatabase } from "../../test-utils/db"; -import { createTestTaxId, createTestUser } from "../../test-utils/factories"; +import { createTestTaxId, createTestUser, updatePartnerPricing } from "../../test-utils/factories"; import { type FakeWorld, installFakeWorld } from "../../test-utils/fake-world"; import { installFakeSupabaseAuth, testUserToken } from "../../test-utils/fake-world/fake-auth"; import { startTestApp, type TestApp } from "../../test-utils/test-app"; @@ -103,10 +102,7 @@ describe("BRL offramp cross-chain corridor (USDC on Polygon → Base → pix via await resetTestDatabase(); // The EVM fee distribution transaction builder requires the vortex // partner's EVM payout address even when the resulting fees are zero. - await Partner.update( - { payoutAddressEvm: "0x000000000000000000000000000000000000fee5" }, - { where: { name: "vortex", rampType: RampDirection.SELL } } - ); + await updatePartnerPricing("vortex", RampDirection.SELL, { payoutAddressEvm: "0x000000000000000000000000000000000000fee5" }); world.evm.failNextSends = 0; world.evm.onTransaction = undefined; world.brla.onPixOutputTicket = undefined; @@ -396,6 +392,64 @@ describe("BRL offramp cross-chain corridor (USDC on Polygon → Base → pix via 60000 ); + it( + "pre-existing allowance: the ramp completes when only the swap hash is reported (no approve hash)", + async () => { + const setup = await setUpRegisteredRamp({ reportHashes: false }); + scriptHappyWorld(setup); + + // The user's wallet already held a sufficient allowance for the squid + // router, so no approve tx was submitted — only the swap hash arrives. + const rampState = await RampState.findByPk(setup.rampId); + await rampState?.update({ + state: { ...rampState?.state, squidRouterSwapHash: setup.swapHash } + }); + + await phaseProcessor.processRamp(setup.rampId); + + const final = await RampState.findByPk(setup.rampId); + expect(final?.currentPhase).toBe("complete"); + expect(final?.phaseHistory.map(entry => entry.phase)).toEqual(HAPPY_PATH_PHASES); + expect(final?.processingLock).toEqual({ locked: false, lockedAt: null }); + expect(world.evm.erc20Balance(Networks.Base, BRLA_ON_BASE, world.brla.subaccountEvmWallet)).toBe(setup.swapOutputRaw); + }, + 30000 + ); + + it( + "security: a reported approve hash whose calldata differs from the blueprint still fails the ramp", + async () => { + const setup = await setUpRegisteredRamp({ reportHashes: false }); + scriptHappyWorld(setup); + + // The approve hash is optional, but when one IS reported it must still + // match the blueprint — relaxing the presence check must not disable + // content verification. + const approveTxData = setup.approveBlueprint.txData as unknown as { to: `0x${string}`; value?: string }; + const tamperedHash = world.evm.broadcastUserTransaction(Networks.Polygon, setup.userWallet.address, { + data: "0xdeadbeef", + to: approveTxData.to, + value: BigInt(approveTxData.value ?? "0") + }); + const rampState = await RampState.findByPk(setup.rampId); + await rampState?.update({ + state: { ...rampState?.state, squidRouterApproveHash: tamperedHash, squidRouterSwapHash: setup.swapHash } + }); + + await phaseProcessor.processRamp(setup.rampId); + + const final = await RampState.findByPk(setup.rampId); + expect(final?.currentPhase).toBe("failed"); + expect(final?.phaseHistory.map(entry => entry.phase)).not.toContain("complete"); + expect(final?.processingLock).toEqual({ locked: false, lockedAt: null }); + expect(final?.errorLogs.some(log => log.error.includes("calldata does not match"))).toBe(true); + expect(submissionsOf(setup.signedNablaSwap)).toBe(0); + expect(submissionsOf(setup.signedPayout)).toBe(0); + expect(world.evm.erc20Balance(Networks.Base, BRLA_ON_BASE, world.brla.subaccountEvmWallet)).toBe(0n); + }, + 30000 + ); + it( "security regression (F-021 class): a reported swap hash whose calldata differs from the blueprint fails the ramp", async () => { diff --git a/apps/api/src/tests/corridors/brl-offramp.scenario.test.ts b/apps/api/src/tests/corridors/brl-offramp.scenario.test.ts index 3484de44d..4389cb104 100644 --- a/apps/api/src/tests/corridors/brl-offramp.scenario.test.ts +++ b/apps/api/src/tests/corridors/brl-offramp.scenario.test.ts @@ -15,10 +15,9 @@ import phaseProcessor from "../../api/services/phases/phase-processor"; import QuoteTicket from "../../models/quoteTicket.model"; import RampState from "../../models/rampState.model"; import Subsidy from "../../models/subsidy.model"; -import Partner from "../../models/partner.model"; import type { SubsidyToken } from "../../models/subsidy.model"; import { resetTestDatabase, setupTestDatabase } from "../../test-utils/db"; -import { createTestTaxId, createTestUser } from "../../test-utils/factories"; +import { createTestTaxId, createTestUser, updatePartnerPricing } from "../../test-utils/factories"; import { type FakeWorld, installFakeWorld } from "../../test-utils/fake-world"; import { installFakeSupabaseAuth, testUserToken } from "../../test-utils/fake-world/fake-auth"; import { startTestApp, type TestApp } from "../../test-utils/test-app"; @@ -95,10 +94,7 @@ describe("BRL offramp swap corridor (USDC on Base → pix via Avenia)", () => { await resetTestDatabase(); // The EVM fee distribution transaction builder requires the vortex // partner's EVM payout address even when the resulting fees are zero. - await Partner.update( - { payoutAddressEvm: "0x000000000000000000000000000000000000fee5" }, - { where: { name: "vortex", rampType: RampDirection.SELL } } - ); + await updatePartnerPricing("vortex", RampDirection.SELL, { payoutAddressEvm: "0x000000000000000000000000000000000000fee5" }); world.evm.failNextSends = 0; world.evm.onTransaction = undefined; world.brla.onPixOutputTicket = undefined; diff --git a/apps/api/src/tests/corridors/brl-onramp-crosschain.scenario.test.ts b/apps/api/src/tests/corridors/brl-onramp-crosschain.scenario.test.ts index 1e1cf7cf9..eea7af1ed 100644 --- a/apps/api/src/tests/corridors/brl-onramp-crosschain.scenario.test.ts +++ b/apps/api/src/tests/corridors/brl-onramp-crosschain.scenario.test.ts @@ -11,11 +11,10 @@ import { import { decodeFunctionData, erc20Abi, parseTransaction, parseUnits } from "viem"; import { generatePrivateKey, privateKeyToAccount, type PrivateKeyAccount } from "viem/accounts"; import phaseProcessor from "../../api/services/phases/phase-processor"; -import Partner from "../../models/partner.model"; import QuoteTicket from "../../models/quoteTicket.model"; import RampState from "../../models/rampState.model"; import { resetTestDatabase, setupTestDatabase } from "../../test-utils/db"; -import { createTestTaxId, createTestUser } from "../../test-utils/factories"; +import { createTestTaxId, createTestUser, updatePartnerPricing } from "../../test-utils/factories"; import { type FakeWorld, installFakeWorld } from "../../test-utils/fake-world"; import { installFakeSupabaseAuth, testUserToken } from "../../test-utils/fake-world/fake-auth"; import { startTestApp, type TestApp } from "../../test-utils/test-app"; @@ -110,10 +109,7 @@ describe("BRL onramp cross-chain corridor (pix → Base mint+swap → USDC on Ar await resetTestDatabase(); // The EVM fee distribution transaction builder requires the vortex // partner's EVM payout address even when the resulting fees are zero. - await Partner.update( - { payoutAddressEvm: "0x000000000000000000000000000000000000fee5" }, - { where: { name: "vortex", rampType: RampDirection.BUY } } - ); + await updatePartnerPricing("vortex", RampDirection.BUY, { payoutAddressEvm: "0x000000000000000000000000000000000000fee5" }); world.evm.failNextSends = 0; world.evm.onTransaction = undefined; world.brla.onPixOutputTicket = undefined; diff --git a/apps/api/src/tests/corridors/eur-offramp.scenario.test.ts b/apps/api/src/tests/corridors/eur-offramp.scenario.test.ts index 07151bc2e..223c1e3f7 100644 --- a/apps/api/src/tests/corridors/eur-offramp.scenario.test.ts +++ b/apps/api/src/tests/corridors/eur-offramp.scenario.test.ts @@ -17,13 +17,12 @@ import phaseProcessor from "../../api/services/phases/phase-processor"; import { validateEphemeralAccountsFresh } from "../../api/services/ramp/ephemeral-freshness"; import { normalizeAndValidateSigningAccounts } from "../../api/services/ramp/ramp.service"; import { prepareOfframpTransactions } from "../../api/services/transactions/offramp"; -import Partner from "../../models/partner.model"; import QuoteTicket from "../../models/quoteTicket.model"; import RampState from "../../models/rampState.model"; import Subsidy from "../../models/subsidy.model"; import type { SubsidyToken } from "../../models/subsidy.model"; import { resetTestDatabase, setupTestDatabase } from "../../test-utils/db"; -import { createTestRampState, createTestUser } from "../../test-utils/factories"; +import { createTestRampState, createTestUser, updatePartnerPricing } from "../../test-utils/factories"; import { type FakeWorld, installFakeWorld } from "../../test-utils/fake-world"; import { installFakeSupabaseAuth, testUserToken } from "../../test-utils/fake-world/fake-auth"; import { startTestApp, type TestApp } from "../../test-utils/test-app"; @@ -108,10 +107,7 @@ describe("EUR offramp corridor (USDC on Base → SEPA via Mykobo)", () => { await resetTestDatabase(); // The EVM fee distribution transaction builder requires the vortex // partner's EVM payout address even when the resulting fees are zero. - await Partner.update( - { payoutAddressEvm: "0x000000000000000000000000000000000000fee5" }, - { where: { name: "vortex", rampType: RampDirection.SELL } } - ); + await updatePartnerPricing("vortex", RampDirection.SELL, { payoutAddressEvm: "0x000000000000000000000000000000000000fee5" }); world.evm.failNextSends = 0; world.evm.onTransaction = undefined; world.mykobo.failNextIntent = null; diff --git a/apps/api/src/tests/corridors/eur-onramp.scenario.test.ts b/apps/api/src/tests/corridors/eur-onramp.scenario.test.ts index 084d93296..4e80325e7 100644 --- a/apps/api/src/tests/corridors/eur-onramp.scenario.test.ts +++ b/apps/api/src/tests/corridors/eur-onramp.scenario.test.ts @@ -22,7 +22,8 @@ import { resolveMykoboCustomerForUser } from "../../api/services/mykobo/mykobo-c import { normalizeAndValidateSigningAccounts } from "../../api/services/ramp/ramp.service"; import { validateEphemeralAccountsFresh } from "../../api/services/ramp/ephemeral-freshness"; import { prepareMykoboToEvmOnrampTransactions } from "../../api/services/transactions/onramp/routes/mykobo-to-evm"; -import MykoboCustomer from "../../models/mykoboCustomer.model"; +import CustomerEntity from "../../models/customerEntity.model"; +import ProviderCustomer, { VerificationStatus } from "../../models/providerCustomer.model"; import QuoteTicket from "../../models/quoteTicket.model"; import RampState from "../../models/rampState.model"; import { resetTestDatabase, setupTestDatabase } from "../../test-utils/db"; @@ -346,8 +347,11 @@ describe("EUR onramp direct corridor (SEPA → EURC on Base via Mykobo)", () => expect(intents.length).toBe(1); expect(intents[0].transaction_type).toBe(MykoboTransactionType.DEPOSIT); expect(intents[0].value).toBe("100.00"); - expect((await MykoboCustomer.findOne({ where: { userId: final?.userId as string } }))?.status).toBe( - MykoboCustomerStatus.APPROVED + const entity = await CustomerEntity.findOne({ where: { profileId: final?.userId as string } }); + expect( + (await ProviderCustomer.findOne({ where: { customerEntityId: entity?.id as string, provider: "mykobo" } }))?.status + ).toBe( + VerificationStatus.Approved ); }, 30000 diff --git a/apps/api/src/tests/notifications-onboarding.integration.test.ts b/apps/api/src/tests/notifications-onboarding.integration.test.ts new file mode 100644 index 000000000..4fe5944e3 --- /dev/null +++ b/apps/api/src/tests/notifications-onboarding.integration.test.ts @@ -0,0 +1,506 @@ +import { afterAll, beforeAll, beforeEach, describe, expect, it, mock } from "bun:test"; +import { + AlfredPayCountry, + AlfredPayStatus, + AlfredpayApiService, + AlfredpayCustomerType, + AlfredpayKycStatus, + BrlaApiService, + KycAttemptResult, + KycAttemptStatus +} from "@vortexfi/shared"; +import { createAlfredpayCustomer } from "../api/services/alfredpay/alfredpay-customer.service"; +import { emitNotification } from "../api/services/notifications/notification.service"; +import CustomerEntity from "../models/customerEntity.model"; +import KycCase from "../models/kycCase.model"; +import ProviderCustomer, { VerificationStatus } from "../models/providerCustomer.model"; +import User from "../models/user.model"; +import { resetTestDatabase, setupTestDatabase } from "../test-utils/db"; +import { createTestAlfredpayCustomer, createTestTaxId, 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"; + +let api: TestApp; +let fakeAuth: FakeSupabaseAuth; + +beforeAll(async () => { + await setupTestDatabase(); + fakeAuth = installFakeSupabaseAuth(); + api = await startTestApp(); +}); + +afterAll(async () => { + await api.close(); + fakeAuth.restore(); +}); + +beforeEach(async () => { + await resetTestDatabase(); +}); + +function authHeaders(token: string): Record { + return { Authorization: `Bearer ${token}`, "Content-Type": "application/json" }; +} + +async function createAuthedUser(email: string): Promise<{ user: User; token: string }> { + const user = await createTestUser({ email }); + return { token: testUserToken(user.id, email), user }; +} + +describe("GET /v1/notifications", () => { + it("requires authentication", async () => { + const response = await api.request("/v1/notifications"); + expect(response.status).toBe(401); + }); + + it("returns the newest-first feed with the unread count, honoring limit", async () => { + const { user, token } = await createAuthedUser("user@example.com"); + await emitNotification(user.id, { title: "First", type: "test_event" }); + await emitNotification(user.id, { body: "details", title: "Second", type: "test_event" }); + + const response = await api.request("/v1/notifications?limit=1", { headers: authHeaders(token) }); + expect(response.status).toBe(200); + const body = (await response.json()) as { notifications: Array<{ title: string }>; unreadCount: number }; + expect(body.unreadCount).toBe(2); + expect(body.notifications).toHaveLength(1); + expect(body.notifications[0].title).toBe("Second"); + }); +}); + +describe("notification read state", () => { + it("marks a single notification read, scoped to the owner", async () => { + const { user, token } = await createAuthedUser("user@example.com"); + const stranger = await createAuthedUser("stranger@example.com"); + const notification = await emitNotification(user.id, { title: "First", type: "test_event" }); + if (!notification) throw new Error("notification not created"); + + const denied = await api.request(`/v1/notifications/${notification.id}/read`, { + headers: authHeaders(stranger.token), + method: "POST" + }); + expect(denied.status).toBe(404); + + const marked = await api.request(`/v1/notifications/${notification.id}/read`, { + headers: authHeaders(token), + method: "POST" + }); + expect(marked.status).toBe(204); + + const feed = await api.request("/v1/notifications", { headers: authHeaders(token) }); + const body = (await feed.json()) as { unreadCount: number }; + expect(body.unreadCount).toBe(0); + }); + + it("marks all notifications read", async () => { + const { user, token } = await createAuthedUser("user@example.com"); + await emitNotification(user.id, { title: "First", type: "test_event" }); + await emitNotification(user.id, { title: "Second", type: "test_event" }); + + const response = await api.request("/v1/notifications/read-all", { headers: authHeaders(token), method: "POST" }); + expect(response.status).toBe(204); + + const feed = await api.request("/v1/notifications", { headers: authHeaders(token) }); + const body = (await feed.json()) as { unreadCount: number }; + expect(body.unreadCount).toBe(0); + }); +}); + +describe("notification preferences", () => { + it("returns defaults on first read and persists updates", async () => { + const { token } = await createAuthedUser("user@example.com"); + + const defaults = await api.request("/v1/notifications/preferences", { headers: authHeaders(token) }); + expect(defaults.status).toBe(200); + expect(await defaults.json()).toEqual({ emailEnabled: true, prefs: {} }); + + const updated = await api.request("/v1/notifications/preferences", { + body: JSON.stringify({ emailEnabled: false, prefs: { rampCompleted: false } }), + headers: authHeaders(token), + method: "PUT" + }); + expect(updated.status).toBe(200); + + const reread = await api.request("/v1/notifications/preferences", { headers: authHeaders(token) }); + expect(await reread.json()).toEqual({ emailEnabled: false, prefs: { rampCompleted: false } }); + }); + + it("rejects a non-boolean emailEnabled", async () => { + const { token } = await createAuthedUser("user@example.com"); + const response = await api.request("/v1/notifications/preferences", { + body: JSON.stringify({ emailEnabled: "yes" }), + headers: authHeaders(token), + method: "PUT" + }); + expect(response.status).toBe(400); + }); +}); + +describe("GET /v1/onboarding/status", () => { + it("requires authentication", async () => { + const response = await api.request("/v1/onboarding/status"); + expect(response.status).toBe(401); + }); + + it("maps initial AlfredPay customer creation to started", async () => { + const { user } = await createAuthedUser("alfredpay-started@example.com"); + const view = await createAlfredpayCustomer(user.id, { + alfredPayId: "alfredpay-started", + country: AlfredPayCountry.MX, + status: AlfredPayStatus.Consulted, + type: AlfredpayCustomerType.INDIVIDUAL + }); + + const customer = await ProviderCustomer.findOne({ where: { providerCustomerId: "alfredpay-started" } }); + const kycCase = await KycCase.findOne({ where: { providerCustomerId: customer?.id } }); + expect(customer?.status).toBe(VerificationStatus.Started); + expect(kycCase?.status).toBe(VerificationStatus.Started); + + await view.update({ statusExternal: null, verificationStatus: VerificationStatus.Pending }); + await customer?.reload(); + await kycCase?.reload(); + expect(customer?.status).toBe(VerificationStatus.Pending); + expect(kycCase?.status).toBe(VerificationStatus.Pending); + }); + + it("reflects an Alfredpay approval that lands after the wizard closed, without a reopen", async () => { + const { user, token } = await createAuthedUser("alfredpay-late-approval@example.com"); + const customer = await createTestAlfredpayCustomer(user.id, { alfredPayId: "ap-late", country: AlfredPayCountry.MX }); + // Submitted, provider still reviewing — the state the card is stuck on once the modal closes. + await customer.update({ status: VerificationStatus.InReview }); + + const getInstance = AlfredpayApiService.getInstance; + AlfredpayApiService.getInstance = mock( + () => + ({ + getKycStatus: mock(async () => ({ status: AlfredpayKycStatus.COMPLETED })), + getLastKycSubmission: mock(async () => ({ submissionId: "sub-1" })) + }) as unknown as AlfredpayApiService + ); + + try { + const response = await api.request("/v1/onboarding/status", { headers: authHeaders(token) }); + const body = (await response.json()) as { + entities: Array<{ accounts: Array<{ kycCase: { status: string } | null; provider: string; state: string }> }>; + }; + const account = body.entities[0].accounts.find(item => item.provider === "alfredpay"); + expect(account?.state).toBe("approved"); + expect(account?.kycCase?.status).toBe("approved"); + } finally { + AlfredpayApiService.getInstance = getInstance; + } + + await customer.reload(); + expect(customer.status).toBe(VerificationStatus.Approved); + }); + + it("reflects an Avenia individual approval that lands after the wizard closed, without a reopen", async () => { + const { user, token } = await createAuthedUser("avenia-late-approval@example.com"); + const customer = await createTestTaxId(user.id, { subAccountId: "sub-late" }); + await customer.update({ status: VerificationStatus.InReview }); + + const getInstance = BrlaApiService.getInstance; + BrlaApiService.getInstance = mock( + () => + ({ + getKycAttempts: mock(async () => ({ + attempts: [{ result: KycAttemptResult.APPROVED, status: KycAttemptStatus.COMPLETED }] + })) + }) as unknown as BrlaApiService + ); + + try { + const response = await api.request("/v1/onboarding/status", { headers: authHeaders(token) }); + const body = (await response.json()) as { entities: Array<{ accounts: Array<{ provider: string; state: string }> }> }; + expect(body.entities[0].accounts.find(account => account.provider === "avenia")?.state).toBe("approved"); + } finally { + BrlaApiService.getInstance = getInstance; + } + + await customer.reload(); + expect(customer.status).toBe(VerificationStatus.Approved); + }); + + it("keeps an individual resumable when the Avenia attempt expired without a decision", async () => { + const { user, token } = await createAuthedUser("avenia-kyc-expired@example.com"); + const customer = await createTestTaxId(user.id, { subAccountId: "sub-expired" }); + await customer.update({ status: VerificationStatus.InReview }); + + const getInstance = BrlaApiService.getInstance; + BrlaApiService.getInstance = mock( + () => + ({ + getKycAttempts: mock(async () => ({ attempts: [{ status: KycAttemptStatus.EXPIRED }] })) + }) as unknown as BrlaApiService + ); + + try { + const response = await api.request("/v1/onboarding/status", { headers: authHeaders(token) }); + const body = (await response.json()) as { entities: Array<{ accounts: Array<{ provider: string; state: string }> }> }; + expect(body.entities[0].accounts.find(account => account.provider === "avenia")?.state).toBe("pending"); + } finally { + BrlaApiService.getInstance = getInstance; + } + + await customer.reload(); + expect(customer.status).toBe(VerificationStatus.Pending); + }); + + it("keeps an individual resumable while the Avenia attempt is still PENDING", async () => { + const { user, token } = await createAuthedUser("avenia-kyc-unfinished@example.com"); + const customer = await createTestTaxId(user.id, { subAccountId: "sub-unfinished" }); + await customer.update({ status: VerificationStatus.InReview }); + + const getInstance = BrlaApiService.getInstance; + BrlaApiService.getInstance = mock( + () => + ({ + getKycAttempts: mock(async () => ({ attempts: [{ status: KycAttemptStatus.PENDING }] })) + }) as unknown as BrlaApiService + ); + + try { + const response = await api.request("/v1/onboarding/status", { headers: authHeaders(token) }); + const body = (await response.json()) as { entities: Array<{ accounts: Array<{ provider: string; state: string }> }> }; + expect(body.entities[0].accounts.find(account => account.provider === "avenia")?.state).toBe("pending"); + } finally { + BrlaApiService.getInstance = getInstance; + } + + await customer.reload(); + expect(customer.status).toBe(VerificationStatus.Pending); + }); + + it("throttles provider refreshes: back-to-back polls hit Avenia only once per customer", async () => { + const { user, token } = await createAuthedUser("avenia-refresh-throttle@example.com"); + const customer = await createTestTaxId(user.id, { subAccountId: "sub-throttled" }); + await customer.update({ status: VerificationStatus.InReview }); + + const getKycAttempts = mock(async () => ({ + attempts: [{ result: KycAttemptResult.APPROVED, status: KycAttemptStatus.COMPLETED }] + })); + const getInstance = BrlaApiService.getInstance; + BrlaApiService.getInstance = mock(() => ({ getKycAttempts }) as unknown as BrlaApiService); + + try { + await api.request("/v1/onboarding/status", { headers: authHeaders(token) }); + await api.request("/v1/onboarding/status", { headers: authHeaders(token) }); + expect(getKycAttempts).toHaveBeenCalledTimes(1); + } finally { + BrlaApiService.getInstance = getInstance; + } + }); + + it("keeps a business KYB pending (resumable) while the Avenia attempt is still PENDING", async () => { + const { user, token } = await createAuthedUser("avenia-kyb-pending@example.com"); + const business = await createTestTaxId(user.id, { + customerType: "business", + subAccountId: "kyb-subaccount", + taxId: "11222333000181" + }); + // The stuck shape reported from staging: our row says in_review while Avenia's attempt is + // still PENDING because the user never finished (or misclicked past) the hosted steps. + await business.update({ status: VerificationStatus.InReview, statusExternal: KycAttemptStatus.PENDING }); + const kycCase = await KycCase.create({ + customerEntityId: business.customerEntityId, + level: "level_1", + provider: "avenia", + providerCaseId: "attempt-1", + providerCustomerId: business.id, + status: VerificationStatus.InReview, + statusExternal: KycAttemptStatus.PENDING, + type: "kyb" + }); + + const getInstance = BrlaApiService.getInstance; + BrlaApiService.getInstance = mock( + () => + ({ + getKybAttemptStatus: mock(async () => ({ attempt: { id: "attempt-1", status: KycAttemptStatus.PENDING } })) + }) as unknown as BrlaApiService + ); + + try { + const response = await api.request("/v1/onboarding/status", { headers: authHeaders(token) }); + expect(response.status).toBe(200); + const body = (await response.json()) as { + entities: Array<{ accounts: Array<{ provider: string; state: string; taxReference: string | null }> }>; + }; + const aveniaAccount = body.entities[0].accounts.find(account => account.provider === "avenia"); + expect(aveniaAccount?.state).toBe("pending"); + // The dashboard resumes the company flow from this — the CNPJ the owner already supplied. + expect(aveniaAccount?.taxReference).toBe("11222333000181"); + } finally { + BrlaApiService.getInstance = getInstance; + } + + await business.reload(); + await kycCase.reload(); + expect(business.status).toBe(VerificationStatus.Pending); + expect(kycCase.status).toBe(VerificationStatus.Pending); + }); + + it("aggregates provider accounts and KYC cases per entity with a normalized state", async () => { + const { user, token } = await createAuthedUser("user@example.com"); + const avenia = await createTestTaxId(user.id); + await KycCase.create({ + customerEntityId: avenia.customerEntityId, + level: "level_1", + provider: "avenia", + providerCustomerId: avenia.id, + status: VerificationStatus.Approved, + type: "kyc" + }); + const alfredpay = await createTestAlfredpayCustomer(user.id, { country: AlfredPayCountry.MX }); + await alfredpay.update({ status: VerificationStatus.InReview }); + + const response = await api.request("/v1/onboarding/status", { headers: authHeaders(token) }); + expect(response.status).toBe(200); + const body = (await response.json()) as { + entities: Array<{ + type: string; + accounts: Array<{ + provider: string; + state: string; + status: string; + taxReference: string | null; + kycCase: { status: string } | null; + }>; + }>; + }; + + expect(body.entities).toHaveLength(1); + const accounts = body.entities[0].accounts; + expect(accounts).toHaveLength(2); + + const aveniaAccount = accounts.find(account => account.provider === "avenia"); + expect(aveniaAccount?.state).toBe("approved"); + expect(aveniaAccount?.status).toBe(VerificationStatus.Approved); + expect(aveniaAccount?.kycCase?.status).toBe(VerificationStatus.Approved); + // Individual CPFs are never exposed; only business CNPJs are (for company-flow resume). + expect(aveniaAccount?.taxReference).toBeNull(); + + const alfredpayAccount = accounts.find(account => account.provider === "alfredpay"); + // VERIFYING means the customer has submitted and the provider is actively reviewing. + expect(alfredpayAccount?.state).toBe("in_review"); + expect(alfredpayAccount?.kycCase).toBeNull(); + }); + + it("returns an empty aggregate for a fresh profile", async () => { + const { token } = await createAuthedUser("fresh@example.com"); + const response = await api.request("/v1/onboarding/status", { headers: authHeaders(token) }); + expect(response.status).toBe(200); + const body = (await response.json()) as { + activeEntityId: string | null; + entities: unknown[]; + selectionRequired: boolean; + }; + // The lazy entity fallback only runs on entity-scoped writes; a fresh profile + // that never onboarded may legitimately have no entities yet. + expect(Array.isArray(body.entities)).toBe(true); + expect(body.activeEntityId).toBeNull(); + expect(body.selectionRequired).toBe(true); + }); + + it("hydrates a missing Avenia business name without exposing it for personal accounts", async () => { + const { user, token } = await createAuthedUser("business@example.com"); + const business = await createTestTaxId(user.id, { customerType: "business", subAccountId: "business-subaccount" }); + await business.update({ companyName: null }); + const getInstance = BrlaApiService.getInstance; + BrlaApiService.getInstance = mock( + () => + ({ + subaccountInfo: mock(async () => ({ accountInfo: { fullName: "", name: "Acme Ltda" } })) + }) as unknown as BrlaApiService + ); + + try { + const response = await api.request("/v1/onboarding/status", { headers: authHeaders(token) }); + expect(response.status).toBe(200); + const body = (await response.json()) as { + entities: Array<{ accounts: Array<{ companyName: string | null; provider: string }> }>; + }; + expect(body.entities[0].accounts.find(account => account.provider === "avenia")?.companyName).toBe("Acme Ltda"); + await business.reload(); + expect(business.companyName).toBe("Acme Ltda"); + } finally { + BrlaApiService.getInstance = getInstance; + } + }); +}); + +describe("PUT /v1/onboarding/active-entity", () => { + it("requires authentication and validates the account type", async () => { + const unauthorized = await api.request("/v1/onboarding/active-entity", { + body: JSON.stringify({ type: "individual" }), + headers: { "Content-Type": "application/json" }, + method: "PUT" + }); + expect(unauthorized.status).toBe(401); + + const { token } = await createAuthedUser("invalid-type@example.com"); + const invalid = await api.request("/v1/onboarding/active-entity", { + body: JSON.stringify({ type: "company" }), + headers: authHeaders(token), + method: "PUT" + }); + expect(invalid.status).toBe(400); + }); + + it("persists an initial selection, is idempotent, and rejects changing it", async () => { + const { user, token } = await createAuthedUser("selection@example.com"); + const request = () => + api.request("/v1/onboarding/active-entity", { + body: JSON.stringify({ type: "business" }), + headers: authHeaders(token), + method: "PUT" + }); + + const selected = await request(); + expect(selected.status).toBe(200); + const firstBody = (await selected.json()) as { activeEntityId: string; type: string }; + expect(firstBody.type).toBe("business"); + + const retry = await request(); + expect(retry.status).toBe(200); + expect(await retry.json()).toEqual(firstBody); + expect(await CustomerEntity.count({ where: { profileId: user.id } })).toBe(1); + + const changed = await api.request("/v1/onboarding/active-entity", { + body: JSON.stringify({ type: "individual" }), + headers: authHeaders(token), + method: "PUT" + }); + expect(changed.status).toBe(409); + + const status = await api.request("/v1/onboarding/status", { headers: authHeaders(token) }); + const statusBody = (await status.json()) as { activeEntityId: string | null; selectionRequired: boolean }; + expect(statusBody.activeEntityId).toBe(firstBody.activeEntityId); + expect(statusBody.selectionRequired).toBe(false); + }); + + it("cannot reach an ambiguous same-type state: the unique index rejects duplicate entities", async () => { + // The ACTIVE_ENTITY_AMBIGUOUS branch in selectActiveCustomerEntity is defense-in-depth: + // migration 049's partial unique index on (profile_id, type) makes the duplicate state + // it guards against impossible to create in the first place. + const { user } = await createAuthedUser("ambiguous@example.com"); + await CustomerEntity.create({ profileId: user.id, status: "active", type: "individual" }); + + const duplicate = CustomerEntity.create({ profileId: user.id, status: "active", type: "individual" }); + await expect(duplicate).rejects.toMatchObject({ name: "SequelizeUniqueConstraintError" }); + }); + + it("rejects a persisted selection owned by another profile", async () => { + const { user, token } = await createAuthedUser("owner@example.com"); + const stranger = await createAuthedUser("stranger-owner@example.com"); + const entity = await CustomerEntity.create({ profileId: stranger.user.id, status: "active", type: "individual" }); + await user.update({ activeCustomerEntityId: entity.id }); + + const response = await api.request("/v1/onboarding/active-entity", { + body: JSON.stringify({ type: "individual" }), + headers: authHeaders(token), + method: "PUT" + }); + expect(response.status).toBe(409); + expect(((await response.json()) as { error: { code: string } }).error.code).toBe("ACTIVE_ENTITY_OWNERSHIP_MISMATCH"); + }); +}); diff --git a/apps/api/src/tests/quote-consumption.invariants.test.ts b/apps/api/src/tests/quote-consumption.invariants.test.ts index a150c9377..ed7702522 100644 --- a/apps/api/src/tests/quote-consumption.invariants.test.ts +++ b/apps/api/src/tests/quote-consumption.invariants.test.ts @@ -119,6 +119,66 @@ describe("quote consumption invariants (BRL onramp)", () => { expect(ramps.length).toBe(1); }); + it("allows only one active ramp per user across different quotes", async () => { + const user = await createTestUser(); + await createTestTaxId(user.id, { taxId: TAX_ID }); + const firstQuote = await createQuoteViaApi(); + const secondQuote = await createQuoteViaApi(); + + const [first, second] = await Promise.all([ + registerViaApi(firstQuote.id, user.id), + registerViaApi(secondQuote.id, user.id) + ]); + + const statuses = [first.status, second.status].sort(); + expect(statuses).toEqual([201, 409]); + expect(await RampState.count({ where: { userId: user.id } })).toBe(1); + }); + + it("releases an unstarted ramp after the start window expires", async () => { + const user = await createTestUser(); + await createTestTaxId(user.id, { taxId: TAX_ID }); + const firstQuote = await createQuoteViaApi(); + const firstResponse = await registerViaApi(firstQuote.id, user.id); + expect(firstResponse.status).toBe(201); + const firstRamp = (await firstResponse.json()) as { id: string }; + + await RampState.update( + { createdAt: new Date(Date.now() - 16 * 60 * 1000) }, + { where: { id: firstRamp.id } } + ); + + const secondQuote = await createQuoteViaApi(); + const secondResponse = await registerViaApi(secondQuote.id, user.id); + + expect(secondResponse.status).toBe(201); + expect((await RampState.findByPk(firstRamp.id))?.currentPhase).toBe("timedOut"); + }); + + // Pins the sweep's deliberate narrowness: only ramps still in `initial` are reaped. + // A ramp that advanced past `initial` may hold user funds, so it keeps blocking new + // registrations even after the start window — releasing it requires it to reach a + // terminal phase (complete/failed/timedOut) through the state machine. + it("keeps blocking new registrations while a ramp is wedged past the initial phase", async () => { + const user = await createTestUser(); + await createTestTaxId(user.id, { taxId: TAX_ID }); + const firstQuote = await createQuoteViaApi(); + const firstResponse = await registerViaApi(firstQuote.id, user.id); + expect(firstResponse.status).toBe(201); + const firstRamp = (await firstResponse.json()) as { id: string }; + + await RampState.update( + { createdAt: new Date(Date.now() - 16 * 60 * 1000), currentPhase: "nablaSwap" }, + { where: { id: firstRamp.id } } + ); + + const secondQuote = await createQuoteViaApi(); + const secondResponse = await registerViaApi(secondQuote.id, user.id); + + expect(secondResponse.status).toBe(409); + expect((await RampState.findByPk(firstRamp.id))?.currentPhase).toBe("nablaSwap"); + }); + // Pins the atomic-UPDATE backstop directly: even if the registration flow's // row-locked pre-check were removed, consumeQuote must refuse a non-pending // quote at the database level (WHERE status = 'pending'). diff --git a/apps/api/src/tests/quote-pricing.golden.test.ts b/apps/api/src/tests/quote-pricing.golden.test.ts index 3b63e66c9..55bcb4fc3 100644 --- a/apps/api/src/tests/quote-pricing.golden.test.ts +++ b/apps/api/src/tests/quote-pricing.golden.test.ts @@ -180,7 +180,10 @@ describe("quote pricing goldens (fixed input matrix)", () => { network: "base", networkFeeFiat: "0", networkFeeUsd: "0", - outputAmount: "500.00", + // 100 BRLA is worth ~100 BRL (1:1 peg), not 500: the discount engine now values the + // BRLA input in USD (~20 USD at 5 BRL/USD) before applying the inverted oracle rate, + // instead of treating 100 BRLA as 100 USD → 500 BRL. + outputAmount: "100.00", outputCurrency: "BRL", partnerFeeFiat: "0", partnerFeeUsd: "0", diff --git a/apps/api/src/tests/recipients.integration.test.ts b/apps/api/src/tests/recipients.integration.test.ts new file mode 100644 index 000000000..bb8a67196 --- /dev/null +++ b/apps/api/src/tests/recipients.integration.test.ts @@ -0,0 +1,741 @@ +import { afterAll, beforeAll, beforeEach, describe, expect, it } from "bun:test"; +import Notification from "../models/notification.model"; +import CustomerEntity from "../models/customerEntity.model"; +import ProviderCustomer, { VerificationStatus } from "../models/providerCustomer.model"; +import RecipientInvitation from "../models/recipientInvitation.model"; +import RecipientPayoutReference from "../models/recipientPayoutReference.model"; +import SenderRecipient from "../models/senderRecipient.model"; +import User from "../models/user.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"; + +let api: TestApp; +let fakeAuth: FakeSupabaseAuth; + +beforeAll(async () => { + await setupTestDatabase(); + fakeAuth = installFakeSupabaseAuth(); + api = await startTestApp(); +}); + +afterAll(async () => { + await api.close(); + fakeAuth.restore(); +}); + +beforeEach(async () => { + await resetTestDatabase(); +}); + +function authHeaders(token: string): Record { + return { Authorization: `Bearer ${token}`, "Content-Type": "application/json" }; +} + +async function createAuthedUser(email: string): Promise<{ user: User; token: string }> { + const user = await createTestUser({ email }); + return { token: testUserToken(user.id, email), user }; +} + +/** A sender with one approved corridor — invite creation requires at least one approval. */ +async function createApprovedSender(email: string): Promise<{ user: User; token: string; entity: CustomerEntity }> { + const { user, token } = await createAuthedUser(email); + const entity = await CustomerEntity.create({ profileId: user.id, type: "individual" }); + await ProviderCustomer.create({ + country: "MX", + customerEntityId: entity.id, + customerType: "individual", + provider: "alfredpay", + rail: "mxn", + status: VerificationStatus.Approved + }); + return { entity, token, user }; +} + +const MX_CORRIDOR = { country: "MX", payoutCurrency: "mxn", rail: "mxn" }; + +async function createInvite( + token: string, + overrides: Record = {} +): Promise<{ status: number; body: Record }> { + const response = await api.request("/v1/recipients/invite", { + body: JSON.stringify({ ...MX_CORRIDOR, ...overrides }), + headers: authHeaders(token), + method: "POST" + }); + return { body: (await response.json()) as Record, status: response.status }; +} + +async function acceptInvite(token: string, inviteToken: string): Promise<{ status: number; body: Record }> { + const response = await api.request(`/v1/recipients/invite/${inviteToken}/accept`, { + headers: authHeaders(token), + method: "POST" + }); + return { body: (await response.json()) as Record, status: response.status }; +} + +describe("POST /v1/recipients/invite", () => { + it("requires authentication", async () => { + const response = await api.request("/v1/recipients/invite", { + body: JSON.stringify(MX_CORRIDOR), + headers: { "Content-Type": "application/json" }, + method: "POST" + }); + expect(response.status).toBe(401); + }); + + it("creates an invite, returning the raw token and storing it alongside its hash while pending", async () => { + const sender = await createApprovedSender("sender@example.com"); + const { status, body } = await createInvite(sender.token, { alias: "Bob's link", inviteeEmail: "Bob@Example.com" }); + + expect(status).toBe(201); + expect(typeof body.token).toBe("string"); + expect((body.token as string).length).toBeGreaterThanOrEqual(24); + expect(body.status).toBe("pending"); + expect(body.alias).toBe("Bob's link"); + expect(body.expiresAt).toBeTruthy(); + + const stored = await RecipientInvitation.findByPk(body.id as string); + expect(stored).not.toBeNull(); + // The hash stays the redemption key; the raw token is retained for sender re-copy. + expect(stored?.tokenHash).not.toBe(body.token); + expect(stored?.tokenHash).toMatch(/^[0-9a-f]{64}$/); + expect(stored?.token).toBe(body.token as string); + expect(stored?.alias).toBe("Bob's link"); + expect(stored?.inviteeEmailCanonical).toBe("bob@example.com"); + }); + + it("rejects an invite without a corridor", async () => { + const sender = await createApprovedSender("sender@example.com"); + const { status, body } = await createInvite(sender.token, { rail: undefined }); + expect(status).toBe(400); + expect((body.error as { code: string }).code).toBe("INVALID_INVITE_CORRIDOR"); + }); + + it("rejects an alias longer than 100 characters", async () => { + const sender = await createApprovedSender("sender@example.com"); + const { status, body } = await createInvite(sender.token, { alias: "x".repeat(101) }); + expect(status).toBe(400); + expect((body.error as { code: string }).code).toBe("INVALID_ALIAS"); + }); + + it("rejects an unknown corridor and a rail that does not match the corridor", async () => { + const sender = await createApprovedSender("sender@example.com"); + + const unknown = await createInvite(sender.token, { country: "ZZ", payoutCurrency: "zzz", rail: "zzz" }); + expect(unknown.status).toBe(400); + expect((unknown.body.error as { code: string }).code).toBe("INVALID_INVITE_CORRIDOR"); + + const mismatched = await createInvite(sender.token, { country: "MX", rail: "brl" }); + expect(mismatched.status).toBe(400); + expect((mismatched.body.error as { code: string }).code).toBe("INVALID_INVITE_CORRIDOR"); + }); + + it("rejects combinations the corridor's provider cannot onboard (AR business)", async () => { + const sender = await createApprovedSender("sender@example.com"); + const { status, body } = await createInvite(sender.token, { + country: "AR", + inviteeType: "business", + payoutCurrency: "ars", + rail: "ars" + }); + expect(status).toBe(400); + expect((body.error as { code: string }).code).toBe("UNSUPPORTED_INVITEE_TYPE"); + + const individual = await createInvite(sender.token, { country: "AR", payoutCurrency: "ars", rail: "ars" }); + expect(individual.status).toBe(201); + }); + + it("rejects invite creation while the sender has no approved corridor", async () => { + const sender = await createAuthedUser("unapproved-sender@example.com"); + const { status, body } = await createInvite(sender.token); + expect(status).toBe(403); + expect((body.error as { code: string }).code).toBe("NO_APPROVED_CORRIDOR"); + }); +}); + +describe("POST /v1/recipients/invite/:token/accept", () => { + it("returns the invitee type and binds business recipients to a business entity", async () => { + const sender = await createApprovedSender("sender@example.com"); + const recipient = await createAuthedUser("business-recipient@example.com"); + const invite = await createInvite(sender.token, { inviteeType: "business" }); + + const { status, body } = await acceptInvite(recipient.token, invite.body.token as string); + + expect(status).toBe(201); + expect((body.invitation as { inviteeType: string }).inviteeType).toBe("business"); + const relationship = await SenderRecipient.findByPk(body.id as string); + const entity = await CustomerEntity.findByPk(relationship?.recipientCustomerEntityId); + expect(entity?.type).toBe("business"); + }); + + it("accepts a pending invite, creating an active relationship and notifying the sender", async () => { + const sender = await createApprovedSender("sender@example.com"); + const recipient = await createAuthedUser("recipient@example.com"); + const invite = await createInvite(sender.token); + + const { status, body } = await acceptInvite(recipient.token, invite.body.token as string); + expect(status).toBe(201); + expect(body.relationshipStatus).toBe("active"); + + const invitation = await RecipientInvitation.findByPk(invite.body.id as string); + expect(invitation?.status).toBe("accepted"); + expect(invitation?.acceptedByProfileId).toBe(recipient.user.id); + // Acceptance clears the retained raw token — re-copy is a pending-only affordance. + expect(invitation?.token).toBeNull(); + + const relationship = await SenderRecipient.findByPk(body.id as string); + expect(relationship?.relationshipStatus).toBe("active"); + + const senderNotifications = await Notification.findAll({ where: { profileId: sender.user.id } }); + expect(senderNotifications.map(n => n.type)).toContain("recipient_invite_accepted"); + }); + + it("lets the accepting recipient re-enter their own link without re-notifying the sender", async () => { + const sender = await createApprovedSender("sender@example.com"); + const recipient = await createAuthedUser("recipient@example.com"); + const invite = await createInvite(sender.token); + + const first = await acceptInvite(recipient.token, invite.body.token as string); + expect(first.status).toBe(201); + const invitation = await RecipientInvitation.findByPk(invite.body.id as string); + const originalAcceptedAt = invitation?.acceptedAt; + + // Reopening the link mid-KYC resumes the same relationship rather than 409-ing. + const second = await acceptInvite(recipient.token, invite.body.token as string); + expect(second.status).toBe(200); + expect(second.body.id).toBe(first.body.id as string); + expect(second.body.relationshipStatus).toBe("active"); + + const reread = await RecipientInvitation.findByPk(invite.body.id as string); + expect(reread?.acceptedAt).toEqual(originalAcceptedAt as Date); + + const accepted = (await Notification.findAll({ where: { profileId: sender.user.id } })).filter( + n => n.type === "recipient_invite_accepted" + ); + expect(accepted).toHaveLength(1); + }); + + it("rejects a second acceptance by a different recipient", async () => { + const sender = await createApprovedSender("sender@example.com"); + const recipient = await createAuthedUser("recipient@example.com"); + const stranger = await createAuthedUser("stranger@example.com"); + const invite = await createInvite(sender.token); + + await acceptInvite(recipient.token, invite.body.token as string); + const second = await acceptInvite(stranger.token, invite.body.token as string); + expect(second.status).toBe(409); + expect((second.body.error as { code: string }).code).toBe("INVITE_ALREADY_ACCEPTED"); + }); + + // Invariant 3 (one relationship per invite) under concurrency: both requests pass the + // unlocked pre-checks; the row-locked re-check inside the transaction must let exactly + // one of them create a relationship. + it("lets exactly one of two concurrent acceptances by different recipients through", async () => { + const sender = await createApprovedSender("sender@example.com"); + const recipient = await createAuthedUser("recipient@example.com"); + const stranger = await createAuthedUser("stranger@example.com"); + const invite = await createInvite(sender.token); + + const [first, second] = await Promise.all([ + acceptInvite(recipient.token, invite.body.token as string), + acceptInvite(stranger.token, invite.body.token as string) + ]); + + const statuses = [first.status, second.status].sort(); + expect(statuses).toEqual([201, 409]); + expect(await SenderRecipient.count()).toBe(1); + + const invitation = await RecipientInvitation.findByPk(invite.body.id as string); + const winner = first.status === 201 ? recipient : stranger; + expect(invitation?.acceptedByProfileId).toBe(winner.user.id); + }); + + it("lets the recipient re-enter after the invite's expiry passes", async () => { + const sender = await createApprovedSender("sender@example.com"); + const recipient = await createAuthedUser("recipient@example.com"); + const invite = await createInvite(sender.token); + await acceptInvite(recipient.token, invite.body.token as string); + + // KYC can take days; an expiry passing after acceptance must not strand the recipient. + await RecipientInvitation.update({ expiresAt: new Date(Date.now() - 1000) }, { where: { id: invite.body.id as string } }); + + const { status } = await acceptInvite(recipient.token, invite.body.token as string); + expect(status).toBe(200); + const invitation = await RecipientInvitation.findByPk(invite.body.id as string); + expect(invitation?.status).toBe("accepted"); + }); + + it("does not revive an archived relationship on re-entry", async () => { + const sender = await createApprovedSender("sender@example.com"); + const recipient = await createAuthedUser("recipient@example.com"); + const invite = await createInvite(sender.token); + const accepted = await acceptInvite(recipient.token, invite.body.token as string); + + await api.request(`/v1/recipients/${accepted.body.id}`, { + body: JSON.stringify({ status: "archived" }), + headers: authHeaders(sender.token), + method: "PATCH" + }); + + const { status } = await acceptInvite(recipient.token, invite.body.token as string); + expect(status).toBe(200); + const relationship = await SenderRecipient.findByPk(accepted.body.id as string); + expect(relationship?.relationshipStatus).toBe("archived"); + }); + + it("still blocks re-entry once the sender has blocked the relationship", async () => { + const sender = await createApprovedSender("sender@example.com"); + const recipient = await createAuthedUser("recipient@example.com"); + const invite = await createInvite(sender.token); + const accepted = await acceptInvite(recipient.token, invite.body.token as string); + + await api.request(`/v1/recipients/${accepted.body.id}`, { + body: JSON.stringify({ status: "blocked" }), + headers: authHeaders(sender.token), + method: "PATCH" + }); + + const retry = await acceptInvite(recipient.token, invite.body.token as string); + expect(retry.status).toBe(409); + expect((retry.body.error as { code: string }).code).toBe("RELATIONSHIP_BLOCKED"); + }); + + it("still rejects re-entry on a revoked invite", async () => { + const sender = await createApprovedSender("sender@example.com"); + const recipient = await createAuthedUser("recipient@example.com"); + const invite = await createInvite(sender.token); + await acceptInvite(recipient.token, invite.body.token as string); + + await RecipientInvitation.update({ status: "revoked" }, { where: { id: invite.body.id as string } }); + + const { status, body } = await acceptInvite(recipient.token, invite.body.token as string); + expect(status).toBe(410); + expect((body.error as { code: string }).code).toBe("INVITE_REVOKED"); + }); + + it("binds redemption to the invitee email when one was recorded (case-insensitive)", async () => { + const sender = await createApprovedSender("sender@example.com"); + const invite = await createInvite(sender.token, { inviteeEmail: "Bob@Example.com" }); + + const stranger = await createAuthedUser("mallory@example.com"); + const denied = await acceptInvite(stranger.token, invite.body.token as string); + expect(denied.status).toBe(403); + expect((denied.body.error as { code: string }).code).toBe("INVITE_EMAIL_MISMATCH"); + + const bob = await createAuthedUser("bob@example.com"); + const accepted = await acceptInvite(bob.token, invite.body.token as string); + expect(accepted.status).toBe(201); + }); + + it("rejects accepting your own invite", async () => { + const sender = await createApprovedSender("sender@example.com"); + const invite = await createInvite(sender.token); + const { status, body } = await acceptInvite(sender.token, invite.body.token as string); + expect(status).toBe(409); + expect((body.error as { code: string }).code).toBe("CANNOT_ACCEPT_OWN_INVITE"); + }); + + it("rejects an expired invite and marks it expired", async () => { + const sender = await createApprovedSender("sender@example.com"); + const recipient = await createAuthedUser("recipient@example.com"); + const invite = await createInvite(sender.token); + await RecipientInvitation.update({ expiresAt: new Date(Date.now() - 1000) }, { where: { id: invite.body.id as string } }); + + const { status, body } = await acceptInvite(recipient.token, invite.body.token as string); + expect(status).toBe(410); + expect((body.error as { code: string }).code).toBe("INVITE_EXPIRED"); + const invitation = await RecipientInvitation.findByPk(invite.body.id as string); + expect(invitation?.status).toBe("expired"); + // The expiry transition is the third token-clearing site — no live secret at rest. + expect(invitation?.token).toBeNull(); + }); + + it("returns 404 for an unknown token", async () => { + const recipient = await createAuthedUser("recipient@example.com"); + const { status } = await acceptInvite(recipient.token, "not-a-real-token"); + expect(status).toBe(404); + }); + + it("does not resurrect a blocked relationship through a new invite", async () => { + const sender = await createApprovedSender("sender@example.com"); + const recipient = await createAuthedUser("recipient@example.com"); + const first = await createInvite(sender.token); + const accepted = await acceptInvite(recipient.token, first.body.token as string); + + await api.request(`/v1/recipients/${accepted.body.id}`, { + body: JSON.stringify({ status: "blocked" }), + headers: authHeaders(sender.token), + method: "PATCH" + }); + + const second = await createInvite(sender.token); + const retry = await acceptInvite(recipient.token, second.body.token as string); + expect(retry.status).toBe(409); + expect((retry.body.error as { code: string }).code).toBe("RELATIONSHIP_BLOCKED"); + + const relationship = await SenderRecipient.findByPk(accepted.body.id as string); + expect(relationship?.relationshipStatus).toBe("blocked"); + const invitation = await RecipientInvitation.findByPk(second.body.id as string); + expect(invitation?.status).toBe("pending"); + }); +}); + +describe("GET /v1/recipients", () => { + it("lists pending invitations before acceptance and relationships after", async () => { + const sender = await createApprovedSender("sender@example.com"); + const recipient = await createAuthedUser("recipient@example.com"); + const invite = await createInvite(sender.token, { alias: "Maria · MXN" }); + + const beforeAccept = await api.request("/v1/recipients", { headers: authHeaders(sender.token) }); + const beforeBody = (await beforeAccept.json()) as { + recipients: unknown[]; + pendingInvitations: Array<{ alias: string | null; token: string | null }>; + }; + expect(beforeBody.recipients).toHaveLength(0); + expect(beforeBody.pendingInvitations).toHaveLength(1); + // The pending list carries the alias and the raw token so the sender can re-copy the link. + expect(beforeBody.pendingInvitations[0].alias).toBe("Maria · MXN"); + expect(beforeBody.pendingInvitations[0].token).toBe(invite.body.token as string); + + await acceptInvite(recipient.token, invite.body.token as string); + + const afterAccept = await api.request("/v1/recipients", { headers: authHeaders(sender.token) }); + const afterBody = (await afterAccept.json()) as { + recipients: Array<{ + relationshipStatus: string; + onboardingStatus: string; + invitation: { rail: string; alias: string | null }; + }>; + pendingInvitations: unknown[]; + }; + expect(afterBody.pendingInvitations).toHaveLength(0); + expect(afterBody.recipients).toHaveLength(1); + expect(afterBody.recipients[0].relationshipStatus).toBe("active"); + expect(afterBody.recipients[0].onboardingStatus).toBe("pending"); + expect(afterBody.recipients[0].invitation.rail).toBe("mxn"); + expect(afterBody.recipients[0].invitation.alias).toBe("Maria · MXN"); + }); + + it("hides archived relationships from the list", async () => { + const sender = await createApprovedSender("sender@example.com"); + const recipient = await createAuthedUser("recipient@example.com"); + const invite = await createInvite(sender.token); + const accepted = await acceptInvite(recipient.token, invite.body.token as string); + + await api.request(`/v1/recipients/${accepted.body.id}`, { + body: JSON.stringify({ status: "archived" }), + headers: authHeaders(sender.token), + method: "PATCH" + }); + + const response = await api.request("/v1/recipients", { headers: authHeaders(sender.token) }); + const body = (await response.json()) as { recipients: unknown[]; pendingInvitations: unknown[] }; + expect(body.recipients).toHaveLength(0); + expect(body.pendingInvitations).toHaveLength(0); + }); + + it("expires overdue pending invitations but keeps them listed as expired without a token", async () => { + const sender = await createApprovedSender("sender@example.com"); + const invite = await createInvite(sender.token); + await RecipientInvitation.update({ expiresAt: new Date(Date.now() - 1000) }, { where: { id: invite.body.id as string } }); + + const response = await api.request("/v1/recipients", { headers: authHeaders(sender.token) }); + const body = (await response.json()) as { + recipients: unknown[]; + pendingInvitations: Array<{ id: string; isExpired: boolean; token: string | null }>; + }; + // The row stays visible so the sender sees why the invite stopped working; the token is gone. + expect(body.pendingInvitations).toHaveLength(1); + expect(body.pendingInvitations[0].isExpired).toBe(true); + expect(body.pendingInvitations[0].token).toBeNull(); + + const invitation = await RecipientInvitation.findByPk(invite.body.id as string); + expect(invitation?.status).toBe("expired"); + expect(invitation?.token).toBeNull(); + }); + + it("hides an archived expired invitation", async () => { + const sender = await createApprovedSender("sender@example.com"); + const invite = await createInvite(sender.token); + await RecipientInvitation.update({ expiresAt: new Date(Date.now() - 1000) }, { where: { id: invite.body.id as string } }); + + const archive = await api.request(`/v1/recipients/invitations/${invite.body.id}`, { + body: JSON.stringify({ archived: true }), + headers: authHeaders(sender.token), + method: "PATCH" + }); + expect(archive.status).toBe(200); + + const response = await api.request("/v1/recipients", { headers: authHeaders(sender.token) }); + const body = (await response.json()) as { pendingInvitations: unknown[] }; + expect(body.pendingInvitations).toHaveLength(0); + }); + + it("returns 404 for a malformed invitation id instead of a database error", async () => { + const sender = await createApprovedSender("sender@example.com"); + const response = await api.request("/v1/recipients/invitations/not-a-uuid", { + body: JSON.stringify({ archived: true }), + headers: authHeaders(sender.token), + method: "PATCH" + }); + expect(response.status).toBe(404); + }); + + it("does not report a business recipient approved off an individual provider approval", async () => { + const sender = await createApprovedSender("sender@example.com"); + const recipient = await createAuthedUser("recipient@example.com"); + const invite = await createInvite(sender.token, { inviteeType: "business" }); + await acceptInvite(recipient.token, invite.body.token as string); + + const recipientEntity = await CustomerEntity.findOne({ where: { profileId: recipient.user.id, type: "business" } }); + if (!recipientEntity) throw new Error("recipient business entity missing"); + // Approved as an individual only — the business invite's corridor is still unonboarded, + // so the summary must agree with the eligibility gate and stay pending. + await ProviderCustomer.create({ + country: "MX", + customerEntityId: recipientEntity.id, + customerType: "individual", + provider: "alfredpay", + providerCustomerId: "alfredpay-individual-1", + rail: "mxn", + status: VerificationStatus.Approved + }); + + const response = await api.request("/v1/recipients", { headers: authHeaders(sender.token) }); + const body = (await response.json()) as { recipients: Array<{ onboardingStatus: string }> }; + expect(body.recipients).toHaveLength(1); + expect(body.recipients[0].onboardingStatus).toBe("pending"); + + await ProviderCustomer.create({ + country: "MX", + customerEntityId: recipientEntity.id, + customerType: "business", + provider: "alfredpay", + providerCustomerId: "alfredpay-business-1", + rail: "mxn", + status: VerificationStatus.Approved + }); + + const afterBusiness = await api.request("/v1/recipients", { headers: authHeaders(sender.token) }); + const afterBody = (await afterBusiness.json()) as { recipients: Array<{ onboardingStatus: string }> }; + expect(afterBody.recipients[0].onboardingStatus).toBe("approved"); + }); +}); + +describe("PATCH /v1/recipients/invitations/:id", () => { + async function archiveInvitation( + senderToken: string, + invitationId: string, + archived: unknown + ): Promise<{ status: number; body: Record }> { + const response = await api.request(`/v1/recipients/invitations/${invitationId}`, { + body: JSON.stringify({ archived }), + headers: authHeaders(senderToken), + method: "PATCH" + }); + return { body: (await response.json()) as Record, status: response.status }; + } + + it("requires authentication", async () => { + const response = await api.request("/v1/recipients/invitations/some-id", { + body: JSON.stringify({ archived: true }), + headers: { "Content-Type": "application/json" }, + method: "PATCH" + }); + expect(response.status).toBe(401); + }); + + it("archives a pending invitation out of the list without revoking its token", async () => { + const sender = await createApprovedSender("sender@example.com"); + const recipient = await createAuthedUser("recipient@example.com"); + const invite = await createInvite(sender.token); + + const archived = await archiveInvitation(sender.token, invite.body.id as string, true); + expect(archived.status).toBe(200); + expect(archived.body.archived).toBe(true); + + const list = await api.request("/v1/recipients", { headers: authHeaders(sender.token) }); + const listBody = (await list.json()) as { pendingInvitations: unknown[] }; + expect(listBody.pendingInvitations).toHaveLength(0); + + // Archive is a list hide, not a revocation: the link still redeems and KYC can proceed. + const accepted = await acceptInvite(recipient.token, invite.body.token as string); + expect(accepted.status).toBe(201); + expect(accepted.body.relationshipStatus).toBe("active"); + }); + + it("unarchives an invitation back into the list", async () => { + const sender = await createApprovedSender("sender@example.com"); + const invite = await createInvite(sender.token); + + await archiveInvitation(sender.token, invite.body.id as string, true); + const unarchived = await archiveInvitation(sender.token, invite.body.id as string, false); + expect(unarchived.status).toBe(200); + expect(unarchived.body.archived).toBe(false); + + const list = await api.request("/v1/recipients", { headers: authHeaders(sender.token) }); + const listBody = (await list.json()) as { pendingInvitations: unknown[] }; + expect(listBody.pendingInvitations).toHaveLength(1); + }); + + it("rejects a non-boolean archived flag", async () => { + const sender = await createApprovedSender("sender@example.com"); + const invite = await createInvite(sender.token); + const { status, body } = await archiveInvitation(sender.token, invite.body.id as string, "yes"); + expect(status).toBe(400); + expect((body.error as { code: string }).code).toBe("INVALID_ARCHIVED"); + }); + + it("is scoped to the sender that owns the invitation", async () => { + const sender = await createApprovedSender("sender@example.com"); + const invite = await createInvite(sender.token); + const otherSender = await createAuthedUser("other-sender@example.com"); + const { status } = await archiveInvitation(otherSender.token, invite.body.id as string, true); + expect(status).toBe(404); + }); +}); + +describe("PATCH /v1/recipients/:id", () => { + async function acceptedRelationship() { + const sender = await createApprovedSender("sender@example.com"); + const recipient = await createAuthedUser("recipient@example.com"); + const invite = await createInvite(sender.token); + const accepted = await acceptInvite(recipient.token, invite.body.token as string); + return { recipient, relationshipId: accepted.body.id as string, sender }; + } + + it("updates nickname and status", async () => { + const { sender, relationshipId } = await acceptedRelationship(); + + const response = await api.request(`/v1/recipients/${relationshipId}`, { + body: JSON.stringify({ nickname: "Mom", status: "archived" }), + headers: authHeaders(sender.token), + method: "PATCH" + }); + expect(response.status).toBe(200); + const relationship = await SenderRecipient.findByPk(relationshipId); + expect(relationship?.nickname).toBe("Mom"); + expect(relationship?.relationshipStatus).toBe("archived"); + expect(relationship?.disabledAt).not.toBeNull(); + }); + + it("rejects an invalid status", async () => { + const { sender, relationshipId } = await acceptedRelationship(); + const response = await api.request(`/v1/recipients/${relationshipId}`, { + body: JSON.stringify({ status: "invited" }), + headers: authHeaders(sender.token), + method: "PATCH" + }); + expect(response.status).toBe(400); + }); + + it("is scoped to the sender that owns the relationship", async () => { + const { relationshipId } = await acceptedRelationship(); + const otherSender = await createAuthedUser("other-sender@example.com"); + const response = await api.request(`/v1/recipients/${relationshipId}`, { + body: JSON.stringify({ nickname: "Not yours" }), + headers: authHeaders(otherSender.token), + method: "PATCH" + }); + expect(response.status).toBe(404); + }); +}); + +describe("GET /v1/recipients/:id/eligibility", () => { + async function eligibilityOf(senderToken: string, relationshipId: string): Promise> { + const response = await api.request(`/v1/recipients/${relationshipId}/eligibility`, { + headers: authHeaders(senderToken) + }); + expect(response.status).toBe(200); + return (await response.json()) as Record; + } + + it("walks the full gate: onboarding → payout reference → eligible → restricted", async () => { + const sender = await createApprovedSender("sender@example.com"); + const recipient = await createAuthedUser("recipient@example.com"); + const invite = await createInvite(sender.token); + const accepted = await acceptInvite(recipient.token, invite.body.token as string); + const relationshipId = accepted.body.id as string; + + // No provider account yet. + expect(await eligibilityOf(sender.token, relationshipId)).toEqual({ + blockingReasonCode: "recipient_onboarding_pending", + canCreateTransfer: false + }); + + // Provider onboarding in progress. + const recipientEntity = await CustomerEntity.findOne({ where: { profileId: recipient.user.id, type: "individual" } }); + if (!recipientEntity) throw new Error("recipient entity missing"); + const providerCustomer = await ProviderCustomer.create({ + country: "MX", + customerEntityId: recipientEntity.id, + customerType: "individual", + provider: "alfredpay", + providerCustomerId: "alfredpay-recipient-1", + rail: "mxn", + status: VerificationStatus.InReview + }); + expect(await eligibilityOf(sender.token, relationshipId)).toEqual({ + blockingReasonCode: "recipient_onboarding_pending", + canCreateTransfer: false + }); + + // Onboarding approved, but no verified payout reference. + await providerCustomer.update({ status: VerificationStatus.Approved }); + expect(await eligibilityOf(sender.token, relationshipId)).toEqual({ + blockingReasonCode: "provider_payout_reference_unverified", + canCreateTransfer: false + }); + + // Verified payout reference → transfers allowed. + const payoutReference = await RecipientPayoutReference.create({ + country: "MX", + currency: "mxn", + instrumentType: "clabe", + maskedDisplayLabel: "CLABE ****7895", + provider: "alfredpay", + providerInstrumentId: "fiat-account-1", + rail: "mxn", + recipientCustomerEntityId: recipientEntity.id, + senderRecipientId: relationshipId, + status: "verified" + }); + expect(await eligibilityOf(sender.token, relationshipId)).toEqual({ canCreateTransfer: true }); + + // Provider later restricts the account. + await providerCustomer.update({ status: VerificationStatus.Rejected }); + expect(await eligibilityOf(sender.token, relationshipId)).toEqual({ + blockingReasonCode: "provider_restricted", + canCreateTransfer: false + }); + + // Payout reference disabled → back to unverified even when provider recovers. + await providerCustomer.update({ status: VerificationStatus.Approved }); + await payoutReference.update({ status: "disabled" }); + expect(await eligibilityOf(sender.token, relationshipId)).toEqual({ + blockingReasonCode: "provider_payout_reference_unverified", + canCreateTransfer: false + }); + }); + + it("reports a blocked relationship as not active", async () => { + const sender = await createApprovedSender("sender@example.com"); + const recipient = await createAuthedUser("recipient@example.com"); + const invite = await createInvite(sender.token); + const accepted = await acceptInvite(recipient.token, invite.body.token as string); + const relationshipId = accepted.body.id as string; + + await api.request(`/v1/recipients/${relationshipId}`, { + body: JSON.stringify({ status: "blocked" }), + headers: authHeaders(sender.token), + method: "PATCH" + }); + + expect(await eligibilityOf(sender.token, relationshipId)).toEqual({ + blockingReasonCode: "relationship_not_active", + canCreateTransfer: false + }); + }); +}); diff --git a/apps/api/src/tests/sdk-contract.alfredpay-offramp.test.ts b/apps/api/src/tests/sdk-contract.alfredpay-offramp.test.ts index 021a38234..49f4b0954 100644 --- a/apps/api/src/tests/sdk-contract.alfredpay-offramp.test.ts +++ b/apps/api/src/tests/sdk-contract.alfredpay-offramp.test.ts @@ -16,7 +16,7 @@ import { import { BaseError, ContractFunctionExecutionError, decodeFunctionData, erc20Abi, parseTransaction, parseUnits } from "viem"; import { generatePrivateKey, privateKeyToAccount } from "viem/accounts"; import { VortexSdk } from "../../../../packages/sdk/src"; -import type AlfredPayCustomer from "../models/alfredPayCustomer.model"; +import type ProviderCustomer from "../models/providerCustomer.model"; import QuoteTicket from "../models/quoteTicket.model"; import RampState from "../models/rampState.model"; import { resetTestDatabase, setupTestDatabase } from "../test-utils/db"; @@ -198,7 +198,7 @@ describe("SDK ↔ API contract (Alfredpay offramps, USDT on Polygon → bank pay async function createUserSdk(country: AlfredPayCountry): Promise<{ sdk: VortexSdk; userId: string; - customer: AlfredPayCustomer; + customer: ProviderCustomer; }> { const user = await createTestUser(); const customer = await createTestAlfredpayCustomer(user.id, { country }); @@ -301,8 +301,8 @@ describe("SDK ↔ API contract (Alfredpay offramps, USDT on Polygon → bank pay // The frontend-SDK flow picks the payout destination from the user's // fiat accounts registered with the anchor. - world.alfredpay.fiatAccountsByCustomer.set(customer.alfredPayId, [ - { ...currency.fiatAccount, customerId: customer.alfredPayId } + world.alfredpay.fiatAccountsByCustomer.set(customer.providerCustomerId as string, [ + { ...currency.fiatAccount, customerId: customer.providerCustomerId as string } ]); const accounts = await sdk.listAlfredpayFiatAccounts(currency.country); expect(accounts).toHaveLength(1); diff --git a/apps/api/src/tests/sdk-contract.offramp.test.ts b/apps/api/src/tests/sdk-contract.offramp.test.ts index 4477a4e93..c7fc16384 100644 --- a/apps/api/src/tests/sdk-contract.offramp.test.ts +++ b/apps/api/src/tests/sdk-contract.offramp.test.ts @@ -16,11 +16,16 @@ import { import { parseUnits } from "viem"; import { generatePrivateKey, privateKeyToAccount } from "viem/accounts"; import { VortexSdk } from "../../../../packages/sdk/src"; -import Partner from "../models/partner.model"; import QuoteTicket from "../models/quoteTicket.model"; import RampState from "../models/rampState.model"; import { resetTestDatabase, setupTestDatabase } from "../test-utils/db"; -import { createTestAlfredpayCustomer, createTestApiKey, createTestTaxId, createTestUser } from "../test-utils/factories"; +import { + createTestAlfredpayCustomer, + createTestApiKey, + createTestTaxId, + createTestUser, + updatePartnerPricing +} from "../test-utils/factories"; import { type FakeWorld, installFakeWorld } from "../test-utils/fake-world"; import { startTestApp, type TestApp } from "../test-utils/test-app"; @@ -97,10 +102,7 @@ describe("SDK ↔ API contract (BRL offramp, USDC on Polygon → pix)", () => { beforeEach(async () => { await resetTestDatabase(); - await Partner.update( - { payoutAddressEvm: "0x000000000000000000000000000000000000fee5" }, - { where: { name: "vortex", rampType: RampDirection.SELL } } - ); + await updatePartnerPricing("vortex", RampDirection.SELL, { payoutAddressEvm: "0x000000000000000000000000000000000000fee5" }); world.evm.failNextSends = 0; world.evm.onTransaction = undefined; world.brla.onPixOutputTicket = undefined; @@ -319,11 +321,11 @@ describe("SDK ↔ API contract (BRL offramp, USDC on Polygon → pix)", () => { const user = await createTestUser(); const { plaintextKey } = await createTestApiKey({ userId: user.id }); const customer = await createTestAlfredpayCustomer(user.id, { country: AlfredPayCountry.MX }); - world.alfredpay.fiatAccountsByCustomer.set(customer.alfredPayId, [ + world.alfredpay.fiatAccountsByCustomer.set(customer.providerCustomerId as string, [ { accountNumber: "646180157000000004", accountType: "checking", - customerId: customer.alfredPayId, + customerId: customer.providerCustomerId as string, fiatAccountId: "fiat-account-1", type: AlfredpayFiatAccountType.SPEI } diff --git a/apps/api/src/tests/sdk-contract.test.ts b/apps/api/src/tests/sdk-contract.test.ts index 38c301b60..88a4525da 100644 --- a/apps/api/src/tests/sdk-contract.test.ts +++ b/apps/api/src/tests/sdk-contract.test.ts @@ -96,9 +96,11 @@ describe("SDK ↔ API contract (BRL onramp, pix → BRLA on Base)", () => { }); /** A KYC'd user with a user-linked secret key, and an SDK authenticated as them. */ - async function createUserSdk(): Promise<{ sdk: VortexSdk; userId: string }> { + async function createUserSdk(subAccountId?: string): Promise<{ sdk: VortexSdk; userId: string }> { const user = await createTestUser(); - await createTestTaxId(user.id); + // provider_customers enforces UNIQUE(provider, provider_subaccount_id); secondary users + // in a test must bring their own (unused) subaccount id. + await createTestTaxId(user.id, subAccountId ? { subAccountId } : {}); const { plaintextKey } = await createTestApiKey({ userId: user.id }); // storeEphemeralKeys: false keeps the SDK from writing ephemerals_.json to disk. const sdk = new VortexSdk({ apiBaseUrl: app.baseUrl, secretKey: plaintextKey, storeEphemeralKeys: false }); @@ -298,7 +300,7 @@ describe("SDK ↔ API contract (BRL onramp, pix → BRLA on Base)", () => { "a foreign user's ramp surfaces as a typed VortexSdkError with status 403", async () => { const owner = await createUserSdk(); - const stranger = await createUserSdk(); + const stranger = await createUserSdk("test-subaccount-id-stranger"); const destination = privateKeyToAccount(generatePrivateKey()).address; const quote = await owner.sdk.createQuote(quoteRequest()); diff --git a/apps/dashboard/.env.example b/apps/dashboard/.env.example new file mode 100644 index 000000000..10e83fbc3 --- /dev/null +++ b/apps/dashboard/.env.example @@ -0,0 +1,15 @@ +# Vortex API origin. Dev default (unset) is http://localhost:3000; production builds +# are served same-origin under /dashboard/, so set this only when that doesn't hold. +VITE_API_URL=http://localhost:3000 + +# WalletConnect project id from cloud.reown.com; without it the connect modal renders +# but real WalletConnect sessions won't establish. +VITE_WALLETCONNECT_PROJECT_ID= + +# Optional: used when presigning ephemeral EVM transactions via signUnsignedTransactions. +VITE_ALCHEMY_API_KEY= + +# Widget origin used for recipient onboarding links. Dev default (unset) is +# http://127.0.0.1:5173; production builds fall back to the page origin. Set this only +# when the widget is not served on the same origin as the dashboard. +VITE_WIDGET_URL= diff --git a/apps/dashboard/.gitignore b/apps/dashboard/.gitignore new file mode 100644 index 000000000..290bcd476 --- /dev/null +++ b/apps/dashboard/.gitignore @@ -0,0 +1,7 @@ +node_modules +dist +.output +.nitro +.tanstack +dev-boot.log +*.log diff --git a/apps/dashboard/components.json b/apps/dashboard/components.json new file mode 100644 index 000000000..3846d54d8 --- /dev/null +++ b/apps/dashboard/components.json @@ -0,0 +1,21 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "aliases": { + "components": "@/components", + "hooks": "@/hooks", + "lib": "@/lib", + "ui": "@/components/ui", + "utils": "@/lib/cn" + }, + "iconLibrary": "lucide", + "rsc": false, + "style": "new-york", + "tailwind": { + "baseColor": "neutral", + "config": "", + "css": "src/App.css", + "cssVariables": true, + "prefix": "" + }, + "tsx": true +} diff --git a/apps/dashboard/docs/full-scope-mock-build-plan.md b/apps/dashboard/docs/full-scope-mock-build-plan.md new file mode 100644 index 000000000..437feb1f4 --- /dev/null +++ b/apps/dashboard/docs/full-scope-mock-build-plan.md @@ -0,0 +1,143 @@ +# Vortex Dashboard — Full-Scope Mock Build Plan + +Plan to bring the mocked dashboard app in line with the full product brief +(sender onboarding → recipient KYC/KYB invite → wallet-to-fiat payout). + +This app is a **standalone, fully-mocked** React app (no API). All flows are +simulated client-side via Zustand stores + XState machines + timers. + +Stack: React 19, TanStack Router, XState 5, Zustand (persist), shadcn/ui, +Tailwind v4, react-hook-form + zod, sonner. + +--- + +## Workstream 1 — Domain & data model + +`src/domain/` + +1. **Routing method.** Add `OnboardingRoute = "headless" | "google_form" | "redirect"`. + Add `routeFor(corridorId, kind)` helper implementing the §2 matrix: + - `US` → `redirect` + - `EU` + `kyb` → `google_form` + - else → `headless` +2. **Make all 6 corridors onboardable.** Drop the `coming_soon` gate for + onboarding (or repurpose it). Remove the `supportsKyb`-only-for-BR gate; + `kind = accountType === "company" ? "kyb" : "kyc"` for every country. +3. **Recipient model (see Decision Q1).** Target shape per brief: + `{ id, accountId, email, recipientType: AccountType, corridorId, + amount, payoutCurrency, bankDetails: { method, value... }, status, + createdAt }`. +4. **Recipient status model.** Expand `RecipientStatus` to + `"invite_sent" | "pending" | "approved" | "rejected"`. +5. **Transaction model.** Rework to payout-centric per §8: + `{ id, accountId, recipientId, payinWallet, payinNetwork, amountIn, + amountInToken, fiatPayoutAmount, payoutCurrency, corridorId, + status, createdAt }`. + Add `TransactionStatus = "awaiting_payin" | "processing" | "completed" | "failed"`. +6. Update `STATUS_META` / `TX_STATUS_META` for the new statuses. + +## Workstream 2 — Sender onboarding (account type + routing) + +`routes/_app/overview.tsx` → rename to **Onboarding**; `components/onboarding/` + +1. **Account type selection.** On first login (empty account) prompt + Individual vs Company before/with country selection. Persist on + `SenderAccount.type`. Allow change while no onboardings exist. +2. **Generic headless machines.** Replace the 3 bespoke machines with **2 + config-driven machines** (`headlessKyc`, `headlessKyb`) parameterized by a + per-country step config map (`onboardingSteps[corridorId][kind]`). Covers + BR/EU/CO/MX/AR KYC and BR/CO/MX/AR KYB. +3. **Google Form route** (EU company KYB). `OnboardingWizard` branches on + `routeFor`: render a card with an "Open Google Form" external link + + "I've submitted the form" → status `pending` → (timer) `approved`. +4. **Redirect route** (USA). Render a "You'll be redirected to our partner" + screen → button simulates redirect (mock partner screen / new tab) → + returns `pending` → (timer) `approved`. +5. `CorridorCard` actions already cover not_started/pending/in_review/approved/ + rejected — keep; wire the three route branches into the action handler. +6. `AddCorridorDropdown` — list all 6, no "Soon" gating. + +## Workstream 3 — Recipients + +`routes/_app/recipients.tsx`; `components/recipients/` + +1. **Rich invite form** (Decision Q1) — fields: email, recipient type + (individual/company), country, amount, payout currency (derived from + country, editable if needed), bank payout details (method-specific input: + PIX key / IBAN / CLABE / ACH routing+account, driven by + `corridor.recipientMethod`). +2. **4-status model + actions** per §6: + - `invite_sent` → Resend invite · View + - `pending` → View (transfer blocked) + - `approved` → Create transfer + - `rejected` → Retry · View (transfer blocked) +3. **Recipient onboarding simulation** (Decision Q2) — route the invited + recipient through widget/Google-Form/redirect per the same matrix, ending + in pending → approved/rejected. +4. `RecipientsTable` — columns: Recipient (email), Type, Country, Amount, + Payout currency, Status, Added. Compliance status only (no payment status). + +## Workstream 4 — New transfer (Privy payin) + +`routes/_app/transfer.tsx`; `components/transfer/TransferForm.tsx` + +1. **Recipient-driven.** Select an **approved** recipient (only approved are + selectable — blocking rule §7; others shown disabled with reason). +2. Show read-only **amount + bank payout details** captured at recipient + creation (no amount input here). +3. **Privy wallet payin mock.** "Create / use Vortex wallet" → show a + generated deposit **address** + network + payin instructions (static mock + address; no real Privy). +4. "I've sent the payin" → create transaction `awaiting_payin` → + (timer) `processing` → `completed`; navigate to Transactions. + +## Workstream 5 — Transactions + +`routes/_app/transactions.tsx`; `components/transactions/TransactionsTable.tsx` + +1. Drop the onramp/offramp tabs (brief transactions are payout-centric). +2. Columns per §8: Created at · Recipient · Payin wallet (shortened addr) · + Amount in · Fiat payout amount · Country/currency · Status. +3. Status badges: Awaiting payin · Processing · Completed · Failed. + +## Workstream 6 — Settings & nav + +1. Sidebar/nav: label first tab **Onboarding**. +2. Settings — keep read-only profile + accounts; add basic notification + toggle stub if cheap (per §3 "notifications, basic workspace settings"). + +## Workstream 7 — Seed data refresh + +`src/domain/seed.ts` + +- Refresh seed accounts to include an Individual example and multi-country + onboarding (EU + BR + MX) in mixed statuses. +- Seed recipients with the rich shape across statuses (invite_sent/pending/ + approved/rejected). +- Seed transactions with the new payout-centric shape across all 4 statuses. +- Bump `dashboard.store` persist version (→ v4) so the new shapes migrate. + +--- + +## Decisions (locked) + +- **Q1 — Recipient form richness → RICH FORM (follow brief).** Sender captures + email, recipient type, country, amount, payout currency, and method-specific + bank details. Reverts the Jun-24 email-only simplification. +- **Q2 — Recipient-side onboarding mock → TIMER AUTO-ADVANCE.** No recipient- + facing screens; status advances on a timer + (`invite_sent → pending → approved`), matching the existing pattern. No + `/invite` route built. +- **Q3 — Country scope → ALL 6 (BR, EU, CO, MX, AR, US).** Full §2 matrix: + headless for BR/EU/CO/MX/AR KYC + BR/CO/MX/AR KYB, Google Form for EU company + KYB, redirect for US. `coming_soon` no longer gates onboarding. + +## Suggested build order + +1. WS1 domain types + seed scaffolding (unblocks everything). +2. WS2 onboarding (account type + routing matrix + generic machines). +3. WS3 recipients (rich form + statuses + sim). +4. WS4 transfer (Privy payin). +5. WS5 transactions. +6. WS6 nav/settings polish. +7. Lint, typecheck, visual pass per `figma-design-system.md`. diff --git a/apps/dashboard/docs/registration-and-country-selection-plan.md b/apps/dashboard/docs/registration-and-country-selection-plan.md new file mode 100644 index 000000000..235a11be9 --- /dev/null +++ b/apps/dashboard/docs/registration-and-country-selection-plan.md @@ -0,0 +1,207 @@ +# Plan — Registration + Country-of-Interest Selection (Vortex Dashboard) + +> Status: **PLAN ONLY — not implemented.** Scope addition to the existing mocked +> `apps/dashboard`. This document is the spec to implement later. + +## 1. Goal / User story + +> As a new Vortex user, I want to **register for the service** and **choose which +> countries/corridors I'm interested in**. The dashboard then shows only those +> corridors for verification, and I can **add the remaining corridors later from a +> dropdown**. + +This builds on the existing dashboard (fake login, account switcher with 2 seeded +accounts, Brazil/Europe corridor cards, XState KYB/KYC wizards, recipients gated by +approval, notifications). + +## 2. Decisions locked during grilling + +| Decision | Choice | +|---|---| +| Entry routes | Two dedicated routes: **`/register`** and **`/login`** (cross-linked). | +| What register produces | Creates a **new sender account**, added to the switcher and made active. The 2 seeded demo accounts **remain**. | +| Auth UX | **Mirror the Vortex Widget**: email + Terms checkbox → OTP (6-digit) → authenticated. Same for KYB/KYC (already mirrored). | +| Corridor catalog | **Brazil + Europe are the only working corridors.** Alfredpay corridors (Mexico, Colombia, USA, Argentina) appear as **"Coming soon"** (selectable for interest, locked in dashboard). | +| Country selection | Chosen during `/register`; dashboard shows only selected corridors; the rest are addable from an **"Add country" dropdown**. | +| State management | XState v5 for the auth flow (consistent with the widget and existing dashboard wizards); Zustand for stored data. | + +## 3. What "mirror the Vortex Widget" means (reference) + +Source flow in `apps/frontend` (the widget), to replicate **as a mock** (no real +Supabase / no real API — accept any email, any 6-digit code): + +``` +EnterEmail [AuthEmailStep] email + Terms & Conditions checkbox → ENTER_EMAIL + → CheckingEmail (mock: always proceed) + → RequestingOTP (mock: pretend to send code) + → EnterOTP [AuthOTPStep] 6-digit InputOTP, auto-submits on 6th digit → VERIFY_OTP + → VerifyingOTP (mock: any code accepted) + → authenticated (store mock token in localStorage) +``` + +Widget reference files (for UX parity only — do **not** import from the frontend app): +- `apps/frontend/src/components/widget-steps/AuthEmailStep/index.tsx` (email + T&C) +- `apps/frontend/src/components/widget-steps/AuthOTPStep/index.tsx` (6-digit `InputOTP`, auto-submit, "we sent a code to ") +- `apps/frontend/src/components/widget-steps/RegionSelectStep/index.tsx` (region `DropdownSelector`) +- `apps/frontend/src/machines/ramp.machine.ts` (auth states embedded), `src/machines/actors/auth.actor.ts` +- Provider routing: `src/machines/kyc.states.ts` (BRL→Avenia, EURC→Mykobo, ARS/USD/MXN/COP→Alfredpay) + +Mock parity notes: +- Email step requires checking the **Terms & Conditions** box before continuing. +- OTP step uses a **6-digit numeric `InputOTP`** that **auto-submits** when full; shows + the target email; offers "Change email" and "Resend code" (both no-op/mock). +- Tokens stored in `localStorage` (reuse the existing persisted auth store). + +## 4. Corridor catalog change + +Today corridors come from a fixed `CORRIDOR_LIST` of 2 (BR, EU). Expand the **catalog** +to 6, matching the real `FiatToken` set, with an availability flag. + +| Corridor | Country | Currency | Provider | Availability | +|---|---|---|---|---| +| `BR` | Brazil | BRL | Avenia | **live** | +| `EU` | Europe | EURC | Mykobo | **live** | +| `MX` | Mexico | MXN | Alfredpay | coming_soon | +| `CO` | Colombia | COP | Alfredpay | coming_soon | +| `US` | USA | USD | Alfredpay | coming_soon | +| `AR` | Argentina | ARS | Alfredpay | coming_soon | + +- Add `availability: "live" | "coming_soon"` to the `Corridor` type. +- Only `live` corridors run the XState wizards. `coming_soon` corridors render a + locked card (badge "Coming soon", no Start button, disabled in the wizard). +- `coming_soon` corridors **can still be selected** at registration / added later + (the user expresses interest); they simply can't be verified yet. + +## 5. Data model changes + +### `Corridor` (`src/domain/types.ts` + `corridors.ts`) +- Add `availability: "live" | "coming_soon"`. +- Add the 4 Alfredpay corridors to `CORRIDORS` and `CORRIDOR_LIST`. + +### `SenderAccount` (`src/domain/types.ts`) +- Add `selectedCorridors: CorridorId[]` — the corridors the account chose to track. +- `onboardings` becomes **partial**: `Partial>`, + populated only for selected corridors. (Adding a country creates its onboarding.) +- Helper: when a corridor is selected, its onboarding initializes to + `not_started` (live) — coming_soon corridors show a derived "Coming soon" state. + +### Seed (`src/domain/seed.ts`) +- Give the 2 seeded accounts a `selectedCorridors: ["BR", "EU"]` so existing demo + is unchanged. + +### New status surface +- Either add a derived display state `"coming_soon"` in `STATUS_META` / `StatusBadge`, + or compute it from `corridor.availability` at render. Recommended: compute from + `corridor.availability` (don't pollute the onboarding status enum, which mirrors + real provider enums). + +## 6. New routes & flow + +### `/register` (outside the app shell, like `/login`) +A multi-step flow (XState `registerMachine`), mirroring the widget: + +1. **Account details** — name, email, account type (company / individual), + **Terms & Conditions** checkbox. (RHF + Zod.) +2. **OTP** — 6-digit `InputOTP`, auto-submit, mock-accept any code. "Change email" / + "Resend". +3. **Choose countries** — multi-select grid/list over the 6-corridor catalog: + - Brazil & Europe tagged **Available**; the 4 Alfredpay corridors tagged **Coming soon**. + - At least one selection required (recommend defaulting Brazil + Europe checked). +4. **Finish** → `useDashboardStore.createAccount({...})`: + - creates a new `SenderAccount` with `selectedCorridors`, account type, name, identifier; + - initializes onboardings (`not_started` for selected live corridors); + - sets it active; authenticates (auth store `login(email)`); navigates to `/overview`. + +### `/login` (existing, refactored to mirror widget) +- Step 1: email + Terms checkbox. +- Step 2: OTP (6-digit, auto-submit, mock). +- → `/overview` (seeded demo accounts). +- Add a "Don't have an account? **Register**" link; `/register` gets "Already have an + account? **Log in**". + +### Route guards +- `_app` layout already redirects to `/login` when unauthenticated — unchanged. +- `/register` and `/login`: if already authenticated, ``. + +## 7. Dashboard changes (Overview) + +- **Filter corridor cards to `activeAccount.selectedCorridors`** (instead of the full + `CORRIDOR_LIST`). +- **"Add country" dropdown** (top-right of the corridors section): lists catalog + corridors **not** in `selectedCorridors`. Selecting one calls + `dashboardStore.addCorridorToAccount(accountId, corridorId)` → appends to + `selectedCorridors` + creates its onboarding → card appears. + - Live corridors appear startable; coming_soon corridors appear locked. +- **Coming-soon card**: badge "Coming soon", greyed progress, button disabled + ("Available soon"). +- Summary cards: count over selected corridors (Corridors / Approved / In progress); + optionally a 4th "Coming soon" count. +- Recipients gating unchanged (only **approved live** corridors unlock recipients). + +## 8. Store changes (`src/stores/dashboard.store.ts`) +- `createAccount(input: { name; email; type; selectedCorridors })`: builds a + `SenderAccount`, pushes to `accounts`, sets `activeAccountId`, returns id. +- `addCorridorToAccount(accountId, corridorId)`: adds to `selectedCorridors` and seeds + its `Onboarding` (`not_started`). No-op if already present. +- Existing `setOnboardingStatus` / recipient actions unchanged (guard for partial + onboardings). +- Consider persisting accounts to `localStorage` so a registered account survives a + reload (today the dashboard store is in-memory; auth persists but accounts don't — + a full reload currently resets to seeds). **Decision needed** (see open questions). + +## 9. New / changed files (estimate) + +**New** +- `src/routes/register.tsx` — `/register` route hosting the register flow. +- `src/machines/register.machine.ts` — XState v5 (details → otp → countries → done). +- `src/machines/auth.machine.ts` *(optional)* — shared email→OTP sub-flow reused by + `/login` and `/register`. +- `src/components/auth/AuthEmailStep.tsx` — email + T&C (mirrors widget). +- `src/components/auth/AuthOtpStep.tsx` — 6-digit OTP (needs an `InputOTP` ui component). +- `src/components/auth/CountrySelectStep.tsx` — catalog multi-select. +- `src/components/onboarding/AddCorridorDropdown.tsx` — "Add country" dropdown. +- `src/components/ui/input-otp.tsx` — shadcn `InputOTP` (uses `input-otp` dep — add it). + +**Changed** +- `src/domain/types.ts` — `Corridor.availability`, `SenderAccount.selectedCorridors`, partial onboardings. +- `src/domain/corridors.ts` — add MX/CO/US/AR + availability + flags. +- `src/domain/seed.ts` — `selectedCorridors` on seeded accounts. +- `src/domain/status.ts` / `StatusBadge.tsx` — coming-soon rendering. +- `src/stores/dashboard.store.ts` — `createAccount`, `addCorridorToAccount`. +- `src/routes/login.tsx` — refactor to email→OTP, add register link. +- `src/routes/_app/overview.tsx` — filter by selectedCorridors + Add-country dropdown. +- `src/components/onboarding/CorridorCard.tsx` — coming-soon variant. +- `package.json` — add `input-otp` (and `@radix-ui`? no — `input-otp` is standalone). + +## 10. Dependencies +- Add **`input-otp`** (used by the widget for the 6-digit code) — `bun add input-otp -F vortex-dashboard`. + +## 11. Edge cases / rules +- Register requires ≥1 selected country and the Terms box checked. +- OTP mock: accept any 6 digits; "Resend" is a no-op toast. +- Adding a country already selected is a no-op. +- Coming-soon corridors: never startable, never unlock recipients, excluded from + "Approved/In progress" counts (or shown separately). +- Switching accounts shows that account's `selectedCorridors` only. +- A registered account with no live corridors selected → Recipients stays locked. + +## 12. Verification (when implemented) +- `bun typecheck`, `bun lint:fix` clean; `vite build` + prerender pass. +- Live browser walk-through: + 1. `/register` → details + T&C → OTP → select Brazil + Mexico → land on dashboard + showing Brazil (startable) + Mexico (coming soon); new account active in switcher. + 2. "Add country" → add Europe → card appears. + 3. Start Brazil KYC → approve → recipients unlock. + 4. `/login` (email→OTP) → lands on seeded demo accounts. + +## 13. Open questions (resolve before building) +1. **Persist registered accounts to localStorage?** (so they survive reload like auth + does). Recommended: yes, persist `accounts` + `activeAccountId` + `recipients` so the + registered account isn't lost on refresh. Trade-off: seeded demo edits also persist. +2. **`/login` Terms checkbox** — widget shows T&C on the email step always; for a + returning-user login it's arguably redundant. Recommended: show T&C only on + `/register`, plain email on `/login`. +3. **Country selection minimum** — force Brazil+Europe preselected, or start empty? + Recommended: preselect Brazil + Europe (the live corridors), allow deselect. +4. **Coming-soon in summary counts** — separate "Coming soon" stat card, or hide from + counts? Recommended: separate stat. diff --git a/apps/dashboard/e2e/account-type-selection.spec.ts b/apps/dashboard/e2e/account-type-selection.spec.ts new file mode 100644 index 000000000..ef4d44173 --- /dev/null +++ b/apps/dashboard/e2e/account-type-selection.spec.ts @@ -0,0 +1,18 @@ +import { expect, test } from "@playwright/test"; +import { mockBackend } from "./support/mockBackend"; +import { seedSession } from "./support/session"; + +test("a new dashboard user selects and persists a company sender entity", async ({ page }) => { + await mockBackend(page, { selectionRequired: true }); + await seedSession(page); + await page.goto("/overview"); + + await expect(page.getByRole("heading", { name: "How will you use Vortex?" })).toBeVisible(); + await page.getByRole("button", { name: "Continue as company" }).click(); + await expect(page.getByRole("heading", { name: "Onboarding" })).toBeVisible(); + await expect(page.getByText("No corridors added yet")).toBeVisible(); + + await page.reload(); + await expect(page.getByRole("heading", { name: "Onboarding" })).toBeVisible(); + await expect(page.getByRole("heading", { name: "How will you use Vortex?" })).toBeHidden(); +}); diff --git a/apps/dashboard/e2e/auth-gate.spec.ts b/apps/dashboard/e2e/auth-gate.spec.ts new file mode 100644 index 000000000..11b172d7a --- /dev/null +++ b/apps/dashboard/e2e/auth-gate.spec.ts @@ -0,0 +1,58 @@ +import { expect, test } from "@playwright/test"; +import { mockBackend } from "./support/mockBackend"; +import { seedSession } from "./support/session"; + +// The _app layout route gates every page behind a session (src/routes/_app.tsx), and /login +// bounces an already-authenticated user back out (src/routes/login.tsx). + +test("the local server root redirects into the dashboard", async ({ page }) => { + await page.goto("/"); + + await expect(page).toHaveURL(/\/login/, { timeout: 20_000 }); + await expect(page.getByText("Connect with Vortex")).toBeVisible(); +}); + +test("an unauthenticated deep link redirects to the login page", async ({ page }) => { + await mockBackend(page); + + await page.goto("/transfer"); + + await expect(page).toHaveURL(/\/login/, { timeout: 20_000 }); + await expect(page.getByText("Connect with Vortex")).toBeVisible(); +}); + +test("a seeded session renders the app shell instead of redirecting", async ({ page }) => { + const backend = await mockBackend(page); + await seedSession(page); + + await page.goto("/overview"); + + await expect(page.getByRole("heading", { name: "Onboarding" })).toBeVisible({ timeout: 20_000 }); + await expect(page.getByRole("link", { name: "New transfer" })).toBeVisible(); + await expect(page).toHaveURL(/\/overview/); + + // Nothing reached for an API route the mock does not serve, and nothing escaped to an + // unblocked external origin. + expect(backend.unmatchedRequests).toEqual([]); + expect(backend.unexpectedExternalRequests).toEqual([]); +}); + +test("a started onboarding account renders without crashing the overview", async ({ page }) => { + await mockBackend(page, { onboardingState: "started" }); + await seedSession(page); + + await page.goto("/overview"); + + await expect(page.getByText("Started", { exact: true })).toBeVisible({ timeout: 20_000 }); + // A started account is pre-submission and resumable — the card offers an enabled Continue action. + await expect(page.getByRole("button", { name: "Continue KYC" })).toBeEnabled(); +}); + +test("an authenticated user is redirected away from the login page", async ({ page }) => { + await mockBackend(page); + await seedSession(page); + + await page.goto("/login"); + + await expect(page).toHaveURL(/\/overview/, { timeout: 20_000 }); +}); diff --git a/apps/dashboard/e2e/funding-gate.spec.ts b/apps/dashboard/e2e/funding-gate.spec.ts new file mode 100644 index 000000000..ee4a82ce9 --- /dev/null +++ b/apps/dashboard/e2e/funding-gate.spec.ts @@ -0,0 +1,29 @@ +import { expect, test } from "@playwright/test"; +import { mockBackend } from "./support/mockBackend"; +import { injectMockWallet } from "./support/mockWallet"; +import { seedSession } from "./support/session"; + +// Self-custodial crypto deposits are not supported, and self-serve transfers are enabled per +// account on request — so the funding panel offers the connected wallet only, with its submit +// button commented out. This pins that gate; the money-path journeys in transfer-mxn-journey.spec.ts +// are skipped until the button returns. +test("funding panel is gated: no crypto tab, no submit button, support note shown", async ({ page }) => { + await mockBackend(page); + await injectMockWallet(page, { chainIdHex: "0x89" }); + await seedSession(page); + + await page.goto("/transfer"); + + const amountInput = page.locator("#payout-amount"); + await expect(amountInput).toBeVisible({ timeout: 20_000 }); + await amountInput.fill("1000"); + + // The quote lands and the funding panel renders the connected wallet. + await expect(page.getByText("How you'll fund this")).toBeVisible({ timeout: 20_000 }); + await expect(page.getByText("Connected")).toBeVisible({ timeout: 20_000 }); + + await expect(page.getByText("Send crypto")).toHaveCount(0); + await expect(page.getByRole("button", { name: /^Send/ })).toHaveCount(0); + await expect(page.getByText(/reach out to/)).toBeVisible(); + await expect(page.getByRole("link", { name: "support@vortexfinance.co" })).toBeVisible(); +}); diff --git a/apps/dashboard/e2e/login.spec.ts b/apps/dashboard/e2e/login.spec.ts new file mode 100644 index 000000000..e8d206ea3 --- /dev/null +++ b/apps/dashboard/e2e/login.spec.ts @@ -0,0 +1,64 @@ +import { expect, test } from "@playwright/test"; +import { mockBackend } from "./support/mockBackend"; +import { E2E_USER_ID, SESSION_KEYS } from "./support/session"; + +const EMAIL = "e2e@vortexfinance.co"; +const OTP_CODE = "123456"; + +// The dashboard's only unauthenticated entry point: email -> OTP -> /overview, against the +// mocked /v1/auth/* endpoints. Every other spec skips this by seeding the session directly +// (e2e/support/session.ts), so the session this flow writes to localStorage is asserted here. +test("login: email, OTP, lands on the overview with a stored session", async ({ page }) => { + const backend = await mockBackend(page); + + await page.goto("/login"); + await expect(page.getByText("Connect with Vortex")).toBeVisible(); + + await page.getByLabel("Email").fill(EMAIL); + await page.getByRole("button", { name: "Continue" }).click(); + + // The OTP step replaces the email form in the same card. + await expect(page.getByText("Verify your email")).toBeVisible({ timeout: 20_000 }); + + // input-otp renders a single hidden input; entering the 6th digit fires onComplete, which + // submits without a click (AuthOtpStep wires onComplete={onVerify}). + await page.locator('input[autocomplete="one-time-code"]').pressSequentially(OTP_CODE); + + await expect(page).toHaveURL(/\/overview/, { timeout: 20_000 }); + await expect(page.getByRole("heading", { name: "Onboarding" })).toBeVisible(); + + expect(backend.requestOtpRequests).toEqual([{ email: EMAIL }]); + expect(backend.verifyOtpRequests).toEqual([{ email: EMAIL, token: OTP_CODE }]); + + // The session the rest of the suite seeds by hand is the one this flow actually writes. + const session = await page.evaluate( + keys => ({ + accessToken: localStorage.getItem(keys.accessToken), + userEmail: localStorage.getItem(keys.userEmail), + userId: localStorage.getItem(keys.userId) + }), + SESSION_KEYS + ); + expect(session).toEqual({ accessToken: "e2e-access-token", userEmail: EMAIL, userId: E2E_USER_ID }); +}); + +test("login: a rejected OTP surfaces an error and keeps the user signed out", async ({ page }) => { + await mockBackend(page, { + verifyOtp: () => ({ body: { error: "Invalid or expired code" }, status: 401 }) + }); + + await page.goto("/login"); + await page.getByLabel("Email").fill(EMAIL); + await page.getByRole("button", { name: "Continue" }).click(); + + await expect(page.getByText("Verify your email")).toBeVisible({ timeout: 20_000 }); + await page.locator('input[autocomplete="one-time-code"]').pressSequentially(OTP_CODE); + + // The toast carries the backend's message; the route gate keeps us on /login. + await expect(page.getByText("Verification failed")).toBeVisible({ timeout: 20_000 }); + await expect(page.getByText("Invalid or expired code")).toBeVisible(); + await expect(page).toHaveURL(/\/login/); + + const accessToken = await page.evaluate(key => localStorage.getItem(key), SESSION_KEYS.accessToken); + expect(accessToken).toBeNull(); +}); diff --git a/apps/dashboard/e2e/onboarding-alfredpay-mxn-kyb.spec.ts b/apps/dashboard/e2e/onboarding-alfredpay-mxn-kyb.spec.ts new file mode 100644 index 000000000..cff33a401 --- /dev/null +++ b/apps/dashboard/e2e/onboarding-alfredpay-mxn-kyb.spec.ts @@ -0,0 +1,102 @@ +import { expect, test } from "@playwright/test"; +import { mockBackend } from "./support/mockBackend"; +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 ({ + page +}) => { + const backend = 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(); + const wizard = page.getByRole("dialog"); + await expect(wizard.getByText("We'll create your Mexico business verification profile")).toBeVisible({ timeout: 20_000 }); + await wizard.getByRole("button", { name: "Continue" }).click(); + + await expect(page.locator('input[name="businessName"]')).toBeVisible({ timeout: 20_000 }); + await page.locator('input[name="businessName"]').fill("Vortex Mexico SA de CV"); + await page.locator('input[name="taxId"]').fill("VME260714AB1"); + await page.locator('input[name="website"]').fill("https://vortexfinance.co"); + await page.locator('input[name="address"]').fill("Paseo de la Reforma 100"); + await page.locator('input[name="city"]').fill("Ciudad de Mexico"); + await page.locator('input[name="state"]').fill("CDMX"); + await page.locator('input[name="zipCode"]').fill("06600"); + await page.locator('input[name="repFirstName"]').fill("Maria"); + await page.locator('input[name="repLastName"]').fill("Gomez"); + await page.locator('input[name="repDateOfBirth"]').fill("1990-05-20"); + await page.locator('input[name="repDni"]').fill("GOMM900520MDFXYZ01"); + await wizard.getByRole("button", { name: "Continue" }).click(); + + // Alfredpay's compliance questionnaire — it rejects the submission without these (110002). + await expect(wizard.getByText("Compliance questionnaire")).toBeVisible({ timeout: 20_000 }); + await page.locator('input[name="sourceOfFunds"]').fill("Sale of goods/services"); + await page.locator('input[name="businessActivities"]').fill("Cross-border payments software"); + await page.locator('input[name="accountPurpose"]').fill("Treasury management"); + await page.locator('input[name="walletAddresses"]').fill("N/A"); + await page.locator('input[name="expectedMonthlyVolumeUsd"]').fill("50000"); + await page.locator('input[name="expectedMonthlyTransactions"]').fill("120"); + await wizard.getByRole("button", { name: "Continue" }).click(); + + // Six on the unregulated branch: the four company documents plus the representative's ID pair. + await expect(wizard.getByText("All six files are required")).toBeVisible({ timeout: 20_000 }); + const fileInputs = page.locator('input[type="file"]'); + await expect(fileInputs).toHaveCount(6); + for (let index = 0; index < 6; index++) { + await fileInputs.nth(index).setInputFiles({ ...documentFile, name: `document-${index + 1}.pdf` }); + } + await wizard.getByRole("button", { name: "Submit documents" }).click(); + + await expect(wizard.getByText("Alfredpay is reviewing your submission")).toBeVisible({ timeout: 20_000 }); + expect(backend.kybFormSubmissions).toEqual([ + expect.objectContaining({ + accountPurpose: "Treasury management", + businessActivities: "Cross-border payments software", + businessName: "Vortex Mexico SA de CV", + country: "MX", + expectedMonthlyTransactions: 120, + expectedMonthlyVolumeUsd: 50000, + isRegulatedBusiness: false, + operatesInSanctionedCountries: false, + relatedPersons: [ + expect.objectContaining({ + email: "e2e@vortexfinance.co", + firstName: "Maria", + lastName: "Gomez", + nationalities: ["MX"], + pep: false + }) + ], + sourceOfFunds: "Sale of goods/services", + taxId: "VME260714AB1", + transmitsCustomerFunds: false, + walletAddresses: "N/A" + }) + ]); + // The questionnaire's conditionals stay off the wire while their triggers are false. + const submitted = backend.kybFormSubmissions[0] as Record; + expect(submitted).not.toHaveProperty("conductsComplianceScreening"); + expect(submitted).not.toHaveProperty("complianceScreeningDescription"); + expect(backend.kybUploads).toEqual({ businessFiles: 4, relatedPersonFiles: 2 }); + // Alfredpay keys each document by fileType and rejects an unknown value, so the names matter as + // much as the count. Unregulated: no businessLicense/uploadAmlPolicy. + expect(backend.kybFileTypes).toEqual(["taxIdDocument", "articlesIncorporation", "proofAddress", "shareholderRegistry"]); + expect(backend.kybRelatedPersonFileTypes).toEqual(["docFront", "docBack"]); + // Finalization is a separate call from the polling below; assert it actually happened. + expect(backend.kyc.submitted).toBe(true); + + backend.kyc.approved = true; + await expect(wizard.getByText("Mexico KYB is complete")).toBeVisible({ timeout: 15_000 }); + await expect(wizard.getByRole("button", { name: "Done" })).toBeVisible(); + expect(backend.unmatchedRequests).toEqual([]); + expect(backend.unexpectedExternalRequests).toEqual([]); +}); diff --git a/apps/dashboard/e2e/onboarding-alfredpay-mxn.spec.ts b/apps/dashboard/e2e/onboarding-alfredpay-mxn.spec.ts new file mode 100644 index 000000000..b2790a9e2 --- /dev/null +++ b/apps/dashboard/e2e/onboarding-alfredpay-mxn.spec.ts @@ -0,0 +1,147 @@ +import { expect, type Locator, type Page, test } from "@playwright/test"; +import { mockBackend } from "./support/mockBackend"; +import { seedSession } from "./support/session"; + +// A document file — the drop zone only checks mimeType and size, not the bytes. +const idDocument = { buffer: Buffer.from("e2e-id-document"), mimeType: "image/png", name: "id.png" }; + +/** + * Real Alfredpay MX individual KYC, driven end to end: add the corridor, create the customer, + * fill the form, upload the ID documents, and land on the "In review" screen where the machine + * is polling for the outcome. Returns the open wizard dialog. Login is seeded (the OTP flow is + * covered by login.spec.ts); the KYC machine's own logic is unit-tested in packages/kyc, so this + * exercises the dashboard's wiring around it. + */ +async function driveToInReview(page: Page): Promise { + await seedSession(page); + await page.goto("/overview"); + + // Add the Mexico corridor (onboarding status starts empty, so nothing is pre-added). + 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(); + + // Start KYC → the machine checks status (404, no customer) → customer-definition screen. + await page.getByRole("button", { name: "Start KYC" }).click(); + const wizard = page.getByRole("dialog"); + await expect(wizard.getByText("We'll create your Mexico individual verification profile")).toBeVisible({ timeout: 20_000 }); + await wizard.getByRole("button", { name: "Continue" }).click(); + + // Customer created → the MX KYC form. + await expect(page.locator('input[name="firstName"]')).toBeVisible({ timeout: 20_000 }); + 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"); + 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"); + await page.locator('input[name="zipCode"]').fill("06600"); + await wizard.getByRole("button", { name: "Continue" }).click(); + + // Information submitted → the document upload screen (MX needs front + back, no selfie). + await expect(wizard.getByText("Identity document")).toBeVisible({ timeout: 20_000 }); + const fileInputs = page.locator('input[type="file"]'); + await fileInputs.nth(0).setInputFiles({ ...idDocument, name: "front.png" }); + await fileInputs.nth(1).setInputFiles({ ...idDocument, name: "back.png" }); + const submit = wizard.getByRole("button", { name: "Submit documents" }); + await expect(submit).toBeEnabled(); + await submit.click(); + + // Files uploaded + submission sent → the machine is polling for the review outcome. + await expect(wizard.getByText("Alfredpay is reviewing your submission")).toBeVisible({ timeout: 20_000 }); + return wizard; +} + +test("Alfredpay MX KYC: approval arrives while the wizard stays open", async ({ page }) => { + const backend = await mockBackend(page, { alfredpayKyc: {} }); + const wizard = await driveToInReview(page); + + await expect(wizard.getByRole("button", { name: "Continue in background" })).toBeVisible(); + expect(backend.kycFormSubmissions).toHaveLength(1); + expect(backend.kycFormSubmissions[0]).toMatchObject({ country: "MX", firstName: "Maria", lastName: "Gomez" }); + + // The provider approves — the machine's next poll picks it up and the wizard advances itself. + backend.kyc.approved = true; + await expect(wizard.getByText("You can now register recipients")).toBeVisible({ timeout: 15_000 }); + await expect(wizard.getByRole("button", { name: "Done" })).toBeVisible(); + // The real flow fires the completion notification (the mocked flows don't reach it). + await expect(page.getByText("Mexico KYC approved")).toBeVisible(); + + expect(backend.unmatchedRequests).toEqual([]); + expect(backend.unexpectedExternalRequests).toEqual([]); +}); + +test("Alfredpay MX KYC: continuing in the background, the card reacts to approval without a reload", async ({ page }) => { + const backend = await mockBackend(page, { alfredpayKyc: {} }); + const wizard = await driveToInReview(page); + + // Leave the wizard; the machine is torn down, so only the card's onboarding-status polling + // can surface the outcome now. + await wizard.getByRole("button", { name: "Continue in background" }).click(); + await expect(page.getByRole("button", { name: "Awaiting provider review" })).toBeVisible({ timeout: 20_000 }); + + backend.kyc.approved = true; + // No interaction, no reload — the 15s onboarding-status refetch flips the card to approved. + await expect(page.getByRole("button", { name: "Verification complete" })).toBeVisible({ timeout: 25_000 }); +}); + +test("Alfredpay MX KYC: closing on the details form and reloading keeps the card resumable", async ({ page }) => { + // Regression: creating the customer moves the backend to `started`; the card used to render a + // disabled "Verification started", locking the user out of a KYC they hadn't finished. It must + // stay resumable across a modal close + full reload. + await mockBackend(page, { alfredpayKyc: {} }); + await seedSession(page); + await page.goto("/overview"); + + // Add Mexico and start KYC through customer creation, landing on the details form. + 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 KYC" }).click(); + const wizard = page.getByRole("dialog"); + await expect(wizard.getByText("We'll create your Mexico individual verification profile")).toBeVisible({ timeout: 20_000 }); + await wizard.getByRole("button", { name: "Continue" }).click(); + await expect(page.locator('input[name="firstName"]')).toBeVisible({ timeout: 20_000 }); + + // Abandon the form and reload — the backend still holds the half-finished `started` account. + await page.keyboard.press("Escape"); + await expect(page.getByRole("dialog")).toBeHidden(); + await page.reload(); + + // The card offers a resumable action instead of a dead disabled button. + const resume = page.getByRole("button", { name: "Continue KYC" }); + await expect(resume).toBeEnabled({ timeout: 20_000 }); + + // Reopening re-checks canonical status (CONSULTED) and returns straight to the details form. + await resume.click(); + await expect(page.locator('input[name="firstName"]')).toBeVisible({ timeout: 20_000 }); +}); + +test("Alfredpay MX KYC: closing while pending and reopening shows the approval in the wizard", async ({ page }) => { + // reflectOnboarding:false keeps the onboarding aggregator behind the provider, so the card + // action stays enabled and the wizard can be reopened while the provider is still reviewing. + const backend = await mockBackend(page, { alfredpayKyc: { reflectOnboarding: false } }); + const wizard = await driveToInReview(page); + await expect(wizard.getByRole("button", { name: "Continue in background" })).toBeVisible(); + + // Dismiss the wizard while the review is still pending. + await page.keyboard.press("Escape"); + await expect(page.getByRole("dialog")).toBeHidden(); + + // Reopen it — the machine re-checks status (VERIFYING) and returns straight to "In review". + await page.getByRole("button", { name: "Start KYC" }).click(); + const reopened = page.getByRole("dialog"); + await expect(reopened.getByText("Alfredpay is reviewing your submission")).toBeVisible({ timeout: 20_000 }); + + // Approval now arrives — the reopened wizard advances to the approved screen. + backend.kyc.approved = true; + await expect(reopened.getByText("You can now register recipients")).toBeVisible({ timeout: 15_000 }); +}); diff --git a/apps/dashboard/e2e/onboarding-avenia-brl-kyb.spec.ts b/apps/dashboard/e2e/onboarding-avenia-brl-kyb.spec.ts new file mode 100644 index 000000000..23441e92d --- /dev/null +++ b/apps/dashboard/e2e/onboarding-avenia-brl-kyb.spec.ts @@ -0,0 +1,57 @@ +import { expect, test } from "@playwright/test"; +import { mockBackend } from "./support/mockBackend"; +import { seedSession } from "./support/session"; + +test("Avenia BR business KYB completes company and representative hosted steps before approval", async ({ page }) => { + await page.addInitScript(() => { + const browserWindow = window as Window & { __e2eOpenedUrls?: string[] }; + browserWindow.__e2eOpenedUrls = []; + window.open = ((url?: string | URL) => { + browserWindow.__e2eOpenedUrls?.push(String(url)); + return null; + }) as typeof window.open; + }); + const backend = await mockBackend(page, { aveniaKyb: true, 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: /Brazil/ }).click(); + await addDialog.getByRole("button", { name: "Add card" }).click(); + + await page.getByRole("button", { name: "Start KYB" }).click(); + const wizard = page.getByRole("dialog"); + await expect(wizard.getByText("Company information")).toBeVisible(); + await page.locator('input[name="fullName"]').fill("Vortex Brasil Ltda"); + await page.locator('input[name="taxId"]').fill("12.345.678/0001-95"); + await wizard.getByRole("button", { exact: true, name: "Continue" }).click(); + + await expect(wizard.getByText("Verify your company")).toBeVisible({ timeout: 20_000 }); + await wizard.getByRole("button", { name: "Continue to Avenia" }).click(); + await expect + .poll(() => page.evaluate(() => (window as Window & { __e2eOpenedUrls?: string[] }).__e2eOpenedUrls)) + .toEqual(["https://hosted.avenia.example/company"]); + await wizard.getByRole("button", { name: "I completed this step" }).click(); + + await expect(wizard.getByText("Verify the company representative")).toBeVisible(); + await wizard.getByRole("button", { name: "Continue to Avenia" }).click(); + await expect + .poll(() => page.evaluate(() => (window as Window & { __e2eOpenedUrls?: string[] }).__e2eOpenedUrls)) + .toEqual(["https://hosted.avenia.example/company", "https://hosted.avenia.example/representative"]); + await wizard.getByRole("button", { name: "I completed this step" }).click(); + + await expect(wizard.getByText("Avenia is reviewing the company and representative information")).toBeVisible(); + expect(backend.brlaCreateSubaccountRequests).toEqual([ + { accountType: "COMPANY", name: "Vortex Brasil Ltda", taxId: "12.345.678/0001-95" } + ]); + await expect.poll(() => backend.avenia.statusPolls).toBeGreaterThan(0); + + backend.avenia.approved = true; + await expect(wizard.getByText("Your Brazil KYB is complete")).toBeVisible({ timeout: 10_000 }); + await expect(wizard.getByRole("button", { name: "Done" })).toBeVisible(); + expect(backend.unmatchedRequests).toEqual([]); + expect(backend.unexpectedExternalRequests).toEqual([]); +}); diff --git a/apps/dashboard/e2e/onboarding-monerium-eu.spec.ts b/apps/dashboard/e2e/onboarding-monerium-eu.spec.ts new file mode 100644 index 000000000..e1701d990 --- /dev/null +++ b/apps/dashboard/e2e/onboarding-monerium-eu.spec.ts @@ -0,0 +1,96 @@ +import { expect, test } from "@playwright/test"; +import { mockBackend } from "./support/mockBackend"; +import { E2E_USER_EMAIL, E2E_USER_ID, SESSION_KEYS, seedSession } from "./support/session"; + +test("Monerium EU OAuth returns securely and refreshes approval", async ({ page }) => { + const backend = await mockBackend(page, { moneriumKyc: 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: /Europe/ }).click(); + await addDialog.getByRole("button", { name: "Add card" }).click(); + + await page.getByRole("button", { name: "Start KYC" }).click(); + const wizard = page.getByRole("dialog"); + await expect(wizard.getByText("Verify with Monerium")).toBeVisible({ timeout: 20_000 }); + let continueNavigation: () => void; + const navigationGate = new Promise(resolve => { + continueNavigation = resolve; + }); + await page.route(`${new URL(page.url()).origin}/monerium/callback?**`, async route => { + await navigationGate; + await route.continue(); + }); + const connectingShown = page.evaluate( + () => + new Promise(resolve => { + const observer = new MutationObserver(() => { + if (document.body.textContent?.includes("Connecting to Monerium")) { + observer.disconnect(); + resolve(true); + } + }); + observer.observe(document.body, { childList: true, subtree: true }); + }) + ); + const navigation = wizard.getByRole("button", { name: "Continue to Monerium" }).click(); + try { + await expect(connectingShown).resolves.toBe(true); + } finally { + continueNavigation(); + } + await navigation; + + await expect(page).toHaveURL(/\/overview\?onboarding=EU$/, { timeout: 20_000 }); + await expect(page.getByRole("dialog").getByText("Verification in review")).toBeVisible(); + await expect(page.getByRole("button", { name: "Continue in background" })).toBeVisible(); + expect(backend.monerium.startRequests).toEqual([{ customerType: "individual" }]); + + await page.getByRole("button", { name: "Continue in background" }).click(); + await expect.poll(() => new URL(page.url()).search).toBe(""); + await expect(page.getByRole("button", { name: "Awaiting provider review" })).toBeVisible({ timeout: 20_000 }); + + backend.monerium.approved = true; + await expect(page.getByRole("button", { name: "Verification complete" })).toBeVisible({ timeout: 25_000 }); + expect(backend.unmatchedRequests).toEqual([]); + expect(backend.unexpectedExternalRequests).toEqual([]); +}); + +test("Monerium callback refreshes an expired dashboard session before exchange", async ({ page }) => { + const backend = await mockBackend(page, { moneriumKyc: true, moneriumRequireRefresh: true }); + await page.addInitScript( + ({ email, keys, userId }) => { + localStorage.setItem(keys.accessToken, "eyJhbGciOiJub25lIn0.eyJleHAiOjF9."); + localStorage.setItem(keys.refreshToken, "e2e-refresh-token"); + localStorage.setItem(keys.userId, userId); + localStorage.setItem(keys.userEmail, email); + }, + { email: E2E_USER_EMAIL, keys: SESSION_KEYS, userId: E2E_USER_ID } + ); + + await page.goto("/monerium/callback?code=e2e-code&state=e2e-state"); + + await expect(page).toHaveURL(/\/overview\?onboarding=EU$/, { timeout: 20_000 }); + await expect(page.getByRole("dialog").getByText("Verification in review")).toBeVisible(); + expect(backend.auth.refreshes).toBe(1); +}); + +test("in-review Monerium onboarding offers reauthentication when the status response requires it", async ({ page }) => { + const backend = await mockBackend(page, { moneriumKyc: true }); + backend.monerium.completed = true; + await seedSession(page); + await page.goto("/overview"); + + const reauthenticateButton = page.getByRole("button", { name: "Re-authenticate with Monerium" }); + await expect(reauthenticateButton).toBeVisible({ timeout: 20_000 }); + await reauthenticateButton.click(); + + const wizard = page.getByRole("dialog"); + await expect(wizard.getByText("Verify with Monerium")).toBeVisible(); + await expect(wizard.getByRole("button", { name: "Continue to Monerium" })).toBeVisible(); + expect(backend.unmatchedRequests).toEqual([]); +}); diff --git a/apps/dashboard/e2e/recipient-invite.spec.ts b/apps/dashboard/e2e/recipient-invite.spec.ts new file mode 100644 index 000000000..af2389be9 --- /dev/null +++ b/apps/dashboard/e2e/recipient-invite.spec.ts @@ -0,0 +1,117 @@ +import { expect, test } from "@playwright/test"; +import { mockBackend } from "./support/mockBackend"; +import { seedSession } from "./support/session"; + +test("long recipient invite links stay within the share dialog", async ({ page }) => { + const backend = await mockBackend(page); + await seedSession(page); + await page.goto("/recipients"); + + await page.getByRole("button", { name: "Add recipient" }).first().click(); + const dialog = page.getByRole("dialog"); + await dialog.getByLabel("Alias").fill("Maria · MXN"); + await dialog.getByRole("button", { name: "Create invite link" }).click(); + + await expect(dialog.getByText("Invite link ready")).toBeVisible(); + const preview = dialog.getByTestId("invite-link-preview"); + await expect(preview).toHaveCSS("overflow-x", "hidden"); + await expect + .poll(async () => { + const [dialogBox, previewBox] = await Promise.all([dialog.boundingBox(), preview.boundingBox()]); + return !!dialogBox && !!previewBox && previewBox.x + previewBox.width <= dialogBox.x + dialogBox.width; + }) + .toBe(true); + expect(backend.unmatchedRequests).toEqual([]); +}); + +test("one approved corridor unlocks inviting to every live corridor, minus unsupported combinations", async ({ page }) => { + const backend = await mockBackend(page); + await seedSession(page); + await page.goto("/recipients"); + + await page.getByRole("button", { name: "Add recipient" }).first().click(); + const dialog = page.getByRole("dialog"); + + // Only MX is approved in the mock backend: it is the default, but every live corridor is offered. + const corridorSelect = dialog.getByRole("combobox"); + await expect(corridorSelect).toContainText("Mexico"); + await corridorSelect.click(); + for (const country of ["Brazil", "Europe", "Mexico", "Colombia", "USA", "Argentina"]) { + await expect(page.getByRole("option", { name: new RegExp(country) })).toBeVisible(); + } + + // Alfredpay has no AR company KYB: switching to Company drops Argentina and resets the selection. + await page.getByRole("option", { name: /Argentina/ }).click(); + await expect(corridorSelect).toContainText("Argentina"); + await dialog.getByRole("tab", { name: "Company" }).click(); + await expect(corridorSelect).not.toContainText("Argentina"); + await corridorSelect.click(); + await expect(page.getByRole("option", { name: /Argentina/ })).toHaveCount(0); + await expect(page.getByRole("option", { name: /Brazil/ })).toBeVisible(); + await page.keyboard.press("Escape"); + expect(backend.unmatchedRequests).toEqual([]); +}); + +// US individual KYC runs through Alfredpay's hosted redirect in the widget; the deep link must +// pin the corridor (kybLocked=US) and carry the invite token. +test("a US individual invite deep link pins the widget to the US corridor", async ({ page }) => { + const backend = await mockBackend(page); + await seedSession(page); + await page.goto("/recipients"); + + await page.getByRole("button", { name: "Add recipient" }).first().click(); + const dialog = page.getByRole("dialog"); + await dialog.getByRole("combobox").click(); + await page.getByRole("option", { name: /USA/ }).click(); + await dialog.getByLabel("Alias").fill("Joe · USD"); + await dialog.getByRole("button", { name: "Create invite link" }).click(); + + await expect(dialog.getByText("Invite link ready")).toBeVisible(); + const link = await dialog.getByTestId("invite-link-preview").innerText(); + expect(link).toContain("kybLocked=US"); + expect(link).toContain("invite=e2e-"); + expect(backend.unmatchedRequests).toEqual([]); +}); + +test("a pending invite row reopens the link for re-copy and removal archives the invitation", async ({ page }) => { + const backend = await mockBackend(page, { + pendingInvitations: [ + { + alias: "Maria · MXN", + country: "MX", + createdAt: new Date().toISOString(), + expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(), + id: "invitation-e2e-1", + inviteeEmail: null, + inviteeType: "individual", + isExpired: false, + payoutCurrency: "mxn", + rail: "mxn", + token: "e2e-recopy-token" + } + ] + }); + await seedSession(page); + await page.goto("/recipients"); + + await page.getByRole("cell", { name: "Maria · MXN" }).click(); + const dialog = page.getByRole("dialog"); + await expect(dialog.getByRole("heading", { name: "Maria · MXN" })).toBeVisible(); + await expect(dialog.getByTestId("invite-link-preview")).toContainText("e2e-recopy-token"); + + // The row is focusable and labeled, so the modal is also reachable without a pointer. + await page.keyboard.press("Escape"); + await expect(dialog).not.toBeVisible(); + await page.getByRole("row", { name: "Manage Maria · MXN" }).focus(); + await page.keyboard.press("Enter"); + await expect(dialog.getByRole("heading", { name: "Maria · MXN" })).toBeVisible(); + + await dialog.getByRole("button", { name: "Remove from list" }).click(); + await dialog.getByRole("button", { name: "Remove — are you sure?" }).click(); + + await expect(dialog).not.toBeVisible(); + expect(backend.archiveInvitationRequests).toEqual([{ archived: true, id: "invitation-e2e-1" }]); + // The list refetch drops the archived row — the user-visible outcome of removal. + await expect(page.getByRole("cell", { name: "Maria · MXN" })).toHaveCount(0); + expect(backend.unmatchedRequests).toEqual([]); +}); diff --git a/apps/dashboard/e2e/support/mockBackend.ts b/apps/dashboard/e2e/support/mockBackend.ts new file mode 100644 index 000000000..703a1cbd3 --- /dev/null +++ b/apps/dashboard/e2e/support/mockBackend.ts @@ -0,0 +1,782 @@ +import type { Page } from "@playwright/test"; +import { MOCK_WALLET_ADDRESS } from "./mockWallet"; +import { E2E_USER_ID } from "./session"; + +export const APP_ORIGIN = "http://127.0.0.1:5174"; +export const E2E_RAMP_ID = "ramp-e2e-1"; +export const E2E_QUOTE_ID = "quote-e2e-1"; +export const E2E_FIAT_ACCOUNT_ID = "fiat-account-e2e-mx"; +export const E2E_FIAT_ACCOUNT_ID_2 = "fiat-account-e2e-mx-2"; +/** USDC_RATES.MX in src/domain/transfer.ts — the rate the form inverts to size the payin. */ +export const MX_USDC_RATE = 18.5; + +const POLYGON_USDT = "0xc2132d05d31c914a87c6611c10748aeb04b58e8f"; + +type OnboardingState = "approved" | "in_review" | "pending" | "rejected" | "started"; + +/** + * One MX/Alfredpay account under an individual entity, as served by GET /v1/onboarding/status + * (OnboardingStatusResponse in src/services/api/onboarding.service.ts). corridorFromProviderAccount + * resolves by rail first, then provider + country — both are set so the corridor maps to MX + * whichever branch runs; `state` (not `status`) is what the approval gate reads. + */ +export function buildOnboardingStatus(state: OnboardingState = "approved") { + return { + activeEntityId: "entity-e2e-1", + entities: [ + { + accounts: [ + { + country: "MX", + customerType: "individual", + id: "acct-e2e-mx", + kycCase: null, + provider: "alfredpay", + rail: "mxn", + state, + status: state + } + ], + id: "entity-e2e-1", + status: state, + type: "individual" + } + ], + selectionRequired: false + }; +} + +export function buildMoneriumOnboardingStatus(state: OnboardingState = "approved", reauthenticationRequired = false) { + return { + activeEntityId: "entity-e2e-1", + entities: [ + { + accounts: [ + { + country: null, + customerType: "individual", + error: reauthenticationRequired + ? { + code: "MONERIUM_REAUTHENTICATION_REQUIRED", + message: "Monerium reauthentication is required" + } + : null, + id: "acct-e2e-eu", + kycCase: null, + provider: "monerium", + rail: "eur", + state, + status: state + } + ], + id: "entity-e2e-1", + status: state, + type: "individual" + } + ], + selectionRequired: false + }; +} + +export function buildCompanyOnboardingStatus(provider: "alfredpay" | "avenia", country: "BR" | "MX", state: OnboardingState) { + return { + activeEntityId: "entity-e2e-company-1", + entities: [ + { + accounts: [ + { + companyName: "Vortex E2E Ltda", + country, + customerType: "business", + error: null, + id: `acct-e2e-${country.toLowerCase()}-company`, + kycCase: null, + provider, + rail: country === "BR" ? "pix" : "mxn", + state, + status: state, + statusExternal: state + } + ], + id: "entity-e2e-company-1", + status: state, + type: "business" + } + ], + selectionRequired: false + }; +} + +export function buildEmptyOnboardingStatus(companyMode = false) { + return { + activeEntityId: companyMode ? "entity-e2e-company-1" : "entity-e2e-1", + entities: [ + { + accounts: [], + id: companyMode ? "entity-e2e-company-1" : "entity-e2e-1", + status: "active", + type: companyMode ? "business" : "individual" + } + ], + selectionRequired: false + }; +} + +/** + * AlfredpayListFiatAccountsResponse is a bare array; selfRecipientsFromFiatAccounts reads these + * fields and turns each account into its own "send to yourself" recipient. Two accounts, so the + * recipient selector has something to choose between: the first is auto-selected, and picking the + * second must change the fiatAccountId the offramp registers against. + */ +export function buildFiatAccounts() { + return [ + { + accountName: "Vortex E2E CLABE", + accountNumber: "646180157000000004", + accountType: "CLABE", + createdAt: "2026-01-01T00:00:00.000Z", + customerId: "alfred-customer-e2e-1", + fiatAccountId: E2E_FIAT_ACCOUNT_ID, + metadata: { accountHolderName: "Vortex E2E" }, + type: "SPEI" + }, + { + accountName: "Vortex E2E Savings", + accountNumber: "646180157000000099", + accountType: "CLABE", + createdAt: "2026-01-02T00:00:00.000Z", + customerId: "alfred-customer-e2e-1", + fiatAccountId: E2E_FIAT_ACCOUNT_ID_2, + metadata: { accountHolderName: "Vortex E2E" }, + type: "SPEI" + } + ]; +} + +/** + * The SELL quote the dashboard's QuoteSummary and FundingMethods render. outputAmount is derived + * from the requested inputAmount at the same rate the form used to size it, so fetchOfframpQuote's + * refinement pass never fires and exactly one quote request is made. + */ +export function buildQuoteResponse(inputAmount: string, overrides: Record = {}) { + return { + createdAt: new Date().toISOString(), + expiresAt: new Date(Date.now() + 10 * 60 * 1000).toISOString(), + feeCurrency: "MXN", + from: "polygon", + id: E2E_QUOTE_ID, + inputAmount, + inputCurrency: "USDC", + network: "polygon", + networkFeeFiat: "2.00", + networkFeeUsd: "0.11", + outputAmount: (Number(inputAmount) * MX_USDC_RATE).toFixed(2), + outputCurrency: "MXN", + paymentMethod: "spei", + processingFeeFiat: "8.00", + processingFeeUsd: "0.43", + rampType: "SELL", + to: "spei", + totalFeeFiat: "10.00", + totalFeeUsd: "0.54", + ...overrides + }; +} + +/** RampProcess (packages/shared/src/endpoints/ramp.endpoints.ts) for a SELL USDC-on-Polygon -> MXN ramp. */ +export function buildRampProcess(overrides: Record = {}) { + return { + createdAt: new Date().toISOString(), + currentPhase: "initial", + from: "polygon", + id: E2E_RAMP_ID, + inputAmount: "54.054054", + inputCurrency: "USDC", + outputAmount: "1000.00", + outputCurrency: "MXN", + paymentMethod: "spei", + quoteId: E2E_QUOTE_ID, + to: "spei", + type: "SELL", + unsignedTxs: [], + updatedAt: new Date().toISOString(), + ...overrides + }; +} + +/** + * Mirrors the API's evm-to-alfredpay offramp preparation on the direct Polygon no-permit path: + * the USER wallet signs one squidRouterNoPermitTransfer, and the EVM ephemeral signs the + * Alfredpay deposit transfer, its fallback (both nonce 0 — only one executes) and the cleanup. + * + * Every transaction is an EVM tx on Polygon on purpose. registerTransfer (src/machines/ + * transfer.actors.ts) opens a real WebSocket RPC for any ephemeral tx on Pendulum, Hydration or + * substrate-format Moonbeam, which would make the run non-hermetic. + */ +export function buildSellUnsignedTxs(evmEphemeral: string) { + const evmTx = (signer: string, nonce: number, phase: string) => ({ + meta: {}, + network: "polygon", + nonce, + phase, + signer, + txData: { + data: `0xa9059cbb${"00".repeat(12)}${evmEphemeral.slice(2).toLowerCase()}${"00".repeat(30)}04c4`, + gas: "150000", + maxFeePerGas: "5000000000", + maxPriorityFeePerGas: "5000000000", + nonce, + to: POLYGON_USDT, + value: "0" + } + }); + return [ + evmTx(MOCK_WALLET_ADDRESS, 0, "squidRouterNoPermitTransfer"), + evmTx(evmEphemeral, 0, "alfredpayOfframpTransfer"), + evmTx(evmEphemeral, 0, "alfredpayOfframpTransferFallback"), + evmTx(evmEphemeral, 1, "polygonCleanupAxlUsdc") + ]; +} + +interface MockBackendOptions { + onboardingState?: OnboardingState; + companyMode?: boolean; + selectionRequired?: boolean; + // Full response for POST /v1/auth/verify-otp. Default: a successful session. + verifyOtp?: (requestBody: Record) => { status: number; body: unknown }; + // How many GET /v1/ramp/:id polls report an in-progress ramp before flipping to COMPLETE. + pendingStatusPolls?: number; + // Drives the Alfredpay MX KYC endpoints and, unless reflectOnboarding is false, makes + // GET /v1/onboarding/status mirror KYC progress (empty → started once the customer exists → + // in_review once submitted → approved). + // Default onboarding status (an already-approved MX corridor) applies when this is unset. + alfredpayKyc?: { reflectOnboarding?: boolean }; + aveniaKyb?: boolean; + moneriumKyc?: boolean; + moneriumRequireRefresh?: boolean; + // Served as GET /v1/recipients → pendingInvitations (default: none). + pendingInvitations?: Array>; +} + +// AlfredPayStatus values the machine branches on (packages/shared AlfredPayStatus). +const ALFREDPAY_SUCCESS = "SUCCESS"; +const ALFREDPAY_VERIFYING = "VERIFYING"; +// Customer exists but no KYC submission yet — a reopened wizard resumes into the details form. +const ALFREDPAY_CONSULTED = "CONSULTED"; + +// Chains the dashboard's wagmi config can reach (src/lib/wagmi.ts uses http() with no URL, so +// viem falls back to these per-chain defaults). Polygon is genuinely exercised — the user +// transaction's receipt is awaited through the wagmi transport, not the wallet — and mainnet is +// hit by ConnectKit's ENS lookup after connect. +const RPC_ENDPOINTS: Array<{ chainIdHex: string; pattern: string }> = [ + { chainIdHex: "0x89", pattern: "https://polygon.drpc.org/**" }, + { chainIdHex: "0x1", pattern: "https://eth.merkle.io/**" }, + { chainIdHex: "0xa4b1", pattern: "https://arb1.arbitrum.io/**" }, + { chainIdHex: "0x2105", pattern: "https://mainnet.base.org/**" } +]; + +// Third parties the app reaches for but does not need. main.tsx always mounts WagmiProvider + +// ConnectKitProvider, which probes for the Family wallet and loads the Coinbase Wallet SDK; the +// Topbar renders a connect button. index.html pulls a Google font. All have graceful fallbacks. +const THIRD_PARTY_BLOCKLIST = [ + "**/*.walletconnect.com/**", + "**/*.walletconnect.org/**", + "**/*.web3modal.org/**", + "https://app.family.co/**", + "https://cca-lite.coinbase.com/**", + "https://fonts.googleapis.com/**", + "https://fonts.gstatic.com/**" +]; + +type RpcRequest = { id?: number; method?: string; params?: unknown[] }; + +/** Reads one text field out of a multipart body — the uploads are FormData, not JSON. */ +function multipartField(body: string | null, name: string): string { + const match = body?.match(new RegExp(`name="${name}"\\r?\\n\\r?\\n([^\\r\\n]*)`)); + return match?.[1] ?? ""; +} + +function answerRpc(chainIdHex: string) { + const answerOne = (req: RpcRequest) => { + const hash = (req.params?.[0] as string) ?? `0x${"cd".repeat(32)}`; + let result: unknown = null; + switch (req.method) { + case "eth_chainId": + result = chainIdHex; + break; + // Contract reads (ENS resolution): empty return data, which viem surfaces as a failed + // read. ConnectKit falls back to the truncated address. + case "eth_call": + result = "0x"; + break; + case "eth_blockNumber": + result = "0x1"; + break; + case "eth_getBlockByNumber": + result = { baseFeePerGas: "0x1", number: "0x1" }; + break; + case "eth_getTransactionByHash": + result = { blockHash: `0x${"ef".repeat(32)}`, blockNumber: "0x1", from: null, hash, input: "0x", value: "0x0" }; + break; + case "eth_getTransactionReceipt": + result = { + blockHash: `0x${"ef".repeat(32)}`, + blockNumber: "0x1", + contractAddress: null, + cumulativeGasUsed: "0x5208", + effectiveGasPrice: "0x3b9aca00", + gasUsed: "0x5208", + logs: [], + logsBloom: `0x${"00".repeat(256)}`, + status: "0x1", + transactionHash: hash, + transactionIndex: "0x0", + type: "0x2" + }; + break; + } + return { id: req.id ?? 1, jsonrpc: "2.0", result }; + }; + return (body: RpcRequest | RpcRequest[]) => (Array.isArray(body) ? body.map(answerOne) : answerOne(body)); +} + +/** + * Intercepts the API origin (http://localhost:3000) so specs run without a backend, answers the + * chain RPCs hermetically, and aborts every other external request. + * + * Two escape hatches are recorded rather than tolerated: `unmatchedRequests` (an API path this + * mock does not serve, 404ed) and `unexpectedExternalRequests` (any origin outside the app that + * is not in THIRD_PARTY_BLOCKLIST). Specs assert both are empty, so a newly-called endpoint or a + * changed default RPC URL fails the suite instead of silently reaching the network. + */ +export async function mockBackend(page: Page, options: MockBackendOptions = {}) { + const requestOtpRequests: Array> = []; + const verifyOtpRequests: Array> = []; + const quoteRequests: Array> = []; + const registerRequests: Array> = []; + const updateRequests: Array> = []; + const startRequests: Array> = []; + const kycFormSubmissions: Array> = []; + const kybFormSubmissions: Array> = []; + const brlaCreateSubaccountRequests: Array> = []; + const archiveInvitationRequests: Array> = []; + const unmatchedRequests: string[] = []; + const unexpectedExternalRequests: string[] = []; + const status = { polls: 0 }; + // Alfredpay KYC progress. Route handlers are Node closures, so a spec flips `kyc.approved` + // between assertions and the browser's next poll (machine getKycStatus, or the card's + // onboarding-status refetch) observes it — deterministic, no reliance on poll counts. + const kyc = { approved: false, customerCreated: false, submitted: false }; + const kybUploads = { businessFiles: 0, relatedPersonFiles: 0 }; + // The fileType is the whole contract of these uploads: Alfredpay keys the document by it and + // rejects an unknown value, so counting requests alone would not catch sending the wrong one. + const kybFileTypes: string[] = []; + const kybRelatedPersonFileTypes: string[] = []; + const avenia = { approved: false, statusPolls: 0, submitted: false }; + const monerium = { + approved: false, + authorized: false, + completed: false, + startRequests: [] as Array> + }; + const auth = { refreshes: 0 }; + let selectedCompany = options.companyMode ?? false; + let hasActiveEntity = options.selectionRequired !== true; + + // The real API keeps returning the ramp's unsignedTxs on /ramp/update; the signing step reads + // the user-wallet transaction from that response. + let unsignedTxs: unknown[] = []; + + // Registered first so the specific routes below take precedence: Playwright runs route + // handlers in reverse registration order. + await page.route("**/*", async route => { + const url = route.request().url(); + if (url.startsWith(APP_ORIGIN) || url.startsWith("data:") || url.startsWith("blob:")) { + await route.continue(); + return; + } + unexpectedExternalRequests.push(url); + await route.abort(); + }); + + await page.route("http://localhost:3000/**", async route => { + const request = route.request(); + const url = new URL(request.url()); + const path = url.pathname; + const method = request.method(); + + const fulfillJson = (body: unknown, code = 200) => route.fulfill({ json: body as object, status: code }); + + // Auth shapes mirror apps/api/src/api/controllers/auth.controller.ts: snake_case on the + // wire, mapped to camelCase by src/services/api/auth.api.ts. + if (path === "/v1/auth/request-otp" && method === "POST") { + requestOtpRequests.push(request.postDataJSON() as Record); + await fulfillJson({ message: "OTP sent" }); + return; + } + if (path === "/v1/auth/verify-otp" && method === "POST") { + const body = request.postDataJSON() as Record; + verifyOtpRequests.push(body); + const result = options.verifyOtp?.(body) ?? { + body: { + access_token: "e2e-access-token", + refresh_token: "e2e-refresh-token", + success: true, + user_id: E2E_USER_ID + }, + status: 200 + }; + await fulfillJson(result.body, result.status); + return; + } + if (path === "/v1/auth/refresh" && method === "POST") { + auth.refreshes += 1; + await fulfillJson({ access_token: "e2e-access-token", refresh_token: "e2e-refresh-token", success: true }); + return; + } + + if (path === "/v1/onboarding/active-entity" && method === "PUT") { + const body = request.postDataJSON() as { type?: string }; + selectedCompany = body.type === "business"; + hasActiveEntity = true; + await fulfillJson({ + activeEntityId: selectedCompany ? "entity-e2e-company-1" : "entity-e2e-1", + type: selectedCompany ? "business" : "individual" + }); + return; + } + + if (path === "/v1/onboarding/status" && method === "GET") { + if (!hasActiveEntity) { + await fulfillJson({ activeEntityId: null, entities: [], selectionRequired: true }); + return; + } + if (options.selectionRequired) { + await fulfillJson(buildEmptyOnboardingStatus(selectedCompany)); + return; + } + if (options.aveniaKyb) { + await fulfillJson( + avenia.submitted + ? buildCompanyOnboardingStatus("avenia", "BR", avenia.approved ? "approved" : "in_review") + : buildEmptyOnboardingStatus(true) + ); + return; + } + if (options.moneriumKyc) { + await fulfillJson( + monerium.completed + ? buildMoneriumOnboardingStatus(monerium.approved ? "approved" : "in_review", !monerium.authorized) + : buildEmptyOnboardingStatus() + ); + return; + } + if (!options.alfredpayKyc) { + await fulfillJson( + options.companyMode + ? buildCompanyOnboardingStatus("alfredpay", "MX", options.onboardingState ?? "approved") + : buildOnboardingStatus(options.onboardingState) + ); + return; + } + // KYC test: reflect progress so the card tracks it (empty keeps the card action enabled for + // reopen; in_review turns on the 15s background refetch that flips it to approved live). + if (options.alfredpayKyc.reflectOnboarding === false) { + await fulfillJson(buildEmptyOnboardingStatus(options.companyMode)); + } else if (kyc.approved) { + await fulfillJson( + options.companyMode ? buildCompanyOnboardingStatus("alfredpay", "MX", "approved") : buildOnboardingStatus("approved") + ); + } else if (kyc.submitted) { + await fulfillJson( + options.companyMode + ? buildCompanyOnboardingStatus("alfredpay", "MX", "in_review") + : buildOnboardingStatus("in_review") + ); + } else if (kyc.customerCreated) { + // Real backend: createCustomer writes provider_customers.status = started (Consulted) + // before any KYC data is submitted, so the aggregator reports `started` here — the card + // must keep this resumable, not lock the user out. + await fulfillJson( + options.companyMode ? buildCompanyOnboardingStatus("alfredpay", "MX", "started") : buildOnboardingStatus("started") + ); + } else { + await fulfillJson(buildEmptyOnboardingStatus(options.companyMode)); + } + return; + } + + if (path === "/v1/monerium/status" && method === "GET" && options.moneriumKyc) { + if (!monerium.authorized) { + await fulfillJson( + { message: "Monerium reauthentication is required", type: "MONERIUM_REAUTHENTICATION_REQUIRED" }, + 404 + ); + } else { + await fulfillJson({ + customerType: "individual", + profileId: "monerium-profile-e2e", + status: monerium.approved ? "APPROVED" : "PENDING", + statusExternal: monerium.approved ? "approved" : "pending" + }); + } + return; + } + if (path === "/v1/monerium/oauth/start" && method === "POST" && options.moneriumKyc) { + monerium.startRequests.push(request.postDataJSON() as Record); + await fulfillJson({ + authorizationUrl: `${APP_ORIGIN}/monerium/callback?code=e2e-code&state=e2e-state` + }); + return; + } + if (path === "/v1/monerium/oauth/complete" && method === "POST" && options.moneriumKyc) { + if (options.moneriumRequireRefresh && request.headers().authorization === "Bearer eyJhbGciOiJub25lIn0.eyJleHAiOjF9.") { + await fulfillJson({ error: "Access token expired" }, 401); + return; + } + monerium.completed = true; + monerium.authorized = true; + await fulfillJson({ + customerType: "individual", + profileId: "monerium-profile-e2e", + status: "PENDING", + statusExternal: "pending" + }); + return; + } + + // --- Alfredpay MX individual KYC (packages/kyc alfredpay machine drives this sequence) --- + // getAlfredpayStatus (machine entry): 404 before a customer exists → CustomerDefinition; + // once submitted → VERIFYING so a reopened wizard resumes into PollingStatus. + if (path === "/v1/alfredpay/alfredpayStatus" && method === "GET") { + if (!kyc.customerCreated) { + await fulfillJson({ error: "Customer not found" }, 404); + } else { + await fulfillJson({ + country: "MX", + creationTime: new Date().toISOString(), + status: kyc.approved ? ALFREDPAY_SUCCESS : kyc.submitted ? ALFREDPAY_VERIFYING : ALFREDPAY_CONSULTED + }); + } + return; + } + if (path === "/v1/alfredpay/createIndividualCustomer" && method === "POST") { + kyc.customerCreated = true; + await fulfillJson({ createdAt: new Date().toISOString() }); + return; + } + if (path === "/v1/alfredpay/createBusinessCustomer" && method === "POST") { + kyc.customerCreated = true; + await fulfillJson({ createdAt: new Date().toISOString() }); + return; + } + if (path === "/v1/alfredpay/submitKycInformation" && method === "POST") { + kycFormSubmissions.push(request.postDataJSON() as Record); + await fulfillJson({ submissionId: "kyc-submission-e2e-1" }); + return; + } + // Document uploads post multipart FormData — never call postDataJSON on these. + if (path === "/v1/alfredpay/submitKycFile" && method === "POST") { + await fulfillJson({ success: true }); + return; + } + if (path === "/v1/alfredpay/sendKycSubmission" && method === "POST") { + kyc.submitted = true; + await fulfillJson({ success: true }); + return; + } + if (path === "/v1/alfredpay/submitKybInformation" && method === "POST") { + kybFormSubmissions.push(request.postDataJSON() as Record); + await fulfillJson({ submissionId: "kyb-submission-e2e-1" }); + return; + } + if (path === "/v1/alfredpay/submitKybFile" && method === "POST") { + kybUploads.businessFiles += 1; + kybFileTypes.push(multipartField(request.postData(), "fileType")); + await fulfillJson({ success: true }); + return; + } + if (path === "/v1/alfredpay/findKybCustomerAndBusiness" && method === "GET") { + await fulfillJson([ + { relatedPersons: [{ idRelatedPerson: "related-person-e2e-1" }], submissionId: "kyb-submission-e2e-1" } + ]); + return; + } + if (path === "/v1/alfredpay/submitKybRelatedPersonFile" && method === "POST") { + kybUploads.relatedPersonFiles += 1; + kybRelatedPersonFileTypes.push(multipartField(request.postData(), "fileType")); + await fulfillJson({ success: true }); + return; + } + if (path === "/v1/alfredpay/sendKybSubmission" && method === "POST") { + kyc.submitted = true; + await fulfillJson({ success: true }); + return; + } + // getKycStatus (machine PollingStatus): VERIFYING keeps the machine polling; SUCCESS lands it + // on VerificationDone. The spec flips kyc.approved to make approval "arrive". + if (path === "/v1/alfredpay/getKycStatus" && method === "GET") { + await fulfillJson({ + alfred_pay_id: "alfred-e2e-1", + country: "MX", + status: kyc.approved ? ALFREDPAY_SUCCESS : ALFREDPAY_VERIFYING, + updated_at: new Date().toISOString() + }); + return; + } + + // --- Avenia BR company KYB (packages/kyc Avenia machine drives this sequence) --- + if (path === "/v1/brla/getUser" && method === "GET" && options.aveniaKyb) { + await fulfillJson({ error: "User not found" }, 404); + return; + } + if (path === "/v1/brla/createSubaccount" && method === "POST" && options.aveniaKyb) { + brlaCreateSubaccountRequests.push(request.postDataJSON() as Record); + await fulfillJson({ subAccountId: "avenia-subaccount-e2e-1" }); + return; + } + if (path === "/v1/brla/kyb/new-level-1/web-sdk" && method === "POST" && options.aveniaKyb) { + await fulfillJson({ + attemptId: "avenia-attempt-e2e-1", + authorizedRepresentativeUrl: "https://hosted.avenia.example/representative", + basicCompanyDataUrl: "https://hosted.avenia.example/company" + }); + return; + } + if (path === "/v1/brla/kyb/attempt-status" && method === "GET" && options.aveniaKyb) { + avenia.statusPolls += 1; + avenia.submitted = true; + await fulfillJson(avenia.approved ? { result: "APPROVED", status: "COMPLETED" } : { status: "PROCESSING" }); + return; + } + + // Saved payout accounts: each becomes a "send to yourself" recipient. Only the approved + // corridor (MX) is ever queried. + if (path === "/v1/alfredpay/fiatAccounts" && method === "GET") { + await fulfillJson(url.searchParams.get("country") === "MX" ? buildFiatAccounts() : []); + return; + } + // Third-party recipients: seeded pending invitations only, so the auto-selected + // self-recipient stays selected in transfer specs. + if (path === "/v1/recipients" && method === "GET") { + // Archived invitations disappear from subsequent lists, like the real backend, so specs + // can assert the user-visible outcome of removal rather than just the PATCH. + const archivedIds = new Set( + archiveInvitationRequests.filter(request => request.archived === true).map(request => request.id) + ); + await fulfillJson({ + pendingInvitations: (options.pendingInvitations ?? []).filter(invitation => !archivedIds.has(invitation.id)), + recipients: [] + }); + return; + } + if (path.startsWith("/v1/recipients/invitations/") && method === "PATCH") { + const body = request.postDataJSON() as Record; + archiveInvitationRequests.push({ ...body, id: path.split("/").pop() }); + await fulfillJson({ archived: body.archived, id: path.split("/").pop() }); + return; + } + if (path === "/v1/recipients/invite" && method === "POST") { + const body = request.postDataJSON() as Record; + await fulfillJson({ + ...body, + createdAt: new Date().toISOString(), + expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(), + id: "invite-e2e-1", + inviteeEmail: null, + status: "pending", + token: `e2e-${"long-token-".repeat(30)}` + }); + return; + } + + if (path === "/v1/quotes" && method === "POST") { + const body = request.postDataJSON() as Record; + quoteRequests.push(body); + await fulfillJson(buildQuoteResponse(body.inputAmount as string)); + return; + } + + // Ramp lifecycle. The ephemeral EVM address is echoed back from the registration request so + // the returned transactions are owned by the keys the page just generated. + if (path === "/v1/ramp/register" && method === "POST") { + const body = request.postDataJSON() as { + signingAccounts?: Array<{ address: string; type: string }>; + } & Record; + registerRequests.push(body); + const evmEphemeral = body.signingAccounts?.find(account => account.type === "EVM")?.address ?? POLYGON_USDT; + unsignedTxs = buildSellUnsignedTxs(evmEphemeral); + await fulfillJson(buildRampProcess({ unsignedTxs })); + return; + } + if (path === "/v1/ramp/update" && method === "POST") { + updateRequests.push(request.postDataJSON() as Record); + await fulfillJson(buildRampProcess({ unsignedTxs })); + return; + } + if (path === "/v1/ramp/start" && method === "POST") { + startRequests.push(request.postDataJSON() as Record); + await fulfillJson(buildRampProcess({ currentPhase: "squidRouterPay", status: "PENDING", unsignedTxs })); + return; + } + // Checked before the /v1/ramp/:id status route below, which would otherwise swallow it. + if (path.startsWith("/v1/ramp/history/") && method === "GET") { + await fulfillJson({ totalCount: 0, transactions: [] }); + return; + } + if (path === `/v1/ramp/${E2E_RAMP_ID}` && method === "GET") { + status.polls++; + const complete = status.polls > (options.pendingStatusPolls ?? 1); + await fulfillJson( + buildRampProcess({ + currentPhase: complete ? "complete" : "squidRouterPay", + feeCurrency: "MXN", + networkFeeFiat: "2.00", + processingFeeFiat: "8.00", + status: complete ? "COMPLETE" : "PENDING", + totalFeeFiat: "10.00", + unsignedTxs + }) + ); + return; + } + + unmatchedRequests.push(`${method} ${path}`); + await route.fulfill({ json: {}, status: 404 }); + }); + + for (const { chainIdHex, pattern } of RPC_ENDPOINTS) { + const answer = answerRpc(chainIdHex); + await page.route(pattern, async route => { + const body = route.request().postDataJSON() as Parameters[0]; + await route.fulfill({ json: answer(body) }); + }); + } + + for (const pattern of THIRD_PARTY_BLOCKLIST) { + await page.route(pattern, route => route.abort()); + } + + return { + archiveInvitationRequests, + auth, + avenia, + brlaCreateSubaccountRequests, + kybFileTypes, + kybFormSubmissions, + kybRelatedPersonFileTypes, + kybUploads, + kyc, + kycFormSubmissions, + monerium, + quoteRequests, + registerRequests, + requestOtpRequests, + startRequests, + status, + unexpectedExternalRequests, + unmatchedRequests, + updateRequests, + verifyOtpRequests + }; +} diff --git a/apps/dashboard/e2e/support/mockWallet.ts b/apps/dashboard/e2e/support/mockWallet.ts new file mode 100644 index 000000000..8c0cb59f5 --- /dev/null +++ b/apps/dashboard/e2e/support/mockWallet.ts @@ -0,0 +1,95 @@ +import type { Page } from "@playwright/test"; + +export const MOCK_WALLET_ADDRESS = "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266"; +export const MOCK_WALLET_NAME = "E2E Mock Wallet"; +/** The hash the stub returns for every eth_sendTransaction. */ +export const MOCK_WALLET_TX_HASH = `0x${"cd".repeat(32)}`; + +// Copied from apps/frontend/e2e/support/mockWallet.ts. Injects a minimal EIP-1193 provider +// announced via EIP-6963 before the app loads. wagmi discovers announced providers by default +// and reconnects to any connector whose isAuthorized() passes — the stub answers eth_accounts +// with an address, so it auto-connects without driving the ConnectKit modal. +// +// The dashboard's transfer journey lives on Polygon, so callers pass chainIdHex "0x89" to avoid +// a mid-flow chain switch in signAndSubmitEvmTransaction. +export async function injectMockWallet(page: Page, options: { chainIdHex?: string } = {}) { + await page.addInitScript( + ({ address, name, chainIdHex, txHash }) => { + // biome-ignore lint/suspicious/noExplicitAny: minimal EIP-1193 stub + const listeners: Record void>> = {}; + const provider = { + isMetaMask: false, + // biome-ignore lint/suspicious/noExplicitAny: minimal EIP-1193 stub + on: (event: string, cb: (...args: any[]) => void) => { + (listeners[event] ??= []).push(cb); + }, + // biome-ignore lint/suspicious/noExplicitAny: minimal EIP-1193 stub + removeListener: (event: string, cb: (...args: any[]) => void) => { + listeners[event] = (listeners[event] ?? []).filter(l => l !== cb); + }, + request: async ({ method }: { method: string }) => { + switch (method) { + case "eth_requestAccounts": + case "eth_accounts": + return [address]; + case "eth_chainId": + return chainIdHex; + case "net_version": + return String(Number(chainIdHex)); + case "wallet_switchEthereumChain": + case "wallet_addEthereumChain": + return null; + case "wallet_requestPermissions": + return [{ parentCapability: "eth_accounts" }]; + case "personal_sign": + case "eth_signTypedData_v4": + return `0x${"ab".repeat(65)}`; + // The offramp's user-owned squidRouterNoPermitTransfer is broadcast through here. + case "eth_sendTransaction": + return txHash; + case "eth_getTransactionReceipt": + return { + blockHash: `0x${"ef".repeat(32)}`, + blockNumber: "0x1", + status: "0x1", + transactionHash: txHash + }; + case "eth_estimateGas": + return "0x5208"; + case "eth_gasPrice": + return "0x3b9aca00"; + case "eth_getTransactionCount": + return "0x0"; + case "eth_getBalance": + return "0xde0b6b3a7640000"; // 1 native token + case "eth_blockNumber": + return "0x1"; + default: + return null; + } + } + }; + + const info = Object.freeze({ + icon: "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIzMiIgaGVpZ2h0PSIzMiIvPg==", + name, + rdns: "co.vortexfinance.e2e", + uuid: "e2e00000-0000-4000-8000-000000000001" + }); + const announce = () => + window.dispatchEvent(new CustomEvent("eip6963:announceProvider", { detail: Object.freeze({ info, provider }) })); + window.addEventListener("eip6963:requestProvider", announce); + announce(); + + // Also expose as the legacy injected provider for connectors that look for it. + // biome-ignore lint/suspicious/noExplicitAny: window.ethereum has no typed slot here + (window as any).ethereum = provider; + }, + { + address: MOCK_WALLET_ADDRESS, + chainIdHex: options.chainIdHex ?? "0x2105", + name: MOCK_WALLET_NAME, + txHash: MOCK_WALLET_TX_HASH + } + ); +} diff --git a/apps/dashboard/e2e/support/session.ts b/apps/dashboard/e2e/support/session.ts new file mode 100644 index 000000000..c77497af7 --- /dev/null +++ b/apps/dashboard/e2e/support/session.ts @@ -0,0 +1,31 @@ +import type { Page } from "@playwright/test"; + +export const E2E_USER_ID = "user-e2e-1"; +export const E2E_USER_EMAIL = "e2e@vortexfinance.co"; +// displayNameFromEmail("e2e@vortexfinance.co") in src/stores/auth.store.ts. +export const E2E_USER_NAME = "E2e"; + +// Keys mirror AuthService's private constants (src/services/auth.ts). +export const SESSION_KEYS = { + accessToken: "vortex_dashboard_access_token", + refreshToken: "vortex_dashboard_refresh_token", + userEmail: "vortex_dashboard_user_email", + userId: "vortex_dashboard_user_id" +} as const; + +/** + * Boots the app already authenticated: useAuthStore reads the session from localStorage at + * module init, so it has to be there before any app script runs. The access token is not a + * JWT on purpose — AuthService.isAuthenticated() treats an undecodable expiry as valid. + */ +export async function seedSession(page: Page) { + await page.addInitScript( + ({ email, keys, userId }) => { + localStorage.setItem(keys.accessToken, "e2e-access-token"); + localStorage.setItem(keys.refreshToken, "e2e-refresh-token"); + localStorage.setItem(keys.userId, userId); + localStorage.setItem(keys.userEmail, email); + }, + { email: E2E_USER_EMAIL, keys: SESSION_KEYS, userId: E2E_USER_ID } + ); +} diff --git a/apps/dashboard/e2e/transfer-mxn-journey.spec.ts b/apps/dashboard/e2e/transfer-mxn-journey.spec.ts new file mode 100644 index 000000000..c2bfa05b8 --- /dev/null +++ b/apps/dashboard/e2e/transfer-mxn-journey.spec.ts @@ -0,0 +1,149 @@ +import { expect, test } from "@playwright/test"; +import { E2E_FIAT_ACCOUNT_ID, E2E_FIAT_ACCOUNT_ID_2, E2E_QUOTE_ID, E2E_RAMP_ID, mockBackend } from "./support/mockBackend"; +import { injectMockWallet, MOCK_WALLET_ADDRESS, MOCK_WALLET_TX_HASH } from "./support/mockWallet"; +import { seedSession } from "./support/session"; + +const PAYOUT_MXN = "1000"; +// The quote endpoint is input-driven, so the form inverts the payout at USDC_RATES.MX (18.5) +// and quotes for that many USDC: (1000 / 18.5).toFixed(6). +const EXPECTED_PAYIN_USDC = "54.054054"; + +// The dashboard's money path: a SELL (offramp) transfer of USDC on Polygon to an MXN payout +// account, against the mocked backend and the injected mock wallet. +// +// quote -> register (fresh ephemeral keypairs) -> the ephemeral-owned transactions are signed +// in-page and posted to /ramp/update -> the user-owned transaction is broadcast by the connected +// wallet and its hash reported in a second update -> /ramp/start -> status polling reaches +// COMPLETE while the form navigates to /transactions. +// +// Coverage limits: +// - No chain is touched. Ephemeral signing is real (viem serializes an EIP-1559 transaction +// offline), which is what the raw-0x02 assertions below pin down. The user transaction is +// "broadcast" by the wallet stub, and its receipt is answered by the mocked Polygon RPC. +// - Only the direct Polygon no-permit path is exercised; the permit/TokenRelayer variant needs +// relayer-contract execution that the mock does not model. +// +// Skipped while the submit button in FundingMethods is commented out — self-serve transfers are +// enabled per account on request, so there is no button to drive. Un-skip together with it. +test.skip("SELL MXN transfer: quote, register, ephemeral presigning, wallet broadcast, start, tracking", async ({ page }) => { + const backend = await mockBackend(page); + // The whole journey lives on Polygon, so the wallet connects on chain 137 and + // signAndSubmitEvmTransaction never needs a chain switch. + await injectMockWallet(page, { chainIdHex: "0x89" }); + await seedSession(page); + + await page.goto("/transfer"); + + // Stage 1: the only approved corridor is MX, and its single saved payout account becomes an + // approved self-recipient that the form auto-selects — so the amount field is already live. + const amountInput = page.locator("#payout-amount"); + await expect(amountInput).toBeVisible({ timeout: 20_000 }); + await expect(page.getByText("SPEI · Vortex E2E CLABE · ••••0004")).toBeVisible(); + await amountInput.fill(PAYOUT_MXN); + + // Stage 2: a quote arrives and the funding panel shows the auto-connected wallet, so the + // submit button carries the payin amount. + const sendButton = page.getByRole("button", { name: /Send/ }); + await expect(sendButton).toBeEnabled({ timeout: 20_000 }); + await expect(sendButton).toContainText(EXPECTED_PAYIN_USDC); + + // Stage 3: submitting runs register -> presign -> user signing -> start. The form toasts and + // navigates once the machine reaches Tracking. + await sendButton.click(); + await expect(page.getByText("Transfer initiated")).toBeVisible({ timeout: 30_000 }); + await expect(page).toHaveURL(/\/transactions/); + + // The quote was requested once, for the inverted payin amount on the SELL rail. + expect(backend.quoteRequests).toHaveLength(1); + expect(backend.quoteRequests[0]).toMatchObject({ + countryCode: "MX", + inputAmount: EXPECTED_PAYIN_USDC, + inputCurrency: "USDC", + network: "polygon", + outputCurrency: "MXN", + paymentMethod: "spei", + rampType: "SELL" + }); + + // Registration carried the quote, both ephemeral signing accounts, and the payout target the + // backend cannot derive (the saved Alfredpay fiat account). + expect(backend.registerRequests).toHaveLength(1); + const registerBody = backend.registerRequests[0] as { + quoteId: string; + signingAccounts: Array<{ type: string }>; + additionalData?: { fiatAccountId?: string; walletAddress?: string; pixDestination?: string }; + }; + expect(registerBody.quoteId).toBe(E2E_QUOTE_ID); + expect(registerBody.signingAccounts.map(account => account.type).sort()).toEqual(["EVM", "Substrate"]); + expect(registerBody.additionalData?.fiatAccountId).toBe(E2E_FIAT_ACCOUNT_ID); + expect(registerBody.additionalData?.walletAddress?.toLowerCase()).toBe(MOCK_WALLET_ADDRESS.toLowerCase()); + // The Avenia-only field never travels on the Alfredpay rail. + expect(registerBody.additionalData?.pixDestination).toBeUndefined(); + + // Update #1: the three ephemeral transactions came back locally signed as raw EIP-1559 + // transactions. The user-owned transfer must NOT be among them. + expect(backend.updateRequests).toHaveLength(2); + const ephemeralUpdate = backend.updateRequests[0] as { presignedTxs: Array<{ txData: unknown; phase: string }> }; + expect(ephemeralUpdate.presignedTxs.map(tx => tx.phase).sort()).toEqual([ + "alfredpayOfframpTransfer", + "alfredpayOfframpTransferFallback", + "polygonCleanupAxlUsdc" + ]); + for (const tx of ephemeralUpdate.presignedTxs) { + expect(typeof tx.txData).toBe("string"); + expect(tx.txData as string).toMatch(/^0x02/); + } + + // Update #2: the hash of the wallet-broadcast transfer, reported as additionalData. + const signingUpdate = backend.updateRequests[1] as { additionalData?: Record }; + expect(signingUpdate.additionalData?.squidRouterNoPermitTransferHash).toBe(MOCK_WALLET_TX_HASH); + + // The offramp starts without a manual payment-confirmation step, then polls to a terminal phase. + expect(backend.startRequests).toHaveLength(1); + expect(backend.startRequests[0]).toMatchObject({ rampId: E2E_RAMP_ID }); + await expect.poll(() => backend.status.polls, { timeout: 20_000 }).toBeGreaterThanOrEqual(2); + + // Nothing escaped to a real server or a real chain. + expect(backend.unmatchedRequests).toEqual([]); + expect(backend.unexpectedExternalRequests).toEqual([]); +}); + +// Each saved payout account is its own self-recipient, and the offramp registers against the +// selected one's fiatAccountId. Picking the second account must send the money there — the first +// account is auto-selected, so a broken selector would silently pay out to the wrong account. +// +// Skipped for the same reason as the journey above: no submit button to drive. +test.skip("SELL MXN transfer: choosing a different payout account registers against that account", async ({ page }) => { + const backend = await mockBackend(page); + await injectMockWallet(page, { chainIdHex: "0x89" }); + await seedSession(page); + + await page.goto("/transfer"); + + // The first account is selected on load; the payin-network select is the other combobox. + const recipientSelect = page.getByRole("combobox").filter({ hasText: "Vortex E2E CLABE" }); + await expect(recipientSelect).toBeVisible({ timeout: 20_000 }); + + const amountInput = page.locator("#payout-amount"); + await amountInput.fill(PAYOUT_MXN); + + await recipientSelect.click(); + await page.getByRole("option", { name: /Vortex E2E Savings/ }).click(); + + // Switching recipients clears the amount (recipients no longer carry a default amount), + // so it has to be entered again before a quote is requested. + await expect(page.getByText("SPEI · Vortex E2E Savings · ••••0099")).toBeVisible(); + await expect(amountInput).toHaveValue(""); + await amountInput.fill(PAYOUT_MXN); + + const sendButton = page.getByRole("button", { name: /Send/ }); + await expect(sendButton).toBeEnabled({ timeout: 20_000 }); + await sendButton.click(); + + await expect(page.getByText("Transfer initiated")).toBeVisible({ timeout: 30_000 }); + + expect(backend.registerRequests).toHaveLength(1); + const registerBody = backend.registerRequests[0] as { additionalData?: { fiatAccountId?: string } }; + expect(registerBody.additionalData?.fiatAccountId).toBe(E2E_FIAT_ACCOUNT_ID_2); + expect(registerBody.additionalData?.fiatAccountId).not.toBe(E2E_FIAT_ACCOUNT_ID); +}); diff --git a/apps/dashboard/index.html b/apps/dashboard/index.html new file mode 100644 index 000000000..283ab6d9f --- /dev/null +++ b/apps/dashboard/index.html @@ -0,0 +1,18 @@ + + + + + + Vortex Dashboard + + + + + +
+ + + diff --git a/apps/dashboard/netlify.toml b/apps/dashboard/netlify.toml new file mode 100644 index 000000000..91a047857 --- /dev/null +++ b/apps/dashboard/netlify.toml @@ -0,0 +1,28 @@ +# Netlify config for the dashboard site (e.g. dashboard.vortexfinance.co), served at +# the domain root. +# +# Requires the Netlify site's "Base directory" to be set to `apps/dashboard` so this +# file is picked up. The build runs from the repo root because the dashboard depends +# on the workspace packages (@vortexfi/shared must be built first). +# +# Required build-time environment variables (set in the Netlify UI): +# VITE_API_URL – the API origin, e.g. https://api-staging.vortexfinance.co +# VITE_WALLETCONNECT_PROJECT_ID – WalletConnect project id +# VITE_WIDGET_URL – the widget site's origin (production builds default to +# https://app.vortexfinance.co; set explicitly everywhere +# else — a wrong value burns invite one-time tokens) +# VITE_ALCHEMY_API_KEY – optional, for ephemeral EVM presigning +# +# The API's CORS whitelist must include this site's origin (DASHBOARD_ORIGINS env var on +# the backend), and the Monerium application must have +# https:///monerium/callback registered as a redirect URI (matching the +# backend's MONERIUM_REDIRECT_URI). + +[build] + command = "cd ../.. && bun install --frozen-lockfile && bun run build:shared && bun run build:dashboard" + publish = "dist" + +[[redirects]] + from = "/*" + to = "/index.html" + status = 200 diff --git a/apps/dashboard/package.json b/apps/dashboard/package.json new file mode 100644 index 000000000..ccaa99770 --- /dev/null +++ b/apps/dashboard/package.json @@ -0,0 +1,53 @@ +{ + "dependencies": { + "@hookform/resolvers": "^4.1.3", + "@tanstack/react-query": "^5.101.2", + "@tanstack/react-router": "^1.170.16", + "@vortexfi/kyc": "workspace:*", + "@vortexfi/shared": "workspace:*", + "@xstate/react": "^6.0.0", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "connectkit": "^1.9.2", + "input-otp": "^1.4.2", + "lucide-react": "^0.562.0", + "motion": "^12.41.0", + "radix-ui": "^1.4.3", + "react": "19.2.0", + "react-dom": "19.2.0", + "react-hook-form": "^7.65.0", + "sonner": "^1.7.1", + "tailwind-merge": "^3.4.0", + "viem": "^2.51.2", + "wagmi": "^2.19.5", + "xstate": "^5.20.1", + "zod": "^4.3.6", + "zustand": "^5.0.2" + }, + "devDependencies": { + "@playwright/test": "^1.61.1", + "@tailwindcss/vite": "^4.0.3", + "@tanstack/router-plugin": "^1.168.11", + "@types/node": "catalog:", + "@types/react": "^19.2.0", + "@types/react-dom": "^19.2.0", + "@vitejs/plugin-react": "^5.2.0", + "tailwindcss": "^4.0.3", + "tw-animate-css": "^1.4.0", + "typescript": "catalog:", + "vite": "^7.3.5" + }, + "name": "vortex-dashboard", + "private": true, + "scripts": { + "build": "vite build", + "dev": "vite dev --port 5174 --host", + "preview": "vite preview --port 5174", + "test": "bun test src", + "test:e2e": "playwright test", + "typecheck": "tsc --noEmit", + "verify": "biome check --no-errors-on-unmatched" + }, + "type": "module", + "version": "0.1.0" +} diff --git a/apps/dashboard/playwright.config.ts b/apps/dashboard/playwright.config.ts new file mode 100644 index 000000000..3d23e57f8 --- /dev/null +++ b/apps/dashboard/playwright.config.ts @@ -0,0 +1,29 @@ +import { defineConfig, devices } from "@playwright/test"; + +// E2E journeys are non-PR-blocking (see docs/testing-strategy.md): they run nightly in CI +// and locally via `bun test:e2e`. The backend is mocked per-test with page.route, so no +// API server, database, or chain access is needed — only the Vite dev server. +export default defineConfig({ + forbidOnly: !!process.env.CI, + fullyParallel: true, + projects: [{ name: "chromium", use: { ...devices["Desktop Chrome"] } }], + reporter: process.env.CI ? [["list"], ["github"]] : [["list"]], + retries: process.env.CI ? 2 : 0, + testDir: "./e2e", + timeout: 60_000, + use: { + baseURL: "http://127.0.0.1:5174", + trace: "on-first-retry" + }, + webServer: { + // --host 127.0.0.1 is required: vite's default binds IPv6 ::1 only, which the url check + // below (and every page.goto) would never reach. + command: "bun x --bun vite --port 5174 --strictPort --host 127.0.0.1", + // No env is needed: the dashboard has no Supabase import, VITE_API_URL already defaults + // to http://localhost:3000 (intercepted per-test) and VITE_WALLETCONNECT_PROJECT_ID + // falls back to a placeholder in src/lib/wagmi.ts. + reuseExistingServer: !process.env.CI, + timeout: 120_000, + url: "http://127.0.0.1:5174/" + } +}); diff --git a/apps/dashboard/src/App.css b/apps/dashboard/src/App.css new file mode 100644 index 000000000..0c4c8e02f --- /dev/null +++ b/apps/dashboard/src/App.css @@ -0,0 +1,163 @@ +@import "tailwindcss"; +@import "tw-animate-css"; + +@custom-variant dark (&:is(.dark *)); + +/* + Vortex design tokens (from apps/frontend/App.css) mapped onto the shadcn + token contract. Values are the exact Vortex OKLCH tokens so shadcn primitives + render in the Vortex palette. Light-only, matching the main app. +*/ +:root { + --radius: 0.625rem; + + --background: oklch(0.98 0.005 210); + --foreground: oklch(0.15 0 0); + + --card: oklch(1 0 0); + --card-foreground: oklch(0.15 0 0); + + --popover: oklch(1 0 0); + --popover-foreground: oklch(0.15 0 0); + + --primary: oklch(0.45 0.2 260); + --primary-foreground: oklch(1 0 0); + + --secondary: oklch(0.97 0.003 260); + --secondary-foreground: oklch(0.4 0.04 250); + + --muted: oklch(0.96 0.005 250); + --muted-foreground: oklch(0.55 0.03 255); + + --accent: oklch(0.97 0.003 260); + --accent-foreground: oklch(0.4 0.04 250); + + --destructive: oklch(0.444 0.177 26.899); + --destructive-foreground: oklch(1 0 0); + + --border: oklch(0.92 0.008 250); + --input: oklch(0.92 0.008 250); + --ring: oklch(0.623 0.214 259); + + /* Vortex brand/status extensions (not part of base shadcn contract) */ + --brand-accent: oklch(0.55 0.2 350); + --brand-accent-foreground: oklch(1 0 0); + --success: oklch(0.448 0.119 151.328); + --success-foreground: oklch(1 0 0); + --warning: oklch(0.77 0.16 83); + --warning-foreground: oklch(0.15 0 0); + --info: oklch(0.623 0.214 259); + --info-foreground: oklch(1 0 0); + + --chart-1: oklch(0.623 0.214 259); + --chart-2: oklch(0.45 0.2 260); + --chart-3: oklch(0.55 0.2 350); + --chart-4: oklch(0.77 0.16 83); + --chart-5: oklch(0.448 0.119 151.328); + + --sidebar: oklch(1 0 0); + --sidebar-foreground: oklch(0.4 0.04 250); + --sidebar-primary: oklch(0.45 0.2 260); + --sidebar-primary-foreground: oklch(1 0 0); + --sidebar-accent: oklch(0.97 0.003 260); + --sidebar-accent-foreground: oklch(0.4 0.04 250); + --sidebar-border: oklch(0.92 0.008 250); + --sidebar-ring: oklch(0.623 0.214 259); +} + +@theme inline { + --font-sans: "Red Hat Display", ui-sans-serif, system-ui, sans-serif; + + --animate-caret-blink: caret-blink 1.25s ease-out infinite; + + @keyframes caret-blink { + 0%, + 70%, + 100% { + opacity: 1; + } + 20%, + 50% { + opacity: 0; + } + } + + --radius-sm: calc(var(--radius) - 4px); + --radius-md: calc(var(--radius) - 2px); + --radius-lg: var(--radius); + --radius-xl: calc(var(--radius) + 4px); + + --color-background: var(--background); + --color-foreground: var(--foreground); + --color-card: var(--card); + --color-card-foreground: var(--card-foreground); + --color-popover: var(--popover); + --color-popover-foreground: var(--popover-foreground); + --color-primary: var(--primary); + --color-primary-foreground: var(--primary-foreground); + --color-secondary: var(--secondary); + --color-secondary-foreground: var(--secondary-foreground); + --color-muted: var(--muted); + --color-muted-foreground: var(--muted-foreground); + --color-accent: var(--accent); + --color-accent-foreground: var(--accent-foreground); + --color-destructive: var(--destructive); + --color-destructive-foreground: var(--destructive-foreground); + --color-border: var(--border); + --color-input: var(--input); + --color-ring: var(--ring); + + --color-brand-accent: var(--brand-accent); + --color-brand-accent-foreground: var(--brand-accent-foreground); + --color-success: var(--success); + --color-success-foreground: var(--success-foreground); + --color-warning: var(--warning); + --color-warning-foreground: var(--warning-foreground); + --color-info: var(--info); + --color-info-foreground: var(--info-foreground); + + --color-chart-1: var(--chart-1); + --color-chart-2: var(--chart-2); + --color-chart-3: var(--chart-3); + --color-chart-4: var(--chart-4); + --color-chart-5: var(--chart-5); + + --color-sidebar: var(--sidebar); + --color-sidebar-foreground: var(--sidebar-foreground); + --color-sidebar-primary: var(--sidebar-primary); + --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); + --color-sidebar-accent: var(--sidebar-accent); + --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); + --color-sidebar-border: var(--sidebar-border); + --color-sidebar-ring: var(--sidebar-ring); +} + +@layer base { + * { + @apply border-border outline-ring/50; + } + + html, + body { + font-family: var(--font-sans); + } + + body { + @apply bg-background text-foreground; + /* Crisper text on macOS (Emil design-engineering principle). */ + -webkit-font-smoothing: antialiased; + } +} + +/* + Raised surface treatment: a top inner highlight + a 1px bottom edge (plus a soft + lift to keep the surface separated from the background) used in place of a solid + border on cards and surface panels. The lift is intentionally light so nested + panels don't read as floating. +*/ +@utility surface-raised { + box-shadow: + inset 0 1px 0 0 #fff, + 0 1px 0 0 rgba(0, 0, 0, 0.04), + 0 1px 2px 0 rgba(0, 0, 0, 0.05); +} diff --git a/apps/dashboard/src/components/auth/AuthOtpStep.tsx b/apps/dashboard/src/components/auth/AuthOtpStep.tsx new file mode 100644 index 000000000..588c54b62 --- /dev/null +++ b/apps/dashboard/src/components/auth/AuthOtpStep.tsx @@ -0,0 +1,61 @@ +import { useState } from "react"; +import { toast } from "sonner"; +import { Button } from "@/components/ui/button"; +import { InputOTP, InputOTPGroup, InputOTPSlot } from "@/components/ui/input-otp"; + +interface AuthOtpStepProps { + email: string; + onVerify: (code: string) => void; + onChangeEmail: () => void; + onResend: () => Promise; + submitting?: boolean; +} + +export function AuthOtpStep({ email, onVerify, onChangeEmail, onResend, submitting }: AuthOtpStepProps) { + const [code, setCode] = useState(""); + + async function resend() { + try { + await onResend(); + toast.success(`A new code was sent to ${email}`); + } catch (error) { + toast.error("Could not resend the code", { + description: error instanceof Error ? error.message : undefined + }); + } + } + + return ( +
+

+ We sent a 6-digit code to {email}. Enter it below. +

+ +
+ + + + + + + + + + +
+ + + +
+ + +
+
+ ); +} diff --git a/apps/dashboard/src/components/layout/AccountSwitcher.tsx b/apps/dashboard/src/components/layout/AccountSwitcher.tsx new file mode 100644 index 000000000..f784fbb3e --- /dev/null +++ b/apps/dashboard/src/components/layout/AccountSwitcher.tsx @@ -0,0 +1,22 @@ +import { Building2, User } from "lucide-react"; +import { useActiveAccount } from "@/hooks/useActiveAccount"; + +/** The authenticated sender account. One session = one account, so there's nothing to switch. */ +export function AccountSwitcher() { + const account = useActiveAccount(); + + if (!account) { + return null; + } + + return ( +
+ {account.type === "company" ? ( + + ) : ( + + )} + {account.name} +
+ ); +} diff --git a/apps/dashboard/src/components/layout/AppSidebar.tsx b/apps/dashboard/src/components/layout/AppSidebar.tsx new file mode 100644 index 000000000..db7e6746e --- /dev/null +++ b/apps/dashboard/src/components/layout/AppSidebar.tsx @@ -0,0 +1,55 @@ +import { Link, useRouterState } from "@tanstack/react-router"; +import { ArrowLeftRight, Send, Settings, ShieldCheck, Users } from "lucide-react"; +import { + Sidebar, + SidebarContent, + SidebarGroup, + SidebarGroupContent, + SidebarHeader, + SidebarMenu, + SidebarMenuButton, + SidebarMenuItem, + SidebarRail +} from "@/components/ui/sidebar"; +import { VortexLogo } from "./VortexLogo"; + +const NAV_ITEMS = [ + { icon: ShieldCheck, label: "Onboarding", to: "/overview" }, + { icon: Users, label: "Recipients", to: "/recipients" }, + { icon: Send, label: "New transfer", to: "/transfer" }, + { icon: ArrowLeftRight, label: "Transactions", to: "/transactions" }, + { icon: Settings, label: "Settings", to: "/settings" } +] as const; + +export function AppSidebar() { + const pathname = useRouterState({ select: state => state.location.pathname }); + + return ( + + +
+ +
+
+ + + + + {NAV_ITEMS.map(item => ( + + + + + {item.label} + + + + ))} + + + + + +
+ ); +} diff --git a/apps/dashboard/src/components/layout/ConnectWalletButton.tsx b/apps/dashboard/src/components/layout/ConnectWalletButton.tsx new file mode 100644 index 000000000..f13008bf2 --- /dev/null +++ b/apps/dashboard/src/components/layout/ConnectWalletButton.tsx @@ -0,0 +1,28 @@ +import { ConnectKitButton } from "connectkit"; +import { Wallet } from "lucide-react"; +import { Button } from "@/components/ui/button"; + +export function ConnectWalletButton() { + return ( + + {({ isConnected, isConnecting, show, truncatedAddress, ensName }) => ( + + )} + + ); +} diff --git a/apps/dashboard/src/components/layout/NotificationsBell.tsx b/apps/dashboard/src/components/layout/NotificationsBell.tsx new file mode 100644 index 000000000..69ffd1343 --- /dev/null +++ b/apps/dashboard/src/components/layout/NotificationsBell.tsx @@ -0,0 +1,56 @@ +import { Bell, MailCheck } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; +import { Separator } from "@/components/ui/separator"; +import { unreadCount, useNotificationsStore } from "@/stores/notifications.store"; + +export function NotificationsBell() { + const items = useNotificationsStore(state => state.items); + const markAllRead = useNotificationsStore(state => state.markAllRead); + const unread = unreadCount(items); + + return ( + open && unread > 0 && markAllRead()}> + + + + +
+

Email updates

+ {items.length > 0 && {items.length}} +
+ + {items.length === 0 ? ( +
+ +

No updates yet. Completion emails will show up here.

+
+ ) : ( +
    + {items.map(item => ( +
  • +
    + +
    +

    {item.title}

    +

    {item.body}

    +

    + {new Date(item.createdAt).toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit" })} +

    +
    +
    +
  • + ))} +
+ )} +
+
+ ); +} diff --git a/apps/dashboard/src/components/layout/Topbar.tsx b/apps/dashboard/src/components/layout/Topbar.tsx new file mode 100644 index 000000000..910a8ce8d --- /dev/null +++ b/apps/dashboard/src/components/layout/Topbar.tsx @@ -0,0 +1,21 @@ +import { Separator } from "@/components/ui/separator"; +import { SidebarTrigger } from "@/components/ui/sidebar"; +import { AccountSwitcher } from "./AccountSwitcher"; +import { ConnectWalletButton } from "./ConnectWalletButton"; +import { NotificationsBell } from "./NotificationsBell"; +import { UserMenu } from "./UserMenu"; + +export function Topbar() { + return ( +
+ + + +
+ + + +
+
+ ); +} diff --git a/apps/dashboard/src/components/layout/UserMenu.tsx b/apps/dashboard/src/components/layout/UserMenu.tsx new file mode 100644 index 000000000..571e003bb --- /dev/null +++ b/apps/dashboard/src/components/layout/UserMenu.tsx @@ -0,0 +1,61 @@ +import { useNavigate } from "@tanstack/react-router"; +import { LogOut } from "lucide-react"; +import { Avatar, AvatarFallback } from "@/components/ui/avatar"; +import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger +} from "@/components/ui/dropdown-menu"; +import { useAuthStore } from "@/stores/auth.store"; + +export function UserMenu() { + const user = useAuthStore(state => state.user); + const logout = useAuthStore(state => state.logout); + const navigate = useNavigate(); + + if (!user) { + return null; + } + + const initials = user.name + .split(" ") + .map(part => part.charAt(0)) + .slice(0, 2) + .join("") + .toUpperCase(); + + return ( + + + + + + +
+ {user.name} + {user.email} +
+
+ + { + logout(); + navigate({ to: "/login" }); + }} + variant="destructive" + > + + Log out + +
+
+ ); +} diff --git a/apps/dashboard/src/components/layout/VortexLogo.tsx b/apps/dashboard/src/components/layout/VortexLogo.tsx new file mode 100644 index 000000000..93b41fadd --- /dev/null +++ b/apps/dashboard/src/components/layout/VortexLogo.tsx @@ -0,0 +1,17 @@ +import { cn } from "@/lib/cn"; + +export function VortexLogo({ className, showWordmark = true }: { className?: string; showWordmark?: boolean }) { + return ( +
+ + V + + {showWordmark && ( +
+ Vortex + Dashboard +
+ )} +
+ ); +} diff --git a/apps/dashboard/src/components/motion/Stagger.tsx b/apps/dashboard/src/components/motion/Stagger.tsx new file mode 100644 index 000000000..0bfe74b28 --- /dev/null +++ b/apps/dashboard/src/components/motion/Stagger.tsx @@ -0,0 +1,20 @@ +import { type HTMLMotionProps, motion } from "motion/react"; +import { riseItem, staggerList } from "@/lib/motion"; + +/** Wrap a group of ``s to reveal them in a cascade on mount. */ +export function Stagger({ children, ...props }: HTMLMotionProps<"div">) { + return ( + + {children} + + ); +} + +/** A single member of a `` cascade. Spread `whileHover`/`whileTap` for extra interactivity. */ +export function StaggerItem({ children, ...props }: HTMLMotionProps<"div">) { + return ( + + {children} + + ); +} diff --git a/apps/dashboard/src/components/onboarding/AccountTypeSelector.tsx b/apps/dashboard/src/components/onboarding/AccountTypeSelector.tsx new file mode 100644 index 000000000..f14b28198 --- /dev/null +++ b/apps/dashboard/src/components/onboarding/AccountTypeSelector.tsx @@ -0,0 +1,76 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { Building2, UserRound } from "lucide-react"; +import { toast } from "sonner"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { ONBOARDING_STATUS_QUERY_KEY } from "@/hooks/useApprovedCorridors"; +import { cn } from "@/lib/cn"; +import { type ActiveEntityType, OnboardingService } from "@/services/api/onboarding.service"; + +const OPTIONS = [ + { + description: "Verify your personal identity and send as yourself.", + icon: UserRound, + label: "Individual", + type: "individual" + }, + { + description: "Verify a legal entity and send on behalf of your company.", + icon: Building2, + label: "Company", + type: "business" + } +] as const; + +export function AccountTypeSelector() { + const queryClient = useQueryClient(); + const mutation = useMutation({ + mutationFn: (type: ActiveEntityType) => OnboardingService.selectActiveEntity(type), + onError: error => { + toast.error("Could not select an account type", { + description: error instanceof Error ? error.message : undefined + }); + }, + onSuccess: () => queryClient.invalidateQueries({ queryKey: ONBOARDING_STATUS_QUERY_KEY }) + }); + + return ( +
+
+
+

Set up your sender profile

+

How will you use Vortex?

+

+ Choose the legal identity for this account. This selection cannot be changed later. +

+
+
+ {OPTIONS.map(option => { + const Icon = option.icon; + const isPending = mutation.isPending && mutation.variables === option.type; + return ( + + +
+ +
+ {option.label} + {option.description} +
+ + + +
+ ); + })} +
+
+
+ ); +} diff --git a/apps/dashboard/src/components/onboarding/CorridorCard.tsx b/apps/dashboard/src/components/onboarding/CorridorCard.tsx new file mode 100644 index 000000000..910e9c07a --- /dev/null +++ b/apps/dashboard/src/components/onboarding/CorridorCard.tsx @@ -0,0 +1,159 @@ +import { ArrowRight, ExternalLink, FileText, RotateCcw } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardFooter, CardHeader } from "@/components/ui/card"; +import { Progress } from "@/components/ui/progress"; +import { isOnboardingAvailable, onboardingKindFor, PROVIDER_LABEL, routeFor } from "@/domain/corridors"; +import { STATUS_META } from "@/domain/status"; +import type { Corridor, OnboardingRoute, OnboardingStatus, SenderAccount } from "@/domain/types"; +import { StatusBadge } from "./StatusBadge"; + +interface CorridorCardProps { + account: SenderAccount; + corridor: Corridor; + onStart: () => void; +} + +const ROUTE_HINT: Record = { + google_form: { icon: FileText, label: "Completed via external Google Form" }, + headless: null, + redirect: { icon: ExternalLink, label: "Completed via partner redirect" } +}; + +/** Progress bar colour carries the outcome: green when approved, red when rejected, brand blue mid-flow. */ +const BAR_TONE: Record = { + approved: "bg-success", + in_review: "bg-primary", + not_started: "bg-primary", + pending: "bg-primary", + rejected: "bg-destructive", + started: "bg-primary" +}; + +export function CorridorCard({ account, corridor, onStart }: CorridorCardProps) { + const kind = onboardingKindFor(corridor, account.type); + const available = isOnboardingAvailable(corridor, kind); + const onboarding = account.onboardings[corridor.id]; + const meta = onboarding ? STATUS_META[onboarding.status] : null; + const hint = ROUTE_HINT[routeFor(corridor.id, kind)]; + + return ( + + +
+
+ {corridor.flag} +
+

{corridor.name}

+

+ {kind.toUpperCase()} · {PROVIDER_LABEL[corridor.provider]} · {corridor.currency} +

+
+
+ {onboarding && } +
+
+ + + + {onboarding?.status === "rejected" ? ( +

Verification was rejected — retry below or contact support.

+ ) : hint ? ( +

+ + {hint.label} +

+ ) : ( +

+ {onboarding + ? `Updated ${new Date(onboarding.updatedAt).toLocaleDateString(undefined, { + day: "numeric", + month: "short", + year: "numeric" + })}` + : ""} +

+ )} +
+ + + {!available && (!onboarding || onboarding.status === "not_started" || onboarding.status === "rejected") ? ( + + ) : onboarding ? ( + + ) : ( + + )} + +
+ ); +} + +function CorridorAction({ + status, + kind, + onStart, + reauthenticationRequired +}: { + status: OnboardingStatus; + kind: "kyb" | "kyc"; + onStart: () => void; + reauthenticationRequired: boolean; +}) { + if (status === "not_started") { + return ( + + ); + } + if (status === "pending" || status === "started") { + // The provider account exists but the user hasn't submitted their details yet — this is a + // resumable, pre-submission state (e.g. the customer was created then the wizard was closed). + // Keep the action enabled: reopening re-checks the canonical backend status and drops the user + // back onto the right step, so they're never locked out after a refresh or modal close. + return ( + + ); + } + if (status === "rejected") { + return ( + + ); + } + if (status === "in_review") { + if (reauthenticationRequired) { + return ( + + ); + } + return ( + + ); + } + return ( + + ); +} diff --git a/apps/dashboard/src/components/onboarding/OnboardingWizard.tsx b/apps/dashboard/src/components/onboarding/OnboardingWizard.tsx new file mode 100644 index 000000000..e412d91f4 --- /dev/null +++ b/apps/dashboard/src/components/onboarding/OnboardingWizard.tsx @@ -0,0 +1,113 @@ +import { useQueryClient } from "@tanstack/react-query"; +import { useCallback } from "react"; +import { Button } from "@/components/ui/button"; +import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"; +import { isCorridorAvailableForAccountType, onboardingKindFor, PROVIDER_LABEL, routeFor } from "@/domain/corridors"; +import type { Corridor, OnboardingKind, OnboardingStatus, SenderAccount } from "@/domain/types"; +import { ONBOARDING_STATUS_QUERY_KEY } from "@/hooks/useApprovedCorridors"; +import { notifyOnboardingStatus } from "@/lib/notify"; +import { AlfredpayKycFlow } from "./alfredpay/AlfredpayKycFlow"; +import { AveniaKycFlow } from "./avenia/AveniaKycFlow"; +import { MoneriumKycFlow } from "./monerium/MoneriumKycFlow"; + +interface OnboardingWizardProps { + account: SenderAccount; + corridor: Corridor; + onClose: () => void; +} + +/** + * Sender onboarding backed by the shared provider machines where the corridor supports the + * selected legal entity type: Alfredpay KYC/KYB (MX/CO, US companies), Avenia KYC/KYB (BR), + * Monerium OAuth for EU. Combinations without a real provider flow (US individuals, AR + * companies) are unavailable — never simulated. + */ +export function OnboardingWizard({ account, corridor, onClose }: OnboardingWizardProps) { + const queryClient = useQueryClient(); + const kind = onboardingKindFor(corridor, account.type); + const route = routeFor(corridor.id, kind); + const isAvailable = isCorridorAvailableForAccountType(corridor.id, account.type); + const isRealAlfredpayKyc = + corridor.provider === "alfredpay" && isAvailable && (route === "headless" || (corridor.id === "US" && kind === "kyb")); + const isLiveAveniaKyc = corridor.provider === "avenia" && isAvailable && route === "headless"; + const isLiveMoneriumKyc = corridor.provider === "monerium" && route === "headless"; + + // A pending company flow whose Avenia subaccount already exists (CNPJ and company name supplied): + // resume straight at the verification links instead of re-asking the form. + const onboarding = account.onboardings[corridor.id]; + const aveniaResume = + kind === "kyb" && onboarding?.status === "pending" && onboarding.taxReference + ? { companyName: onboarding.companyName ?? null, taxId: onboarding.taxReference } + : undefined; + + /** The real flow already moved the provider's status — refetch it rather than override it. */ + const onSettled = useCallback( + (status: OnboardingStatus) => { + notifyOnboardingStatus(corridor.name, kind, status); + queryClient.invalidateQueries({ queryKey: ONBOARDING_STATUS_QUERY_KEY }); + }, + [corridor.name, kind, queryClient] + ); + + return ( + !isOpen && onClose()} open> + + + + {corridor.flag} + {corridor.name} {kind.toUpperCase()} + + + {account.name} · {PROVIDER_LABEL[corridor.provider]} + + + + {isRealAlfredpayKyc ? ( + + ) : isLiveAveniaKyc ? ( + + ) : isLiveMoneriumKyc ? ( + + ) : ( + + )} + + + ); +} + +/** Reached only via deep links — the corridor card already disables entry for these combinations. */ +function UnavailableNotice({ corridor, kind, onClose }: { corridor: Corridor; kind: OnboardingKind; onClose: () => void }) { + return ( + <> +
+

+ {corridor.name} {kind.toUpperCase()} verification is not available yet. Please contact support if you need this + corridor. +

+
+ + + + + ); +} diff --git a/apps/dashboard/src/components/onboarding/StatusBadge.tsx b/apps/dashboard/src/components/onboarding/StatusBadge.tsx new file mode 100644 index 000000000..74bb82662 --- /dev/null +++ b/apps/dashboard/src/components/onboarding/StatusBadge.tsx @@ -0,0 +1,24 @@ +import { CheckCircle2, CircleDashed, Clock, Loader2, XCircle } from "lucide-react"; +import { Badge } from "@/components/ui/badge"; +import { STATUS_META } from "@/domain/status"; +import type { OnboardingStatus } from "@/domain/types"; + +const ICONS: Record = { + approved: CheckCircle2, + in_review: Loader2, + not_started: CircleDashed, + pending: Clock, + rejected: XCircle, + started: Clock +}; + +export function StatusBadge({ status }: { status: OnboardingStatus }) { + const meta = STATUS_META[status]; + const Icon = ICONS[status]; + return ( + + + {meta.label} + + ); +} diff --git a/apps/dashboard/src/components/onboarding/alfredpay/AlfredpayKycFlow.tsx b/apps/dashboard/src/components/onboarding/alfredpay/AlfredpayKycFlow.tsx new file mode 100644 index 000000000..d5c60b0fc --- /dev/null +++ b/apps/dashboard/src/components/onboarding/alfredpay/AlfredpayKycFlow.tsx @@ -0,0 +1,271 @@ +import type { AlfredpayKycFormData, KybBusinessFiles, KybFormData, KybQuestionnaireData, MxnKycFiles } from "@vortexfi/kyc"; +import { useMachine } from "@xstate/react"; +import { AlertTriangle, CheckCircle2, Loader2 } from "lucide-react"; +import { useEffect, useRef } from "react"; +import { Button } from "@/components/ui/button"; +import { DialogFooter } from "@/components/ui/dialog"; +import type { Corridor, OnboardingStatus } from "@/domain/types"; +import { alfredpayKycMachine } from "@/machines/alfredpayKyc.machine"; +import { CORRIDOR_COUNTRY } from "@/services/api/mappers"; +import { DocumentUploadScreen } from "./DocumentUploadScreen"; +import { KybDocumentUploadScreen } from "./KybDocumentUploadScreen"; +import { KybFormScreen } from "./KybFormScreen"; +import { KybQuestionnaireScreen } from "./KybQuestionnaireScreen"; +import { type AlfredpayKycCountry, KycFormScreen } from "./KycFormScreen"; + +interface AlfredpayKycFlowProps { + corridor: Corridor; + /** Fired once per status the provider reports, so the caller can notify and refetch. */ + onSettled: (status: OnboardingStatus) => void; + onClose: () => void; + userEmail?: string; + business?: boolean; +} + +/** Machine states that correspond to a status the rest of the dashboard cares about. */ +const STATUS_BY_STATE: Record = { + FailureKyc: "rejected", + PollingStatus: "in_review", + VerificationDone: "approved" +}; + +const BUSY_STATES = new Set([ + "CheckingStatus", + "ValidatingInput", + "CreatingCustomer", + "SubmittingKycInfo", + "SubmittingFiles", + "SendingSubmission", + "Retrying", + "SubmittingKybInfo", + "SubmittingKybBusinessFiles", + "FindingKybCustomerAndBusiness", + "SubmittingKybRelatedPersonBundle", + "SendingKybSubmission", + "OpeningLink", + "FinishingFilling" +]); + +function Centered({ children }: { children: React.ReactNode }) { + return
{children}
; +} + +export function AlfredpayKycFlow({ corridor, onSettled, onClose, userEmail, business = false }: AlfredpayKycFlowProps) { + const [state, send] = useMachine(alfredpayKycMachine, { + input: { business, country: CORRIDOR_COUNTRY[corridor.id] } + }); + + const value = String(state.value); + const { error, country, kybFormData, kybQuestionnaireData } = state.context; + + const reported = useRef(null); + useEffect(() => { + const status = STATUS_BY_STATE[value]; + if (!status || reported.current === status) { + return; + } + reported.current = status; + onSettled(status); + }, [value, onSettled]); + + if (BUSY_STATES.has(value)) { + return ( + + +

Talking to Alfredpay…

+
+ ); + } + + if (value === "CustomerDefinition") { + return ( + <> + +

+ We'll create your {corridor.name} {business ? "business" : "individual"} verification profile with Alfredpay. +

+
+ + + + + + ); + } + + if (value === "FillingKycForm") { + // Only MX/CO/AR reach this state: US redirects to the provider, and the wizard only mounts + // this flow for Alfredpay corridors on the headless route. + return ( + send({ data, type: "SUBMIT_FORM" })} + userEmail={userEmail} + /> + ); + } + + if (value === "FillingKybForm" && (country === "MX" || country === "CO")) { + return ( + send({ data, type: "SUBMIT_KYB_FORM" })} + userEmail={userEmail} + /> + ); + } + + if (value === "UploadingDocuments") { + return ( + send({ type: "GO_BACK" })} + onSubmit={(files: MxnKycFiles) => send({ files, type: "SUBMIT_FILES" })} + /> + ); + } + + if (value === "FillingKybQuestionnaire") { + return ( + send({ type: "GO_BACK" })} + onSubmit={(data: KybQuestionnaireData) => send({ data, type: "SUBMIT_KYB_QUESTIONNAIRE" })} + /> + ); + } + + if (value === "UploadingKybBusinessDocs") { + return ( + send({ type: "GO_BACK" })} + onSubmit={(files: KybBusinessFiles) => send({ files, type: "SUBMIT_KYB_BUSINESS_FILES" })} + /> + ); + } + + if (value === "LinkReady") { + return ( + <> + +
+

Continue with Alfredpay

+

+ Alfredpay hosts the US {business ? "KYB" : "KYC"} process in a secure new tab. +

+
+
+ + + + + + ); + } + + if (value === "FillingKyc") { + return ( + <> + +
+

Complete verification with Alfredpay

+

Return here after completing the hosted form.

+
+
+ + + + + + ); + } + + if (value === "PollingStatus") { + return ( + <> + + +
+

In review

+

+ Alfredpay is reviewing your submission. We'll email you when it's complete. +

+
+
+ + + + + ); + } + + if (value === "VerificationDone") { + return ( + <> + + +
+

Approved

+

+ {corridor.name} {business ? "KYB" : "KYC"} is complete. You can now register recipients. +

+
+
+ + + + + ); + } + + // `FailureKyc` is the provider rejecting the submission; `Failure` is our call to them failing. + if (value === "FailureKyc" || value === "Failure") { + const isProviderRejection = value === "FailureKyc"; + return ( + <> + + +
+

{isProviderRejection ? "Verification failed" : "Something went wrong"}

+

{error?.message ?? "Please try again."}

+
+
+ + + + + + ); + } + + return null; +} diff --git a/apps/dashboard/src/components/onboarding/alfredpay/DocumentUploadScreen.tsx b/apps/dashboard/src/components/onboarding/alfredpay/DocumentUploadScreen.tsx new file mode 100644 index 000000000..9a16b0812 --- /dev/null +++ b/apps/dashboard/src/components/onboarding/alfredpay/DocumentUploadScreen.tsx @@ -0,0 +1,109 @@ +import { KYC_FILE_ACCEPTED_TYPES, KYC_FILE_MAX_BYTES, type MxnKycFiles } from "@vortexfi/kyc"; +import { UploadCloud } from "lucide-react"; +import { useRef, useState } from "react"; +import { Button } from "@/components/ui/button"; +import { DialogFooter } from "@/components/ui/dialog"; +import { cn } from "@/lib/cn"; + +interface DocumentUploadScreenProps { + /** Argentina additionally requires a selfie. */ + includeSelfie: boolean; + /** Upload failure from the machine's previous attempt. */ + error?: string; + onSubmit: (files: MxnKycFiles) => void; + onBack: () => void; +} + +function FileDropZone({ label, file, onChange }: { label: string; file: File | null; onChange: (file: File) => void }) { + const inputRef = useRef(null); + const [rejected, setRejected] = useState(null); + + const handleFile = (candidate: File) => { + if (!KYC_FILE_ACCEPTED_TYPES.includes(candidate.type)) { + setRejected("Use a JPG, PNG or PDF file."); + return; + } + if (candidate.size > KYC_FILE_MAX_BYTES) { + setRejected("That file is over 5 MB."); + return; + } + setRejected(null); + onChange(candidate); + }; + + return ( +
+

{label}

+ + {rejected &&

{rejected}

} +
+ ); +} + +export function DocumentUploadScreen({ includeSelfie, error, onSubmit, onBack }: DocumentUploadScreenProps) { + const [front, setFront] = useState(null); + const [back, setBack] = useState(null); + const [selfie, setSelfie] = useState(null); + + const isComplete = front !== null && back !== null && (!includeSelfie || selfie !== null); + + const handleSubmit = () => { + if (!front || !back) return; + if (includeSelfie && !selfie) return; + onSubmit({ back, front, selfie: selfie ?? undefined }); + }; + + return ( + <> +
+
+

Identity document

+

JPG, PNG or PDF, up to 5 MB each.

+
+ + + + {includeSelfie && } + + {error &&

{error}

} +
+ + + + + + + ); +} diff --git a/apps/dashboard/src/components/onboarding/alfredpay/KybDocumentUploadScreen.tsx b/apps/dashboard/src/components/onboarding/alfredpay/KybDocumentUploadScreen.tsx new file mode 100644 index 000000000..3d1c8bbf4 --- /dev/null +++ b/apps/dashboard/src/components/onboarding/alfredpay/KybDocumentUploadScreen.tsx @@ -0,0 +1,137 @@ +import { KYC_FILE_ACCEPTED_TYPES, KYC_FILE_MAX_BYTES, type KybBusinessFiles } from "@vortexfi/kyc"; +import { UploadCloud } from "lucide-react"; +import { useRef, useState } from "react"; +import { Button } from "@/components/ui/button"; +import { DialogFooter } from "@/components/ui/dialog"; +import { cn } from "@/lib/cn"; + +function FileDropZone({ label, file, onChange }: { label: string; file: File | null; onChange: (file: File) => void }) { + const inputRef = useRef(null); + const [rejected, setRejected] = useState(null); + + const handleFile = (candidate: File) => { + if (!KYC_FILE_ACCEPTED_TYPES.includes(candidate.type)) { + setRejected("Use a JPG, PNG or PDF file."); + return; + } + if (candidate.size > KYC_FILE_MAX_BYTES) { + setRejected("That file is over 5 MB."); + return; + } + setRejected(null); + onChange(candidate); + }; + + return ( +
+

{label}

+ + {rejected &&

{rejected}

} +
+ ); +} + +interface KybDocumentUploadScreenProps { + error?: string; + /** Answered on the questionnaire step; Alfredpay then also requires a licence and AML policy. */ + isRegulatedBusiness?: boolean; + onBack: () => void; + onSubmit: (files: KybBusinessFiles) => void; +} + +export function KybDocumentUploadScreen({ error, isRegulatedBusiness, onBack, onSubmit }: KybDocumentUploadScreenProps) { + const [taxIdDocument, setTaxIdDocument] = useState(null); + const [articlesIncorporation, setArticlesIncorporation] = useState(null); + const [proofAddress, setProofAddress] = useState(null); + const [shareholderRegistry, setShareholderRegistry] = useState(null); + const [docFront, setDocFront] = useState(null); + const [docBack, setDocBack] = useState(null); + const [businessLicense, setBusinessLicense] = useState(null); + const [uploadAmlPolicy, setUploadAmlPolicy] = useState(null); + const regulatedComplete = !isRegulatedBusiness || (!!businessLicense && !!uploadAmlPolicy); + const complete = + !!taxIdDocument && + !!articlesIncorporation && + !!proofAddress && + !!shareholderRegistry && + !!docFront && + !!docBack && + regulatedComplete; + + const handleSubmit = () => { + if (!taxIdDocument || !articlesIncorporation || !proofAddress || !shareholderRegistry || !docFront || !docBack) return; + if (!regulatedComplete) return; + onSubmit({ + articlesIncorporation, + // Sent only on the regulated branch — Alfredpay rejects the submission without them there. + businessLicense: isRegulatedBusiness ? (businessLicense ?? undefined) : undefined, + docBack, + docFront, + proofAddress, + shareholderRegistry, + taxIdDocument, + uploadAmlPolicy: isRegulatedBusiness ? (uploadAmlPolicy ?? undefined) : undefined + }); + }; + + return ( + <> +
+
+

Company and representative documents

+

+ {isRegulatedBusiness ? "All eight files are required." : "All six files are required."} JPG, PNG or PDF, up to 5 MB + each. +

+
+ + + + + {isRegulatedBusiness && ( + <> + + + + )} + + + {error &&

{error}

} +
+ + + + + + ); +} diff --git a/apps/dashboard/src/components/onboarding/alfredpay/KybFormScreen.tsx b/apps/dashboard/src/components/onboarding/alfredpay/KybFormScreen.tsx new file mode 100644 index 000000000..51f52e898 --- /dev/null +++ b/apps/dashboard/src/components/onboarding/alfredpay/KybFormScreen.tsx @@ -0,0 +1,121 @@ +import { standardSchemaResolver } from "@hookform/resolvers/standard-schema"; +import { type KybFormData, type KybFormValues, kybFormSchema, mapKybFormValues, toKybFormValues } from "@vortexfi/kyc"; +import { useForm } from "react-hook-form"; +import { Button } from "@/components/ui/button"; +import { Checkbox } from "@/components/ui/checkbox"; +import { DialogFooter } from "@/components/ui/dialog"; +import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; + +interface KybFormScreenProps { + country: "MX" | "CO"; + /** Details already given, so stepping back from the questionnaire does not blank the form. */ + defaults?: KybFormData; + onCancel: () => void; + onSubmit: (data: KybFormData) => void; + userEmail?: string; +} + +export function KybFormScreen({ country, defaults, onCancel, onSubmit, userEmail }: KybFormScreenProps) { + const form = useForm({ + defaultValues: defaults + ? toKybFormValues(defaults) + : { + address: "", + businessName: "", + city: "", + repDateOfBirth: "", + repDni: "", + repEmail: userEmail ?? "", + repFirstName: "", + repLastName: "", + repNationality: country, + repPep: false, + state: "", + taxId: "", + website: "", + zipCode: "" + }, + resolver: standardSchemaResolver(kybFormSchema) + }); + + const field = (name: keyof KybFormValues, label: string, type = "text", readOnly = false) => ( + ( + + {label} + + + + + + )} + /> + ); + + return ( +
+ onSubmit(mapKybFormValues(values)))}> +
+
+

Company details

+

Enter the legal details registered for this business.

+
+ {field("businessName", "Legal business name")} +
+ {field("taxId", "Tax ID")} + {field("website", "Website", "url")} +
+ {field("address", "Registered address")} +
+ {field("city", "City")} + {field("state", "State")} +
+ {field("zipCode", "Postal code")} + +
+

Authorized representative

+

This person's identity document is required on the next step.

+
+
+ {field("repFirstName", "First name")} + {field("repLastName", "Last name")} +
+ {field("repEmail", "Email", "email", !!userEmail)} +
+ {field("repDateOfBirth", "Date of birth", "date")} + {field("repDni", "Document number")} +
+ {field("repNationality", "Nationality (2-letter code)")} + ( + + + input.onChange(checked === true)} /> + + This person is a politically exposed person + + )} + /> +
+ + + + +
+ + ); +} diff --git a/apps/dashboard/src/components/onboarding/alfredpay/KybQuestionnaireScreen.tsx b/apps/dashboard/src/components/onboarding/alfredpay/KybQuestionnaireScreen.tsx new file mode 100644 index 000000000..9207cb8c5 --- /dev/null +++ b/apps/dashboard/src/components/onboarding/alfredpay/KybQuestionnaireScreen.tsx @@ -0,0 +1,174 @@ +import { standardSchemaResolver } from "@hookform/resolvers/standard-schema"; +import { + type KybQuestionnaireData, + type KybQuestionnaireValues, + kybQuestionnaireSchema, + mapKybQuestionnaireValues +} from "@vortexfi/kyc"; +import { useForm } from "react-hook-form"; +import { Button } from "@/components/ui/button"; +import { Checkbox } from "@/components/ui/checkbox"; +import { DialogFooter } from "@/components/ui/dialog"; +import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; + +interface KybQuestionnaireScreenProps { + /** Answers already given, so stepping back from the documents does not blank the form. */ + defaults?: KybQuestionnaireData; + onBack: () => void; + onSubmit: (data: KybQuestionnaireData) => void; +} + +/** + * Alfredpay's compliance questionnaire. Every question here is one Alfredpay requires before it will + * accept the submission — see GET …/penny/kybRequirements?country=. The two conditionals mirror its + * `requiredIf`, and answering "regulated business" reveals two more documents on the next step. + */ +export function KybQuestionnaireScreen({ defaults, onBack, onSubmit }: KybQuestionnaireScreenProps) { + const form = useForm({ + defaultValues: { + accountPurpose: defaults?.accountPurpose ?? "", + businessActivities: defaults?.businessActivities ?? "", + complianceScreeningDescription: defaults?.complianceScreeningDescription ?? "", + conductsComplianceScreening: defaults?.conductsComplianceScreening ?? false, + expectedMonthlyTransactions: defaults?.expectedMonthlyTransactions, + expectedMonthlyVolumeUsd: defaults?.expectedMonthlyVolumeUsd, + isRegulatedBusiness: defaults?.isRegulatedBusiness ?? false, + operatesInSanctionedCountries: defaults?.operatesInSanctionedCountries ?? false, + sourceOfFunds: defaults?.sourceOfFunds ?? "", + transmitsCustomerFunds: defaults?.transmitsCustomerFunds ?? false, + walletAddresses: defaults?.walletAddresses ?? "" + }, + resolver: standardSchemaResolver(kybQuestionnaireSchema) + }); + + const transmitsCustomerFunds = form.watch("transmitsCustomerFunds"); + const conductsComplianceScreening = form.watch("conductsComplianceScreening"); + + const textField = (name: keyof KybQuestionnaireValues, label: string, description?: string, placeholder?: string) => ( + ( + + {label} + {description && {description}} + + + + + + )} + /> + ); + + const numberField = ( + name: "expectedMonthlyVolumeUsd" | "expectedMonthlyTransactions", + label: string, + placeholder: string + ) => ( + ( + + {label} + + field.onChange(event.target.value === "" ? undefined : event.target.valueAsNumber)} + placeholder={placeholder} + type="number" + value={typeof field.value === "number" ? field.value : ""} + /> + + + + )} + /> + ); + + const checkboxField = ( + name: "transmitsCustomerFunds" | "operatesInSanctionedCountries" | "isRegulatedBusiness" | "conductsComplianceScreening", + label: string, + description?: string + ) => ( + ( + + + field.onChange(checked === true)} /> + +
+ {label} + {description && {description}} + +
+
+ )} + /> + ); + + return ( +
+ onSubmit(mapKybQuestionnaireValues(values)))}> +
+
+

Compliance questionnaire

+

Alfredpay requires these answers before it can review the business.

+
+ {textField( + "sourceOfFunds", + "Source of funds", + "The primary source of the company's revenue or the funds used with Alfredpay.", + "Sale of goods/services, investments, venture capital" + )} + {textField( + "businessActivities", + "Business activities", + undefined, + "Money services, lending, FX, virtual currencies brokerage" + )} + {textField("accountPurpose", "Primary account purpose", undefined, "Treasury management, cross-border transfers")} + {textField( + "walletAddresses", + "Wallet addresses", + "List the wallets that will interact with Alfredpay and their chain. Enter N/A if the business will not transact on-chain.", + "ETH - 0x1234abcd…; TRX - TAbcd1234…" + )} +
+ {numberField("expectedMonthlyVolumeUsd", "Expected monthly volume (USD)", "50000")} + {numberField("expectedMonthlyTransactions", "Expected monthly transactions", "120")} +
+ +
+

Declarations

+

Tick only what applies to the business.

+
+ {checkboxField("transmitsCustomerFunds", "We transmit funds on behalf of our customers")} + {transmitsCustomerFunds && + checkboxField("conductsComplianceScreening", "We conduct compliance screening (KYC, KYB and AML)")} + {transmitsCustomerFunds && + conductsComplianceScreening && + textField( + "complianceScreeningDescription", + "Describe your compliance screening", + undefined, + "KYC, KYB and AML checks on every counterparty" + )} + {checkboxField("operatesInSanctionedCountries", "We operate in Cuba, Iran, Myanmar, North Korea or Syria")} + {checkboxField("isRegulatedBusiness", "We perform regulated activities", "Adds two documents to the next step.")} +
+ + + + +
+ + ); +} diff --git a/apps/dashboard/src/components/onboarding/alfredpay/KycFormScreen.tsx b/apps/dashboard/src/components/onboarding/alfredpay/KycFormScreen.tsx new file mode 100644 index 000000000..3c641457d --- /dev/null +++ b/apps/dashboard/src/components/onboarding/alfredpay/KycFormScreen.tsx @@ -0,0 +1,309 @@ +import { standardSchemaResolver } from "@hookform/resolvers/standard-schema"; +import { + type AlfredpayKycFormData, + AR_KYC_DEFAULTS, + type ArKycFormValues, + arKycSchema, + type ColKycFormValues, + colKycSchema, + type MxnKycFormValues, + mxnKycSchema, + toArPhoneNumber, + toColPhoneNumber +} from "@vortexfi/kyc"; +import { AlfredpayArgentinaDocumentType, AlfredpayColombiaDocumentType } from "@vortexfi/shared"; +import { type Control, type FieldPath, type FieldValues, useForm } from "react-hook-form"; +import { Button } from "@/components/ui/button"; +import { Checkbox } from "@/components/ui/checkbox"; +import { DialogFooter } from "@/components/ui/dialog"; +import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; + +export type AlfredpayKycCountry = "MX" | "CO" | "AR"; + +interface KycFormScreenProps { + country: AlfredpayKycCountry; + onSubmit: (data: AlfredpayKycFormData) => void; + onCancel: () => void; + userEmail?: string; +} + +interface TextFieldProps { + control: Control; + name: FieldPath; + label: string; + placeholder?: string; + readOnly?: boolean; + type?: string; +} + +function TextField({ control, name, label, placeholder, readOnly, type = "text" }: TextFieldProps) { + return ( + ( + + {label} + + + + + + )} + /> + ); +} + +/** + * Phone numbers are stored in the `+` form Alfredpay validates against, so the + * normaliser runs on every keystroke rather than at submit — the schema checks the stored value. + */ +function PhoneField({ + control, + name, + label, + placeholder, + normalise +}: TextFieldProps & { normalise: (value: string) => string }) { + return ( + ( + + {label} + + field.onChange(normalise(event.target.value))} + placeholder={placeholder} + type="tel" + value={field.value ?? ""} + /> + + + + )} + /> + ); +} + +function NameAndBirth({ control }: { control: Control }) { + return ( + <> +
+ } /> + } /> +
+ } type="date" /> + + ); +} + +function AddressFields({ control }: { control: Control }) { + return ( + <> + } /> +
+ } /> + } /> +
+ } /> + + ); +} + +function FormShell({ + children, + onCancel, + onSubmit +}: { + children: React.ReactNode; + onCancel: () => void; + onSubmit: () => void; +}) { + return ( +
+
{children}
+ + + + +
+ ); +} + +function MxKycForm({ onSubmit, onCancel, userEmail }: Omit) { + const form = useForm({ + defaultValues: { + address: "", + city: "", + dateOfBirth: "", + dni: "", + email: userEmail ?? "", + firstName: "", + lastName: "", + state: "", + zipCode: "" + }, + resolver: standardSchemaResolver(mxnKycSchema) + }); + + return ( +
+ + + + + + +
+ ); +} + +function ColKycForm({ onSubmit, onCancel }: Omit) { + const form = useForm({ + defaultValues: { + address: "", + city: "", + dateOfBirth: "", + dni: "", + firstName: "", + lastName: "", + phoneNumber: "", + state: "", + typeDocumentCol: AlfredpayColombiaDocumentType.CC, + zipCode: "" + }, + resolver: standardSchemaResolver(colKycSchema) + }); + + return ( +
+ + + ( + + Document type + + + + )} + /> + + + + +
+ ); +} + +function ArKycForm({ onSubmit, onCancel, userEmail }: Omit) { + const form = useForm({ + defaultValues: { + ...AR_KYC_DEFAULTS, + address: "", + city: "", + dateOfBirth: "", + dni: "", + email: userEmail ?? "", + firstName: "", + lastName: "", + phoneNumber: "", + state: "", + zipCode: "" + }, + resolver: standardSchemaResolver(arKycSchema) + }); + + return ( +
+ + + + + ( + + Document type + + + + )} + /> + + + + ( + + + + + I am a politically exposed person + + )} + /> + +
+ ); +} + +export function KycFormScreen({ country, onSubmit, onCancel, userEmail }: KycFormScreenProps) { + if (country === "MX") { + return ; + } + if (country === "CO") { + return ; + } + return ; +} diff --git a/apps/dashboard/src/components/onboarding/avenia/AveniaDocumentUploadScreen.tsx b/apps/dashboard/src/components/onboarding/avenia/AveniaDocumentUploadScreen.tsx new file mode 100644 index 000000000..7a350d576 --- /dev/null +++ b/apps/dashboard/src/components/onboarding/avenia/AveniaDocumentUploadScreen.tsx @@ -0,0 +1,190 @@ +import type { UploadIds } from "@vortexfi/kyc"; +import { AveniaDocumentType } from "@vortexfi/shared"; +import { CheckCircle2, FileText, Loader2, UploadCloud } from "lucide-react"; +import { useRef, useState } from "react"; +import { Button } from "@/components/ui/button"; +import { DialogFooter } from "@/components/ui/dialog"; +import { Label } from "@/components/ui/label"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { cn } from "@/lib/cn"; +import { BrlaService } from "@/services/api/brla.service"; + +interface AveniaDocumentUploadScreenProps { + onBack: () => void; + onSubmit: (uploadIds: UploadIds) => void; + taxId: string; +} + +const MAX_FILE_SIZE = 15 * 1024 * 1024; +const ALLOWED_TYPES = ["image/png", "image/jpeg", "application/pdf"]; + +async function uploadFile(file: File, url: string) { + const response = await fetch(url, { + body: await file.arrayBuffer(), + headers: { + "Content-Type": file.type || "application/octet-stream", + "If-None-Match": "*" + }, + method: "PUT" + }); + + if (!response.ok) { + throw new Error(`Upload failed: ${response.status} ${response.statusText}`); + } +} + +export function AveniaDocumentUploadScreen({ onBack, onSubmit, taxId }: AveniaDocumentUploadScreenProps) { + const [docType, setDocType] = useState(AveniaDocumentType.DRIVERS_LICENSE); + const [front, setFront] = useState(null); + const [back, setBack] = useState(null); + const [error, setError] = useState(null); + const [isUploading, setIsUploading] = useState(false); + + const requiresBack = docType === AveniaDocumentType.ID; + const canSubmit = !!taxId && !!front && (!requiresBack || !!back) && !isUploading; + + const handleSubmit = async () => { + if (!front || (requiresBack && !back)) return; + + setError(null); + setIsUploading(true); + try { + const response = await BrlaService.getUploadUrls({ documentType: docType, taxId }); + const uploads = [uploadFile(front, response.idUpload.uploadURLFront)]; + + if (requiresBack) { + if (!back || !response.idUpload.uploadURLBack) { + throw new Error("Avenia did not return a back-side upload URL."); + } + uploads.push(uploadFile(back, response.idUpload.uploadURLBack)); + } + + await Promise.all(uploads); + onSubmit({ + livenessUrl: response.selfieUpload.livenessUrl ?? "", + uploadedDocumentId: response.idUpload.id, + uploadedSelfieId: response.selfieUpload.id + }); + } catch (uploadError) { + setError(uploadError instanceof Error ? uploadError.message : "Could not upload documents."); + } finally { + setIsUploading(false); + } + }; + + return ( + <> +
+
+

Upload identity documents

+

Files are uploaded directly to Avenia using provider upload URLs.

+
+ +
+ + +
+ + + {requiresBack && } + + {!taxId &&

Tax ID is missing. Go back and enter your CPF again.

} + {error &&

{error}

} +
+ + + + + + + ); +} + +function FileUploadCard({ + file, + label, + onChange +}: { + file: File | null; + label: string; + onChange: (file: File | null) => void; +}) { + const inputRef = useRef(null); + const [rejected, setRejected] = useState(null); + + const handleFile = (candidate: File) => { + if (!ALLOWED_TYPES.includes(candidate.type)) { + setRejected("Use a JPG, PNG, or PDF file."); + onChange(null); + return; + } + if (candidate.size > MAX_FILE_SIZE) { + setRejected("That file is over 15 MB."); + onChange(null); + return; + } + + setRejected(null); + onChange(candidate); + }; + + return ( +
+ + {rejected &&

{rejected}

} +
+ ); +} diff --git a/apps/dashboard/src/components/onboarding/avenia/AveniaKybFormScreen.tsx b/apps/dashboard/src/components/onboarding/avenia/AveniaKybFormScreen.tsx new file mode 100644 index 000000000..8d1e0b2ce --- /dev/null +++ b/apps/dashboard/src/components/onboarding/avenia/AveniaKybFormScreen.tsx @@ -0,0 +1,91 @@ +import { standardSchemaResolver } from "@hookform/resolvers/standard-schema"; +import type { AveniaKycFormData } from "@vortexfi/kyc"; +import { isValidCnpj } from "@vortexfi/shared"; +import { useForm } from "react-hook-form"; +import { z } from "zod"; +import { Button } from "@/components/ui/button"; +import { DialogFooter } from "@/components/ui/dialog"; +import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; + +const schema = z.object({ + fullName: z.string().min(3, "Enter the registered company name"), + taxId: z.string().refine(isValidCnpj, "Enter a valid CNPJ") +}); + +type FormValues = z.infer; + +interface AveniaKybFormScreenProps { + initialData?: AveniaKycFormData; + onCancel: () => void; + onSubmit: (data: AveniaKycFormData) => void; +} + +export function AveniaKybFormScreen({ initialData, onCancel, onSubmit }: AveniaKybFormScreenProps) { + const form = useForm({ + defaultValues: { fullName: initialData?.fullName ?? "", taxId: initialData?.taxId ?? "" }, + resolver: standardSchemaResolver(schema) + }); + + return ( +
+ + onSubmit({ + birthdate: "", + cep: "", + city: "", + email: "", + fullName: values.fullName, + number: "", + pixId: "", + state: "", + street: "", + taxId: values.taxId + }) + )} + > +
+
+

Company information

+

+ Avenia uses this information to create your Brazilian business profile. +

+
+ ( + + Registered company name + + + + + + )} + /> + ( + + CNPJ + + + + + + )} + /> +
+ + + + +
+ + ); +} diff --git a/apps/dashboard/src/components/onboarding/avenia/AveniaKybHostedStep.tsx b/apps/dashboard/src/components/onboarding/avenia/AveniaKybHostedStep.tsx new file mode 100644 index 000000000..0f031dc8e --- /dev/null +++ b/apps/dashboard/src/components/onboarding/avenia/AveniaKybHostedStep.tsx @@ -0,0 +1,46 @@ +import { ExternalLink, ShieldCheck } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { DialogFooter } from "@/components/ui/dialog"; + +interface AveniaKybHostedStepProps { + description: string; + isOpened: boolean; + onBack: () => void; + onDone: () => void; + onOpen: () => void; + title: string; + url: string; +} + +export function AveniaKybHostedStep({ description, isOpened, onBack, onDone, onOpen, title, url }: AveniaKybHostedStepProps) { + function openVerification() { + window.open(url, "_blank", "noopener,noreferrer"); + onOpen(); + } + + return ( + <> +
+
+ +
+
+

{title}

+

{description}

+
+ +
+ + + + + + ); +} diff --git a/apps/dashboard/src/components/onboarding/avenia/AveniaKycFlow.tsx b/apps/dashboard/src/components/onboarding/avenia/AveniaKycFlow.tsx new file mode 100644 index 000000000..ee32fcf94 --- /dev/null +++ b/apps/dashboard/src/components/onboarding/avenia/AveniaKycFlow.tsx @@ -0,0 +1,265 @@ +import type { AveniaKycFormData, UploadIds } from "@vortexfi/kyc"; +import { createAveniaKycApi, createAveniaKycMachine, KycStatus } from "@vortexfi/kyc"; +import { useMachine } from "@xstate/react"; +import { AlertTriangle, CheckCircle2, Loader2 } from "lucide-react"; +import { useEffect, useRef } from "react"; +import { Button } from "@/components/ui/button"; +import { DialogFooter } from "@/components/ui/dialog"; +import type { Corridor, OnboardingStatus } from "@/domain/types"; +import { apiClient } from "@/services/api/api-client"; +import { AveniaDocumentUploadScreen } from "./AveniaDocumentUploadScreen"; +import { AveniaKybFormScreen } from "./AveniaKybFormScreen"; +import { AveniaKybHostedStep } from "./AveniaKybHostedStep"; +import { AveniaKycFormScreen } from "./AveniaKycFormScreen"; +import { AveniaLivenessScreen } from "./AveniaLivenessScreen"; + +interface AveniaKycFlowProps { + business?: boolean; + corridor: Corridor; + onClose: () => void; + onSettled: (status: OnboardingStatus) => void; + /** Existing Avenia account data — skips the form and resumes at the verification links. */ + resume?: { companyName: string | null; taxId: string }; +} + +const aveniaKycMachine = createAveniaKycMachine({ + api: createAveniaKycApi(apiClient) +}); + +const STATUS_BY_STATE: Record = { + DocumentUpload: "pending", + FormFilling: "pending", + LivenessCheck: "pending", + RefreshingLivenessUrl: "pending", + Submit: "in_review", + Success: "approved", + Verifying: "in_review" +}; + +const BUSY_STATES = new Set(["SubaccountSetup", "Submit", "Verifying"]); + +export function AveniaKycFlow({ business = false, corridor, onClose, onSettled, resume }: AveniaKycFlowProps) { + const [state, send] = useMachine(aveniaKycMachine, { + input: resume + ? { + kycFormData: { + companyName: resume.companyName ?? undefined, + fullName: resume.companyName ?? "", + taxId: resume.taxId + } as AveniaKycFormData, + resumeExistingAccount: true, + taxId: resume.taxId + } + : { taxId: "" } + }); + const value = String(state.value); + const isCompanyVerification = state.matches({ KYBFlow: "CompanyVerification" }); + const isRepresentativeVerification = state.matches({ KYBFlow: "RepresentativeVerification" }); + const isStatusVerification = state.matches({ KYBFlow: "StatusVerification" }); + + const reported = useRef(null); + useEffect(() => { + const status = isStatusVerification + ? state.context.kycStatus === KycStatus.APPROVED + ? "approved" + : state.context.kycStatus === KycStatus.REJECTED + ? "rejected" + : "in_review" + : STATUS_BY_STATE[value]; + if (!status || reported.current === status) return; + reported.current = status; + onSettled(status); + }, [value, isStatusVerification, state.context.kycStatus, onSettled]); + + if (BUSY_STATES.has(value)) { + return ( + <> + + +
+

+ {value === "SubaccountSetup" + ? resume + ? "Resuming your verification" + : "Creating your Avenia profile" + : "Submitting your application"} +

+

+ {value === "SubaccountSetup" && resume + ? "Your Avenia profile already exists — fetching the verification steps." + : "This can take a moment while Avenia processes your information."} +

+
+
+ {value !== "SubaccountSetup" && ( + + + + )} + + ); + } + + if (value === "FormFilling") { + if (business) { + return ( + send({ formData, type: "FORM_SUBMIT" })} + /> + ); + } + return ( + send({ formData, type: "FORM_SUBMIT" })} + /> + ); + } + + if (isCompanyVerification && state.context.kybUrls) { + return ( + send({ type: "GO_BACK" })} + onDone={() => send({ type: "KYB_COMPANY_DONE" })} + onOpen={() => send({ type: "COMPANY_VERIFICATION_STARTED" })} + title="Verify your company" + url={state.context.kybUrls.basicCompanyDataUrl} + /> + ); + } + + if (isRepresentativeVerification && state.context.kybUrls) { + return ( + send({ type: "KYB_COMPANY_BACK" })} + onDone={() => send({ type: "KYB_REPRESENTATIVE_DONE" })} + onOpen={() => send({ type: "REPRESENTATIVE_VERIFICATION_STARTED" })} + title="Verify the company representative" + url={state.context.kybUrls.authorizedRepresentativeUrl} + /> + ); + } + + if (isStatusVerification) { + const isApproved = state.context.kycStatus === KycStatus.APPROVED; + const isRejected = state.context.kycStatus === KycStatus.REJECTED; + return ( + <> + + {isApproved ? ( + + ) : isRejected ? ( + + ) : ( + + )} +
+

{isApproved ? "Approved" : isRejected ? "Verification failed" : "In review"}

+

+ {isApproved + ? "Your Brazil KYB is complete." + : isRejected + ? (state.context.rejectReason ?? "Avenia rejected the application.") + : "Avenia is reviewing the company and representative information."} +

+
+
+ + {isRejected ? ( + + ) : ( + + )} + + + ); + } + + if (value === "DocumentUpload") { + return ( + send({ type: "DOCUMENTS_BACK" })} + onSubmit={(documentsId: UploadIds) => send({ documentsId, type: "DOCUMENTS_SUBMIT" })} + taxId={state.context.taxId} + /> + ); + } + + if (value === "LivenessCheck" || value === "RefreshingLivenessUrl") { + return ( + send({ type: "GO_BACK" })} + onDone={() => send({ type: "LIVENESS_DONE" })} + onOpen={() => send({ type: "LIVENESS_OPENED" })} + onRefresh={() => send({ type: "REFRESH_LIVENESS_URL" })} + /> + ); + } + + if (value === "Success") { + return ( + <> + + +
+

Approved

+

+ {corridor.name} {business ? "KYB" : "KYC"} is complete. You can now register recipients. +

+
+
+ + + + + ); + } + + if (value === "Failure" || value === "Rejected") { + return ( + <> + + +
+

Something went wrong

+

+ {state.context.error?.message ?? state.context.rejectReason ?? "Please try again."} +

+
+
+ + + + + + ); + } + + return null; +} + +function Centered({ children }: { children: React.ReactNode }) { + return
{children}
; +} diff --git a/apps/dashboard/src/components/onboarding/avenia/AveniaKycFormScreen.tsx b/apps/dashboard/src/components/onboarding/avenia/AveniaKycFormScreen.tsx new file mode 100644 index 000000000..7b88d545e --- /dev/null +++ b/apps/dashboard/src/components/onboarding/avenia/AveniaKycFormScreen.tsx @@ -0,0 +1,114 @@ +import { standardSchemaResolver } from "@hookform/resolvers/standard-schema"; +import type { AveniaKycFormData } from "@vortexfi/kyc"; +import { type Control, type FieldPath, type FieldValues, useForm } from "react-hook-form"; +import { z } from "zod"; +import { Button } from "@/components/ui/button"; +import { DialogFooter } from "@/components/ui/dialog"; +import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; + +interface AveniaKycFormScreenProps { + initialData?: AveniaKycFormData; + onCancel: () => void; + onSubmit: (data: AveniaKycFormData) => void; +} + +const DEFAULT_VALUES: AveniaKycFormData = { + birthdate: "", + cep: "", + city: "", + email: "", + fullName: "", + number: "", + pixId: "", + state: "", + street: "", + taxId: "" +}; + +const aveniaKycFormSchema = z.object({ + birthdate: z.string().min(1, "Enter a birth date"), + cep: z.string().min(8, "Enter a valid CEP"), + city: z.string().min(1, "Enter a city"), + email: z.string().email("Enter a valid email"), + fullName: z.string().min(2, "Enter a full name"), + number: z.string().min(1, "Enter a number"), + pixId: z.string().min(1, "Enter a PIX key"), + state: z.string().min(2, "Enter a state"), + street: z.string().min(1, "Enter a street"), + taxId: z.string().min(11, "Enter a CPF") +}) satisfies z.ZodType; + +export function AveniaKycFormScreen({ initialData, onCancel, onSubmit }: AveniaKycFormScreenProps) { + const form = useForm({ + defaultValues: initialData ?? DEFAULT_VALUES, + resolver: standardSchemaResolver(aveniaKycFormSchema) + }); + + return ( +
+ +
+
+

Personal information

+

+ This information is submitted to Avenia for BRL sender verification. +

+
+ + +
+ + +
+ + +
+ + +
+
+ + +
+ +
+ + + + + +
+ + ); +} + +function TextField({ + control, + label, + name, + type = "text" +}: { + control: Control; + label: string; + name: FieldPath; + type?: string; +}) { + return ( + ( + + {label} + + + + + + )} + /> + ); +} diff --git a/apps/dashboard/src/components/onboarding/avenia/AveniaLivenessScreen.tsx b/apps/dashboard/src/components/onboarding/avenia/AveniaLivenessScreen.tsx new file mode 100644 index 000000000..b8d16496e --- /dev/null +++ b/apps/dashboard/src/components/onboarding/avenia/AveniaLivenessScreen.tsx @@ -0,0 +1,108 @@ +import { ExternalLink, Loader2, RefreshCw, ShieldCheck } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { DialogFooter } from "@/components/ui/dialog"; + +interface AveniaLivenessScreenProps { + isRefreshing: boolean; + isOpened: boolean; + livenessUrl?: string; + onBack: () => void; + onDone: () => void; + onOpen: () => void; + onRefresh: () => void; +} + +export function AveniaLivenessScreen({ + isOpened, + isRefreshing, + livenessUrl, + onBack, + onDone, + onOpen, + onRefresh +}: AveniaLivenessScreenProps) { + const openLiveness = () => { + if (!livenessUrl) return; + onOpen(); + window.open(livenessUrl, "_blank", "noopener,noreferrer"); + }; + + return ( + <> +
+
+

Face verification

+

+ Open Avenia's liveness check in a new tab, then return here after completing it. +

+
+ +
+
+ + + +
+

Avenia liveness link

+

+ {isRefreshing ? "Refreshing liveness link..." : (livenessUrl ?? "No liveness link is available yet.")} +

+
+
+
+ {isOpened ? ( + + ) : ( + + )} + {isOpened && ( + + )} + +
+
+
+ + + + {!isOpened && } + + + ); +} diff --git a/apps/dashboard/src/components/onboarding/monerium/MoneriumKycFlow.tsx b/apps/dashboard/src/components/onboarding/monerium/MoneriumKycFlow.tsx new file mode 100644 index 000000000..4f5515698 --- /dev/null +++ b/apps/dashboard/src/components/onboarding/monerium/MoneriumKycFlow.tsx @@ -0,0 +1,140 @@ +import { createMoneriumKycApi, createMoneriumKycMachine, type MoneriumCustomerType } from "@vortexfi/kyc"; +import { useMachine } from "@xstate/react"; +import { AlertTriangle, CheckCircle2, ExternalLink, Loader2, ShieldCheck } from "lucide-react"; +import { useEffect, useRef } from "react"; +import { Button } from "@/components/ui/button"; +import { DialogFooter } from "@/components/ui/dialog"; +import type { Corridor, OnboardingStatus } from "@/domain/types"; +import { apiClient } from "@/services/api/api-client"; + +interface MoneriumKycFlowProps { + corridor: Corridor; + customerType: MoneriumCustomerType; + onClose: () => void; + onSettled: (status: OnboardingStatus) => void; +} + +const moneriumKycMachine = createMoneriumKycMachine({ + api: createMoneriumKycApi(apiClient), + openAuthorizationUrl: url => requestAnimationFrame(() => window.location.assign(url)) +}); + +const STATUS_BY_STATE: Record = { + Approved: "approved", + InReview: "in_review", + Rejected: "rejected" +}; + +export function MoneriumKycFlow({ corridor, customerType, onClose, onSettled }: MoneriumKycFlowProps) { + const [state, send] = useMachine(moneriumKycMachine, { input: { customerType } }); + const value = String(state.value); + const reported = useRef(null); + + useEffect(() => { + const status = STATUS_BY_STATE[value]; + if (!status || reported.current === status) return; + reported.current = status; + onSettled(status); + }, [onSettled, value]); + + if (value === "Routing" || value === "CheckingStatus" || value === "StartingAuthorization" || value === "Redirecting") { + return ( + + +
+

Connecting to Monerium

+

Checking your secure onboarding status.

+
+
+ ); + } + + if (value === "Ready") { + return ( + <> + + +
+

Verify with Monerium

+

+ Monerium will securely collect the information required for your {customerType === "business" ? "KYB" : "KYC"}. + You will return here when finished. +

+
+
+ + + + + + ); + } + + if (value === "InReview") { + return ( + <> + + +
+

Verification in review

+

Monerium is reviewing your information. You can return later.

+
+
+ + + + + + ); + } + + if (value === "Approved") { + return ( + <> + + +
+

Approved

+

Your {corridor.name} onboarding is complete.

+
+
+ + + + + ); + } + + if (value === "Rejected" || value === "Failure") { + return ( + <> + + +
+

{value === "Rejected" ? "Verification was not approved" : "Could not continue"}

+

{state.context.error?.message ?? "Contact support or try again."}

+
+
+ + + + + + ); + } + + return null; +} + +function Centered({ children }: { children: React.ReactNode }) { + return
{children}
; +} diff --git a/apps/dashboard/src/components/recipients/RecipientActionsDialog.tsx b/apps/dashboard/src/components/recipients/RecipientActionsDialog.tsx new file mode 100644 index 000000000..9cf9b29dd --- /dev/null +++ b/apps/dashboard/src/components/recipients/RecipientActionsDialog.tsx @@ -0,0 +1,92 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { Trash2 } from "lucide-react"; +import { useState } from "react"; +import { toast } from "sonner"; +import { Button } from "@/components/ui/button"; +import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"; +import { inviteUrl, recipientLabel } from "@/domain/recipient"; +import type { Recipient } from "@/domain/types"; +import { RECIPIENTS_QUERY_KEY } from "@/hooks/useRecipients"; +import { RecipientsService } from "@/services/api/recipients.service"; +import { InviteLinkCopy } from "./RecipientDialog"; + +/** + * Row-click management modal: re-copy the invite link (only while the invite is pending + * and its token is retained) and remove the entry from the list. Removal archives — the + * link stays valid and the recipient can still complete KYC. + */ +export function RecipientActionsDialog({ + recipient, + onOpenChange +}: { + recipient: Recipient | null; + onOpenChange: (open: boolean) => void; +}) { + const [confirmingRemove, setConfirmingRemove] = useState(false); + const queryClient = useQueryClient(); + + const remove = useMutation({ + mutationFn: (target: Recipient): Promise => + target.kind === "invitation" + ? RecipientsService.archiveInvitation(target.id) + : RecipientsService.archiveRecipient(target.id), + onError: error => { + toast.error("Could not remove the recipient", { description: error instanceof Error ? error.message : undefined }); + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: RECIPIENTS_QUERY_KEY }); + onOpenChange(false); + } + }); + + function handleOpenChange(open: boolean) { + if (!open) { + setConfirmingRemove(false); + } + onOpenChange(open); + } + + if (!recipient) { + return null; + } + + const canRecopy = recipient.kind === "invitation" && recipient.status === "invite_sent" && recipient.inviteCode !== ""; + + return ( + + + + {recipientLabel(recipient)} + + {canRecopy + ? "Share the invite link again, or remove this recipient from your list." + : "Remove this recipient from your list."} + + + + {canRecopy && } + +

+ {recipient.kind === "invitation" + ? "Removing does not invalidate the invite link — the recipient can still open it and complete their verification." + : "Removing only hides this recipient from your list — it does not block them or undo their verification."} +

+ + + + + +
+
+ ); +} diff --git a/apps/dashboard/src/components/recipients/RecipientDialog.tsx b/apps/dashboard/src/components/recipients/RecipientDialog.tsx new file mode 100644 index 000000000..671060f4a --- /dev/null +++ b/apps/dashboard/src/components/recipients/RecipientDialog.tsx @@ -0,0 +1,269 @@ +import { standardSchemaResolver } from "@hookform/resolvers/standard-schema"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { Building2, Check, Copy, Link2, Plus, User } from "lucide-react"; +import { useState } from "react"; +import { useForm } from "react-hook-form"; +import { toast } from "sonner"; +import { z } from "zod"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger +} from "@/components/ui/dialog"; +import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { CORRIDORS, isCorridorAvailableForAccountType } from "@/domain/corridors"; +import { inviteUrl } from "@/domain/recipient"; +import type { AccountType, Corridor, CorridorId, SenderAccount } from "@/domain/types"; +import { RECIPIENTS_QUERY_KEY } from "@/hooks/useRecipients"; +import { notifyInviteCopied, notifyInviteLinkReady } from "@/lib/notify"; +import { CORRIDOR_COUNTRY, CORRIDOR_RAIL } from "@/services/api/mappers"; +import { RecipientsService } from "@/services/api/recipients.service"; + +const schema = z.object({ + alias: z.string().trim().min(1, "Enter a name for this link").max(100, "Keep it under 100 characters"), + corridorId: z.enum(["BR", "EU", "MX", "CO", "US", "AR"]), + recipientType: z.enum(["individual", "company"]) +}); + +type FormValues = z.infer; + +interface CreatedInvite { + id: string; + url: string; + corridorName: string; +} + +export function RecipientDialog({ + account, + corridors, + defaultCorridorId +}: { + account: SenderAccount; + corridors: Corridor[]; + defaultCorridorId?: CorridorId; +}) { + const [open, setOpen] = useState(false); + const [created, setCreated] = useState(null); + const queryClient = useQueryClient(); + + const form = useForm({ + defaultValues: { + alias: "", + corridorId: defaultCorridorId ?? corridors[0]?.id ?? "BR", + recipientType: "individual" + }, + resolver: standardSchemaResolver(schema) + }); + + const corridorId = form.watch("corridorId"); + const recipientType = form.watch("recipientType"); + const corridor = CORRIDORS[corridorId]; + // Not every corridor supports both recipient types (e.g. Alfredpay has no AR company KYB). + const selectableCorridors = corridors.filter(option => isCorridorAvailableForAccountType(option.id, recipientType)); + const disabled = corridors.length === 0; + + const createInvite = useMutation({ + mutationFn: (values: FormValues) => + RecipientsService.createInvite({ + alias: values.alias, + country: CORRIDOR_COUNTRY[values.corridorId], + inviteeType: values.recipientType === "company" ? "business" : "individual", + payoutCurrency: CORRIDOR_RAIL[values.corridorId], + rail: CORRIDOR_RAIL[values.corridorId] + }), + onError: error => { + toast.error("Could not create the invite", { description: error instanceof Error ? error.message : undefined }); + }, + onSuccess: (invite, values) => { + const selected = CORRIDORS[values.corridorId]; + notifyInviteLinkReady(selected.name); + // Show the new invite as a pending recipient the moment it's created. + queryClient.invalidateQueries({ queryKey: RECIPIENTS_QUERY_KEY }); + setCreated({ corridorName: selected.name, id: invite.id, url: inviteUrl(invite.token, values.corridorId) }); + } + }); + + function onSubmit(values: FormValues) { + createInvite.mutate(values); + } + + function reset() { + setCreated(null); + form.reset({ alias: "", corridorId: form.getValues("corridorId"), recipientType: form.getValues("recipientType") }); + } + + function onOpenChange(next: boolean) { + setOpen(next); + if (!next) { + reset(); + } + } + + return ( + + + + + + {created ? ( + onOpenChange(false)} /> + ) : ( + <> + + Add a recipient + + Choose who you're paying and name the link. We'll generate an invite link you send them yourself — they complete + KYC/KYB and add their payout details to receive transfers. + + +
+ + ( + + Recipient type + { + const nextType = value as AccountType; + field.onChange(nextType); + if (!isCorridorAvailableForAccountType(form.getValues("corridorId"), nextType)) { + const fallback = corridors.find(option => isCorridorAvailableForAccountType(option.id, nextType)); + if (fallback) { + form.setValue("corridorId", fallback.id); + } + } + }} + value={field.value} + > + + + + Individual + + + + Company + + + + + )} + /> + +
+ ( + + Country + + + + )} + /> + ( + + Alias + + + + + + )} + /> +
+ + + + + + + + + )} +
+
+ ); +} + +function InviteShare({ created, onDone }: { created: CreatedInvite; onDone: () => void }) { + return ( + <> + + Invite link ready + + Send this {created.corridorName} link to your recipient. They'll complete KYC/KYB and add their payout details — then + you can pay them. + + + + + + + + ); +} + +/** The copyable invite-link block, shared by invite creation and the row management modal. */ +export function InviteLinkCopy({ url }: { url: string }) { + const [copied, setCopied] = useState(false); + + function copy() { + navigator.clipboard?.writeText(url); + notifyInviteCopied(); + setCopied(true); + } + + return ( +
+ Invite link +
+ {url} + +
+
+ ); +} diff --git a/apps/dashboard/src/components/recipients/RecipientsTable.tsx b/apps/dashboard/src/components/recipients/RecipientsTable.tsx new file mode 100644 index 000000000..ffcdffeb7 --- /dev/null +++ b/apps/dashboard/src/components/recipients/RecipientsTable.tsx @@ -0,0 +1,127 @@ +import { useNavigate } from "@tanstack/react-router"; +import { ArrowRight } from "lucide-react"; +import { motion } from "motion/react"; +import { useState } from "react"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; +import { CORRIDORS } from "@/domain/corridors"; +import { recipientLabel } from "@/domain/recipient"; +import { RECIPIENT_STATUS_META } from "@/domain/status"; +import { PAYMENT_METHOD_LABEL } from "@/domain/transfer"; +import type { Recipient } from "@/domain/types"; +import { RecipientActionsDialog } from "./RecipientActionsDialog"; + +const MotionRow = motion.create(TableRow); + +export function RecipientsTable({ recipients }: { recipients: Recipient[] }) { + const [selectedId, setSelectedId] = useState(null); + // Derived from the live list, not a snapshot: a refetch while the modal is open (e.g. the + // invite got accepted and its token cleared) must not keep offering stale data. + const selected = selectedId ? (recipients.find(recipient => recipient.id === selectedId) ?? null) : null; + + return ( + <> + + + + Recipient + Country + Payout + Status + Action + + + + {recipients.map((recipient, i) => { + const corridor = CORRIDORS[recipient.corridorId]; + const status = RECIPIENT_STATUS_META[recipient.status]; + const openActions = recipient.isSelf ? undefined : () => setSelectedId(recipient.id); + return ( + { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + openActions(); + } + }) + } + tabIndex={openActions ? 0 : undefined} + transition={{ bounce: 0, delay: Math.min(i, 10) * 0.04, duration: 0.4, type: "spring" }} + > + +
+ {recipientLabel(recipient)} + + {recipient.isSelf ? "Yourself" : recipient.recipientType} + +
+
+ + {corridor.flag} {corridor.name} + + +
+ {recipient.payoutCurrency} + + {recipient.bankDetails.value + ? `${PAYMENT_METHOD_LABEL[recipient.bankDetails.method]} · ${recipient.bankDetails.value}` + : "Awaiting recipient details"} + +
+
+ + {status.label} + + + + +
+ ); + })} +
+
+ {selected && !open && setSelectedId(null)} recipient={selected} />} + + ); +} + +function RecipientAction({ recipient }: { recipient: Recipient }) { + const navigate = useNavigate(); + + // Only your own payout accounts are sendable today. + if (recipient.isSelf && recipient.status === "approved") { + return ( + + ); + } + + if (recipient.status === "approved") { + return Coming soon; + } + + if (recipient.status === "invite_sent") { + return Invite created; + } + + if (recipient.status === "expired" || recipient.status === "rejected") { + return {RECIPIENT_STATUS_META[recipient.status].label}; + } + + return Pending review; +} diff --git a/apps/dashboard/src/components/transactions/TransactionsTable.tsx b/apps/dashboard/src/components/transactions/TransactionsTable.tsx new file mode 100644 index 000000000..e75f880cb --- /dev/null +++ b/apps/dashboard/src/components/transactions/TransactionsTable.tsx @@ -0,0 +1,129 @@ +import { LifeBuoy } from "lucide-react"; +import { motion } from "motion/react"; +import { toast } from "sonner"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; +import { CORRIDORS } from "@/domain/corridors"; +import { TX_STATUS_META } from "@/domain/status"; +import { shortenAddress, TRANSFER_NETWORKS } from "@/domain/transfer"; +import type { Transaction } from "@/domain/types"; + +const MotionRow = motion.create(TableRow); + +/** A live-status dot with an expanding ping halo — signals the demo is actively settling this row. */ +function LiveDot({ className }: { className: string }) { + return ( + + + + + ); +} + +function networkLabel(id: string): string { + return TRANSFER_NETWORKS.find(network => network.id === id)?.label ?? id; +} + +/** Awaiting/processing are live states the demo settles on a timer — show a pulsing indicator. */ +const LIVE_DOT: Partial> = { + awaiting_payin: "bg-warning", + processing: "bg-info" +}; + +export function TransactionsTable({ transactions }: { transactions: Transaction[] }) { + return ( + + + + Created at + Recipient + Payin wallet + Amount in + Fiat payout + Country / currency + Status + Action + + + + {transactions.map((tx, i) => { + const corridor = CORRIDORS[tx.corridorId]; + const status = TX_STATUS_META[tx.status]; + const dot = LIVE_DOT[tx.status]; + return ( + + + {new Date(tx.createdAt).toLocaleString(undefined, { + day: "numeric", + hour: "2-digit", + minute: "2-digit", + month: "short" + })} + + {tx.recipientEmail} + +
+ {shortenAddress(tx.payinWallet)} + {networkLabel(tx.payinNetwork)} +
+
+ + + {tx.amountIn} {tx.amountInToken} + + + + + {tx.fiatPayoutAmount} {tx.payoutCurrency} + + + + {corridor.flag} {corridor.name} · {corridor.currency} + + + + {dot && } + {status.label} + + + + {tx.status === "failed" ? ( + + ) : ( + + )} + +
+ ); + })} +
+
+ ); +} + +function FailedAction({ reason, recipientEmail }: { reason?: string; recipientEmail: string }) { + return ( + + + + + {reason ?? "This payout failed. Contact support to resolve it."} + + ); +} diff --git a/apps/dashboard/src/components/transfer/FundingMethods.tsx b/apps/dashboard/src/components/transfer/FundingMethods.tsx new file mode 100644 index 000000000..0908cc648 --- /dev/null +++ b/apps/dashboard/src/components/transfer/FundingMethods.tsx @@ -0,0 +1,77 @@ +import type { QuoteResponse } from "@vortexfi/shared"; +import { ConnectKitButton } from "connectkit"; +import { Check, Wallet } from "lucide-react"; +import { useAccount } from "wagmi"; +import { Button } from "@/components/ui/button"; +import { shortenAddress } from "@/domain/transfer"; + +export type FundingSource = "wallet"; + +export interface FundingSubmit { + source: FundingSource; + label: string; + destAddress: string; +} + +interface FundingMethodsProps { + quote: QuoteResponse; + network: string; + submitting: boolean; + onSubmit: (submit: FundingSubmit) => void; +} + +/** + * The ramp is signed by the connected wallet, which is the only funding path — self-custodial + * crypto deposits are not supported. + * + * Submitting is gated off for now: the button below is commented out and accounts are enabled + * on request. `quote`, `submitting` and `onSubmit` stay on the props so restoring it is an + * uncomment plus `const { quote, submitting, onSubmit } = _props`. + */ +export function FundingMethods(_props: FundingMethodsProps) { + const { address, isConnected } = useAccount(); + + if (!isConnected || !address) { + return ( +
+ How you'll fund this +
+

Connect your wallet to send the payin directly.

+ + {({ show }) => ( + + )} + +
+
+ ); + } + + return ( +
+ How you'll fund this +
+
+ + Connected + {shortenAddress(address)} +
+ {/* + + */} +

+ To enable this feature on your account, please reach out to{" "} + + support@vortexfinance.co + +

+
+
+ ); +} diff --git a/apps/dashboard/src/components/transfer/QuoteSummary.tsx b/apps/dashboard/src/components/transfer/QuoteSummary.tsx new file mode 100644 index 000000000..95dc3c738 --- /dev/null +++ b/apps/dashboard/src/components/transfer/QuoteSummary.tsx @@ -0,0 +1,144 @@ +import type { QuoteResponse } from "@vortexfi/shared"; +import { ChevronDown, Info, RefreshCw } from "lucide-react"; +import { AnimatePresence, motion } from "motion/react"; +import { useEffect, useState } from "react"; +import { Progress } from "@/components/ui/progress"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; +import { cn } from "@/lib/cn"; +import { springSnappy } from "@/lib/motion"; + +const QUOTE_TTL_SECONDS = 60; + +function useSecondsLeft(expiresAt: Date | string | undefined): number { + const [now, setNow] = useState(() => Date.now()); + // External sync: tick once a second to drive the quote-expiry countdown. + useEffect(() => { + const timer = setInterval(() => setNow(Date.now()), 1000); + return () => clearInterval(timer); + }, []); + if (!expiresAt) { + return 0; + } + return Math.max(0, Math.round((new Date(expiresAt).getTime() - now) / 1000)); +} + +function formatRate(rate: number): string { + return rate >= 1 ? rate.toFixed(2) : rate.toFixed(4); +} + +interface QuoteSummaryProps { + quote: QuoteResponse; + isFetching: boolean; +} + +export function QuoteSummary({ quote, isFetching }: QuoteSummaryProps) { + const [open, setOpen] = useState(false); + const secondsLeft = useSecondsLeft(quote.expiresAt); + + const input = Number(quote.inputAmount); + const output = Number(quote.outputAmount); + const discount = Number(quote.discountFiat ?? "0"); + const effectiveTotalFee = Number(quote.totalFeeFiat) - discount; + const interbankRate = input > 0 ? (output + effectiveTotalFee) / input : 0; + const netRate = input > 0 ? output / input : 0; + const fiat = quote.feeCurrency; + + const feeItems: { label: string; tooltip: string; value: string }[] = [ + { + label: "Processing fee", + tooltip: "Provider and Vortex fees for settling to the recipient's bank.", + value: `${quote.processingFeeFiat} ${fiat}` + } + ]; + if (discount > 0) { + feeItems.push({ + label: "Discount", + tooltip: "A partner discount applied to this transfer.", + value: `- ${quote.discountFiat} ${quote.discountCurrency ?? fiat}` + }); + } + if (Number(quote.networkFeeFiat) > 0) { + feeItems.push({ + label: "Network fee", + tooltip: "Blockchain gas to move the stablecoin on-chain.", + value: `${quote.networkFeeFiat} ${fiat}` + }); + } + + return ( +
+
+ Rate +
+ + + 1 USDC = {formatRate(interbankRate)} {fiat} + +
+
+ +
+ + + {isFetching ? "refreshing…" : `${secondsLeft}s`} + +
+ + + + + {open && ( + + {feeItems.map(item => ( +
+ + {item.label} + + + + + {item.tooltip} + + + {item.value} +
+ ))} +
+ Total fee + + {effectiveTotalFee.toFixed(2)} {fiat} + +
+
+ + Net rate + + + + + The all-in rate you get after every fee. + + + + 1 USDC = {formatRate(netRate)} {fiat} + +
+
+ )} +
+
+ ); +} diff --git a/apps/dashboard/src/components/transfer/TransferForm.tsx b/apps/dashboard/src/components/transfer/TransferForm.tsx new file mode 100644 index 000000000..23ac5fa25 --- /dev/null +++ b/apps/dashboard/src/components/transfer/TransferForm.tsx @@ -0,0 +1,290 @@ +import { useNavigate } from "@tanstack/react-router"; +import { QuoteError } from "@vortexfi/shared"; +import { useSelector } from "@xstate/react"; +import { Lock, TriangleAlert } from "lucide-react"; +import { useState } from "react"; +import { toast } from "sonner"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { Skeleton } from "@/components/ui/skeleton"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; +import { CORRIDORS } from "@/domain/corridors"; +import { recipientLabel } from "@/domain/recipient"; +import { RECIPIENT_STATUS_META } from "@/domain/status"; +import { PAYMENT_METHOD_LABEL, TRANSFER_NETWORKS } from "@/domain/transfer"; +import type { Recipient, SenderAccount } from "@/domain/types"; +import { buildTransferAdditionalData } from "@/machines/registerAdditionalData"; +import { transferActor } from "@/machines/transferActor"; +import { useOfframpQuote } from "@/services/api/hooks"; +import { FundingMethods, type FundingSubmit } from "./FundingMethods"; +import { QuoteSummary } from "./QuoteSummary"; + +interface TransferFormProps { + account: SenderAccount; + recipients: Recipient[]; + preselectRecipientId?: string; +} + +function extractBackendLimit(message: string): { value: string; currency: string } | undefined { + const match = message.match(/of\s+(\d+(?:\.\d+)?)\s+([A-Z]{3})/); + if (!match?.[1] || !match[2]) { + return undefined; + } + return { currency: match[2], value: match[1] }; +} + +function friendlyQuoteError(message: string): string { + const limit = extractBackendLimit(message); + const suffix = limit ? ` of ${limit.value} ${limit.currency}` : ""; + if (message.includes(QuoteError.BelowLowerLimitSell)) { + return `This amount is below the minimum${suffix}. Try a larger payout.`; + } + if (message.includes(QuoteError.AboveUpperLimitSell)) { + return `This amount is above the maximum${suffix}. Try a smaller payout.`; + } + if (message.includes(QuoteError.LowLiquidity)) { + return QuoteError.LowLiquidity; + } + return "We couldn't fetch a quote right now. Please try again."; +} + +export function TransferForm({ account, recipients, preselectRecipientId }: TransferFormProps) { + const navigate = useNavigate(); + + const firstSelfApproved = recipients.find(recipient => recipient.isSelf && recipient.status === "approved"); + const initialId = preselectRecipientId ?? firstSelfApproved?.id ?? ""; + const [recipientId, setRecipientId] = useState(initialId); + const [network, setNetwork] = useState(TRANSFER_NETWORKS[0].id); + const [amount, setAmount] = useState(""); + const [pixKey, setPixKey] = useState(""); + + const selected = recipients.find(recipient => recipient.id === recipientId); + // Only self-recipients are sendable today; third-party sending is coming soon. + const isSendable = selected?.isSelf === true && selected.status === "approved"; + const corridor = selected ? CORRIDORS[selected.corridorId] : undefined; + const payoutAmount = Number(amount); + // BRL offramps pay out to the user's own PIX key; taxId/receiverTaxId are derived server-side. + const needsPixKey = selected?.corridorId === "BR" && selected.isSelf === true; + const pixReady = !needsPixKey || pixKey.trim().length > 0; + + function selectRecipient(id: string) { + setRecipientId(id); + setAmount(""); + setPixKey(""); + } + + // The transfer machine (ported widget ramp core) owns register → sign → start → track. + const transferState = useSelector(transferActor, snapshot => snapshot.value); + const submitting = transferState === "Registering" || transferState === "SigningUserTxs" || transferState === "Starting"; + const activeTransfer = useSelector( + transferActor, + snapshot => + snapshot.matches("Registering") || + snapshot.matches("SigningUserTxs") || + snapshot.matches("Starting") || + snapshot.matches("Tracking") + ); + const signing = useSelector(transferActor, snapshot => snapshot.matches("SigningUserTxs")); + + const quoteParams = + selected && isSendable && payoutAmount > 0 ? { corridorId: selected.corridorId, network, payoutAmount } : null; + const { data: quote, isFetching, error } = useOfframpQuote(quoteParams); + + function submitTransfer(submit: FundingSubmit) { + if (!selected || !isSendable || !quote || activeTransfer || !pixReady) { + return; + } + const label = recipientLabel(selected); + const summary = `${quote.outputAmount} ${selected.payoutCurrency} to ${label}`; + + // One-shot outcome watcher: navigate when tracking begins, surface the error + // when any stage fails. The actor keeps polling after this form unmounts. + const subscription = transferActor.subscribe(snapshot => { + if (snapshot.matches("Tracking")) { + subscription.unsubscribe(); + toast.success("Transfer initiated", { + description: `Funding via ${submit.label} — we'll pay out ${summary} once your ${quote.inputAmount} USDC lands.` + }); + navigate({ to: "/transactions" }); + } else if (snapshot.matches("Failed")) { + subscription.unsubscribe(); + toast.error("Could not start transfer", { description: snapshot.context.errorMessage ?? undefined }); + } + }); + + transferActor.send({ + additionalData: buildTransferAdditionalData(selected, submit.destAddress, pixKey.trim() || undefined), + meta: { + accountId: account.id, + amountIn: quote.inputAmount, + amountInToken: "USDC", + corridorId: selected.corridorId, + fiatPayoutAmount: quote.outputAmount, + payinNetwork: network, + payoutCurrency: selected.payoutCurrency, + recipientEmail: label, + recipientId: selected.id, + summary + }, + quote, + type: "START" + }); + } + + return ( +
+
+ + +

+ You can send to your own payout accounts. Third-party sending is coming soon. +

+
+ + {selected && !isSendable && ( +
+ +

+ {selected.isSelf + ? `${recipientLabel(selected)} is ${RECIPIENT_STATUS_META[selected.status].label.toLowerCase()}. Transfers stay blocked until it's approved.` + : "Sending to third-party recipients is coming soon — you can only pay your own payout accounts for now."} +

+
+ )} + + {selected && isSendable && corridor && ( + <> +
+
+ +
+ setAmount(event.target.value)} + placeholder="0.00" + value={amount} + /> + {selected.payoutCurrency} +
+

Edit the amount anytime — the quote updates automatically.

+
+ + {corridor.flag} {corridor.name} + + + {PAYMENT_METHOD_LABEL[selected.bankDetails.method]} · {selected.bankDetails.value} + +
+ + {needsPixKey && ( +
+ + setPixKey(event.target.value)} + placeholder="CPF, phone, email or random key" + value={pixKey} + /> +

+ We pay out to your own PIX key — it must be registered to your tax ID. +

+
+ )} + +
+ + +
+ + {activeTransfer && !submitting ? ( +
+ +

A transfer is already in progress. Wait for it to finish before starting another.

+
+ ) : error ? ( +
+ +

{friendlyQuoteError(error.message)}

+
+ ) : payoutAmount <= 0 ? ( +

+ Enter an amount to see the quote. +

+ ) : !pixReady ? ( +

+ Enter your PIX key to continue. +

+ ) : quote ? ( + <> + + + {signing && ( +

+ Confirm the signature request in your wallet to authorize the transfer… +

+ )} + + ) : ( +
+ + +
+ )} + + )} +
+ ); +} + +function Row({ label, children }: { label: string; children: React.ReactNode }) { + return ( +
+ {label} + {children} +
+ ); +} diff --git a/apps/dashboard/src/components/ui/avatar.tsx b/apps/dashboard/src/components/ui/avatar.tsx new file mode 100644 index 000000000..f8164b021 --- /dev/null +++ b/apps/dashboard/src/components/ui/avatar.tsx @@ -0,0 +1,29 @@ +import { Avatar as AvatarPrimitive } from "radix-ui"; +import type * as React from "react"; +import { cn } from "@/lib/cn"; + +function Avatar({ className, ...props }: React.ComponentProps) { + return ( + + ); +} + +function AvatarImage({ className, ...props }: React.ComponentProps) { + return ; +} + +function AvatarFallback({ className, ...props }: React.ComponentProps) { + return ( + + ); +} + +export { Avatar, AvatarImage, AvatarFallback }; diff --git a/apps/dashboard/src/components/ui/badge.tsx b/apps/dashboard/src/components/ui/badge.tsx new file mode 100644 index 000000000..a8cf71ef8 --- /dev/null +++ b/apps/dashboard/src/components/ui/badge.tsx @@ -0,0 +1,36 @@ +import { cva, type VariantProps } from "class-variance-authority"; +import { Slot as SlotPrimitive } from "radix-ui"; +import type * as React from "react"; +import { cn } from "@/lib/cn"; + +const badgeVariants = cva( + "inline-flex items-center justify-center rounded-md border px-2 py-0.5 font-medium text-xs w-fit whitespace-nowrap shrink-0 gap-1 [&>svg]:size-3 [&>svg]:pointer-events-none transition-[color,box-shadow] overflow-hidden", + { + defaultVariants: { + variant: "default" + }, + variants: { + variant: { + default: "border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90", + destructive: "border-transparent bg-destructive text-destructive-foreground [a&]:hover:bg-destructive/90", + info: "border-transparent bg-info/10 text-info", + outline: "text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground", + secondary: "border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90", + success: "border-transparent bg-success/10 text-success", + warning: "border-transparent bg-warning/15 text-warning-foreground" + } + } + } +); + +function Badge({ + className, + variant, + asChild = false, + ...props +}: React.ComponentProps<"span"> & VariantProps & { asChild?: boolean }) { + const Comp = asChild ? SlotPrimitive.Root : "span"; + return ; +} + +export { Badge, badgeVariants }; diff --git a/apps/dashboard/src/components/ui/button.tsx b/apps/dashboard/src/components/ui/button.tsx new file mode 100644 index 000000000..326cdd186 --- /dev/null +++ b/apps/dashboard/src/components/ui/button.tsx @@ -0,0 +1,44 @@ +import { cva, type VariantProps } from "class-variance-authority"; +import { Slot as SlotPrimitive } from "radix-ui"; +import type * as React from "react"; +import { cn } from "@/lib/cn"; + +const buttonVariants = cva( + "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md font-medium text-sm transition-all active:scale-[0.97] motion-reduce:active:scale-100 focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 outline-none aria-invalid:border-destructive aria-invalid:ring-destructive/20 shrink-0", + { + defaultVariants: { + size: "default", + variant: "default" + }, + variants: { + size: { + default: "h-9 px-4 py-2 has-[>svg]:px-3", + icon: "size-9", + lg: "h-10 rounded-md px-6 has-[>svg]:px-4", + sm: "h-8 gap-1.5 rounded-md px-3 has-[>svg]:px-2.5" + }, + variant: { + default: "bg-primary text-primary-foreground shadow-xs hover:bg-primary/90", + destructive: + "bg-destructive text-destructive-foreground shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20", + ghost: "hover:bg-accent hover:text-accent-foreground", + link: "text-primary underline-offset-4 hover:underline", + outline: "border border-input bg-background shadow-xs hover:bg-accent hover:text-accent-foreground", + secondary: "bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80" + } + } + } +); + +function Button({ + className, + variant, + size, + asChild = false, + ...props +}: React.ComponentProps<"button"> & VariantProps & { asChild?: boolean }) { + const Comp = asChild ? SlotPrimitive.Root : "button"; + return ; +} + +export { Button, buttonVariants }; diff --git a/apps/dashboard/src/components/ui/card.tsx b/apps/dashboard/src/components/ui/card.tsx new file mode 100644 index 000000000..2fc0bc4e4 --- /dev/null +++ b/apps/dashboard/src/components/ui/card.tsx @@ -0,0 +1,53 @@ +import type * as React from "react"; +import { cn } from "@/lib/cn"; + +function Card({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function CardHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function CardTitle({ className, ...props }: React.ComponentProps<"div">) { + return
; +} + +function CardDescription({ className, ...props }: React.ComponentProps<"div">) { + return
; +} + +function CardAction({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function CardContent({ className, ...props }: React.ComponentProps<"div">) { + return
; +} + +function CardFooter({ className, ...props }: React.ComponentProps<"div">) { + return
; +} + +export { Card, CardHeader, CardFooter, CardTitle, CardAction, CardDescription, CardContent }; diff --git a/apps/dashboard/src/components/ui/checkbox.tsx b/apps/dashboard/src/components/ui/checkbox.tsx new file mode 100644 index 000000000..45fcb05c5 --- /dev/null +++ b/apps/dashboard/src/components/ui/checkbox.tsx @@ -0,0 +1,26 @@ +import { CheckIcon } from "lucide-react"; +import { Checkbox as CheckboxPrimitive } from "radix-ui"; +import type * as React from "react"; +import { cn } from "@/lib/cn"; + +function Checkbox({ className, ...props }: React.ComponentProps) { + return ( + + + + + + ); +} + +export { Checkbox }; diff --git a/apps/dashboard/src/components/ui/dialog.tsx b/apps/dashboard/src/components/ui/dialog.tsx new file mode 100644 index 000000000..6c12de316 --- /dev/null +++ b/apps/dashboard/src/components/ui/dialog.tsx @@ -0,0 +1,112 @@ +import { XIcon } from "lucide-react"; +import { Dialog as DialogPrimitive } from "radix-ui"; +import type * as React from "react"; +import { cn } from "@/lib/cn"; + +function Dialog({ ...props }: React.ComponentProps) { + return ; +} + +function DialogTrigger({ ...props }: React.ComponentProps) { + return ; +} + +function DialogPortal({ ...props }: React.ComponentProps) { + return ; +} + +function DialogClose({ ...props }: React.ComponentProps) { + return ; +} + +function DialogOverlay({ className, ...props }: React.ComponentProps) { + return ( + + ); +} + +function DialogContent({ + className, + children, + showCloseButton = true, + ...props +}: React.ComponentProps & { showCloseButton?: boolean }) { + return ( + + + + {children} + {showCloseButton && ( + + + Close + + )} + + + ); +} + +function DialogHeader({ className, ...props }: React.ComponentProps<"div">) { + return
; +} + +function DialogFooter({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function DialogTitle({ className, ...props }: React.ComponentProps) { + return ( + + ); +} + +function DialogDescription({ className, ...props }: React.ComponentProps) { + return ( + + ); +} + +export { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogOverlay, + DialogPortal, + DialogTitle, + DialogTrigger +}; diff --git a/apps/dashboard/src/components/ui/dropdown-menu.tsx b/apps/dashboard/src/components/ui/dropdown-menu.tsx new file mode 100644 index 000000000..41bb96bb7 --- /dev/null +++ b/apps/dashboard/src/components/ui/dropdown-menu.tsx @@ -0,0 +1,201 @@ +import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"; +import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui"; +import type * as React from "react"; +import { cn } from "@/lib/cn"; + +function DropdownMenu({ ...props }: React.ComponentProps) { + return ; +} + +function DropdownMenuTrigger({ ...props }: React.ComponentProps) { + return ; +} + +function DropdownMenuContent({ + className, + sideOffset = 4, + ...props +}: React.ComponentProps) { + return ( + + + + ); +} + +function DropdownMenuGroup({ ...props }: React.ComponentProps) { + return ; +} + +function DropdownMenuItem({ + className, + inset, + variant = "default", + ...props +}: React.ComponentProps & { inset?: boolean; variant?: "default" | "destructive" }) { + return ( + + ); +} + +function DropdownMenuCheckboxItem({ + className, + children, + checked, + ...props +}: React.ComponentProps) { + return ( + + + + + + + {children} + + ); +} + +function DropdownMenuRadioGroup({ ...props }: React.ComponentProps) { + return ; +} + +function DropdownMenuRadioItem({ + className, + children, + ...props +}: React.ComponentProps) { + return ( + + + + + + + {children} + + ); +} + +function DropdownMenuLabel({ + className, + inset, + ...props +}: React.ComponentProps & { inset?: boolean }) { + return ( + + ); +} + +function DropdownMenuSeparator({ className, ...props }: React.ComponentProps) { + return ( + + ); +} + +function DropdownMenuShortcut({ className, ...props }: React.ComponentProps<"span">) { + return ( + + ); +} + +function DropdownMenuSub({ ...props }: React.ComponentProps) { + return ; +} + +function DropdownMenuSubTrigger({ + className, + inset, + children, + ...props +}: React.ComponentProps & { inset?: boolean }) { + return ( + + {children} + + + ); +} + +function DropdownMenuSubContent({ className, ...props }: React.ComponentProps) { + return ( + + ); +} + +export { + DropdownMenu, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuLabel, + DropdownMenuItem, + DropdownMenuCheckboxItem, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuSeparator, + DropdownMenuShortcut, + DropdownMenuSub, + DropdownMenuSubTrigger, + DropdownMenuSubContent +}; diff --git a/apps/dashboard/src/components/ui/form.tsx b/apps/dashboard/src/components/ui/form.tsx new file mode 100644 index 000000000..2c0e1c6d6 --- /dev/null +++ b/apps/dashboard/src/components/ui/form.tsx @@ -0,0 +1,128 @@ +import { Label as LabelPrimitive, Slot as SlotPrimitive } from "radix-ui"; +import * as React from "react"; +import { + Controller, + type ControllerProps, + type FieldPath, + type FieldValues, + FormProvider, + useFormContext, + useFormState +} from "react-hook-form"; +import { Label } from "@/components/ui/label"; +import { cn } from "@/lib/cn"; + +const Form = FormProvider; + +type FormFieldContextValue< + TFieldValues extends FieldValues = FieldValues, + TName extends FieldPath = FieldPath +> = { + name: TName; +}; + +const FormFieldContext = React.createContext({} as FormFieldContextValue); + +function FormField< + TFieldValues extends FieldValues = FieldValues, + TName extends FieldPath = FieldPath +>({ ...props }: ControllerProps) { + return ( + + + + ); +} + +function useFormField() { + const fieldContext = React.useContext(FormFieldContext); + const itemContext = React.useContext(FormItemContext); + const { getFieldState } = useFormContext(); + const formState = useFormState({ name: fieldContext.name }); + const fieldState = getFieldState(fieldContext.name, formState); + + if (!fieldContext) { + throw new Error("useFormField should be used within "); + } + + const { id } = itemContext; + + return { + formDescriptionId: `${id}-form-item-description`, + formItemId: `${id}-form-item`, + formMessageId: `${id}-form-item-message`, + id, + name: fieldContext.name, + ...fieldState + }; +} + +type FormItemContextValue = { + id: string; +}; + +const FormItemContext = React.createContext({} as FormItemContextValue); + +function FormItem({ className, ...props }: React.ComponentProps<"div">) { + const id = React.useId(); + return ( + +
+ + ); +} + +function FormLabel({ className, ...props }: React.ComponentProps) { + const { error, formItemId } = useFormField(); + return ( +