From e3a6452004f46afb4ab4d8e1212cc7d3ddaa9c74 Mon Sep 17 00:00:00 2001 From: Kacper Szarkiewicz Date: Fri, 14 Aug 2026 16:20:53 +0200 Subject: [PATCH 1/7] feat(api): add sandbox sales-demo account and restore routine --- apps/api/.env.example | 6 + apps/api/package.json | 1 + apps/api/scripts/seed-demo-account.ts | 40 ++ .../src/api/controllers/auth.controller.ts | 4 + .../services/demo/demo-account.constants.ts | 157 ++++++++ .../api/services/demo/demo-account.service.ts | 353 ++++++++++++++++++ .../demo/demo-alfredpay.provider.test.ts | 99 +++++ .../services/demo/demo-alfredpay.provider.ts | 183 +++++++++ apps/api/src/config/vars.ts | 16 + apps/api/src/index.ts | 4 + .../tests/demo-account.integration.test.ts | 193 ++++++++++ .../src/services/balance.service.test.ts | 20 + .../dashboard/src/services/balance.service.ts | 4 +- docs/README.md | 2 + docs/adr-0003-sandbox-demo-environment.md | 102 +++++ docs/operations-demo-environment.md | 101 +++++ docs/security-spec/01-auth/supabase-otp.md | 5 +- .../05-integrations/alfredpay.md | 4 + 18 files changed, 1292 insertions(+), 2 deletions(-) create mode 100644 apps/api/scripts/seed-demo-account.ts create mode 100644 apps/api/src/api/services/demo/demo-account.constants.ts create mode 100644 apps/api/src/api/services/demo/demo-account.service.ts create mode 100644 apps/api/src/api/services/demo/demo-alfredpay.provider.test.ts create mode 100644 apps/api/src/api/services/demo/demo-alfredpay.provider.ts create mode 100644 apps/api/src/tests/demo-account.integration.test.ts create mode 100644 docs/adr-0003-sandbox-demo-environment.md create mode 100644 docs/operations-demo-environment.md diff --git a/apps/api/.env.example b/apps/api/.env.example index b0afbd769..d1ccff57e 100644 --- a/apps/api/.env.example +++ b/apps/api/.env.example @@ -7,6 +7,12 @@ LOG_LEVEL=info # Environment Configuration SANDBOX_ENABLED=false +# Sales-demo account (sandbox only) — see docs/operations-demo-environment.md +DEMO_ACCOUNT_EMAIL=demo@satoshipay.io +# Serves Alfredpay KYB from canned in-process responses so the demo corridor can be +# re-onboarded endlessly. Refuses to start unless DEPLOYMENT_ENV=sandbox. +DEMO_PROVIDER_ENABLED=false + # Admin Authentication # Generate a strong random secret for production # Example: openssl rand -base64 32 diff --git a/apps/api/package.json b/apps/api/package.json index c3888e4d4..3c95d187b 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -93,6 +93,7 @@ "migrate": "bun -r @swc-node/register src/database/migrator.ts", "migrate:revert": "bun -r @swc-node/register src/database/migrator.ts revert-all", "migrate:revert-last": "bun -r @swc-node/register src/database/migrator.ts revert", + "seed:demo": "bun scripts/seed-demo-account.ts", "seed:phase-metadata": "bun -r @swc-node/register src/database/seeders/phase-metadata.ts", "serve": "bun dist/index.js", "start": "bun run build && bun run serve", diff --git a/apps/api/scripts/seed-demo-account.ts b/apps/api/scripts/seed-demo-account.ts new file mode 100644 index 000000000..bef35be91 --- /dev/null +++ b/apps/api/scripts/seed-demo-account.ts @@ -0,0 +1,40 @@ +/** + * Restores the sales-demo account to its pitch-ready state. + * + * bun run seed:demo + * + * Sandbox only — the restore routine itself refuses to run anywhere else. The demo profile + * must have signed in once via OTP first; the seed cannot forge a Supabase Auth user. + * See docs/operations-demo-environment.md. + */ +import path from "node:path"; +import dotenv from "dotenv"; + +dotenv.config({ path: path.resolve(import.meta.dir, "../.env") }); + +const unknownArguments = process.argv.slice(2); +if (unknownArguments.length > 0) { + throw new Error(`Unknown argument(s): ${unknownArguments.join(", ")}`); +} + +async function main(): Promise { + const [{ default: sequelize }, { restoreDemoAccount }] = await Promise.all([ + import("../src/config/database"), + import("../src/api/services/demo/demo-account.service") + ]); + + try { + const summary = await restoreDemoAccount(); + console.log(`Restored demo account ${summary.profileId} (entity ${summary.senderEntityId}).`); + console.log(` recipients: ${summary.recipients}`); + console.log(` transactions: ${summary.transactions}`); + console.log(` reset-corridor provider rows removed: ${summary.resetCorridorRowsRemoved}`); + } finally { + await sequelize.close(); + } +} + +main().catch(error => { + console.error("Failed to restore the demo account:", error instanceof Error ? error.message : error); + process.exitCode = 1; +}); diff --git a/apps/api/src/api/controllers/auth.controller.ts b/apps/api/src/api/controllers/auth.controller.ts index d55818936..a6a6faef7 100644 --- a/apps/api/src/api/controllers/auth.controller.ts +++ b/apps/api/src/api/controllers/auth.controller.ts @@ -3,6 +3,7 @@ import logger from "../../config/logger"; import User from "../../models/user.model"; import { RefreshTokenError, SupabaseAuthService } from "../services/auth"; import { getOrCreateCustomerEntityForProfile } from "../services/customer-entity.service"; +import { restoreDemoAccountOnLogin } from "../services/demo/demo-account.service"; import { markManagedProfileClaimed, normalizeManagedProfileEmail } from "../services/managed-profile.service"; export class AuthController { @@ -101,6 +102,9 @@ export class AuthController { logger.error("Failed to create customer entity for new profile:", entityError); } + // Sandbox demo account only; a no-op everywhere else. + await restoreDemoAccountOnLogin(email); + return res.json({ access_token: result.access_token, refresh_token: result.refresh_token, diff --git a/apps/api/src/api/services/demo/demo-account.constants.ts b/apps/api/src/api/services/demo/demo-account.constants.ts new file mode 100644 index 000000000..de05fb3d4 --- /dev/null +++ b/apps/api/src/api/services/demo/demo-account.constants.ts @@ -0,0 +1,157 @@ +/** + * Fixed identifiers for the seeded sales-demo account. Every row the demo restore writes + * carries one of these ids, so restore is idempotent and can never touch a real ramp, + * recipient, or provider record created during a demo. + * + * See docs/operations-demo-environment.md. + */ + +/** All demo-owned primary keys share this prefix. */ +const DEMO_UUID_PREFIX = "d3ff0000-0000-4000-8000-"; + +function demoUuid(slot: number): string { + return `${DEMO_UUID_PREFIX}${slot.toString().padStart(12, "0")}`; +} + +/** True for any row the demo restore owns. Used to scope deletes. */ +export function isDemoOwnedId(id: string): boolean { + return id.startsWith(DEMO_UUID_PREFIX); +} + +/** The demo sender's business customer entity, used only when the profile has none yet. */ +export const DEMO_SENDER_ENTITY_ID = demoUuid(1); + +/** The corridor Florian onboards live. Wiped on every restore so it always starts fresh. */ +export const DEMO_RESET_CORRIDOR = { country: "CO", provider: "alfredpay", rail: "cop" } as const; + +/** The corridor with a real Avenia customer behind it. Restore never touches these rows. */ +export const DEMO_REAL_CORRIDOR = { country: "BR", provider: "avenia", rail: "brl" } as const; + +export interface DemoRecipientSeed { + slot: number; + alias: string; + inviteeEmail: string; + country: string; + rail: string; + payoutCurrency: string; + /** Omitted for the invite-only row, which has no relationship or entity yet. */ + relationship?: { + /** Present only when the recipient should read as "Approved" rather than "Pending review". */ + approved?: { + instrumentType: "pix" | "clabe"; + maskedDisplayLabel: string; + }; + }; +} + +/** + * Four rows, deliberately mixed. A wall of identical "Approved" entries is what makes a + * seeded account read as fake; the status vocabulary is part of the pitch. + */ +export const DEMO_RECIPIENTS: DemoRecipientSeed[] = [ + { + alias: "Padaria Aurora LTDA", + country: "BR", + inviteeEmail: "financeiro@padaria-aurora.example", + payoutCurrency: "BRL", + rail: "brl", + relationship: { approved: { instrumentType: "pix", maskedDisplayLabel: "••••4821" } }, + slot: 10 + }, + { + alias: "Miguel Ortega Servicios", + country: "MX", + inviteeEmail: "pagos@ortega-servicios.example", + payoutCurrency: "MXN", + rail: "mxn", + relationship: { approved: { instrumentType: "clabe", maskedDisplayLabel: "••••7390" } }, + slot: 11 + }, + { + alias: "Andrea Rojas", + country: "CO", + inviteeEmail: "andrea.rojas@example.com", + payoutCurrency: "COP", + rail: "cop", + relationship: {}, + slot: 12 + }, + { + alias: "Estudio Belgrano SRL", + country: "AR", + inviteeEmail: "cobros@estudio-belgrano.example", + payoutCurrency: "ARS", + rail: "ars", + slot: 13 + } +]; + +export const demoRecipientEntityId = (slot: number) => demoUuid(100 + slot); +export const demoInvitationId = (slot: number) => demoUuid(200 + slot); +export const demoSenderRecipientId = (slot: number) => demoUuid(300 + slot); +export const demoPayoutReferenceId = (slot: number) => demoUuid(400 + slot); +export const demoRecipientProviderCustomerId = (slot: number) => demoUuid(500 + slot); + +export interface DemoTransactionSeed { + slot: number; + direction: "buy" | "sell"; + /** `complete` renders as completed; anything mid-flight renders as processing. */ + phase: "complete" | "brlaOnrampMint" | "brlaPayoutOnBase"; + inputAmount: string; + inputCurrency: string; + outputAmount: string; + outputCurrency: string; + /** How long before "now" the ramp was created; re-stamped on every restore so dates never rot. */ + ageMinutes: number; +} + +/** + * Two completed (one each direction, so payin and payout both appear) and two frozen at + * processing. Pending rows are written with `presignedTxs = null`, which is what keeps + * RampRecoveryWorker from picking them up and failing them ~15 minutes after seeding. + */ +export const DEMO_TRANSACTIONS: DemoTransactionSeed[] = [ + { + ageMinutes: 60 * 26, + direction: "buy", + inputAmount: "5000.00", + inputCurrency: "BRL", + outputAmount: "912.44", + outputCurrency: "USDC", + phase: "complete", + slot: 20 + }, + { + ageMinutes: 60 * 8, + direction: "sell", + inputAmount: "1500.00", + inputCurrency: "USDC", + outputAmount: "8194.35", + outputCurrency: "BRL", + phase: "complete", + slot: 21 + }, + { + ageMinutes: 95, + direction: "buy", + inputAmount: "2400.00", + inputCurrency: "BRL", + outputAmount: "437.98", + outputCurrency: "USDC", + phase: "brlaOnrampMint", + slot: 22 + }, + { + ageMinutes: 20, + direction: "sell", + inputAmount: "750.00", + inputCurrency: "USDC", + outputAmount: "4097.18", + outputCurrency: "BRL", + phase: "brlaPayoutOnBase", + slot: 23 + } +]; + +export const demoQuoteId = (slot: number) => demoUuid(600 + slot); +export const demoRampId = (slot: number) => demoUuid(700 + slot); diff --git a/apps/api/src/api/services/demo/demo-account.service.ts b/apps/api/src/api/services/demo/demo-account.service.ts new file mode 100644 index 000000000..4e37ad499 --- /dev/null +++ b/apps/api/src/api/services/demo/demo-account.service.ts @@ -0,0 +1,353 @@ +import { createHash } from "node:crypto"; +import { EPaymentMethod, EvmToken, FiatToken, Networks, RampDirection } from "@vortexfi/shared"; +import { Op } from "sequelize"; +import logger from "../../../config/logger"; +import { config } from "../../../config/vars"; +import CustomerEntity from "../../../models/customerEntity.model"; +import KycCase from "../../../models/kycCase.model"; +import ProviderCustomer, { type ProviderName, VerificationStatus } from "../../../models/providerCustomer.model"; +import QuoteTicket from "../../../models/quoteTicket.model"; +import RampState from "../../../models/rampState.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 type { FlowGlobals, FlowMetadata } from "../phases/blocks/core/metadata"; +import { resolveBlockFlow } from "../phases/blocks/flows/catalog"; +import type { StateMetadata } from "../phases/meta-state-types"; +import type { QuoteTicketMetadata } from "../quote/core/types"; +import { + DEMO_RECIPIENTS, + DEMO_RESET_CORRIDOR, + DEMO_SENDER_ENTITY_ID, + DEMO_TRANSACTIONS, + type DemoRecipientSeed, + type DemoTransactionSeed, + demoInvitationId, + demoPayoutReferenceId, + demoQuoteId, + demoRampId, + demoRecipientEntityId, + demoRecipientProviderCustomerId, + demoSenderRecipientId +} from "./demo-account.constants"; + +/** Placeholder counterparty on seeded history rows; never used for a real transfer. */ +const DEMO_WALLET_ADDRESS = "0x000000000000000000000000000000000000dEmO"; + +export interface DemoRestoreSummary { + profileId: string; + senderEntityId: string; + recipients: number; + transactions: number; + resetCorridorRowsRemoved: number; +} + +/** + * Rebuilds the sales-demo account to its pitch-ready state: an onboarded business, a + * populated recipients list, and a transaction history with both completed and in-flight + * rows. Every write is keyed by a fixed demo id, so running it twice is the same as + * running it once and it can never overwrite a real ramp created during a demo. + * + * Sandbox only. See docs/operations-demo-environment.md. + */ +export async function restoreDemoAccount(): Promise { + if (config.deploymentEnv !== "sandbox") { + throw new Error(`Demo restore is sandbox-only; DEPLOYMENT_ENV is '${config.deploymentEnv}'`); + } + + const profile = await User.findOne({ where: { email: config.demoAccountEmail } }); + if (!profile) { + throw new Error( + `No profile for ${config.demoAccountEmail}. The demo account must sign in once via OTP first — ` + + "the seed cannot forge a Supabase Auth user." + ); + } + + const senderEntity = await resolveSenderEntity(profile); + const resetCorridorRowsRemoved = await wipeResetCorridor(senderEntity.id); + + for (const recipient of DEMO_RECIPIENTS) { + await seedRecipient(senderEntity.id, profile.id, recipient); + } + for (const transaction of DEMO_TRANSACTIONS) { + await seedTransaction(profile.id, transaction); + } + + return { + profileId: profile.id, + recipients: DEMO_RECIPIENTS.length, + resetCorridorRowsRemoved, + senderEntityId: senderEntity.id, + transactions: DEMO_TRANSACTIONS.length + }; +} + +/** + * Login hook: restores the demo account so every sales demo starts from the same state, + * without the presenter having to run anything. A no-op for every other login, and never + * a reason for a login to fail — the Supabase session is already minted by this point. + */ +export async function restoreDemoAccountOnLogin(email: string): Promise { + if (config.deploymentEnv !== "sandbox" || email.trim().toLowerCase() !== config.demoAccountEmail) { + return; + } + + try { + await restoreDemoAccount(); + } catch (error) { + logger.error("Failed to restore the demo account on login:", error); + } +} + +/** + * The demo pitches a company account. `active_customer_entity_id` is immutable once set, so a + * profile that picked "Individual" on first login cannot be converted here — say so plainly + * instead of seeding a business entity the dashboard will never show. + */ +async function resolveSenderEntity(profile: User): Promise { + if (profile.activeCustomerEntityId) { + const active = await CustomerEntity.findOne({ where: { id: profile.activeCustomerEntityId, profileId: profile.id } }); + if (!active) { + throw new Error(`Profile ${profile.id} points at a customer entity it does not own`); + } + if (active.type !== "business") { + throw new Error( + `The demo profile's active entity is '${active.type}', but the demo account is a company. ` + + "Active entity selection is immutable — delete the profile in Supabase and sign in again, choosing Company." + ); + } + return active; + } + + const [entity] = await CustomerEntity.findOrCreate({ + defaults: { country: "BR", id: DEMO_SENDER_ENTITY_ID, profileId: profile.id, status: "active", type: "business" }, + where: { profileId: profile.id, type: "business" } + }); + await profile.update({ activeCustomerEntityId: entity.id }); + return entity; +} + +/** + * Clears the corridor onboarded live during the demo so every run starts from an empty state. + * Only this corridor's rows are touched — the real Avenia/BR customer stays intact. + */ +async function wipeResetCorridor(senderEntityId: string): Promise { + const staleCustomers = await ProviderCustomer.findAll({ + attributes: ["id"], + where: { country: DEMO_RESET_CORRIDOR.country, customerEntityId: senderEntityId, provider: DEMO_RESET_CORRIDOR.provider } + }); + if (staleCustomers.length === 0) { + return 0; + } + + const staleIds = staleCustomers.map(customer => customer.id); + // kyc_cases has no country column, so the corridor is identified through the provider customer. + await KycCase.destroy({ where: { customerEntityId: senderEntityId, providerCustomerId: { [Op.in]: staleIds } } }); + return ProviderCustomer.destroy({ where: { id: { [Op.in]: staleIds } } }); +} + +async function seedRecipient(senderEntityId: string, profileId: string, seed: DemoRecipientSeed): Promise { + const invitationId = demoInvitationId(seed.slot); + await RecipientInvitation.upsert({ + acceptedAt: seed.relationship ? minutesFromNow(-60 * 24 * 3) : null, + alias: seed.alias, + archivedAt: null, + country: seed.country, + createdByProfileId: profileId, + // The recipients controller sweeps pending invites past their expiry and clears their token, + // so the window is pushed forward on every restore rather than written once. + expiresAt: minutesFromNow(60 * 24 * 14), + id: invitationId, + inviteeEmail: seed.inviteeEmail, + inviteeEmailCanonical: seed.inviteeEmail.toLowerCase(), + inviteeType: "business", + payoutCurrency: seed.payoutCurrency, + rail: seed.rail, + revokedAt: null, + senderCustomerEntityId: senderEntityId, + status: seed.relationship ? "accepted" : "pending", + token: seed.relationship ? null : `demo-invite-token-${seed.slot}`, + tokenHash: createHash("sha256").update(`demo-invitation-${seed.slot}`).digest("hex") + }); + + if (!seed.relationship) { + // Invitation only: the recipients view lists this as an outstanding invite. + return; + } + + const recipientEntityId = demoRecipientEntityId(seed.slot); + await CustomerEntity.upsert({ + country: seed.country, + id: recipientEntityId, + profileId: null, + status: "active", + type: "business" + }); + + const senderRecipientId = demoSenderRecipientId(seed.slot); + await SenderRecipient.upsert({ + disabledAt: null, + id: senderRecipientId, + invitationId, + nickname: seed.alias, + rail: seed.rail, + recipientCustomerEntityId: recipientEntityId, + relationshipStatus: "active", + senderCustomerEntityId: senderEntityId + }); + + const { approved } = seed.relationship; + if (!approved) { + // No provider customer and no payout reference: the recipients view reads this as pending review. + return; + } + + const provider = providerForDemoRail(seed.rail); + const providerCustomerId = demoRecipientProviderCustomerId(seed.slot); + await ProviderCustomer.upsert({ + companyName: seed.alias, + country: seed.country, + customerEntityId: recipientEntityId, + customerType: "business", + id: providerCustomerId, + provider, + providerCustomerId: `demo-${seed.rail}-${seed.slot}`, + rail: seed.rail, + status: VerificationStatus.Approved + }); + + const payoutReferenceId = demoPayoutReferenceId(seed.slot); + await RecipientPayoutReference.upsert({ + country: seed.country, + currency: seed.payoutCurrency, + id: payoutReferenceId, + instrumentType: approved.instrumentType, + maskedDisplayLabel: approved.maskedDisplayLabel, + provider, + providerInstrumentId: `demo-instrument-${seed.slot}`, + rail: seed.rail, + recipientCustomerEntityId: recipientEntityId, + senderRecipientId, + status: "verified" + }); +} + +/** Mirrors providerForRail in the recipients transfer-eligibility service, which computes displayed status. */ +function providerForDemoRail(rail: string): ProviderName { + if (rail === "eur") return "monerium"; + if (rail === "brl") return "avenia"; + return "alfredpay"; +} + +/** + * The API refuses to boot when a resumable ramp's quote carries unusable block-flow metadata + * (`assertPersistedBlockFlowVersionsSupported`), so the seeded rows have to hold the real + * envelope. It is resolved from the catalog rather than hardcoded, so the seed follows the + * flow definitions instead of drifting behind them. + */ +function buildSeedFlowMetadata(profileId: string, seed: DemoTransactionSeed) { + const isBuy = seed.direction === "buy"; + const request: FlowGlobals["request"] = { + countryCode: "BR", + from: isBuy ? EPaymentMethod.PIX : Networks.PolygonAmoy, + inputAmount: seed.inputAmount, + inputCurrency: isBuy ? FiatToken.BRL : EvmToken.USDC, + network: Networks.PolygonAmoy, + outputCurrency: isBuy ? EvmToken.USDC : FiatToken.BRL, + paymentMethod: EPaymentMethod.PIX, + rampType: isBuy ? RampDirection.BUY : RampDirection.SELL, + to: isBuy ? Networks.PolygonAmoy : EPaymentMethod.PIX, + userId: profileId + }; + + const flow = resolveBlockFlow(request); + const metadata: FlowMetadata = { + // Empty per-block records: the envelope has to be structurally valid, but these rows are + // never resumed, so there is no simulation to persist. + blocks: Object.fromEntries(flow.contextKeys.map(key => [key, {}])), + flow: flow.identity, + globals: { + fees: { usd: { anchor: "0", network: "0", partnerMarkup: "0", total: "0", vortex: "0" } }, + partner: null, + request + } + }; + + return { flow, metadata: metadata as unknown as QuoteTicketMetadata, request }; +} + +async function seedTransaction(profileId: string, seed: DemoTransactionSeed): Promise { + const createdAt = minutesFromNow(-seed.ageMinutes); + const quoteId = demoQuoteId(seed.slot); + const { flow, metadata, request } = buildSeedFlowMetadata(profileId, seed); + + await QuoteTicket.upsert({ + apiCredentialId: null, + apiKey: null, + countryCode: "BR", + createdAt, + expiresAt: new Date(createdAt.getTime() + 10 * 60 * 1000), + flowVariant: config.flowVariant, + from: request.from, + id: quoteId, + inputAmount: seed.inputAmount, + inputCurrency: request.inputCurrency, + metadata, + network: Networks.PolygonAmoy, + outputAmount: seed.outputAmount, + outputCurrency: request.outputCurrency, + partnerId: null, + paymentMethod: EPaymentMethod.PIX, + pricingPartnerId: null, + rampType: request.rampType, + status: "consumed", + to: request.to, + updatedAt: createdAt, + userId: profileId + }); + + const rampId = demoRampId(seed.slot); + await RampState.upsert({ + createdAt, + currentPhase: seed.phase, + errorLogs: [], + flowVariant: config.flowVariant, + from: request.from, + id: rampId, + paymentMethod: EPaymentMethod.PIX, + phaseHistory: [{ phase: seed.phase, timestamp: createdAt }], + postCompleteState: { cleanup: { cleanupAt: null, cleanupCompleted: seed.phase === "complete", errors: null } }, + // RampRecoveryWorker only picks up ramps that have presigned transactions. Leaving these null + // is what keeps the in-flight rows sitting at processing instead of decaying to failed. + presignedTxs: null, + processingLock: { locked: false, lockedAt: null }, + quoteId, + // The startup assertion validates the flow identity and phase sequence of every resumable + // ramp, so both are written here rather than being back-filled on the first boot. + state: { + destinationAddress: DEMO_WALLET_ADDRESS, + flow: flow.identity, + phaseFlow: ["initial", ...flow.phases, "complete"], + walletAddress: DEMO_WALLET_ADDRESS + } as StateMetadata, + to: request.to, + type: request.rampType, + // The model rejects an empty array; one inert placeholder satisfies it without being signable. + unsignedTxs: [ + { meta: {}, network: Networks.PolygonAmoy, nonce: 0, phase: seed.phase, signer: DEMO_WALLET_ADDRESS, txData: "0x" } + ], + updatedAt: createdAt, + userId: profileId + }); + + // Sequelize stamps its own timestamps on upsert, so the createdAt passed above is discarded + // and every row would read as "just now". Forcing them afterwards is what keeps the seeded + // ages (`ageMinutes`) visible in the history. + await QuoteTicket.update({ createdAt, updatedAt: createdAt }, { silent: true, where: { id: quoteId } }); + await RampState.update({ createdAt, updatedAt: createdAt }, { silent: true, where: { id: rampId } }); +} + +function minutesFromNow(minutes: number): Date { + return new Date(Date.now() + minutes * 60 * 1000); +} diff --git a/apps/api/src/api/services/demo/demo-alfredpay.provider.test.ts b/apps/api/src/api/services/demo/demo-alfredpay.provider.test.ts new file mode 100644 index 000000000..082c8ec95 --- /dev/null +++ b/apps/api/src/api/services/demo/demo-alfredpay.provider.test.ts @@ -0,0 +1,99 @@ +import { + AlfredpayApiService, + AlfredpayCustomerType, + AlfredpayKybStatus, + type GetAllConfigsResponse, + type SubmitKybInformationRequest +} from "@vortexfi/shared"; +import { describe, expect, it } from "bun:test"; +import { config } from "../../../config/vars"; +import { createDemoAlfredpayService, installDemoProviders } from "./demo-alfredpay.provider"; + +const REAL_CONFIGS: GetAllConfigsResponse = { supportedPairs: [] }; + +function fakeRealClient(): AlfredpayApiService { + return { getAllConfigs: async () => REAL_CONFIGS } as unknown as AlfredpayApiService; +} + +const KYB_SUBMISSION = { + address: "Calle 1", + businessName: "Demo Corp", + city: "Bogotá", + country: "CO", + relatedPersons: [{ firstName: "Ana", lastName: "Gómez" }], + state: "Cundinamarca", + taxId: "900123456", + website: "https://demo.example", + zipCode: "110111" +} as unknown as SubmitKybInformationRequest; + +describe("demo alfredpay provider", () => { + it("moves a submission from pending to in review once it is sent", async () => { + const service = createDemoAlfredpayService(fakeRealClient); + const customerId = (await service.createCustomer("demo@example.com", AlfredpayCustomerType.BUSINESS, "CO")).customerId; + + const { submissionId } = await service.submitKybInformation(customerId, KYB_SUBMISSION); + expect((await service.getKybStatus(customerId, submissionId)).status).toBe(AlfredpayKybStatus.PENDING); + + await service.sendKybSubmission(customerId, submissionId); + expect((await service.getKybStatus(customerId, submissionId)).status).toBe(AlfredpayKybStatus.IN_REVIEW); + }); + + // The whole point of the demo corridor: the same wizard has to be walkable again and again. + it("accepts a fresh submission after a completed one", async () => { + const service = createDemoAlfredpayService(fakeRealClient); + const customerId = (await service.createCustomer("demo@example.com", AlfredpayCustomerType.BUSINESS, "CO")).customerId; + + const first = await service.submitKybInformation(customerId, KYB_SUBMISSION); + await service.sendKybSubmission(customerId, first.submissionId); + const second = await service.submitKybInformation(customerId, KYB_SUBMISSION); + + expect(second.submissionId).not.toBe(first.submissionId); + expect((await service.getKybStatus(customerId, second.submissionId)).status).toBe(AlfredpayKybStatus.PENDING); + }); + + it("reports no submission for an untouched customer", async () => { + const service = createDemoAlfredpayService(fakeRealClient); + + await expect(service.getLastKybSubmission("demo-customer-unknown")).rejects.toThrow(/404/); + }); + + it("returns the related-person ids the file uploads need", async () => { + const service = createDemoAlfredpayService(fakeRealClient); + const customerId = (await service.createCustomer("demo@example.com", AlfredpayCustomerType.BUSINESS, "CO")).customerId; + await service.submitKybInformation(customerId, KYB_SUBMISSION); + + const [details] = await service.getKybBusinessDetails(customerId); + + expect(details.businessName).toBe("Demo Corp"); + expect(details.relatedPersons.map(person => person.idRelatedPerson)).toEqual(["demo-person-1"]); + }); + + it("passes everything it does not fake through to the real client", async () => { + const service = createDemoAlfredpayService(fakeRealClient); + + expect(await service.getAllConfigs()).toBe(REAL_CONFIGS); + }); + + it("stays uninstalled unless a sandbox deployment opts in", () => { + const originalGetInstance = AlfredpayApiService.getInstance; + const originalFlag = config.demoProviderEnabled; + const originalEnv = config.deploymentEnv; + + try { + config.deploymentEnv = "production"; + + config.demoProviderEnabled = false; + installDemoProviders(); + expect(AlfredpayApiService.getInstance).toBe(originalGetInstance); + + config.demoProviderEnabled = true; + expect(() => installDemoProviders()).toThrow(/sandbox-only/); + expect(AlfredpayApiService.getInstance).toBe(originalGetInstance); + } finally { + config.demoProviderEnabled = originalFlag; + config.deploymentEnv = originalEnv; + AlfredpayApiService.getInstance = originalGetInstance; + } + }); +}); diff --git a/apps/api/src/api/services/demo/demo-alfredpay.provider.ts b/apps/api/src/api/services/demo/demo-alfredpay.provider.ts new file mode 100644 index 000000000..190f69d86 --- /dev/null +++ b/apps/api/src/api/services/demo/demo-alfredpay.provider.ts @@ -0,0 +1,183 @@ +import { + AlfredpayApiService, + AlfredpayCustomerType, + type AlfredpayKybCustomerAndBusiness, + AlfredpayKybStatus, + type CreateAlfredpayCustomerResponse, + type FindAlfredpayCustomerResponse, + type GetKybRedirectLinkResponse, + type GetKybStatusResponse, + type GetKybSubmissionResponse, + type SubmitKybInformationRequest, + type SubmitKybInformationResponse +} from "@vortexfi/shared"; +import logger from "../../../config/logger"; +import { config } from "../../../config/vars"; + +/** How long a sent submission sits in review before it approves itself. */ +const REVIEW_DURATION_MS = 10_000; + +interface DemoSubmission { + submissionId: string; + business: SubmitKybInformationRequest | null; + /** Null until sendKybSubmission is called — an unsent submission reads as PENDING. */ + sentAt: number | null; +} + +/** + * Canned Alfredpay stand-in for the demo corridor. It accepts every submission, hands back stable + * ids, and approves after a short review window, so the onboarding wizard can be walked end to end + * as many times as a demo needs without depending on Alfredpay's sandbox. + * + * Only the KYB surface the wizard touches is implemented. Anything else falls through to the real + * client, so an unimplemented path fails visibly instead of returning invented data. + */ +class DemoAlfredpayKyb { + private readonly submissionsByCustomer = new Map(); + + private counter = 0; + + private nextId(prefix: string): string { + this.counter += 1; + return `demo-${prefix}-${this.counter}`; + } + + async createCustomer(): Promise { + return { createdAt: new Date().toISOString(), customerId: this.nextId("customer") }; + } + + async findCustomer(_email: string, country: string): Promise { + return { + country, + createdAt: new Date().toISOString(), + customerId: this.nextId("customer"), + type: AlfredpayCustomerType.BUSINESS + }; + } + + async submitKybInformation(customerId: string, data: SubmitKybInformationRequest): Promise { + const submissionId = this.nextId("kyb"); + this.submissionsByCustomer.set(customerId, { business: data, sentAt: null, submissionId }); + return { submissionId }; + } + + async updateKybInformation(customerId: string, submissionId: string, data: SubmitKybInformationRequest): Promise { + this.submissionsByCustomer.set(customerId, { business: data, sentAt: null, submissionId }); + } + + async getLastKybSubmission(customerId: string): Promise { + const submission = this.submissionsByCustomer.get(customerId); + if (!submission) { + // Callers treat a throw here as "no previous submission" and start a fresh one. + throw new Error("404 Not Found: no KYB submission for this customer"); + } + return { createdAt: new Date().toISOString(), submissionId: submission.submissionId }; + } + + async getKybStatus(customerId: string): Promise { + const submission = this.submissionsByCustomer.get(customerId); + const updatedAt = new Date().toISOString(); + + if (!submission) { + // The process restarted mid-demo. Approving is where the demo was heading anyway, and it + // beats trapping the presenter in a wizard that can no longer be completed. + return { status: AlfredpayKybStatus.COMPLETED, updatedAt }; + } + if (submission.sentAt === null) { + return { status: AlfredpayKybStatus.PENDING, updatedAt }; + } + const inReview = Date.now() - submission.sentAt < REVIEW_DURATION_MS; + return { status: inReview ? AlfredpayKybStatus.IN_REVIEW : AlfredpayKybStatus.COMPLETED, updatedAt }; + } + + async sendKybSubmission(customerId: string, submissionId: string): Promise { + this.submissionsByCustomer.set(customerId, { + business: this.submissionsByCustomer.get(customerId)?.business ?? null, + sentAt: Date.now(), + submissionId + }); + } + + async getKybRedirectLink(customerId: string): Promise { + const submissionId = this.submissionsByCustomer.get(customerId)?.submissionId ?? this.nextId("kyb"); + return { submissionId, verification_url: "https://demo.vortexfinance.co/kyb-verification" }; + } + + async getKybBusinessDetails(customerId: string): Promise { + const submission = this.submissionsByCustomer.get(customerId); + if (!submission?.business) { + return []; + } + + const business = submission.business; + return [ + { + address: business.address, + businessName: business.businessName, + city: business.city, + country: business.country, + customerId, + relatedPersons: (business.relatedPersons ?? []).map((person, index) => ({ + dateOfBirth: person.dateOfBirth, + email: person.email, + firstName: person.firstName, + idRelatedPerson: `demo-person-${index + 1}`, + lastName: person.lastName + })), + state: business.state, + submissionId: submission.submissionId, + taxId: business.taxId, + website: business.website, + zipCode: business.zipCode + } + ]; + } + + // Document uploads are accepted and discarded — nothing downstream reads them here. + async submitKybFiles(): Promise { + return; + } + + async submitKybRelatedPersonFiles(): Promise { + return; + } +} + +/** + * Wraps the demo KYB surface so every other Alfredpay method still reaches the real client. The + * real instance is resolved lazily: a demo deployment may have no Alfredpay credentials at all, + * and that should only break the calls that genuinely need them. + */ +export function createDemoAlfredpayService(realGetInstance: () => AlfredpayApiService): AlfredpayApiService { + const demoKyb = new DemoAlfredpayKyb() as unknown as Record; + + return new Proxy({} as AlfredpayApiService, { + get(_target, property) { + const demoMethod = demoKyb[property]; + if (typeof demoMethod === "function") { + return demoMethod.bind(demoKyb); + } + const real = realGetInstance() as unknown as Record; + const realMethod = real[property]; + return typeof realMethod === "function" ? realMethod.bind(real) : realMethod; + } + }); +} + +/** + * Swaps the Alfredpay singleton for the demo stand-in. Called once at startup; guarded so it can + * never take effect outside a sandbox deployment that explicitly opted in. + */ +export function installDemoProviders(): void { + if (!config.demoProviderEnabled) { + return; + } + if (config.deploymentEnv !== "sandbox") { + throw new Error(`Demo providers are sandbox-only; DEPLOYMENT_ENV is '${config.deploymentEnv}'`); + } + + const realGetInstance = AlfredpayApiService.getInstance.bind(AlfredpayApiService); + const demoService = createDemoAlfredpayService(realGetInstance); + AlfredpayApiService.getInstance = () => demoService; + logger.warn("Demo provider enabled: Alfredpay KYB onboarding is served by canned in-process responses"); +} diff --git a/apps/api/src/config/vars.ts b/apps/api/src/config/vars.ts index 610fba5be..5ec0763b1 100644 --- a/apps/api/src/config/vars.ts +++ b/apps/api/src/config/vars.ts @@ -119,6 +119,14 @@ function readRecipientInviteDiscountLimit(): number { interface Config { env: string; deploymentEnv: DeploymentEnv; + /** Login email of the seeded sales-demo account. Sandbox only; see docs/operations-demo-environment.md. */ + demoAccountEmail: string; + /** + * Replaces the Alfredpay client with a canned in-process stand-in so the demo corridor can be + * onboarded repeatedly without touching Alfredpay's sandbox. Sandbox only, and off by default — + * a sandbox used for partner integration testing must keep the real provider. + */ + demoProviderEnabled: boolean; flowVariant: FlowVariant; port: string | number; amplitudeWss: string; @@ -225,6 +233,8 @@ export const config: Config = { defaults: { vortexEvmPayoutAddress: process.env.DEFAULT_VORTEX_EVM_PAYOUT_ADDRESS }, + demoAccountEmail: (process.env.DEMO_ACCOUNT_EMAIL || "demo@satoshipay.io").trim().toLowerCase(), + demoProviderEnabled: process.env.DEMO_PROVIDER_ENABLED === "true", deploymentEnv: readDeploymentEnv(), env: nodeEnv, flowVariant: readFlowVariant(), @@ -336,6 +346,12 @@ if (config.deploymentEnv === "sandbox" && !config.sandboxEnabled) { throw new Error("DEPLOYMENT_ENV=sandbox requires SANDBOX_ENABLED=true"); } +if (config.demoProviderEnabled && config.deploymentEnv !== "sandbox") { + throw new Error( + `DEMO_PROVIDER_ENABLED=true requires DEPLOYMENT_ENV=sandbox (got '${config.deploymentEnv}'); refusing to start` + ); +} + if (config.env === "production") { const missing: string[] = []; diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index d16949384..f41f56e3f 100755 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -11,6 +11,7 @@ import { runMigrations } from "./database/migrator"; import "./models"; // Initialize models import { AlfredpayLimitsService } from "./api/services/alfredpay/alfredpay-limits.service"; import { assertApiCredentialSchemaReady } from "./api/services/apiCredential.service"; +import { installDemoProviders } from "./api/services/demo/demo-alfredpay.provider"; import { assertPersistedBlockFlowVersionsSupported, registerBlockFlowHandlers @@ -53,6 +54,9 @@ const initializeApp = async () => { // Initialize RSA keys for webhook signing cryptoService.initializeKeys(); + // Sandbox demo deployments only; a no-op everywhere else. + installDemoProviders(); + // Initialize dynamic EVM tokens from SquidRouter API (falls back to static config on failure) await initializeEvmTokens(); diff --git a/apps/api/src/tests/demo-account.integration.test.ts b/apps/api/src/tests/demo-account.integration.test.ts new file mode 100644 index 000000000..69850fdec --- /dev/null +++ b/apps/api/src/tests/demo-account.integration.test.ts @@ -0,0 +1,193 @@ +import { afterAll, beforeAll, beforeEach, describe, expect, it } from "bun:test"; +import { Op } from "sequelize"; +import { restoreDemoAccount, restoreDemoAccountOnLogin } from "../api/services/demo/demo-account.service"; +import { assertPersistedBlockFlowVersionsSupported } from "../api/services/phases/blocks/register-handlers"; +import { config } from "../config/vars"; +import CustomerEntity from "../models/customerEntity.model"; +import KycCase from "../models/kycCase.model"; +import ProviderCustomer, { VerificationStatus } from "../models/providerCustomer.model"; +import RampState from "../models/rampState.model"; +import RecipientInvitation from "../models/recipientInvitation.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"; + +const DEMO_EMAIL = "demo-account@example.com"; +let originalDeploymentEnv: typeof config.deploymentEnv; +let originalDemoEmail: string; + +beforeAll(async () => { + await setupTestDatabase(); + originalDeploymentEnv = config.deploymentEnv; + originalDemoEmail = config.demoAccountEmail; + config.deploymentEnv = "sandbox"; + config.demoAccountEmail = DEMO_EMAIL; +}); + +afterAll(() => { + config.deploymentEnv = originalDeploymentEnv; + config.demoAccountEmail = originalDemoEmail; +}); + +beforeEach(async () => { + await resetTestDatabase(); +}); + +async function createDemoProfile(): Promise { + return createTestUser({ email: DEMO_EMAIL }); +} + +describe("demo account restore", () => { + it("refuses to run outside sandbox", async () => { + config.deploymentEnv = "production"; + try { + await expect(restoreDemoAccount()).rejects.toThrow(/sandbox-only/); + } finally { + config.deploymentEnv = "sandbox"; + } + }); + + it("explains that the profile has to sign in once first", async () => { + await expect(restoreDemoAccount()).rejects.toThrow(/must sign in once/); + }); + + it("seeds a business sender, recipients, and both completed and in-flight transactions", async () => { + const profile = await createDemoProfile(); + + const summary = await restoreDemoAccount(); + + const entity = await CustomerEntity.findByPk(summary.senderEntityId); + expect(entity?.type).toBe("business"); + expect((await User.findByPk(profile.id))?.activeCustomerEntityId).toBe(summary.senderEntityId); + + expect(await RecipientInvitation.count({ where: { senderCustomerEntityId: summary.senderEntityId } })).toBe(4); + expect(await SenderRecipient.count({ where: { senderCustomerEntityId: summary.senderEntityId } })).toBe(3); + + const ramps = await RampState.findAll({ where: { userId: profile.id } }); + expect(ramps.filter(ramp => ramp.currentPhase === "complete")).toHaveLength(2); + expect(ramps.filter(ramp => ramp.currentPhase !== "complete")).toHaveLength(2); + }); + + // RampRecoveryWorker drives any stale non-terminal ramp through the phase processor, which fails + // it when there is no chain state behind it. Its query skips ramps without presigned transactions, + // so seeded in-flight rows must have none — otherwise the demo history rots into failures. + it("leaves in-flight transactions invisible to the recovery worker", async () => { + const profile = await createDemoProfile(); + await restoreDemoAccount(); + + const inFlight = await RampState.findAll({ + where: { currentPhase: { [Op.notIn]: ["complete", "failed", "initial"] }, userId: profile.id } + }); + + expect(inFlight).toHaveLength(2); + for (const ramp of inFlight) { + expect(ramp.presignedTxs).toBeNull(); + } + }); + + it("is repeatable without duplicating anything", async () => { + const profile = await createDemoProfile(); + + const first = await restoreDemoAccount(); + await restoreDemoAccount(); + const third = await restoreDemoAccount(); + + expect(third.senderEntityId).toBe(first.senderEntityId); + expect(await RecipientInvitation.count({ where: { senderCustomerEntityId: first.senderEntityId } })).toBe(4); + expect(await SenderRecipient.count({ where: { senderCustomerEntityId: first.senderEntityId } })).toBe(3); + expect(await RampState.count({ where: { userId: profile.id } })).toBe(4); + }); + + it("clears the reset corridor but keeps the real one and any real ramp", async () => { + const profile = await createDemoProfile(); + const { senderEntityId } = await restoreDemoAccount(); + + // Stand in for a corridor Florian onboarded live during a demo. + const coCustomer = await ProviderCustomer.create({ + country: "CO", + customerEntityId: senderEntityId, + customerType: "business", + provider: "alfredpay", + rail: "cop", + status: VerificationStatus.Approved + }); + await KycCase.create({ + customerEntityId: senderEntityId, + provider: "alfredpay", + providerCustomerId: coCustomer.id, + status: VerificationStatus.Approved + }); + const realCorridor = await ProviderCustomer.create({ + country: "BR", + customerEntityId: senderEntityId, + customerType: "business", + provider: "avenia", + rail: "brl", + status: VerificationStatus.Approved + }); + + const summary = await restoreDemoAccount(); + + expect(summary.resetCorridorRowsRemoved).toBe(1); + expect(await ProviderCustomer.findByPk(coCustomer.id)).toBeNull(); + expect(await KycCase.count({ where: { providerCustomerId: coCustomer.id } })).toBe(0); + expect(await ProviderCustomer.findByPk(realCorridor.id)).not.toBeNull(); + expect(await RampState.count({ where: { userId: profile.id } })).toBe(4); + }); + + it("backdates the history so the seeded ages survive the upsert", async () => { + const profile = await createDemoProfile(); + const restoredAt = Date.now(); + + await restoreDemoAccount(); + + const ramps = await RampState.findAll({ order: [["createdAt", "ASC"]], where: { userId: profile.id } }); + const agesInMinutes = ramps.map(ramp => Math.round((restoredAt - ramp.createdAt.getTime()) / 60_000)); + // Sequelize's upsert stamps its own timestamps; without the explicit backdate every row + // lands at "now" and the history reads as fabricated. + expect(agesInMinutes).toEqual([60 * 26, 60 * 8, 95, 20]); + expect(new Set(agesInMinutes).size).toBe(4); + }); + + it("leaves the API bootable: seeded rows satisfy the persisted block-flow assertion", async () => { + await createDemoProfile(); + + await restoreDemoAccount(); + + // The seeded in-flight ramps are resumable, so the startup guard walks them. Quotes without + // real block-flow metadata make this throw and the API never finishes booting. + await assertPersistedBlockFlowVersionsSupported(); + }); + + it("refuses a profile that already committed to an individual entity", async () => { + const profile = await createDemoProfile(); + const individual = await CustomerEntity.create({ profileId: profile.id, type: "individual" }); + await profile.update({ activeCustomerEntityId: individual.id }); + + await expect(restoreDemoAccount()).rejects.toThrow(/immutable/); + }); +}); + +describe("demo account restore on login", () => { + it("ignores every other account", async () => { + const other = await createTestUser({ email: "someone-else@example.com" }); + + await restoreDemoAccountOnLogin("someone-else@example.com"); + + expect(await RampState.count({ where: { userId: other.id } })).toBe(0); + }); + + it("does not let a restore failure break the login", async () => { + // No demo profile exists yet, so the restore throws; the login path must swallow it. + await restoreDemoAccountOnLogin(DEMO_EMAIL.toUpperCase()); + }); + + it("restores the demo account", async () => { + const profile = await createDemoProfile(); + + await restoreDemoAccountOnLogin(` ${DEMO_EMAIL.toUpperCase()} `); + + expect(await RampState.count({ where: { userId: profile.id } })).toBe(4); + }); +}); diff --git a/apps/dashboard/src/services/balance.service.test.ts b/apps/dashboard/src/services/balance.service.test.ts index ed5c01375..859b3c248 100644 --- a/apps/dashboard/src/services/balance.service.test.ts +++ b/apps/dashboard/src/services/balance.service.test.ts @@ -2,6 +2,7 @@ import { EvmToken, getEvmTokenConfig, Networks } from "@vortexfi/shared"; import assert from "node:assert/strict"; import { describe, it } from "node:test"; import { + fetchTokenPortfolio, formatTokenBalance, getTokenBalance, hasSufficientTokenBalance, @@ -49,4 +50,23 @@ describe("token portfolio balances", () => { assert.equal(formatTokenBalance(123_459_999n, 6, 2), "123.45"); assert.equal(formatTokenBalance(0n, 18, 6), "0.000000"); }); + + // Sandbox offramps run on Amoy. A missing entry throws before the request is made, and the + // funding gate reads that as "balance unavailable" and blocks registration. + it("looks balances up on Amoy rather than refusing the network", async () => { + const originalFetch = globalThis.fetch; + let requestedNetworks: string[] | undefined; + globalThis.fetch = (async (_url: string, init: { body: string }) => { + requestedNetworks = JSON.parse(init.body).addresses[0].networks; + return { json: async () => ({ data: { tokens: [] } }), ok: true }; + }) as unknown as typeof fetch; + + try { + await fetchTokenPortfolio("0x0000000000000000000000000000000000000001", Networks.PolygonAmoy, "test-key"); + } finally { + globalThis.fetch = originalFetch; + } + + assert.deepEqual(requestedNetworks, ["polygon-amoy"]); + }); }); diff --git a/apps/dashboard/src/services/balance.service.ts b/apps/dashboard/src/services/balance.service.ts index 059d1cec0..6cd910791 100644 --- a/apps/dashboard/src/services/balance.service.ts +++ b/apps/dashboard/src/services/balance.service.ts @@ -10,7 +10,9 @@ const ALCHEMY_NETWORK: Partial> = { [Networks.Base]: "base-mainnet", [Networks.BSC]: "bsc-mainnet", [Networks.Ethereum]: "eth-mainnet", - [Networks.Polygon]: "polygon-mainnet" + [Networks.Polygon]: "polygon-mainnet", + // Sandbox runs offramps on Amoy; without this the funding gate can never read a balance. + [Networks.PolygonAmoy]: "polygon-amoy" }; interface AlchemyBalanceResponse { diff --git a/docs/README.md b/docs/README.md index aa9125fba..16a36618a 100644 --- a/docs/README.md +++ b/docs/README.md @@ -18,7 +18,9 @@ The smaller set of general project documents stays directly in `docs/`: |---|---| | [`adr-0001-user-gated-ramp-registration.md`](adr-0001-user-gated-ramp-registration.md) | Accepted architectural decision and rationale | | [`adr-0002-alfredpay-fee-collection.md`](adr-0002-alfredpay-fee-collection.md) | Accepted decision on Alfredpay fee collection and sequential EVM fee distribution | +| [`adr-0003-sandbox-demo-environment.md`](adr-0003-sandbox-demo-environment.md) | Accepted decision on the seeded sales-demo account in the sandbox environment | | [`architecture-identity-model.md`](architecture-identity-model.md) | Current cross-module identity and ownership architecture | +| [`operations-demo-environment.md`](operations-demo-environment.md) | Setup and runbook for the sandbox sales-demo account | | [`operations-legacy-schema-cleanup.md`](operations-legacy-schema-cleanup.md) | Deployment gates and recovery runbook for irreversible migrations 060-061 | | [`operations-testing.md`](operations-testing.md) | Maintained test strategy and suite boundaries | | [`product-dashboard.md`](product-dashboard.md) | Current dashboard product scope and acknowledged gaps | diff --git a/docs/adr-0003-sandbox-demo-environment.md b/docs/adr-0003-sandbox-demo-environment.md new file mode 100644 index 000000000..0c6ca8635 --- /dev/null +++ b/docs/adr-0003-sandbox-demo-environment.md @@ -0,0 +1,102 @@ +# ADR 0003: Sandbox Sales-Demo Account + +Status: accepted 2026-08-07. Implemented in `apps/api/src/api/services/demo/`; the runbook +is [`operations-demo-environment.md`](operations-demo-environment.md). + +## Context + +Sales needed to demonstrate a working payin and payout on `apps/dashboard` to a prospect, +with onboarded corridors, several recipients, and a transaction history containing both +completed and pending rows. + +No account could show that. Every dashboard view derives from live API calls with no seed +path (`useActiveAccount` → `/v1/onboarding/status`, `useRecipients` → Alfredpay fiat +accounts + `/v1/recipients`, `useTransactions` → `/ramp/history`), and standing up a real +account means completing real provider KYB in every corridor first. + +Four things were conflated in the original request and are kept distinct here: + +| Term | Meaning | +|---|---| +| **Sandbox** | The existing deployment environment. Already ships testnet networks, relaxed chain-id validation, provider sandbox URLs, and 10-second ramp completion. Not new. | +| **Demo account** | One seeded profile inside sandbox, with a business customer entity. | +| **Demo restore** | The idempotent routine that returns that account to a known state. | +| **Demo provider** | A sandbox-only stand-in for Alfredpay on the reset corridor. | + +The request's "mocked KYC" was a misnomer: sandbox KYC already always succeeds — but +because Avenia's and Alfredpay's *own* sandboxes approve, not because Vortex has an +auto-approval branch. What was missing was **repeatability without a live dependency**, +not success. + +Three findings shaped the design: + +- **Payout was blocked outright.** `apps/dashboard`'s `ALCHEMY_NETWORK` map had no entry + for Polygon Amoy and threw at the funding gate, so no sandbox SELL could start. +- **Seeded pending transactions decay into failures.** `RampRecoveryWorker` drives any + stale non-terminal ramp through the phase processor, which fails it when no chain state + backs it. Its query skips ramps with no presigned transactions — the only available + escape hatch. +- **Recipients look payable and are not.** Nothing creates `RecipientPayoutReference` rows + in normal operation, and the transfer tab still renders "Fiat-to-Fiat transfers are + coming soon". Seeding cannot close that gap; the runbook routes the demo around it. + +## Decision + +1. **Demo state lives in the sandbox database, seeded by a script** — not frontend + fixtures, not canned responses in the production API path. The demo has to do real + transaction signing against the real backend; fixtures cannot, and a reload would make + UI and API visibly disagree. +2. **One corridor is real, the rest is seeded.** Bounded setup cost, and the pitch still + proves the product works end to end. +3. **BR (Avenia) is the real corridor.** It supports both BUY and SELL, and its payout + destination is a PIX key entered at ramp time (`fallbackSelfRecipient`), so no payout + account setup is needed. +4. **CO (Alfredpay) is the reset corridor.** Its KYB is entirely in-dashboard — no + liveness step, no hosted redirect, so the demo never leaves the tab. BR, EU, and US all + redirect out; AR has no company KYB at all. +5. **The demo account is a company.** Vortex sells B2B and the dashboard's copy is + KYB-first. `profiles.active_customer_entity_id` is immutable (`ACTIVE_ENTITY_IMMUTABLE`), + so an individual story would need a second account. +6. **The reset corridor is served by a demo provider adapter, not real Alfredpay calls.** + A live provider 5xx mid-pitch has no fallback. The adapter fakes API responses only — + the dashboard UI is unchanged. It is behind `DEMO_PROVIDER_ENABLED`, **off by default**, + because a sandbox also serves partner integration testing, which needs the real + provider. +7. **Restore runs on login, not from a visible button.** Zero-click during a call; a + "Reset demo" control in front of a prospect breaks the illusion. The CLI script and the + login hook call the same routine. +8. **Seeded recipients are third-party rows, for display only.** With BR the only approved + corridor at rest, self-recipients are capped at one. Mixed statuses — approved, pending + review, invite created — because a wall of identical "Approved" rows reads as fake. +9. **Seeded pending transactions carry `presigned_txs = NULL`.** The only way to keep them + pending; see the finding above. +10. **Restore is idempotent, deterministic-UUID keyed, and hard-guarded on + `DEPLOYMENT_ENV=sandbox`.** Every row it writes carries a fixed demo UUID prefix, so it + can be re-run at will and can never touch a real ramp, recipient, or corridor created + during a demo — including the real BR provider rows. + +### Rejected + +- **A frontend demo mode** — contradicts decision 1; two sources of truth that disagree + after a reload. +- **Demo-shaped responses in the production API path** — puts demo lies inside audited code. +- **An ephemeral, never-persisted reset corridor** — the corridor would never *stay* + approved, so "onboarding unlocks transfers" could not be shown across a reload. +- **Faking Alfredpay for all sandbox users** — would break the sandbox KYC flows the public + docs promise partners. Hence the explicit, default-off flag in decision 6. + +## Consequences + +- The demo depends on a hand-created Avenia sandbox company KYB for BR. **This was not + validated before implementation.** If Avenia's sandbox will not approve a company end to + end, decision 3 fails and the real corridor becomes MX — the rest of the design is + unaffected. +- The demo provider mints approved KYB status, and the login hook touches the auth path. + Both are sandbox-guarded and covered by explicit invariants in + `security-spec/05-integrations/alfredpay.md` and `security-spec/01-auth/supabase-otp.md`. +- Sign-in remains real email OTP against a mailbox the sales team can reach. No OTP bypass + was added; adding one would be an auth-path change needing its own decision. +- A second demo account would be required for an individual (non-company) story. +- The moment a second corridor is seeded *approved* without a real provider customer behind + it, transfers will need fencing to real corridors. Not a question while BR is the only + approved corridor at rest. diff --git a/docs/operations-demo-environment.md b/docs/operations-demo-environment.md new file mode 100644 index 000000000..688aa2cd9 --- /dev/null +++ b/docs/operations-demo-environment.md @@ -0,0 +1,101 @@ +# Demo environment — operations + +How to stand up and run the seeded sales-demo account on the dashboard. Applies only to +the **sandbox** deployment (`DEPLOYMENT_ENV=sandbox`, `SANDBOX_ENABLED=true`); every entry +point described here refuses to run anywhere else. Decisions and rationale are in +[`adr-0003-sandbox-demo-environment.md`](adr-0003-sandbox-demo-environment.md). + +## What the demo account shows + +One profile — the login email in `DEMO_ACCOUNT_EMAIL`, defaulting to +`demo@satoshipay.io` — with a `business` customer entity. + +**Onboarding** opens at 🇧🇷 **BR approved** (a real Avenia customer, created once by hand) +and 🇨🇴 **CO not started**. CO is onboarded live during the pitch and ends approved, so the +view finishes with two approved corridors. + +**Recipients** — four seeded rows, display-only: + +| Country | Status | Instrument | +|---|---|---| +| 🇧🇷 Padaria Aurora LTDA | Approved | PIX `••••4821` | +| 🇲🇽 Miguel Ortega Servicios | Approved | CLABE `••••7390` | +| 🇨🇴 Andrea Rojas | Pending review | — | +| 🇦🇷 Estudio Belgrano SRL | Invite created | — | + +**Transactions** — four seeded rows, plus whatever the demo itself creates: + +| Direction | Status | Amounts | +|---|---|---| +| BUY | completed | 5,000.00 BRL → 912.44 USDC | +| SELL | completed | 1,500.00 USDC → 8,194.35 BRL | +| BUY | processing | 2,400.00 BRL → 437.98 USDC | +| SELL | processing | 750.00 USDC → 4,097.18 BRL | + +Every seeded date is relative to restore time, so the history never reads as stale. + +## One-time setup + +1. **Sign in once as the demo account.** `profiles.id` is the Supabase Auth UUID, so the + seed cannot forge the profile — it has to exist first. Log into the sandbox dashboard + with the demo email and complete the email OTP. Restore fails with an explicit message + until this is done. +2. **Onboard BR for real.** Complete Avenia company KYB for the demo entity in the sandbox. + This is the one corridor restore never touches, and it is what makes the transfers real. +3. **Set the sandbox API environment:** + + ```bash + DEPLOYMENT_ENV=sandbox + SANDBOX_ENABLED=true + DEMO_ACCOUNT_EMAIL=demo@satoshipay.io # optional; this is the default + DEMO_PROVIDER_ENABLED=true # canned Alfredpay KYB, see below + ``` + + `DEMO_PROVIDER_ENABLED=true` with any other `DEPLOYMENT_ENV` refuses to start. +4. **Fund the demo wallet** with testnet USDC on Polygon Amoy (or Base Sepolia) for the + SELL demo. Payins need no funding. + +## Restoring the demo state + +Restore is idempotent and can be run as often as you like. + +```bash +cd apps/api && bun seed:demo +``` + +It also runs **automatically after every demo-account login**, so in practice a fresh +browser session is all that is needed before a call. A restore failure never blocks the +login — it is logged and swallowed. + +Each run: + +- ensures the business customer entity exists and is the profile's active entity; +- **wipes the CO corridor** (`provider_customers` + `kyc_cases` for `alfredpay`/`CO`) so + the onboarding wizard is walkable again; +- re-seeds the four recipients and four transactions, re-stamping their dates; +- **leaves BR alone**, along with every ramp, recipient, and corridor created during a + demo — restore only writes rows whose ids carry the demo UUID prefix. + +## Running the demo + +- **Onboarding** — start CO. With `DEMO_PROVIDER_ENABLED=true`, the KYB form, questionnaire, + and document upload accept anything, the submission sits in review for ~10 seconds, then + approves. No hosted redirect, no liveness step: the demo never leaves the tab. +- **Payin (BUY)** — BRL → USDC on BR. Real quote, real signing, and the sandbox's + 10-second auto-completion carries it to `complete`. +- **Payout (SELL)** — USDC → BRL on BR. The PIX key is entered at ramp time, so no + recipient setup is needed. +- **Avoid the transfer tab for the seeded recipients.** No recipient can currently be paid + from the recipients list (see [`product-dashboard.md`](product-dashboard.md)); the tab + still renders "Fiat-to-Fiat transfers are coming soon". Demo payouts run through the BUY/ + SELL flow above. + +## If something goes wrong + +| Symptom | Cause | Fix | +|---|---|---| +| `No profile for . The demo account must sign in once via OTP first` | Step 1 of setup was skipped | Log in once with the demo email | +| `Demo restore is sandbox-only` | `DEPLOYMENT_ENV` is not `sandbox` | Point the script at the sandbox API's `.env` | +| `Active entity selection is immutable` | The profile signed up as an individual | Delete the profile in Supabase, sign in again, and choose **Company** | +| CO onboarding hits the real Alfredpay | `DEMO_PROVIDER_ENABLED` is unset | Set it to `true` and restart the API | +| The processing rows flipped to failed | Something wrote `presigned_txs` on them | Re-run `bun seed:demo` | diff --git a/docs/security-spec/01-auth/supabase-otp.md b/docs/security-spec/01-auth/supabase-otp.md index d47058b8b..8df52fbe9 100644 --- a/docs/security-spec/01-auth/supabase-otp.md +++ b/docs/security-spec/01-auth/supabase-otp.md @@ -26,7 +26,8 @@ Two middleware variants exist: 6. **Auth errors MUST NOT leak token content** — Error responses use generic messages. Logs contain request ID, path, and an error category/message, but no full or truncated bearer-token fragment. 7. **Supabase configuration MUST be present** — If `SUPABASE_URL`, `SUPABASE_ANON_KEY`, or `SUPABASE_SERVICE_KEY` are empty/missing, the auth system is non-functional. The service should fail to start rather than silently accept all tokens. 8. **JWT expiry MUST be enforced** — Supabase tokens have a configurable expiry. The verification MUST reject expired tokens, not just validate the signature. -9. **Session teardown MUST happen only on confirmed-invalid refresh** — The frontend clears the stored session (and forces re-login) only when `/v1/auth/refresh` returns `401` (refresh token invalid/revoked). Transient failures (network errors, 5xx, timeouts) MUST NOT clear the session; they are retried while the existing session is preserved. The backend enforces this contract: `/v1/auth/refresh` returns `401` only for a definite invalid-token error from Supabase and returns `503` for transient/transport failures (and any unexpected error), so a Supabase outage cannot masquerade as an invalid token and log users out. +9. **The sandbox demo restore MUST NOT alter authentication outcomes** — `verifyOTP` calls `restoreDemoAccountOnLogin(email)` after Supabase has already verified the OTP and the profile has been resolved. The hook returns immediately unless `DEPLOYMENT_ENV=sandbox` **and** the verified email equals `DEMO_ACCOUNT_EMAIL` (compared trimmed and lowercased), it never creates or elevates an identity — the profile must already exist, because `profiles.id` is the Supabase Auth UUID and cannot be forged — and every error it raises is caught and logged rather than propagated, so it can neither grant nor deny a login. See `docs/adr-0003-sandbox-demo-environment.md`. +10. **Session teardown MUST happen only on confirmed-invalid refresh** — The frontend clears the stored session (and forces re-login) only when `/v1/auth/refresh` returns `401` (refresh token invalid/revoked). Transient failures (network errors, 5xx, timeouts) MUST NOT clear the session; they are retried while the existing session is preserved. The backend enforces this contract: `/v1/auth/refresh` returns `401` only for a definite invalid-token error from Supabase and returns `503` for transient/transport failures (and any unexpected error), so a Supabase outage cannot masquerade as an invalid token and log users out. ## Threat Vectors & Mitigations @@ -38,6 +39,7 @@ Two middleware variants exist: | **Email enumeration** | Attacker probes OTP endpoint to discover registered emails | OTP flow handled by Supabase — Vortex API never sees OTP requests; Supabase rate limits apply | | **Token reuse after logout** | User "logs out" in frontend but JWT is still valid server-side | Supabase token invalidation on signout; short expiry window limits exposure | | **userId injection** | Attacker sends crafted request with `userId` in body/headers to bypass auth | `req.userId` is set exclusively by middleware; controllers read from `req.userId` not from request body | +| **Demo restore reached in production** | An operator sets `DEMO_ACCOUNT_EMAIL` to a real user on a non-sandbox deployment, hoping the login hook rewrites that account's state | `restoreDemoAccountOnLogin` returns before any database access unless `DEPLOYMENT_ENV=sandbox`, and `restoreDemoAccount` itself throws on the same condition. Both guards are covered by tests. | ## Audit Checklist @@ -54,3 +56,4 @@ Two middleware variants exist: - [x] Frontend refresh goes through `/v1/auth/refresh` (not the anon-key client) and clears the session only on a `401`, retrying transient failures — **PASS** - [x] `/v1/auth/refresh` returns `401` only for a confirmed-invalid refresh token and `503` for transient/unexpected failures (so an outage cannot force logout) — **PASS** - [x] Optional auth is limited to anonymous quote discovery and non-mutating BRLA preflight endpoints; protected KYC/resource mutations require authentication, and an indeterminate presented credential never falls back to anonymous. **PASS** +- [x] The demo restore hook in `verifyOTP` runs after verification, is gated on `DEPLOYMENT_ENV=sandbox` plus an exact demo-email match, and cannot change the login result — **PASS** (`demo-account.integration.test.ts`). diff --git a/docs/security-spec/05-integrations/alfredpay.md b/docs/security-spec/05-integrations/alfredpay.md index 51641e3dc..82b53c583 100644 --- a/docs/security-spec/05-integrations/alfredpay.md +++ b/docs/security-spec/05-integrations/alfredpay.md @@ -73,10 +73,13 @@ For routed Alfredpay onramps (any non-passthrough output), the final quote outpu 23. **Dashboard Alfredpay BUY confirmation MUST only start processing, never assert settlement** — The dashboard renders the server-issued MXN/USD/COP/ARS payment instructions after registration and keeps the ramp unstarted. `I have made the payment` may call `/ramp/start`, but token crediting still depends on Alfredpay's independently verified payment status; the client confirmation is not proof of payment. 24. **Reported Alfredpay usage MUST be user-scoped and provider-leg denominated** — `POST /v1/limits` derives the effective user from authentication and counts only that user's ramps whose `complete` phase-history timestamp falls in the current UTC calendar month. Routed BUY usage is the Alfredpay fiat input; routed SELL usage is `metadata.blocks.alfredpayOfframp.inputAmountDecimal` in `ALFREDPAY_EVM_TOKEN`, not the public source-token amount. This informational aggregate is cached in memory for 60 seconds; quote-time limit enforcement never reads that cache. Alfredpay does not document whether its cumulative quota resets by calendar month or uses a rolling window, so the calendar-month period is an explicit Vortex assumption rather than provider-confirmed semantics. +25. **The demo Alfredpay stand-in MUST be unreachable outside an opted-in sandbox** — `installDemoProviders` (`api/services/demo/demo-alfredpay.provider.ts`, called once at startup) replaces `AlfredpayApiService.getInstance` with canned in-process KYB responses that always approve. It returns without doing anything unless `DEMO_PROVIDER_ENABLED=true`, and throws when that flag is set with any `DEPLOYMENT_ENV` other than `sandbox`; `config/vars.ts` repeats the check at load time so the process refuses to start rather than serving a mixed configuration. The flag is off by default precisely because a sandbox also serves partner integration testing, which must exercise the real provider. Only the KYB surface is faked — every other Alfredpay method falls through to the real client, so an unimplemented path fails visibly instead of returning invented data. The stand-in fabricates provider *status* only; it never writes `provider_customers`, and the demo restore that consumes it is itself sandbox-guarded. See `docs/adr-0003-sandbox-demo-environment.md`. + ## Threat Vectors & Mitigations | Threat | Attack Scenario | Mitigation | |---|---|---| +| **Demo KYB stand-in reaching a real deployment** | An operator copies the sandbox env to staging or production, carrying `DEMO_PROVIDER_ENABLED=true`, and every Alfredpay KYB submission auto-approves without due diligence | The flag is off by default; `config/vars.ts` throws at load and `installDemoProviders` throws at startup when it is set outside `DEPLOYMENT_ENV=sandbox`, so the process fails to boot rather than approving anything. Covered by `demo-alfredpay.provider.test.ts`. | | **Invalid country injection** | Attacker sends unsupported country code to bypass validation | `validateResultCountry` middleware checks against `AlfredPayCountry` enum; rejects invalid values with 400 | | **Fiat payment spoofing (on-ramp)** | User claims payment without paying | Wait for Alfredpay payment confirmation; no token crediting without confirmation | | **Permit replay (off-ramp)** | Attacker replays a previously-used SquidRouter permit | SquidRouter permits include nonces; the permit contract rejects replayed nonces | @@ -120,6 +123,7 @@ For routed Alfredpay onramps (any non-passthrough output), the final quote outpu - [x] Alfredpay quote simulation resolves tracking-only `metadata.customerId` via `resolveAlfredpayQuoteCustomerId` (real id for KYC-completed users, `"anonymous"` sentinel otherwise); provider *orders* always resolve via strict `resolveAlfredpayCustomerId`. **PASS**. - [x] Fiat-account routes resolve the authenticated effective user's Alfredpay customer; the dashboard lists/adds sender self accounts without persisting raw bank-account fields locally, and registration carries only the selected provider `fiatAccountId`. **PASS**. - [x] Dashboard Alfredpay onramps register with an editable EVM `destinationAddress`, require no connected wallet, preserve provider instructions across reload, and do not call `/ramp/start` before explicit payment confirmation. Provider-side payment verification remains authoritative. **PASS**. +- [x] `DEMO_PROVIDER_ENABLED` defaults to off, and both `config/vars.ts` and `installDemoProviders` refuse to start the process when it is set outside `DEPLOYMENT_ENV=sandbox`. **PASS** — `demo-alfredpay.provider.test.ts`. ## Provider-customers cutover (2026-07) From 7c35f7b91b96992cff42e18bd23256617b57a73c Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Mon, 17 Aug 2026 14:29:40 +0200 Subject: [PATCH 2/7] fix(api): make the seeded demo invite token redeemable The stored tokenHash was derived from a different string than the raw token the dashboard offers for re-copy, so the copied invite link resolved to no invitation. --- .../api/services/demo/demo-account.service.ts | 10 +++++++--- .../src/tests/demo-account.integration.test.ts | 17 +++++++++++++++++ 2 files changed, 24 insertions(+), 3 deletions(-) diff --git a/apps/api/src/api/services/demo/demo-account.service.ts b/apps/api/src/api/services/demo/demo-account.service.ts index 4e37ad499..6cf82f9dc 100644 --- a/apps/api/src/api/services/demo/demo-account.service.ts +++ b/apps/api/src/api/services/demo/demo-account.service.ts @@ -1,4 +1,3 @@ -import { createHash } from "node:crypto"; import { EPaymentMethod, EvmToken, FiatToken, Networks, RampDirection } from "@vortexfi/shared"; import { Op } from "sequelize"; import logger from "../../../config/logger"; @@ -16,6 +15,7 @@ import type { FlowGlobals, FlowMetadata } from "../phases/blocks/core/metadata"; import { resolveBlockFlow } from "../phases/blocks/flows/catalog"; import type { StateMetadata } from "../phases/meta-state-types"; import type { QuoteTicketMetadata } from "../quote/core/types"; +import { hashInviteToken } from "../recipients/recipient-invite.service"; import { DEMO_RECIPIENTS, DEMO_RESET_CORRIDOR, @@ -149,6 +149,10 @@ async function wipeResetCorridor(senderEntityId: string): Promise { async function seedRecipient(senderEntityId: string, profileId: string, seed: DemoRecipientSeed): Promise { const invitationId = demoInvitationId(seed.slot); + // The dashboard offers the raw token for re-copy, and redemption looks the invite up by + // hashInviteToken(token) — the stored hash must be derived from the same string or the + // copied link dead-ends on "invalid invitation". + const inviteToken = `demo-invite-token-${seed.slot}`; await RecipientInvitation.upsert({ acceptedAt: seed.relationship ? minutesFromNow(-60 * 24 * 3) : null, alias: seed.alias, @@ -167,8 +171,8 @@ async function seedRecipient(senderEntityId: string, profileId: string, seed: De revokedAt: null, senderCustomerEntityId: senderEntityId, status: seed.relationship ? "accepted" : "pending", - token: seed.relationship ? null : `demo-invite-token-${seed.slot}`, - tokenHash: createHash("sha256").update(`demo-invitation-${seed.slot}`).digest("hex") + token: seed.relationship ? null : inviteToken, + tokenHash: hashInviteToken(inviteToken) }); if (!seed.relationship) { diff --git a/apps/api/src/tests/demo-account.integration.test.ts b/apps/api/src/tests/demo-account.integration.test.ts index 69850fdec..07ab5c595 100644 --- a/apps/api/src/tests/demo-account.integration.test.ts +++ b/apps/api/src/tests/demo-account.integration.test.ts @@ -2,6 +2,7 @@ import { afterAll, beforeAll, beforeEach, describe, expect, it } from "bun:test" import { Op } from "sequelize"; import { restoreDemoAccount, restoreDemoAccountOnLogin } from "../api/services/demo/demo-account.service"; import { assertPersistedBlockFlowVersionsSupported } from "../api/services/phases/blocks/register-handlers"; +import { hashInviteToken } from "../api/services/recipients/recipient-invite.service"; import { config } from "../config/vars"; import CustomerEntity from "../models/customerEntity.model"; import KycCase from "../models/kycCase.model"; @@ -69,6 +70,22 @@ describe("demo account restore", () => { expect(ramps.filter(ramp => ramp.currentPhase !== "complete")).toHaveLength(2); }); + // The dashboard surfaces invitation.token for sender re-copy, and redemption resolves the + // link via hashInviteToken(token) — a hash derived from any other string turns the copied + // invite link into "invalid invitation" mid-demo. + it("stores a pending invite token the accept lookup can resolve", async () => { + await createDemoProfile(); + + const { senderEntityId } = await restoreDemoAccount(); + + const pending = await RecipientInvitation.findOne({ + where: { senderCustomerEntityId: senderEntityId, status: "pending" } + }); + expect(pending?.token).toBeTruthy(); + const resolved = await RecipientInvitation.findOne({ where: { tokenHash: hashInviteToken(pending?.token as string) } }); + expect(resolved?.id).toBe(pending?.id as string); + }); + // RampRecoveryWorker drives any stale non-terminal ramp through the phase processor, which fails // it when there is no chain state behind it. Its query skips ramps without presigned transactions, // so seeded in-flight rows must have none — otherwise the demo history rots into failures. From 3adf7f0a05e0b49647d4951350c3ffed92abc9a6 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Mon, 17 Aug 2026 14:30:48 +0200 Subject: [PATCH 3/7] fix(api): seed display-fiat fees so ramp status serves demo rows getRampStatus rejects quotes without fees.displayFiat with a 500; seeded quotes carried only the USD denomination. --- .../src/api/services/demo/demo-account.service.ts | 7 ++++++- .../src/tests/demo-account.integration.test.ts | 15 +++++++++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/apps/api/src/api/services/demo/demo-account.service.ts b/apps/api/src/api/services/demo/demo-account.service.ts index 6cf82f9dc..199a30ac9 100644 --- a/apps/api/src/api/services/demo/demo-account.service.ts +++ b/apps/api/src/api/services/demo/demo-account.service.ts @@ -272,7 +272,12 @@ function buildSeedFlowMetadata(profileId: string, seed: DemoTransactionSeed) { blocks: Object.fromEntries(flow.contextKeys.map(key => [key, {}])), flow: flow.identity, globals: { - fees: { usd: { anchor: "0", network: "0", partnerMarkup: "0", total: "0", vortex: "0" } }, + // getRampStatus rejects a quote whose fee structure lacks displayFiat with a 500, so + // both denominations are seeded even though every amount is zero. + fees: { + displayFiat: { anchor: "0", currency: FiatToken.BRL, network: "0", partnerMarkup: "0", total: "0", vortex: "0" }, + usd: { anchor: "0", network: "0", partnerMarkup: "0", total: "0", vortex: "0" } + }, partner: null, request } diff --git a/apps/api/src/tests/demo-account.integration.test.ts b/apps/api/src/tests/demo-account.integration.test.ts index 07ab5c595..0e79f9b0f 100644 --- a/apps/api/src/tests/demo-account.integration.test.ts +++ b/apps/api/src/tests/demo-account.integration.test.ts @@ -1,8 +1,10 @@ import { afterAll, beforeAll, beforeEach, describe, expect, it } from "bun:test"; import { Op } from "sequelize"; +import { DEMO_TRANSACTIONS, demoRampId } from "../api/services/demo/demo-account.constants"; import { restoreDemoAccount, restoreDemoAccountOnLogin } from "../api/services/demo/demo-account.service"; import { assertPersistedBlockFlowVersionsSupported } from "../api/services/phases/blocks/register-handlers"; import { hashInviteToken } from "../api/services/recipients/recipient-invite.service"; +import rampService from "../api/services/ramp/ramp.service"; import { config } from "../config/vars"; import CustomerEntity from "../models/customerEntity.model"; import KycCase from "../models/kycCase.model"; @@ -86,6 +88,19 @@ describe("demo account restore", () => { expect(resolved?.id).toBe(pending?.id as string); }); + // getRampStatus rejects quotes whose fee metadata lacks the display-fiat denomination with + // a 500 — a transaction-detail view (or a manual GET /v1/ramp/:id) must not break on demo rows. + it("serves ramp status for every seeded transaction", async () => { + await createDemoProfile(); + await restoreDemoAccount(); + + for (const seed of DEMO_TRANSACTIONS) { + const status = await rampService.getRampStatus(demoRampId(seed.slot)); + expect(status).not.toBeNull(); + expect(status?.currentPhase).toBe(seed.phase); + } + }); + // RampRecoveryWorker drives any stale non-terminal ramp through the phase processor, which fails // it when there is no chain state behind it. Its query skips ramps without presigned transactions, // so seeded in-flight rows must have none — otherwise the demo history rots into failures. From 864d7535ed3a3a788a08cca3d8f66f502b71c44f Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Mon, 17 Aug 2026 14:31:59 +0200 Subject: [PATCH 4/7] fix(api): scope the demo alfredpay stand-in to business KYB Individual (KYC) customer creation now reaches the real client instead of persisting an invented id that later real-client calls reject with confusing provider 404s. findCustomer no longer needs faking: it is only reachable from the real client's 409 recovery. --- .../demo/demo-alfredpay.provider.test.ts | 20 ++++++++++++++ .../services/demo/demo-alfredpay.provider.ts | 26 +++++++++---------- .../05-integrations/alfredpay.md | 2 +- 3 files changed, 33 insertions(+), 15 deletions(-) diff --git a/apps/api/src/api/services/demo/demo-alfredpay.provider.test.ts b/apps/api/src/api/services/demo/demo-alfredpay.provider.test.ts index 082c8ec95..77632694c 100644 --- a/apps/api/src/api/services/demo/demo-alfredpay.provider.test.ts +++ b/apps/api/src/api/services/demo/demo-alfredpay.provider.test.ts @@ -75,6 +75,26 @@ describe("demo alfredpay provider", () => { expect(await service.getAllConfigs()).toBe(REAL_CONFIGS); }); + // Faking individual creation would persist an invented id that every later real-client + // call (redirect link, KYC status) rejects with confusing provider 404s. + it("passes individual customer creation through to the real client", async () => { + const realCalls: string[] = []; + const real = { + createCustomer: async (_email: string, type: AlfredpayCustomerType) => { + realCalls.push(type); + return { createdAt: new Date().toISOString(), customerId: "real-customer-1" }; + } + } as unknown as AlfredpayApiService; + const service = createDemoAlfredpayService(() => real); + + const individual = await service.createCustomer("demo@example.com", AlfredpayCustomerType.INDIVIDUAL, "CO"); + const business = await service.createCustomer("demo@example.com", AlfredpayCustomerType.BUSINESS, "CO"); + + expect(individual.customerId).toBe("real-customer-1"); + expect(realCalls).toEqual([AlfredpayCustomerType.INDIVIDUAL]); + expect(business.customerId).toMatch(/^demo-customer-/); + }); + it("stays uninstalled unless a sandbox deployment opts in", () => { const originalGetInstance = AlfredpayApiService.getInstance; const originalFlag = config.demoProviderEnabled; diff --git a/apps/api/src/api/services/demo/demo-alfredpay.provider.ts b/apps/api/src/api/services/demo/demo-alfredpay.provider.ts index 190f69d86..071638444 100644 --- a/apps/api/src/api/services/demo/demo-alfredpay.provider.ts +++ b/apps/api/src/api/services/demo/demo-alfredpay.provider.ts @@ -4,7 +4,6 @@ import { type AlfredpayKybCustomerAndBusiness, AlfredpayKybStatus, type CreateAlfredpayCustomerResponse, - type FindAlfredpayCustomerResponse, type GetKybRedirectLinkResponse, type GetKybStatusResponse, type GetKybSubmissionResponse, @@ -29,32 +28,31 @@ interface DemoSubmission { * ids, and approves after a short review window, so the onboarding wizard can be walked end to end * as many times as a demo needs without depending on Alfredpay's sandbox. * - * Only the KYB surface the wizard touches is implemented. Anything else falls through to the real - * client, so an unimplemented path fails visibly instead of returning invented data. + * Only the business-KYB surface the wizard touches is implemented. Individual (KYC) customer + * creation and anything else fall through to the real client, so an unimplemented path fails + * visibly instead of returning invented data. */ class DemoAlfredpayKyb { private readonly submissionsByCustomer = new Map(); private counter = 0; + constructor(private readonly realGetInstance: () => AlfredpayApiService) {} + private nextId(prefix: string): string { this.counter += 1; return `demo-${prefix}-${this.counter}`; } - async createCustomer(): Promise { + async createCustomer(email: string, type: AlfredpayCustomerType, country: string): Promise { + if (type !== AlfredpayCustomerType.BUSINESS) { + // KYC is not part of the demo corridor; an invented id here would be persisted and then + // fail with confusing provider 404s on every later real-client call. + return this.realGetInstance().createCustomer(email, type, country); + } return { createdAt: new Date().toISOString(), customerId: this.nextId("customer") }; } - async findCustomer(_email: string, country: string): Promise { - return { - country, - createdAt: new Date().toISOString(), - customerId: this.nextId("customer"), - type: AlfredpayCustomerType.BUSINESS - }; - } - async submitKybInformation(customerId: string, data: SubmitKybInformationRequest): Promise { const submissionId = this.nextId("kyb"); this.submissionsByCustomer.set(customerId, { business: data, sentAt: null, submissionId }); @@ -149,7 +147,7 @@ class DemoAlfredpayKyb { * and that should only break the calls that genuinely need them. */ export function createDemoAlfredpayService(realGetInstance: () => AlfredpayApiService): AlfredpayApiService { - const demoKyb = new DemoAlfredpayKyb() as unknown as Record; + const demoKyb = new DemoAlfredpayKyb(realGetInstance) as unknown as Record; return new Proxy({} as AlfredpayApiService, { get(_target, property) { diff --git a/docs/security-spec/05-integrations/alfredpay.md b/docs/security-spec/05-integrations/alfredpay.md index 4fff8f7fb..fd7a8db80 100644 --- a/docs/security-spec/05-integrations/alfredpay.md +++ b/docs/security-spec/05-integrations/alfredpay.md @@ -79,7 +79,7 @@ For routed Alfredpay onramps (any non-passthrough output), the final quote outpu 27. **The background verification sweep MUST be bounded, fair, and MUST NOT poll accounts it cannot notify** — `AlfredpayStatusWorker` costs two to three Alfredpay calls per account (submission-id resolution, then status). It MUST bound the sweep by account age (60 days on `provider_customers.updatedAt`, since an account abandoned mid-wizard never reaches a terminal status) and by batch size. A stable keyset cursor advances after every full page and wraps at the end; repeatedly selecting only the newest page would starve older eligible accounts. Entities with a null `profile_id` are partner-owned and have no profile to email; they MUST be excluded in the query so they never consume provider requests. Only the `mykobo` flow-variant backend owns the provider status workers, and each cron uses `waitForCompletion`, preventing duplicate cross-backend polls and overlapping same-process cycles. 28. **Alfredpay offramp pricing observations MUST remain source-labelled and descriptive** — The persisted block metadata records the actual Vortex reference-feed source and observation time, Alfredpay's returned gross rate and fee breakdown, the provider net rate derived from `toAmount ÷ fromAmount`, and the customer all-in rate derived from final fiat output divided by the USD-valued quote input. These observations MUST NOT replace the normal Vortex price-conversion source or alter quote amounts, discounts, fees, or subsidies. 29. **Cross-manager email identity adoption is an accepted risk** — Contact-email uniqueness is scoped to one manager, but Alfredpay identifies customers by email. Two managers may therefore submit the same normalized email, and conflict recovery adopts Alfredpay's existing customer when country and type match without independently proving that the second manager controls that provider identity. This is explicitly accepted as [RISK-019](../RISK-REGISTER.md). Manager isolation, immutable manager-scoped email uniqueness, country/type matching, and local provider-ID uniqueness limit accidental attachment; global/provider-scoped ownership or provider claim proof is required before overlapping manager email namespaces are supported. -30. **The demo Alfredpay stand-in MUST be unreachable outside an opted-in sandbox** — `installDemoProviders` (`api/services/demo/demo-alfredpay.provider.ts`, called once at startup) replaces `AlfredpayApiService.getInstance` with canned in-process KYB responses that always approve. It returns without doing anything unless `DEMO_PROVIDER_ENABLED=true`, and throws when that flag is set with any `DEPLOYMENT_ENV` other than `sandbox`; `config/vars.ts` repeats the check at load time so the process refuses to start rather than serving a mixed configuration. The flag is off by default precisely because a sandbox also serves partner integration testing, which must exercise the real provider. Only the KYB surface is faked — every other Alfredpay method falls through to the real client, so an unimplemented path fails visibly instead of returning invented data. The stand-in fabricates provider *status* only; it never writes `provider_customers`, and the demo restore that consumes it is itself sandbox-guarded. See `docs/adr-0004-sandbox-demo-environment.md`. +30. **The demo Alfredpay stand-in MUST be unreachable outside an opted-in sandbox** — `installDemoProviders` (`api/services/demo/demo-alfredpay.provider.ts`, called once at startup) replaces `AlfredpayApiService.getInstance` with canned in-process KYB responses that always approve. It returns without doing anything unless `DEMO_PROVIDER_ENABLED=true`, and throws when that flag is set with any `DEPLOYMENT_ENV` other than `sandbox`; `config/vars.ts` repeats the check at load time so the process refuses to start rather than serving a mixed configuration. The flag is off by default precisely because a sandbox also serves partner integration testing, which must exercise the real provider. Only the business-KYB surface is faked — individual (KYC) customer creation and every other Alfredpay method fall through to the real client, so an unimplemented path fails visibly instead of returning invented data. The stand-in fabricates provider *status* only; it never writes `provider_customers`, and the demo restore that consumes it is itself sandbox-guarded. See `docs/adr-0004-sandbox-demo-environment.md`. ## Threat Vectors & Mitigations From a8dae1503229abce940d1ebffc4a80faa4f4898e Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Mon, 17 Aug 2026 14:32:23 +0200 Subject: [PATCH 5/7] fix(api): keep demo alfredpay customer ids unique across restarts provider_customers is unique on (provider, provider_customer_id); a bare counter restarting at 1 could collide with rows persisted by a previous process. --- .../services/demo/demo-alfredpay.provider.test.ts | 12 ++++++++++++ .../src/api/services/demo/demo-alfredpay.provider.ts | 7 ++++++- 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/apps/api/src/api/services/demo/demo-alfredpay.provider.test.ts b/apps/api/src/api/services/demo/demo-alfredpay.provider.test.ts index 77632694c..9373344f9 100644 --- a/apps/api/src/api/services/demo/demo-alfredpay.provider.test.ts +++ b/apps/api/src/api/services/demo/demo-alfredpay.provider.test.ts @@ -95,6 +95,18 @@ describe("demo alfredpay provider", () => { expect(business.customerId).toMatch(/^demo-customer-/); }); + // provider_customers is unique on (provider, provider_customer_id); ids persisted by a + // previous process must not collide with the ones a restarted stand-in mints. + it("mints customer ids that stay unique across restarts", async () => { + const first = createDemoAlfredpayService(fakeRealClient); + const second = createDemoAlfredpayService(fakeRealClient); + + const a = (await first.createCustomer("demo@example.com", AlfredpayCustomerType.BUSINESS, "CO")).customerId; + const b = (await second.createCustomer("demo@example.com", AlfredpayCustomerType.BUSINESS, "CO")).customerId; + + expect(a).not.toBe(b); + }); + it("stays uninstalled unless a sandbox deployment opts in", () => { const originalGetInstance = AlfredpayApiService.getInstance; const originalFlag = config.demoProviderEnabled; diff --git a/apps/api/src/api/services/demo/demo-alfredpay.provider.ts b/apps/api/src/api/services/demo/demo-alfredpay.provider.ts index 071638444..98d654a8f 100644 --- a/apps/api/src/api/services/demo/demo-alfredpay.provider.ts +++ b/apps/api/src/api/services/demo/demo-alfredpay.provider.ts @@ -35,13 +35,18 @@ interface DemoSubmission { class DemoAlfredpayKyb { private readonly submissionsByCustomer = new Map(); + // Customer ids end up in provider_customers, which is unique on (provider, + // provider_customer_id) — a bare counter restarting at 1 would collide with rows + // persisted by a previous process, so ids carry a per-process seed. + private readonly processSeed = `${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`; + private counter = 0; constructor(private readonly realGetInstance: () => AlfredpayApiService) {} private nextId(prefix: string): string { this.counter += 1; - return `demo-${prefix}-${this.counter}`; + return `demo-${prefix}-${this.processSeed}-${this.counter}`; } async createCustomer(email: string, type: AlfredpayCustomerType, country: string): Promise { From 558e247bf92588fde219b542677201b9ba7a62a8 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Mon, 17 Aug 2026 14:34:29 +0200 Subject: [PATCH 6/7] test(api): harden demo test setup and cover the wiring gaps Config snapshots are now taken before database setup can throw, the verify-otp route is exercised end to end so the login hook cannot be silently unwired, and installDemoProviders' successful swap path is covered alongside its refusal paths. --- .../demo/demo-alfredpay.provider.test.ts | 21 +++++++++ .../tests/demo-account.integration.test.ts | 47 ++++++++++++++++++- 2 files changed, 67 insertions(+), 1 deletion(-) diff --git a/apps/api/src/api/services/demo/demo-alfredpay.provider.test.ts b/apps/api/src/api/services/demo/demo-alfredpay.provider.test.ts index 9373344f9..4779ced7b 100644 --- a/apps/api/src/api/services/demo/demo-alfredpay.provider.test.ts +++ b/apps/api/src/api/services/demo/demo-alfredpay.provider.test.ts @@ -107,6 +107,27 @@ describe("demo alfredpay provider", () => { expect(a).not.toBe(b); }); + it("swaps the singleton for the demo stand-in on an opted-in sandbox", async () => { + const originalGetInstance = AlfredpayApiService.getInstance; + const originalFlag = config.demoProviderEnabled; + const originalEnv = config.deploymentEnv; + + try { + config.deploymentEnv = "sandbox"; + config.demoProviderEnabled = true; + installDemoProviders(); + + expect(AlfredpayApiService.getInstance).not.toBe(originalGetInstance); + const service = AlfredpayApiService.getInstance(); + const customer = await service.createCustomer("demo@example.com", AlfredpayCustomerType.BUSINESS, "CO"); + expect(customer.customerId).toMatch(/^demo-customer-/); + } finally { + config.demoProviderEnabled = originalFlag; + config.deploymentEnv = originalEnv; + AlfredpayApiService.getInstance = originalGetInstance; + } + }); + it("stays uninstalled unless a sandbox deployment opts in", () => { const originalGetInstance = AlfredpayApiService.getInstance; const originalFlag = config.demoProviderEnabled; diff --git a/apps/api/src/tests/demo-account.integration.test.ts b/apps/api/src/tests/demo-account.integration.test.ts index 0e79f9b0f..5dc9bec99 100644 --- a/apps/api/src/tests/demo-account.integration.test.ts +++ b/apps/api/src/tests/demo-account.integration.test.ts @@ -1,5 +1,8 @@ import { afterAll, beforeAll, beforeEach, describe, expect, it } from "bun:test"; +import express from "express"; import { Op } from "sequelize"; +import authRoutes from "../api/routes/v1/auth.route"; +import { SupabaseAuthService } from "../api/services/auth"; import { DEMO_TRANSACTIONS, demoRampId } from "../api/services/demo/demo-account.constants"; import { restoreDemoAccount, restoreDemoAccountOnLogin } from "../api/services/demo/demo-account.service"; import { assertPersistedBlockFlowVersionsSupported } from "../api/services/phases/blocks/register-handlers"; @@ -21,9 +24,11 @@ let originalDeploymentEnv: typeof config.deploymentEnv; let originalDemoEmail: string; beforeAll(async () => { - await setupTestDatabase(); + // Snapshots are taken before the first await: if database setup throws, afterAll must + // still restore real values instead of writing undefined into the shared config. originalDeploymentEnv = config.deploymentEnv; originalDemoEmail = config.demoAccountEmail; + await setupTestDatabase(); config.deploymentEnv = "sandbox"; config.demoAccountEmail = DEMO_EMAIL; }); @@ -223,3 +228,43 @@ describe("demo account restore on login", () => { expect(await RampState.count({ where: { userId: profile.id } })).toBe(4); }); }); + +// Guards the call site, not just the service: the hook must stay wired into verifyOTP. +describe("demo restore via the verify-otp route", () => { + let server: ReturnType; + let baseUrl: string; + const originalVerifyOTP = SupabaseAuthService.verifyOTP; + + beforeAll(() => { + const app = express(); + app.use(express.json()); + app.use("/v1/auth", authRoutes); + server = app.listen(0); + const address = server.address(); + if (!address || typeof address === "string") throw new Error("Could not bind test server"); + baseUrl = `http://127.0.0.1:${address.port}/v1/auth`; + }); + + afterAll(() => { + server?.close(); + SupabaseAuthService.verifyOTP = originalVerifyOTP; + }); + + it("restores the demo account when the demo email verifies", async () => { + const profile = await createDemoProfile(); + SupabaseAuthService.verifyOTP = (async () => ({ + access_token: "test-access", + refresh_token: "test-refresh", + user_id: profile.id + })) as typeof SupabaseAuthService.verifyOTP; + + const response = await fetch(`${baseUrl}/verify-otp`, { + body: JSON.stringify({ email: DEMO_EMAIL, token: "123456" }), + headers: { "Content-Type": "application/json" }, + method: "POST" + }); + + expect(response.status).toBe(200); + expect(await RampState.count({ where: { userId: profile.id } })).toBe(4); + }); +}); From d62901a128a9bc92c452c6365a1114942c5093bc Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Mon, 17 Aug 2026 14:34:41 +0200 Subject: [PATCH 7/7] refactor(api): drop unused isDemoOwnedId helper Deletes are scoped by corridor query and fixed ids; nothing reads the prefix predicate. --- apps/api/src/api/services/demo/demo-account.constants.ts | 5 ----- 1 file changed, 5 deletions(-) diff --git a/apps/api/src/api/services/demo/demo-account.constants.ts b/apps/api/src/api/services/demo/demo-account.constants.ts index de05fb3d4..2ca332575 100644 --- a/apps/api/src/api/services/demo/demo-account.constants.ts +++ b/apps/api/src/api/services/demo/demo-account.constants.ts @@ -13,11 +13,6 @@ function demoUuid(slot: number): string { return `${DEMO_UUID_PREFIX}${slot.toString().padStart(12, "0")}`; } -/** True for any row the demo restore owns. Used to scope deletes. */ -export function isDemoOwnedId(id: string): boolean { - return id.startsWith(DEMO_UUID_PREFIX); -} - /** The demo sender's business customer entity, used only when the profile has none yet. */ export const DEMO_SENDER_ENTITY_ID = demoUuid(1);