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..ef7018fc4 100644 --- a/.gitignore +++ b/.gitignore @@ -67,6 +67,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/apps/api/.env.example b/apps/api/.env.example index aefc6a09f..a9d263ba4 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= @@ -99,6 +98,11 @@ 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 + # Integration test helpers (only required for phase-processor integration tests) # BACKEND_TEST_STARTER_ACCOUNT= # TAX_ID= 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/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..8c0eb0c34 100644 --- a/apps/api/src/api/controllers/alfredpay.controller.ts +++ b/apps/api/src/api/controllers/alfredpay.controller.ts @@ -24,8 +24,9 @@ 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 } from "../services/alfredpay/alfredpay-customer.service"; import { ALFREDPAY_EFFECTIVE_USER_REQUIRED_MESSAGE } from "../services/quote/alfredpay-customer"; export class AlfredpayController { @@ -96,10 +97,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" }); @@ -121,7 +119,10 @@ export class AlfredpayController { const newStatus = isBusiness ? AlfredpayController.mapKybStatus(statusResponse.status) : AlfredpayController.mapKycStatus(statusResponse.status); - const updateData: Partial = {}; + const updateData: Partial<{ status: AlfredPayStatus; statusExternal: string | null; lastFailureReasons: string[] }> = + { + statusExternal: statusResponse.status + }; if (newStatus && newStatus !== alfredPayCustomer.status) { updateData.status = newStatus; @@ -142,10 +143,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 +176,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 +199,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 +222,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" }); @@ -264,16 +262,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 +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.UserCompleted }); + await alfredPayCustomer.update({ status: AlfredPayStatus.UserCompleted, statusExternal: null }); res.json({ success: true }); } catch (error) { @@ -312,10 +304,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" }); @@ -329,6 +318,11 @@ export class AlfredpayController { : await alfredpayService.getLastKycSubmission(alfredPayCustomer.alfredPayId); if (!lastSubmission || !lastSubmission.submissionId) { + await alfredPayCustomer.update({ + status: AlfredPayStatus.Consulted, + statusExternal: null, + verificationStatus: VerificationStatus.Pending + }); return res.status(404).json({ error: "No KYC attempt found" }); } @@ -339,7 +333,9 @@ export class AlfredpayController { const newStatus = isBusiness ? AlfredpayController.mapKybStatus(statusResponse.status) : AlfredpayController.mapKycStatus(statusResponse.status); - const updateData: Partial = {}; + const updateData: Partial<{ status: AlfredPayStatus; statusExternal: string | null; lastFailureReasons: string[] }> = { + statusExternal: statusResponse.status + }; if (newStatus && newStatus !== alfredPayCustomer.status) { updateData.status = newStatus; @@ -374,10 +370,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" }); @@ -405,17 +398,17 @@ export class AlfredpayController { if (isBusiness) { await alfredpayService.retryKybSubmission(alfredPayCustomer.alfredPayId, lastSubmission.submissionId); const linkResponse = await alfredpayService.getKybRedirectLink(alfredPayCustomer.alfredPayId); - await alfredPayCustomer.update({ status: AlfredPayStatus.Consulted }); + await alfredPayCustomer.update({ status: AlfredPayStatus.Consulted, statusExternal: null }); 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); const linkResponse = await alfredpayService.getKycRedirectLink(alfredPayCustomer.alfredPayId, country); - await alfredPayCustomer.update({ status: AlfredPayStatus.Consulted }); + await alfredPayCustomer.update({ status: AlfredPayStatus.Consulted, statusExternal: null }); return res.json(linkResponse as AlfredpayGetKycRedirectLinkResponse); } } catch (error) { @@ -436,9 +429,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 +452,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 +475,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" }); @@ -525,9 +517,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" }); @@ -567,9 +561,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 +593,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,9 +619,11 @@ 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" }); @@ -659,9 +659,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" }); @@ -691,9 +693,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 +734,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 +772,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 +819,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 +901,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 +922,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 66a4d5743..4c09e20c8 100644 --- a/apps/api/src/api/controllers/brla.controller.test.ts +++ b/apps/api/src/api/controllers/brla.controller.test.ts @@ -2,8 +2,11 @@ import {AveniaAccountType, BrlaApiService} 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 {createSubaccount, fetchSubaccountKycStatus, getAveniaUser, recordInitialKycAttempt} from "./brla.controller"; function createResponse() { const res = { @@ -22,8 +25,17 @@ function createResponse() { return res; } +// getOrCreateCustomerEntityForProfile resolves each profile to a deterministic entity id. +function mockEntityPerProfile() { + CustomerEntity.findOrCreate = mock(async (options: { where: { profileId: string } }) => [ + { id: `entity-${options.where.profileId}` }, + false + ]) as unknown as typeof CustomerEntity.findOrCreate; +} + describe("getAveniaUser", () => { - const originalFindOne = TaxId.findOne; + const originalFindOne = ProviderCustomer.findOne; + const originalEntityFindOrCreate = CustomerEntity.findOrCreate; const originalGetInstance = BrlaApiService.getInstance; const originalLoggerError = logger.error; const originalLoggerInfo = logger.info; @@ -34,14 +46,20 @@ describe("getAveniaUser", () => { }); afterEach(() => { - TaxId.findOne = originalFindOne; + ProviderCustomer.findOne = originalFindOne; + 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,30 +159,120 @@ describe("getAveniaUser", () => { }); }); +describe("recordInitialKycAttempt", () => { + const originalProviderFindOne = ProviderCustomer.findOne; + const originalProviderCreate = ProviderCustomer.create; + const originalEntityFindOrCreate = CustomerEntity.findOrCreate; + const originalKycCaseFindOne = KycCase.findOne; + const originalKycCaseCreate = KycCase.create; + + afterEach(() => { + ProviderCustomer.findOne = originalProviderFindOne; + ProviderCustomer.create = originalProviderCreate; + 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 originalEntityFindOrCreate = CustomerEntity.findOrCreate; + const originalKycCaseFindOne = KycCase.findOne; + const originalGetInstance = BrlaApiService.getInstance; + + afterEach(() => { + ProviderCustomer.findOne = originalProviderFindOne; + 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 })); + }); +}); + describe("createSubaccount", () => { - const originalFindByPk = TaxId.findByPk; - const originalCreate = TaxId.create; + const originalProviderFindOne = ProviderCustomer.findOne; + const originalProviderCreate = ProviderCustomer.create; + 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.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 ); } @@ -172,15 +280,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; @@ -198,15 +329,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( @@ -218,19 +353,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( @@ -250,9 +394,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( @@ -266,18 +410,42 @@ 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("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", + status: VerificationStatus.InReview + }); + expect(subaccountInfoMock).toHaveBeenCalledWith("new-subaccount"); }); it("allows overwrite when the existing record is only in Consulted state", 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( diff --git a/apps/api/src/api/controllers/brla.controller.ts b/apps/api/src/api/controllers/brla.controller.ts index 209d757a1..532d47b19 100644 --- a/apps/api/src/api/controllers/brla.controller.ts +++ b/apps/api/src/api/controllers/brla.controller.ts @@ -33,54 +33,42 @@ import { } from "@vortexfi/shared"; import { Request, Response } from "express"; import httpStatus from "http-status"; -import { Op } from "sequelize"; import logger from "../../config/logger"; +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 +148,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 +178,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 +190,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 +211,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 +219,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 +232,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 +274,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 +305,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 +317,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 +346,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 +365,83 @@ 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); + // 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.status !== VerificationStatus.Started && 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 + if (existing) { + await existing.update({ + companyName, + customerType: accountTypeToCustomerType(accountType), + providerSubaccountId: id, + status: VerificationStatus.InReview, + 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: VerificationStatus.InReview, + taxReference: normalizedTaxId, + taxReferenceHash: hashTaxReference(normalizedTaxId) }); } + await upsertAveniaKycCase(existing, VerificationStatus.InReview, null); res.status(httpStatus.OK).json({ subAccountId: id }); } catch (error) { @@ -433,24 +455,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 +499,32 @@ 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); + } + if ( + !kycAttemptStatus.result && + record.status !== VerificationStatus.Approved && + record.status !== VerificationStatus.Rejected + ) { + 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 +584,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 +654,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); + 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 +713,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,20 +760,26 @@ 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; } 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..b9aea54d9 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; } @@ -117,10 +121,19 @@ export const createProfileController = async (req: Request, res: Response): Prom } } + await markMykoboCustomerStarted(req.userId, userEmail); const { profile } = await MykoboApiService.getInstance().createProfile(formData); + await upsertMykoboCustomerFromProfile(req.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..997f1304d --- /dev/null +++ b/apps/api/src/api/controllers/onboarding.controller.ts @@ -0,0 +1,130 @@ +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 { APIError } from "../errors/api-error"; +import { hydrateAveniaCompanyName } from "../services/avenia/avenia-customer.service"; +import { getMoneriumStatus, MONERIUM_REAUTHENTICATION_REQUIRED } from "../services/monerium/monerium.service"; + +/** + * 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 entities = await CustomerEntity.findAll({ where: { profileId: userId } }); + const entityIds = entities.map(entity => entity.id); + + const providerCustomers = entityIds.length + ? await ProviderCustomer.findAll({ order: [["updatedAt", "DESC"]], where: { customerEntityId: entityIds } }) + : []; + 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)) + ); + + 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); + } + } + + res.status(httpStatus.OK).json({ + 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 + }; + }), + id: entity.id, + status: entity.status, + type: entity.type + }; + }) + }); + } 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 + } + }); + } +} 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..0a2a588db --- /dev/null +++ b/apps/api/src/api/controllers/recipients.controller.ts @@ -0,0 +1,398 @@ +import { Request, Response } from "express"; +import httpStatus from "http-status"; +import sequelize from "../../config/database"; +import logger from "../../config/logger"; +import CustomerEntity from "../../models/customerEntity.model"; +import ProviderCustomer 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"; + +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; + amount?: 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, amount, 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 (amount !== undefined && (Number.isNaN(Number(amount)) || Number(amount) <= 0)) { + sendError(res, httpStatus.BAD_REQUEST, "INVALID_AMOUNT", "amount must be a positive number"); + return; + } + + try { + const senderEntity = await getOrCreateCustomerEntityForProfile(userId); + const token = generateInviteToken(); + + const invitation = await RecipientInvitation.create({ + 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, + tokenHash: hashInviteToken(token), + ...(amount !== undefined ? { amount } : {}) + }); + + // The raw token is returned exactly once; only its hash is stored. + res.status(httpStatus.CREATED).json({ + amount: invitation.amount, + 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" }); + } + 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 => { + // 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 } + }); + + 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 null; + } + // 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 (!isReEntry) { + await row.update({ disabledAt: null, invitationId: invitation.id, relationshipStatus: "active" }, { transaction }); + } + } + + if (!isReEntry) { + await invitation.update({ acceptedAt: new Date(), acceptedByProfileId: userId, status: "accepted" }, { transaction }); + } + return row; + }); + + if (!relationship) { + sendError(res, httpStatus.CONFLICT, "RELATIONSHIP_BLOCKED", "The sender has blocked this relationship"); + return; + } + + // Only a first acceptance is news to the sender; re-entry is the recipient resuming onboarding. + if (senderEntity.profileId && !isReEntry) { + await emitNotification(senderEntity.profileId, { + customerEntityId: senderEntity.id, + metadata: { invitationId: invitation.id, senderRecipientId: relationship.id }, + title: "Your recipient invite was accepted", + type: "recipient_invite_accepted" + }); + } + + res.status(isReEntry ? httpStatus.OK : httpStatus.CREATED).json({ + id: relationship.id, + invitation: { + country: invitation.country, + id: invitation.id, + payoutCurrency: invitation.payoutCurrency, + rail: invitation.rail + }, + relationshipStatus: relationship.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"]], + where: { 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, + provider, + ...(provider === "alfredpay" ? { country: invitation.country } : {}) + } + }); + onboardingStatus = providerCustomer + ? isProviderApproved(providerCustomer.status) + ? "approved" + : "pending" + : "pending"; + } + + return { + createdAt: row.createdAt, + id: row.id, + invitation: invitation + ? { + 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 pendingInvitations = await RecipientInvitation.findAll({ + order: [["createdAt", "DESC"]], + where: { senderCustomerEntityId: senderEntity.id, status: "pending" } + }); + + res.status(httpStatus.OK).json({ + pendingInvitations: pendingInvitations.map(invitation => ({ + country: invitation.country, + createdAt: invitation.createdAt, + expiresAt: invitation.expiresAt, + id: invitation.id, + inviteeEmail: invitation.inviteeEmail, + inviteeType: invitation.inviteeType, + isExpired: Boolean(invitation.expiresAt && invitation.expiresAt < new Date()), + payoutCurrency: invitation.payoutCurrency, + rail: invitation.rail + })), + 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"); + } +} + +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..bbf3c4564 100644 --- a/apps/api/src/api/middlewares/apiKeyAuth.helpers.test.ts +++ b/apps/api/src/api/middlewares/apiKeyAuth.helpers.test.ts @@ -17,8 +17,8 @@ const originalPartnerFindOne = Partner.findOne; function createSecretKeyRecord({ userId = null, - partnerName = "TestPartner" -}: { userId?: string | null; partnerName?: string | null } = {}): ApiKey & { raw: string } { + partnerId = "partner-id" +}: { userId?: string | null; partnerId?: string | null } = {}): ApiKey & { raw: string } { const secret = generateApiKey("secret", "test"); const secretHash = bcrypt.hashSync(secret, 4); const record = Object.assign(new ApiKey(), { @@ -27,7 +27,7 @@ function createSecretKeyRecord({ keyHash: secretHash, keyPrefix: getKeyPrefix(secret), keyType: "secret" as const, - partnerName, + partnerId, raw: secret, userId }); @@ -45,7 +45,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 +63,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 +77,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 +94,8 @@ 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; diff --git a/apps/api/src/api/middlewares/apiKeyAuth.helpers.ts b/apps/api/src/api/middlewares/apiKeyAuth.helpers.ts index d799bf97b..0924df9b6 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,15 @@ export async function validatePublicApiKey(apiKey: string): Promise 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..dbc0a1d75 --- /dev/null +++ b/apps/api/src/api/routes/v1/onboarding.route.ts @@ -0,0 +1,15 @@ +import { Request, Response, Router } from "express"; +import { getOnboardingStatus } 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); + +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..f3b326e3e --- /dev/null +++ b/apps/api/src/api/routes/v1/recipients.route.ts @@ -0,0 +1,45 @@ +import { Request, Response, Router } from "express"; +import { + acceptInvite, + 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/: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..2bb98cb7c --- /dev/null +++ b/apps/api/src/api/services/alfredpay/alfredpay-customer.service.ts @@ -0,0 +1,184 @@ +import { AlfredPayCountry, AlfredPayStatus, AlfredpayCustomerType } from "@vortexfi/shared"; +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[]; + }> + ): 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; + } +} + +function toAlfredPayStatus(record: ProviderCustomer): AlfredPayStatus { + switch (record.statusExternal) { + case "IN_REVIEW": + return AlfredPayStatus.Verifying; + case "FAILED": + return AlfredPayStatus.Failed; + case "COMPLETED": + return AlfredPayStatus.Success; + case "UPDATE_REQUIRED": + return AlfredPayStatus.UpdateRequired; + case "CREATED": + return AlfredPayStatus.Consulted; + default: + 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): 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, + ...lifecycle + }); + return; + } + await KycCase.create({ + customerEntityId: record.customerEntityId, + failureReasons: record.lastFailureReasons ?? [], + level: "level_1", + provider: "alfredpay", + 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) { + await syncKycCase(record); + } + }, + 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); + const record = await ProviderCustomer.findOne({ + order: [["updatedAt", "DESC"]], + where: { + country, + customerEntityId: entity.id, + provider: "alfredpay", + ...(type ? { customerType: alfredpayTypeToCustomerType(type) } : {}) + } + }); + return record ? toView(record) : null; +} + +export async function createAlfredpayCustomer( + userId: string, + values: { alfredPayId: string; country: AlfredPayCountry; status: AlfredPayStatus; type: AlfredpayCustomerType } +): Promise { + const entity = await getOrCreateCustomerEntityForProfile(userId); + const record = await ProviderCustomer.create({ + country: values.country, + customerEntityId: entity.id, + customerType: alfredpayTypeToCustomerType(values.type), + 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.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 +): 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({ status, statusExternal, ...lifecycle }); + return; + } + await KycCase.create({ + customerEntityId: record.customerEntityId, + level: "level_1", + provider: "avenia", + providerCustomerId: record.id, + status, + statusExternal, + type: record.customerType === "business" ? "kyb" : "kyc", + ...lifecycle + }); +} + +/** + * Poll-driven KYC outcome transition: flips a `Requested` account to Accepted/Rejected + * (the only mechanism that makes a subaccount ramp-ready). Idempotent — mirrors the legacy + * `UPDATE ... WHERE internal_status = 'Requested'` guard. + */ +export async function updateAveniaKycOutcome( + taxId: string, + outcome: VerificationStatus.Approved | VerificationStatus.Rejected, + statusExternal: string +): Promise { + const record = await findAveniaCustomerByTaxId(taxId); + if (!record || record.status !== VerificationStatus.InReview) { + 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({ error }, "hydrateAveniaCompanyName: Avenia subaccountInfo unavailable, skipping backfill"); + } +} 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..b092c1630 --- /dev/null +++ b/apps/api/src/api/services/customer-entity.service.ts @@ -0,0 +1,19 @@ +import CustomerEntity from "../../models/customerEntity.model"; + +/** + * 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): Promise { + const [entity] = await CustomerEntity.findOrCreate({ + defaults: { + profileId, + status: "active", + type: "individual" + }, + where: { profileId } + }); + return entity; +} 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..ea86269f3 --- /dev/null +++ b/apps/api/src/api/services/monerium/monerium.service.test.ts @@ -0,0 +1,271 @@ +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"; + +const customerUpdate = mock(async (_values: unknown, _options: unknown) => undefined); +const providerFindOrCreate = mock(async (_options: unknown) => [{ 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/dashboard/monerium/callback"; + customerUpdate.mockClear(); + providerFindOrCreate.mockClear(); + providerFindOne.mockClear(); + kycFindOne.mockClear(); + kycCreate.mockClear(); +}); + +afterEach(() => { + globalThis.fetch = originalFetch; +}); + +afterAll(() => { + 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("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", + profiles: [ + { id: "profile-z", kind: "personal" }, + { id: "profile-a", kind: "personal" }, + { id: "corporate-a", kind: "corporate" } + ] + }); + } + 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 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({ profiles: [{ id: "profile-a", kind: "personal" }] }); + } + 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..26a35058b --- /dev/null +++ b/apps/api/src/api/services/monerium/monerium.service.ts @@ -0,0 +1,410 @@ +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; + 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; + profiles?: 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 { + const headers = { Accept: API_V2_ACCEPT, Authorization: `Bearer ${credentials.accessToken}` }; + const context = (await fetchJson(`${config.monerium.apiUrl}/auth/context`, { headers, method: "GET" })) as MoneriumContext; + 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 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" } + }); + 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); + 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.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, + 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 consumeOAuthTransaction(state: string, userId: string, customerEntityId: 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 }); + } + 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 entity = await getOrCreateCustomerEntityForProfile(userId); + 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 + }) + ); + credentialCache.set(credentialsCacheKey(entity.id, pending.customerType), credentials); + const profile = await readProfile(credentials, pending.customerType); + return mirrorProfile(entity.id, pending.customerType, profile); +} + +export async function getMoneriumStatus(userId: string, customerType: ProviderCustomerType): Promise { + const entity = await getOrCreateCustomerEntityForProfile(userId); + 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..6eaf92d4f 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); + 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); + 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 a82dfd06b..7ce3e872b 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"; @@ -65,13 +65,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) { @@ -121,7 +122,7 @@ export class BrlaOnrampMintHandler extends BasePhaseHandler { } }), brlaApiService, - taxIdRecord.subAccountId + aveniaSubAccountId ); if (!ticketFound) { logger.warn( @@ -131,7 +132,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; } @@ -164,7 +165,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."); @@ -186,7 +187,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/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.ts b/apps/api/src/api/services/quote/engines/discount/helpers.ts index c4e56bcc5..7cbb7c11d 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,7 @@ import { RampDirection } from "@vortexfi/shared"; import Big from "big.js"; import { config } from "../../../../../config/vars"; -import Partner from "../../../../../models/partner.model"; +import { findPartnerWithPricing, PartnerWithPricing } from "../../../partners/partner-pricing.service"; import { QuoteContext } from "../../core/types"; import { DiscountComputation } from "./index"; @@ -12,6 +12,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 +24,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 +41,35 @@ 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; } /** @@ -96,19 +106,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 +127,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 +139,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 +155,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/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.ts b/apps/api/src/api/services/ramp/ramp.service.ts index 1401323a9..2ce3fc876 100644 --- a/apps/api/src/api/services/ramp/ramp.service.ts +++ b/apps/api/src/api/services/ramp/ramp.service.ts @@ -46,12 +46,15 @@ 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 { 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"; @@ -254,7 +257,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); @@ -901,15 +904,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 +990,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 +1017,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 +1031,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..ef3509f97 --- /dev/null +++ b/apps/api/src/api/services/recipients/recipient-invite.service.ts @@ -0,0 +1,22 @@ +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. Only its hash is ever stored (plan D1). */ +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..f84da7cca 100644 --- a/apps/api/src/config/express.ts +++ b/apps/api/src/config/express.ts @@ -37,6 +37,9 @@ app.use( 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 (prod is same-origin under /dashboard/) + 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..15c4dbb96 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/dashboard/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 0cb4eb31c..199a2b0e3 100644 --- a/apps/api/src/config/vars.ts +++ b/apps/api/src/config/vars.ts @@ -157,13 +157,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; }; @@ -216,6 +220,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/dashboard/monerium/callback" + }, mykobo: { feeFallback: readMykoboFeeFallback() }, @@ -254,7 +265,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 @@ -306,6 +316,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/index.ts b/apps/api/src/index.ts index 7936ef671..5cfeca1b5 100755 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -27,7 +27,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..106f23f57 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,64 @@ 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" }); +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..28a6497b2 --- /dev/null +++ b/apps/api/src/models/recipientInvitation.model.ts @@ -0,0 +1,197 @@ +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 +// never stored; 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; + amount: string | null; + status: RecipientInvitationStatus; + tokenHash: string; + 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" + | "amount" + | "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 amount: string | null; + declare status: RecipientInvitationStatus; + declare tokenHash: string; + 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 + }, + amount: { + allowNull: true, + type: DataTypes.DECIMAL(38, 18) + }, + 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) + }, + 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/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/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/corridors/brl-offramp-crosschain.scenario.test.ts b/apps/api/src/tests/corridors/brl-offramp-crosschain.scenario.test.ts index 69a6368fd..a88926e80 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; 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..734666c43 --- /dev/null +++ b/apps/api/src/tests/notifications-onboarding.integration.test.ts @@ -0,0 +1,228 @@ +import { afterAll, beforeAll, beforeEach, describe, expect, it, mock } from "bun:test"; +import { AlfredPayCountry, AlfredPayStatus, AlfredpayCustomerType, BrlaApiService } from "@vortexfi/shared"; +import { createAlfredpayCustomer } from "../api/services/alfredpay/alfredpay-customer.service"; +import { emitNotification } from "../api/services/notifications/notification.service"; +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("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; 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); + + 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 { entities: unknown[] }; + // 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); + }); + + 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; + } + }); +}); 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..47208e6c0 --- /dev/null +++ b/apps/api/src/tests/recipients.integration.test.ts @@ -0,0 +1,451 @@ +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 }; +} + +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, returns the raw token once and stores only its hash", async () => { + const sender = await createAuthedUser("sender@example.com"); + const { status, body } = await createInvite(sender.token, { 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.expiresAt).toBeTruthy(); + + const stored = await RecipientInvitation.findByPk(body.id as string); + expect(stored).not.toBeNull(); + expect(stored?.tokenHash).not.toBe(body.token); + expect(stored?.tokenHash).toMatch(/^[0-9a-f]{64}$/); + expect(stored?.inviteeEmailCanonical).toBe("bob@example.com"); + }); + + it("rejects an invite without a corridor", async () => { + const sender = await createAuthedUser("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"); + }); +}); + +describe("POST /v1/recipients/invite/:token/accept", () => { + it("accepts a pending invite, creating an active relationship and notifying the sender", async () => { + const sender = await createAuthedUser("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); + + 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 createAuthedUser("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 createAuthedUser("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"); + }); + + it("lets the recipient re-enter after the invite's expiry passes", async () => { + const sender = await createAuthedUser("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 createAuthedUser("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 createAuthedUser("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 createAuthedUser("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 createAuthedUser("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 createAuthedUser("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 createAuthedUser("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"); + }); + + 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 createAuthedUser("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 createAuthedUser("sender@example.com"); + const recipient = await createAuthedUser("recipient@example.com"); + const invite = await createInvite(sender.token); + + const beforeAccept = await api.request("/v1/recipients", { headers: authHeaders(sender.token) }); + const beforeBody = (await beforeAccept.json()) as { recipients: unknown[]; pendingInvitations: unknown[] }; + expect(beforeBody.recipients).toHaveLength(0); + expect(beforeBody.pendingInvitations).toHaveLength(1); + + 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 } }>; + 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"); + }); +}); + +describe("PATCH /v1/recipients/:id", () => { + async function acceptedRelationship() { + const sender = await createAuthedUser("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 createAuthedUser("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 createAuthedUser("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..2fe46824d --- /dev/null +++ b/apps/dashboard/.env.example @@ -0,0 +1,10 @@ +# 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= 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/auth-gate.spec.ts b/apps/dashboard/e2e/auth-gate.spec.ts new file mode 100644 index 000000000..6abc57297 --- /dev/null +++ b/apps/dashboard/e2e/auth-gate.spec.ts @@ -0,0 +1,57 @@ +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(/\/dashboard\/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("/dashboard/transfer"); + + await expect(page).toHaveURL(/\/dashboard\/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("/dashboard/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(/\/dashboard\/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("/dashboard/overview"); + + await expect(page.getByText("Started", { exact: true })).toBeVisible({ timeout: 20_000 }); + await expect(page.getByRole("button", { name: "Verification started" })).toBeVisible(); +}); + +test("an authenticated user is redirected away from the login page", async ({ page }) => { + await mockBackend(page); + await seedSession(page); + + await page.goto("/dashboard/login"); + + await expect(page).toHaveURL(/\/dashboard\/overview/, { timeout: 20_000 }); +}); diff --git a/apps/dashboard/e2e/login.spec.ts b/apps/dashboard/e2e/login.spec.ts new file mode 100644 index 000000000..51b007e44 --- /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("/dashboard/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(/\/dashboard\/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("/dashboard/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(/\/dashboard\/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.spec.ts b/apps/dashboard/e2e/onboarding-alfredpay-mxn.spec.ts new file mode 100644 index 000000000..5bf745139 --- /dev/null +++ b/apps/dashboard/e2e/onboarding-alfredpay-mxn.spec.ts @@ -0,0 +1,111 @@ +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("/dashboard/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 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 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-monerium-eu.spec.ts b/apps/dashboard/e2e/onboarding-monerium-eu.spec.ts new file mode 100644 index 000000000..674f52bab --- /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("/dashboard/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(`${page.url().split("/dashboard/")[0]}/dashboard/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(/\/dashboard\/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("/dashboard/monerium/callback?code=e2e-code&state=e2e-state"); + + await expect(page).toHaveURL(/\/dashboard\/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("/dashboard/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..2960e731f --- /dev/null +++ b/apps/dashboard/e2e/recipient-invite.spec.ts @@ -0,0 +1,25 @@ +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("/dashboard/recipients"); + + await page.getByRole("button", { name: "Add recipient" }).first().click(); + const dialog = page.getByRole("dialog"); + await dialog.getByLabel(/I will send you/).fill("100"); + 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([]); +}); diff --git a/apps/dashboard/e2e/support/mockBackend.ts b/apps/dashboard/e2e/support/mockBackend.ts new file mode 100644 index 000000000..b0b848c15 --- /dev/null +++ b/apps/dashboard/e2e/support/mockBackend.ts @@ -0,0 +1,587 @@ +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 { + 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" + } + ] + }; +} + +export function buildMoneriumOnboardingStatus(state: OnboardingState = "approved", reauthenticationRequired = false) { + return { + 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" + } + ] + }; +} + +const NO_ONBOARDING = { entities: [] as unknown[] }; + +/** + * 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; + // 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 → in_review once submitted → approved). + // Default onboarding status (an already-approved MX corridor) applies when this is unset. + alfredpayKyc?: { reflectOnboarding?: boolean }; + moneriumKyc?: boolean; + moneriumRequireRefresh?: boolean; +} + +// AlfredPayStatus values the machine branches on (packages/shared AlfredPayStatus). +const ALFREDPAY_SUCCESS = "SUCCESS"; +const ALFREDPAY_VERIFYING = "VERIFYING"; + +// 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[] }; + +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 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 monerium = { + approved: false, + authorized: false, + completed: false, + startRequests: [] as Array> + }; + const auth = { refreshes: 0 }; + + // 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/status" && method === "GET") { + if (options.moneriumKyc) { + await fulfillJson( + monerium.completed + ? buildMoneriumOnboardingStatus(monerium.approved ? "approved" : "in_review", !monerium.authorized) + : NO_ONBOARDING + ); + return; + } + if (!options.alfredpayKyc) { + await fulfillJson(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(NO_ONBOARDING); + } else if (kyc.approved) { + await fulfillJson(buildOnboardingStatus("approved")); + } else if (kyc.submitted) { + await fulfillJson(buildOnboardingStatus("in_review")); + } else { + await fulfillJson(NO_ONBOARDING); + } + 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}/dashboard/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 : ALFREDPAY_VERIFYING + }); + } + return; + } + if (path === "/v1/alfredpay/createIndividualCustomer" && 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; + } + // 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; + } + + // 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: none, so the auto-selected self-recipient stays selected. + if (path === "/v1/recipients" && method === "GET") { + await fulfillJson({ pendingInvitations: [], recipients: [] }); + 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 { + auth, + 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..3d96f611a --- /dev/null +++ b/apps/dashboard/e2e/transfer-mxn-journey.spec.ts @@ -0,0 +1,144 @@ +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. +test("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("/dashboard/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(/\/dashboard\/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. +test("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("/dashboard/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 resets the amount to the new recipient's default, 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("0.00"); + 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/package.json b/apps/dashboard/package.json new file mode 100644 index 000000000..d17e679b5 --- /dev/null +++ b/apps/dashboard/package.json @@ -0,0 +1,51 @@ +{ + "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/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: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..cca6c8ead --- /dev/null +++ b/apps/dashboard/playwright.config.ts @@ -0,0 +1,33 @@ +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. +// +// baseURL deliberately omits the app's "/dashboard/" base path: Playwright resolves a +// leading-slash goto() against the origin, not the base, so specs navigate to the full +// "/dashboard/login" instead of silently landing on "/login". +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/dashboard/" + } +}); 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/CorridorCard.tsx b/apps/dashboard/src/components/onboarding/CorridorCard.tsx new file mode 100644 index 000000000..6e7529ba8 --- /dev/null +++ b/apps/dashboard/src/components/onboarding/CorridorCard.tsx @@ -0,0 +1,149 @@ +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 { 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 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" + })}` + : ""} +

