Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions apps/api/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions apps/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,7 @@
"preview:emails": "bun src/scripts/preview-emails.ts",
"preview:emails:watch": "bun --watch src/scripts/preview-emails.ts",
"register:avenia-webhook": "bun src/scripts/register-avenia-webhook.ts",
"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",
Expand Down
40 changes: 40 additions & 0 deletions apps/api/scripts/seed-demo-account.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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;
});
4 changes: 4 additions & 0 deletions apps/api/src/api/controllers/auth.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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,
Expand Down
152 changes: 152 additions & 0 deletions apps/api/src/api/services/demo/demo-account.constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
/**
* 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")}`;
}

/** 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);
Loading
Loading