+ )} +
+ + + {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") { + return ( + + ); + } + if (status === "rejected") { + return ( + + ); + } + if (status === "in_review") { + if (reauthenticationRequired) { + return ( + + ); + } + return ( + + ); + } + return ( + + ); +} diff --git a/apps/dashboard/src/components/onboarding/DocumentDropzone.tsx b/apps/dashboard/src/components/onboarding/DocumentDropzone.tsx new file mode 100644 index 000000000..8a958b029 --- /dev/null +++ b/apps/dashboard/src/components/onboarding/DocumentDropzone.tsx @@ -0,0 +1,13 @@ +import { UploadCloud } from "lucide-react"; + +export function DocumentDropzone({ label }: { label: string }) { + return ( +
+
+ +
+

{label}

+

Drag & drop or click to upload (demo — no file is sent)

+
+ ); +} diff --git a/apps/dashboard/src/components/onboarding/ExternalFlow.tsx b/apps/dashboard/src/components/onboarding/ExternalFlow.tsx new file mode 100644 index 000000000..d41e389d8 --- /dev/null +++ b/apps/dashboard/src/components/onboarding/ExternalFlow.tsx @@ -0,0 +1,105 @@ +import { useMachine } from "@xstate/react"; +import { CheckCircle2, ExternalLink, FileText, Loader2 } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { DialogFooter } from "@/components/ui/dialog"; +import type { Corridor, OnboardingKind, OnboardingRoute, OnboardingStatus } from "@/domain/types"; +import { externalOnboardingMachine } from "@/machines/externalOnboarding.machine"; + +interface ExternalFlowProps { + corridor: Corridor; + kind: OnboardingKind; + route: Extract; + onStatusChange: (status: OnboardingStatus) => void; + onClose: () => void; +} + +const COPY: Record = { + google_form: { + confirmLabel: "I've submitted the form", + intro: + "EU company KYB is collected through an external Google Form. Open it, complete your company details, then confirm below.", + openLabel: "Open Google Form", + url: "https://docs.google.com/forms" + }, + redirect: { + confirmLabel: "I've completed it", + intro: + "USA onboarding is handled by our verification partner. You'll be redirected to their secure flow, then return here to confirm.", + openLabel: "Continue to partner", + url: "https://partner.vortex.fi/onboarding" + } +}; + +export function ExternalFlow({ corridor, kind, route, onStatusChange, onClose }: ExternalFlowProps) { + const [state, send] = useMachine(externalOnboardingMachine, { input: { onStatusChange } }); + const copy = COPY[route]; + + const value = String(state.value); + const isIntro = value === "intro"; + const isProcessing = value === "verifying" || value === "review"; + const isApproved = value === "approved"; + + return ( + <> +
+ {isIntro && ( +
+ + {route === "google_form" ? : } + +

{copy.intro}

+ +
+ )} + + {isProcessing && ( +
+ +
+

{value === "verifying" ? "Submitting your application" : "In review"}

+

+ {value === "verifying" + ? "Sending your submission to the verification provider…" + : "The provider is reviewing your submission. We'll email you when it's done."} +

+
+
+ )} + + {isApproved && ( +
+ +
+

Approved

+

+ {corridor.name} {kind.toUpperCase()} is complete. You can now register recipients. +

+
+
+ )} +
+ + + {isIntro && ( + <> + + + + )} + {isProcessing && ( + + )} + {isApproved && } + + + ); +} diff --git a/apps/dashboard/src/components/onboarding/HeadlessFlow.tsx b/apps/dashboard/src/components/onboarding/HeadlessFlow.tsx new file mode 100644 index 000000000..dde88fc10 --- /dev/null +++ b/apps/dashboard/src/components/onboarding/HeadlessFlow.tsx @@ -0,0 +1,122 @@ +import { useMachine } from "@xstate/react"; +import { CheckCircle2, Loader2 } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { DialogFooter } from "@/components/ui/dialog"; +import type { Corridor, OnboardingKind, OnboardingStatus } from "@/domain/types"; +import { cn } from "@/lib/cn"; +import { headlessOnboardingMachine } from "@/machines/headlessOnboarding.machine"; +import type { WizardStep } from "@/machines/types"; +import { WizardStepFields } from "./WizardStepFields"; + +interface HeadlessFlowProps { + corridor: Corridor; + kind: OnboardingKind; + steps: WizardStep[]; + onStatusChange: (status: OnboardingStatus) => void; + onClose: () => void; +} + +export function HeadlessFlow({ corridor, kind, steps, onStatusChange, onClose }: HeadlessFlowProps) { + const [state, send] = useMachine(headlessOnboardingMachine, { input: { onStatusChange, stepCount: steps.length } }); + + const value = String(state.value); + const stepIndex = state.context.stepIndex; + const activeStep: WizardStep | undefined = value === "form" ? steps[stepIndex] : undefined; + const isLastStep = stepIndex === steps.length - 1; + const isProcessing = value === "verifying" || value === "review"; + const isApproved = value === "approved"; + + return ( + <> + {activeStep && } + +
+ {activeStep && ( +
+
+

{activeStep.title}

+

{activeStep.description}

+
+ +
+ )} + + {isProcessing && ( +
+ +
+

{value === "verifying" ? "Submitting your application" : "In review"}

+

+ {value === "verifying" + ? "Sending your details to the verification provider…" + : "The provider is reviewing your submission. We'll email you when it's done."} +

+
+
+ )} + + {isApproved && ( +
+ +
+

Approved

+

+ {corridor.name} {kind.toUpperCase()} is complete. You can now register recipients. +

+
+
+ )} +
+ + + {activeStep && ( + <> + + {stepIndex > 0 && ( + + )} + {isLastStep ? ( + + ) : ( + + )} + + )} + {isProcessing && ( + + )} + {isApproved && } + + + ); +} + +function Stepper({ steps, currentIndex }: { steps: WizardStep[]; currentIndex: number }) { + return ( +
+ {steps.map((step, index) => ( +
+
currentIndex && "bg-muted text-muted-foreground" + )} + > + {index + 1} +
+ {index < steps.length - 1 && ( +
+ )} +
+ ))} +
+ ); +} diff --git a/apps/dashboard/src/components/onboarding/OnboardingWizard.tsx b/apps/dashboard/src/components/onboarding/OnboardingWizard.tsx new file mode 100644 index 000000000..0da3f6104 --- /dev/null +++ b/apps/dashboard/src/components/onboarding/OnboardingWizard.tsx @@ -0,0 +1,84 @@ +import { useQueryClient } from "@tanstack/react-query"; +import { useCallback } from "react"; +import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog"; +import { onboardingKindFor, PROVIDER_LABEL, routeFor } from "@/domain/corridors"; +import type { Corridor, OnboardingStatus, SenderAccount } from "@/domain/types"; +import { ONBOARDING_STATUS_QUERY_KEY } from "@/hooks/useApprovedCorridors"; +import { notifyOnboardingStatus } from "@/lib/notify"; +import { getOnboardingSteps } from "@/machines/onboardingSteps"; +import { useOnboardingOverrideStore } from "@/stores/onboardingOverride.store"; +import { AlfredpayKycFlow } from "./alfredpay/AlfredpayKycFlow"; +import { AveniaKycFlow } from "./avenia/AveniaKycFlow"; +import { ExternalFlow } from "./ExternalFlow"; +import { HeadlessFlow } from "./HeadlessFlow"; +import { MoneriumKycFlow } from "./monerium/MoneriumKycFlow"; + +interface OnboardingWizardProps { + account: SenderAccount; + corridor: Corridor; + onClose: () => void; +} + +/** + * Sender onboarding. Individual KYC on Alfredpay corridors (MX/CO/AR) runs the real provider + * machine and submits real data. BRL individual KYC runs the shared live Avenia machine. + * Everything else still uses the generic mocked wizard. + */ +export function OnboardingWizard({ account, corridor, onClose }: OnboardingWizardProps) { + const setOverride = useOnboardingOverrideStore(state => state.set); + const queryClient = useQueryClient(); + const kind = onboardingKindFor(corridor, account.type); + const route = routeFor(corridor.id, kind); + const isRealAlfredpayKyc = corridor.provider === "alfredpay" && kind === "kyc" && route === "headless"; + const isLiveAveniaKyc = corridor.provider === "avenia" && kind === "kyc" && route === "headless"; + const isLiveMoneriumKyc = corridor.provider === "monerium" && route === "headless"; + const steps = getOnboardingSteps(corridor.id, kind); + + /** Mocked flows have no backend to read from, so they fake the corridor's status locally. */ + const onStatusChange = (status: OnboardingStatus) => { + setOverride(corridor.id, status); + notifyOnboardingStatus(corridor.name, kind, status); + }; + + /** 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 ? ( + + ) : route === "headless" ? ( + + ) : ( + + )} + + + ); +} 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/WizardStepFields.tsx b/apps/dashboard/src/components/onboarding/WizardStepFields.tsx new file mode 100644 index 000000000..1c4652fca --- /dev/null +++ b/apps/dashboard/src/components/onboarding/WizardStepFields.tsx @@ -0,0 +1,60 @@ +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { DocumentDropzone } from "./DocumentDropzone"; + +/** + * Visual-only mock fields per wizard step. Nothing is submitted — the wizard + * is driven by its state machine, these inputs just make the flow feel real. + */ +export function WizardStepFields({ stepId }: { stepId: string }) { + switch (stepId) { + case "companyInfo": + return ( +
+ + + +
+ ); + case "representative": + return ( +
+ + + +
+ ); + case "personalInfo": + return ( +
+ + + +
+ ); + case "documents": + return ( +
+ + +
+ ); + case "liveness": + return ( +
+ +
+ ); + default: + return null; + } +} + +function Field({ label, placeholder, type = "text" }: { label: string; placeholder?: string; type?: string }) { + return ( +
+ + +
+ ); +} 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..ea0ed00b8 --- /dev/null +++ b/apps/dashboard/src/components/onboarding/alfredpay/AlfredpayKycFlow.tsx @@ -0,0 +1,182 @@ +import type { AlfredpayKycFormData, 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 { 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; +} + +/** 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", + "CreatingCustomer", + "SubmittingKycInfo", + "SubmittingFiles", + "SendingSubmission", + "Retrying" +]); + +function Centered({ children }: { children: React.ReactNode }) { + return
{children}
; +} + +export function AlfredpayKycFlow({ corridor, onSettled, onClose, userEmail }: AlfredpayKycFlowProps) { + const [state, send] = useMachine(alfredpayKycMachine, { input: { country: CORRIDOR_COUNTRY[corridor.id] } }); + + const value = String(state.value); + const { error, country } = 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} verification profile with Alfredpay, then collect your details. +

+
+ + + + + + ); + } + + 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 === "UploadingDocuments") { + return ( + send({ type: "GO_BACK" })} + onSubmit={(files: MxnKycFiles) => send({ files, type: "SUBMIT_FILES" })} + /> + ); + } + + 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} 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/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/AveniaKycFlow.tsx b/apps/dashboard/src/components/onboarding/avenia/AveniaKycFlow.tsx new file mode 100644 index 000000000..b890bf461 --- /dev/null +++ b/apps/dashboard/src/components/onboarding/avenia/AveniaKycFlow.tsx @@ -0,0 +1,147 @@ +import type { AveniaKycFormData, UploadIds } from "@vortexfi/kyc"; +import { createAveniaKycApi, createAveniaKycMachine } 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 { AveniaKycFormScreen } from "./AveniaKycFormScreen"; +import { AveniaLivenessScreen } from "./AveniaLivenessScreen"; + +interface AveniaKycFlowProps { + corridor: Corridor; + onClose: () => void; + onSettled: (status: OnboardingStatus) => void; +} + +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({ corridor, onClose, onSettled }: AveniaKycFlowProps) { + const [state, send] = useMachine(aveniaKycMachine, { input: { taxId: "" } }); + 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); + }, [value, onSettled]); + + if (BUSY_STATES.has(value)) { + return ( + <> + + +
+

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

+

This can take a moment while Avenia processes your information.

+
+
+ + + + + ); + } + + if (value === "FormFilling") { + return ( + send({ formData, type: "FORM_SUBMIT" })} + /> + ); + } + + 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} 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..5773cad66 --- /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/RecipientDialog.tsx b/apps/dashboard/src/components/recipients/RecipientDialog.tsx new file mode 100644 index 000000000..e8b9b22ee --- /dev/null +++ b/apps/dashboard/src/components/recipients/RecipientDialog.tsx @@ -0,0 +1,241 @@ +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, FormDescription, 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 } from "@/domain/corridors"; +import { inviteUrl } from "@/domain/recipient"; +import type { AccountType, Corridor, 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({ + amount: z.string().refine(value => Number(value) > 0, "Enter an amount"), + 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, approvedCorridors }: { account: SenderAccount; approvedCorridors: Corridor[] }) { + const [open, setOpen] = useState(false); + const [created, setCreated] = useState(null); + const queryClient = useQueryClient(); + + const form = useForm({ + defaultValues: { + amount: "", + corridorId: approvedCorridors[0]?.id ?? "BR", + recipientType: "individual" + }, + resolver: standardSchemaResolver(schema) + }); + + const corridorId = form.watch("corridorId"); + const corridor = CORRIDORS[corridorId]; + const disabled = approvedCorridors.length === 0; + + const createInvite = useMutation({ + mutationFn: (values: FormValues) => + RecipientsService.createInvite({ + amount: Number(values.amount).toFixed(2), + 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 }); + // The raw token is returned exactly once — this is the only chance to build the link. + 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({ amount: "", 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 how much. We'll generate an invite link you send them yourself — they complete + KYC/KYB and add their payout details to receive transfers. + + +
+ + ( + + Recipient type + field.onChange(value as AccountType)} value={field.value}> + + + + Individual + + + + Company + + + + + )} + /> + +
+ ( + + Country + + + + )} + /> + ( + + I will send you ({corridor.currency}) + + + + You can change this per transfer later. + + + )} + /> +
+ + + + + + + + + )} +
+
+ ); +} + +function InviteShare({ created, onDone }: { created: CreatedInvite; onDone: () => void }) { + const [copied, setCopied] = useState(false); + + function copy() { + navigator.clipboard?.writeText(created.url); + notifyInviteCopied(); + setCopied(true); + } + + 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. + + +
+ Invite link +
+ {created.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..c6bda5fd5 --- /dev/null +++ b/apps/dashboard/src/components/recipients/RecipientsTable.tsx @@ -0,0 +1,101 @@ +import { useNavigate } from "@tanstack/react-router"; +import { ArrowRight } from "lucide-react"; +import { motion } from "motion/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, PROVIDER_LABEL } 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"; + +const MotionRow = motion.create(TableRow); + +export function RecipientsTable({ recipients }: { recipients: Recipient[] }) { + return ( + + + + Recipient + Country + Payout + Status + Action + + + + {recipients.map((recipient, i) => { + const corridor = CORRIDORS[recipient.corridorId]; + const status = RECIPIENT_STATUS_META[recipient.status]; + return ( + + +
+ {recipientLabel(recipient)} + + {recipient.isSelf ? "Yourself" : recipient.recipientType} + +
+
+ + {corridor.flag} {corridor.name} + + +
+ + {recipient.amount} {recipient.payoutCurrency} + + + {recipient.bankDetails.value + ? `${PAYMENT_METHOD_LABEL[recipient.bankDetails.method]} · ${recipient.bankDetails.value}` + : "Awaiting recipient details"} + +
+
+ + {status.label} + + + + +
+ ); + })} +
+
+ ); +} + +function RecipientAction({ recipient, provider }: { recipient: Recipient; provider: string }) { + 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 sent; + } + + if (recipient.status === "rejected") { + return Expired; + } + + return Awaiting {provider} 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..23a413fe8 --- /dev/null +++ b/apps/dashboard/src/components/transfer/FundingMethods.tsx @@ -0,0 +1,188 @@ +import { ConnectKitButton } from "connectkit"; +import { ArrowDownToLine, Check, Copy, Loader2, TriangleAlert, Wallet } from "lucide-react"; +import { toast } from "sonner"; +import { useAccount } from "wagmi"; +import { Button } from "@/components/ui/button"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { shortenAddress } from "@/domain/transfer"; +import type { QuoteResponse } from "@/services/api/types"; + +export type FundingSource = "wallet" | "crypto"; + +export interface FundingSubmit { + source: FundingSource; + label: string; + destAddress: string; +} + +interface FundingMethodsProps { + quote: QuoteResponse; + network: string; + networkLabel: string; + submitting: boolean; + onSubmit: (submit: FundingSubmit) => void; +} + +/** + * The ramp is signed by the connected wallet, so both funding paths resolve to that + * address — "Connect wallet" signs in-app; "Send crypto" deposits to it manually. + */ +export function FundingMethods({ quote, networkLabel, submitting, onSubmit }: FundingMethodsProps) { + const { address, isConnected } = useAccount(); + const usdcAmount = quote.inputAmount; + + return ( +
+ How you'll fund this + + + + + Connect wallet + + + + Send crypto + + + + + address && onSubmit({ destAddress: address, label: "Connected wallet", source: "wallet" })} + submitting={submitting} + usdcAmount={usdcAmount} + /> + + + + address && onSubmit({ destAddress: address, label: "Self-custodial deposit", source: "crypto" })} + submitting={submitting} + usdcAmount={usdcAmount} + /> + + +
+ ); +} + +function ConnectTab({ + address, + isConnected, + usdcAmount, + submitting, + onSend +}: { + address?: string; + isConnected: boolean; + usdcAmount: string; + submitting: boolean; + onSend: () => void; +}) { + if (!isConnected || !address) { + return ( +
+

Connect your wallet to send the payin directly.

+ + {({ show }) => ( + + )} + +
+ ); + } + return ( +
+
+ + Connected + {shortenAddress(address)} +
+ +
+ ); +} + +function AddressCard({ address, label, onCopy }: { address: string; label: string; onCopy: () => void }) { + return ( +
+ {label} +
+ {address} + +
+
+ ); +} + +function CryptoTab({ + address, + isConnected, + networkLabel, + usdcAmount, + submitting, + onConfirm +}: { + address?: string; + isConnected: boolean; + networkLabel: string; + usdcAmount: string; + submitting: boolean; + onConfirm: () => void; +}) { + if (!isConnected || !address) { + return ( +
+

Connect your wallet to use it as the deposit destination.

+ + {({ show }) => ( + + )} + +
+ ); + } + return ( +
+
+

What to do

+

+ Send ≈ {usdcAmount} USDC on {networkLabel} to your wallet. +

+
+ { + navigator.clipboard?.writeText(address); + toast.success("Address copied"); + }} + /> +

+ + Crypto transfers are irreversible. Confirm the network and address before sending. +

+ +
+ ); +} diff --git a/apps/dashboard/src/components/transfer/QuoteSummary.tsx b/apps/dashboard/src/components/transfer/QuoteSummary.tsx new file mode 100644 index 000000000..69b3c31c4 --- /dev/null +++ b/apps/dashboard/src/components/transfer/QuoteSummary.tsx @@ -0,0 +1,144 @@ +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"; +import type { QuoteResponse } from "@/services/api/types"; + +const QUOTE_TTL_SECONDS = 60; + +function useSecondsLeft(expiresAt: 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..4e0c319eb --- /dev/null +++ b/apps/dashboard/src/components/transfer/TransferForm.tsx @@ -0,0 +1,278 @@ +import { useNavigate } from "@tanstack/react-router"; +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 { extractBackendLimit, QuoteError } from "@/services/api/types"; +import { FundingMethods, type FundingSubmit } from "./FundingMethods"; +import { QuoteSummary } from "./QuoteSummary"; + +interface TransferFormProps { + account: SenderAccount; + recipients: Recipient[]; + preselectRecipientId?: string; +} + +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(() => recipients.find(recipient => recipient.id === initialId)?.amount ?? ""); + 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 networkLabel = TRANSFER_NETWORKS.find(item => item.id === network)?.label ?? network; + 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(recipients.find(recipient => recipient.id === id)?.amount ?? ""); + setPixKey(""); + } + + // The transfer machine (ported widget ramp core) owns register → sign → start → track. + const submitting = useSelector( + transferActor, + snapshot => snapshot.matches("Registering") || snapshot.matches("SigningUserTxs") || snapshot.matches("Starting") + ); + 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 || submitting || !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. +

+
+ )} + +
+ + +
+ + {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 ( +