feat(cards): import Marqeta Malta EU production card issuing
Some checks failed
CI/CD Pipeline / Solidity Contracts (push) Failing after 55s
CI/CD Pipeline / Security Scanning (push) Successful in 2m35s
CI/CD Pipeline / Lint and Format (push) Failing after 56s
CI/CD Pipeline / Terraform Validation (push) Failing after 24s
CI/CD Pipeline / Kubernetes Validation (push) Successful in 29s
HYBX OMNL TypeScript & anchor / token-aggregation build + reconcile artifact (push) Failing after 23s
Validation / validate-genesis (push) Successful in 30s
Validation / validate-terraform (push) Failing after 29s
Validation / validate-kubernetes (push) Failing after 11s
Validation / validate-smart-contracts (push) Failing after 10s
Validation / validate-security (push) Failing after 1m25s
Validation / validate-documentation (push) Failing after 17s
OMNL reconcile anchor / Run omnl:reconcile and upload artifacts (push) Failing after 20s
Verify Deployment / Verify Deployment (push) Failing after 54s

Add Core API adapter, card-issuer hub routes, webhook sink, TA issue path, smoke script, and portal status UI with fail-closed live gate.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-07-19 12:26:05 -07:00
parent 4048112461
commit 4e04d9ca51
17 changed files with 1259 additions and 20 deletions

View File

@@ -0,0 +1,19 @@
# Marqeta Malta (EU) — production card issuing
# Copy live values into secrets/marqeta/credentials.env (gitignored).
# Docs: https://www.marqeta.com/docs/core-api/authentication
# Do not commit real tokens.
MARQETA_ENABLED=1
# Customer-specific production host (Malta EU program). Sandbox:
# MARQETA_API_BASE_URL=https://sandbox-api.marqeta.com/v3
MARQETA_API_BASE_URL=https://api.marqeta.com/v3
MARQETA_MALTA_API_BASE_URL=
MARQETA_APPLICATION_TOKEN=
MARQETA_ADMIN_ACCESS_TOKEN=
MARQETA_CARD_PRODUCT_TOKEN=
MARQETA_FUNDING_SOURCE_TOKEN=
MARQETA_PROGRAM_NAME=OMNL Malta Marqeta
MARQETA_REGION=MT
MARQETA_WEBHOOK_SECRET=
# Live virtual-card issue gate:
SETTLEMENT_ALLOW_MARQETA_PRODUCTION=0

View File

@@ -0,0 +1,19 @@
# Marqeta Malta (EU) — production card issuing
# Copy live values into secrets/marqeta/credentials.env (gitignored).
# Docs: https://www.marqeta.com/docs/core-api/authentication
# Do not commit real tokens.
MARQETA_ENABLED=1
# Customer-specific production host (Malta EU program). Sandbox:
# MARQETA_API_BASE_URL=https://sandbox-api.marqeta.com/v3
MARQETA_API_BASE_URL=https://api.marqeta.com/v3
MARQETA_MALTA_API_BASE_URL=
MARQETA_APPLICATION_TOKEN=
MARQETA_ADMIN_ACCESS_TOKEN=
MARQETA_CARD_PRODUCT_TOKEN=
MARQETA_FUNDING_SOURCE_TOKEN=
MARQETA_PROGRAM_NAME=OMNL Malta Marqeta
MARQETA_REGION=MT
MARQETA_WEBHOOK_SECRET=
# Live virtual-card issue gate:
SETTLEMENT_ALLOW_MARQETA_PRODUCTION=0

View File

@@ -0,0 +1,87 @@
#!/usr/bin/env node
/**
* Smoke-test Marqeta Malta credentials.
* Loads secrets/marqeta/credentials.env then process.env.
* Usage: node scripts/rails/smoke-marqeta-malta.mjs
*/
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const root = path.resolve(__dirname, '../..');
function loadEnvFile(rel) {
const p = path.join(root, rel);
if (!fs.existsSync(p)) return {};
const out = {};
for (const line of fs.readFileSync(p, 'utf8').split(/\r?\n/)) {
if (!line || line.trim().startsWith('#')) continue;
const i = line.indexOf('=');
if (i < 0) continue;
out[line.slice(0, i).trim()] = line.slice(i + 1).trim().replace(/^["']|["']$/g, '');
}
return out;
}
function applyEnv(obj) {
for (const [k, v] of Object.entries(obj)) {
if (v && (!(k in process.env) || !process.env[k])) process.env[k] = v;
}
}
function redact(s) {
if (!s) return '(empty)';
if (s.length <= 10) return '***';
return `${s.slice(0, 4)}${s.slice(-4)} (len=${s.length})`;
}
applyEnv(loadEnvFile('.env'));
applyEnv(loadEnvFile('secrets/marqeta/credentials.env'));
applyEnv(loadEnvFile('config/rails-marqeta-malta.env'));
async function smoke() {
const app = process.env.MARQETA_APPLICATION_TOKEN || process.env.MARQETA_APP_TOKEN;
const admin = process.env.MARQETA_ADMIN_ACCESS_TOKEN || process.env.MARQETA_ADMIN_TOKEN;
const base = (
process.env.MARQETA_API_BASE_URL ||
process.env.MARQETA_MALTA_API_BASE_URL ||
'https://api.marqeta.com/v3'
).replace(/\/$/, '');
if (!app || !admin) {
return {
ok: false,
configured: false,
error: 'MARQETA_APPLICATION_TOKEN / MARQETA_ADMIN_ACCESS_TOKEN missing',
};
}
const basic = Buffer.from(`${app}:${admin}`).toString('base64');
const res = await fetch(`${base}/cardproducts?count=5`, {
headers: { Authorization: `Basic ${basic}`, Accept: 'application/json' },
});
const text = await res.text();
let body;
try {
body = JSON.parse(text);
} catch {
body = { raw: text.slice(0, 160) };
}
const products = Array.isArray(body?.data) ? body.data : [];
return {
ok: res.status >= 200 && res.status < 300,
configured: true,
http: res.status,
baseUrl: base,
applicationToken: redact(app),
adminToken: redact(admin),
cardProductSet: Boolean(process.env.MARQETA_CARD_PRODUCT_TOKEN),
cardProductCount: products.length,
liveGate: process.env.SETTLEMENT_ALLOW_MARQETA_PRODUCTION === '1',
region: process.env.MARQETA_REGION || 'MT',
error: res.ok ? undefined : body?.error_message || text.slice(0, 180),
};
}
const result = await smoke();
console.log(JSON.stringify({ asOf: new Date().toISOString(), marqetaMalta: result }, null, 2));
process.exit(result.ok ? 0 : result.configured ? 2 : 3);

View File

@@ -0,0 +1,77 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.getCardIssuersStatus = getCardIssuersStatus;
exports.probeCardIssuers = probeCardIssuers;
exports.issueMarqetaMaltaCard = issueMarqetaMaltaCard;
/**
* Card issuers hub — Marqeta Malta (EU) production.
* Fail-closed until credentials + SETTLEMENT_ALLOW_MARQETA_PRODUCTION=1.
*/
const marqeta_malta_1 = require("./marqeta-malta");
function getCardIssuersStatus() {
const mq = (0, marqeta_malta_1.loadMarqetaConfig)();
return [
{
id: 'MARQETA_MALTA',
name: mq.programName,
region: mq.region,
configured: Boolean(mq.applicationToken && mq.adminAccessToken),
liveIssueAllowed: Boolean(mq.applicationToken && mq.adminAccessToken) &&
process.env.SETTLEMENT_ALLOW_MARQETA_PRODUCTION === '1',
baseUrl: mq.baseUrl,
cardProductSet: Boolean(mq.cardProductToken),
note: 'Malta EU Marqeta Core API · set MARQETA_APPLICATION_TOKEN + MARQETA_ADMIN_ACCESS_TOKEN + MARQETA_CARD_PRODUCT_TOKEN',
},
];
}
async function probeCardIssuers() {
const status = getCardIssuersStatus();
const out = [];
for (const s of status) {
if (!s.configured) {
out.push({ ...s, probe: { ok: false, detail: 'credentials missing' } });
continue;
}
try {
const rail = new marqeta_malta_1.MarqetaMaltaRail();
const ping = await rail.ping();
out.push({ ...s, probe: ping });
}
catch (e) {
out.push({
...s,
probe: { ok: false, detail: e instanceof Error ? e.message.slice(0, 200) : String(e) },
});
}
}
return out;
}
async function issueMarqetaMaltaCard(payload) {
const rail = new marqeta_malta_1.MarqetaMaltaRail();
const { user, card } = await rail.issueVirtualCard({
firstName: payload.firstName,
lastName: payload.lastName,
email: payload.email,
userToken: payload.userToken,
cardProductToken: payload.cardProductToken,
revealSecrets: payload.revealSecrets,
activate: true,
});
const lastFour = card.last_four;
const pan = card.pan;
return {
issuer: 'MARQETA_MALTA',
userToken: user.token,
cardToken: card.token,
lastFour,
panMasked: pan
? `${pan.slice(0, 6)}******${pan.slice(-4)}`
: lastFour
? `******${lastFour}`
: undefined,
pan: payload.revealSecrets ? pan : undefined,
cvv: payload.revealSecrets ? card.cvv_number : undefined,
expiry: card.expiration || card.expiration_time,
state: card.state,
};
}

View File

@@ -0,0 +1,172 @@
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.MarqetaMaltaRail = void 0;
exports.loadMarqetaConfig = loadMarqetaConfig;
/**
* Marqeta Malta (EU) Core API — production card issuing.
* Docs: https://www.marqeta.com/docs/core-api/authentication
* Auth: HTTP Basic (application token : admin access token)
*/
const axios_1 = __importDefault(require("axios"));
function loadMarqetaConfig() {
const applicationToken = (process.env.MARQETA_APPLICATION_TOKEN ||
process.env.MARQETA_APP_TOKEN ||
'').trim();
const adminAccessToken = (process.env.MARQETA_ADMIN_ACCESS_TOKEN ||
process.env.MARQETA_ADMIN_TOKEN ||
'').trim();
// Customer-specific prod host (Malta EU program). Override via env.
const baseUrl = (process.env.MARQETA_API_BASE_URL ||
process.env.MARQETA_MALTA_API_BASE_URL ||
'https://api.marqeta.com/v3').replace(/\/$/, '');
return {
baseUrl,
applicationToken,
adminAccessToken,
cardProductToken: (process.env.MARQETA_CARD_PRODUCT_TOKEN || '').trim() || undefined,
fundingSourceToken: (process.env.MARQETA_FUNDING_SOURCE_TOKEN || '').trim() || undefined,
programName: process.env.MARQETA_PROGRAM_NAME?.trim() || 'OMNL Malta Marqeta',
region: process.env.MARQETA_REGION?.trim() || 'MT',
enabled: Boolean(applicationToken && adminAccessToken) && process.env.MARQETA_ENABLED !== '0',
};
}
class MarqetaMaltaRail {
config;
http;
constructor(cfg = loadMarqetaConfig()) {
this.config = cfg;
const basic = Buffer.from(`${cfg.applicationToken}:${cfg.adminAccessToken}`).toString('base64');
this.http = axios_1.default.create({
baseURL: cfg.baseUrl,
timeout: 60000,
headers: {
Authorization: `Basic ${basic}`,
Accept: 'application/json',
'Content-Type': 'application/json',
},
});
}
isReady() {
return this.config.enabled && Boolean(this.config.applicationToken && this.config.adminAccessToken);
}
/** Smoke: list card products (or ping users count). */
async ping() {
if (!this.isReady())
return { ok: false, detail: 'credentials missing' };
try {
const { data, status } = await this.http.get('/cardproducts', {
params: { count: 5 },
validateStatus: () => true,
});
if (status >= 200 && status < 300) {
const count = Array.isArray(data?.data) ? data.data.length : data?.count ?? '?';
return { ok: true, detail: `cardproducts=${count}` };
}
// Fallback: users list
const u = await this.http.get('/users', { params: { count: 1 }, validateStatus: () => true });
if (u.status >= 200 && u.status < 300) {
return { ok: true, detail: `users_ok status=${u.status}` };
}
return {
ok: false,
detail: `HTTP ${status}: ${JSON.stringify(data).slice(0, 180)}`,
};
}
catch (e) {
return { ok: false, detail: e instanceof Error ? e.message.slice(0, 200) : String(e) };
}
}
async createUser(payload) {
if (!this.isReady())
throw new Error('Marqeta Malta not configured');
const body = {
first_name: payload.firstName,
last_name: payload.lastName,
active: true,
notes: payload.notes || 'OMNL Z Online Bank · Malta Marqeta',
};
if (payload.token)
body.token = payload.token;
if (payload.email)
body.email = payload.email;
if (payload.phone)
body.phone = payload.phone;
const { data, status } = await this.http.post('/users', body, { validateStatus: () => true });
if (status >= 400) {
throw new Error(`Marqeta createUser ${status}: ${JSON.stringify(data).slice(0, 300)}`);
}
return data;
}
async createCard(payload) {
if (!this.isReady())
throw new Error('Marqeta Malta not configured');
const cardProductToken = payload.cardProductToken || this.config.cardProductToken;
if (!cardProductToken)
throw new Error('MARQETA_CARD_PRODUCT_TOKEN required');
const qs = [];
if (payload.showPan)
qs.push('show_pan=true');
if (payload.showCvv)
qs.push('show_cvv_number=true');
const path = qs.length ? `/cards?${qs.join('&')}` : '/cards';
const body = {
user_token: payload.userToken,
card_product_token: cardProductToken,
};
if (payload.token)
body.token = payload.token;
const { data, status } = await this.http.post(path, body, { validateStatus: () => true });
if (status >= 400) {
throw new Error(`Marqeta createCard ${status}: ${JSON.stringify(data).slice(0, 300)}`);
}
return data;
}
async transitionCard(cardToken, state, reason = 'OMNL') {
if (!this.isReady())
throw new Error('Marqeta Malta not configured');
const { data, status } = await this.http.post('/cardtransitions', {
card_token: cardToken,
channel: 'API',
state,
reason_code: '01',
reason,
}, { validateStatus: () => true });
if (status >= 400) {
throw new Error(`Marqeta transition ${status}: ${JSON.stringify(data).slice(0, 300)}`);
}
return data;
}
/** Full production issue: user → virtual card (optionally reveal PAN/CVV). */
async issueVirtualCard(payload) {
if (process.env.SETTLEMENT_ALLOW_MARQETA_PRODUCTION !== '1') {
throw new Error('SETTLEMENT_ALLOW_MARQETA_PRODUCTION=1 required for live Marqeta issue');
}
const user = await this.createUser({
token: payload.userToken,
firstName: payload.firstName,
lastName: payload.lastName,
email: payload.email,
});
const card = await this.createCard({
userToken: user.token,
cardProductToken: payload.cardProductToken,
token: payload.cardToken,
showPan: Boolean(payload.revealSecrets),
showCvv: Boolean(payload.revealSecrets),
});
if (payload.activate !== false && String(card.state || '').toUpperCase() !== 'ACTIVE') {
try {
await this.transitionCard(card.token, 'ACTIVE', 'OMNL activate on issue');
card.state = 'ACTIVE';
}
catch {
// Card product may already activate_upon_issue
}
}
return { user, card };
}
}
exports.MarqetaMaltaRail = MarqetaMaltaRail;

View File

@@ -16,6 +16,7 @@ const btc_l1_settlement_1 = require("../../workflows/btc-l1-settlement");
const fineract_1 = require("../../adapters/fineract");
const pricing_1 = require("../../adapters/pricing");
const external_banks_hub_1 = require("../../adapters/external-banks-hub");
const card_issuers_hub_1 = require("../../adapters/card-issuers-hub");
const auth_1 = require("../../middleware/auth");
async function respondPublicMoneySupply(res, officeId) {
const cfg = (0, config_1.loadConfig)();
@@ -462,6 +463,71 @@ function createSettlementRouter() {
res.status(422).json({ error: e instanceof Error ? e.message : String(e) });
}
});
router.get('/rails/cards', (_req, res) => {
res.json({
asOf: new Date().toISOString(),
issuers: (0, card_issuers_hub_1.getCardIssuersStatus)(),
usage: {
issue: 'POST /rails/cards/issue (Marqeta Malta)',
probe: 'GET /rails/cards/probe',
smoke: 'node scripts/rails/smoke-marqeta-malta.mjs',
},
});
});
router.get('/rails/cards/probe', async (req, res) => {
if (!(0, auth_1.requireApiKey)(req, res))
return;
try {
const issuers = await (0, card_issuers_hub_1.probeCardIssuers)();
res.json({ asOf: new Date().toISOString(), issuers });
}
catch (e) {
res.status(500).json({ error: e instanceof Error ? e.message : String(e) });
}
});
router.post('/rails/cards/issue', async (req, res) => {
if (!(0, auth_1.requireApiKey)(req, res))
return;
try {
const firstName = String(req.body?.firstName || req.body?.first_name || '').trim();
const lastName = String(req.body?.lastName || req.body?.last_name || '').trim();
if (!firstName || !lastName) {
res.status(400).json({ error: 'firstName and lastName required' });
return;
}
const out = await (0, card_issuers_hub_1.issueMarqetaMaltaCard)({
firstName,
lastName,
email: req.body?.email ? String(req.body.email) : undefined,
userToken: req.body?.userToken ? String(req.body.userToken) : undefined,
cardProductToken: req.body?.cardProductToken
? String(req.body.cardProductToken)
: undefined,
revealSecrets: Boolean(req.body?.revealPan || req.body?.revealSecrets),
});
res.status(201).json({ ok: true, ...out });
}
catch (e) {
res.status(422).json({ error: e instanceof Error ? e.message : String(e) });
}
});
/** Marqeta webhook sink (auth via shared secret header when configured). */
router.post('/webhooks/marqeta', async (req, res) => {
const secret = (process.env.MARQETA_WEBHOOK_SECRET || '').trim();
if (secret) {
const hdr = String(req.headers['x-marqeta-signature'] || req.headers['x-webhook-secret'] || '').trim();
if (hdr !== secret) {
res.status(401).json({ error: 'invalid webhook secret' });
return;
}
}
res.status(200).json({
ok: true,
received: true,
type: req.body?.type || req.body?.event || null,
asOf: new Date().toISOString(),
});
});
router.get('/tokens/m2', (_req, res) => {
try {
const fs = require('fs');

View File

@@ -44,13 +44,15 @@ const rootEnv = path_1.default.resolve(__dirname, '../../../.env');
if ((0, fs_1.existsSync)(rootEnv))
dotenv.config({ path: rootEnv });
dotenv.config();
/** Production rail secrets (gitignored) — Revolut / Wise / Binance */
/** Production rail / card-issuer secrets (gitignored) */
const repoRoot = path_1.default.resolve(__dirname, '../../..');
for (const rel of [
'secrets/revolut/credentials.env',
'secrets/wise/credentials.env',
'secrets/binance/credentials.env',
'secrets/marqeta/credentials.env',
'config/rails-revolut-wise-binance.env',
'config/rails-marqeta-malta.env',
]) {
const p = path_1.default.join(repoRoot, rel);
if ((0, fs_1.existsSync)(p))

View File

@@ -0,0 +1,107 @@
/**
* Card issuers hub — Marqeta Malta (EU) production.
* Fail-closed until credentials + SETTLEMENT_ALLOW_MARQETA_PRODUCTION=1.
*/
import { MarqetaMaltaRail, loadMarqetaConfig } from './marqeta-malta';
export type CardIssuerId = 'MARQETA_MALTA';
export type CardIssuerStatus = {
id: CardIssuerId;
name: string;
region: string;
configured: boolean;
liveIssueAllowed: boolean;
baseUrl: string;
cardProductSet: boolean;
note: string;
};
export function getCardIssuersStatus(): CardIssuerStatus[] {
const mq = loadMarqetaConfig();
return [
{
id: 'MARQETA_MALTA',
name: mq.programName,
region: mq.region,
configured: Boolean(mq.applicationToken && mq.adminAccessToken),
liveIssueAllowed:
Boolean(mq.applicationToken && mq.adminAccessToken) &&
process.env.SETTLEMENT_ALLOW_MARQETA_PRODUCTION === '1',
baseUrl: mq.baseUrl,
cardProductSet: Boolean(mq.cardProductToken),
note: 'Malta EU Marqeta Core API · set MARQETA_APPLICATION_TOKEN + MARQETA_ADMIN_ACCESS_TOKEN + MARQETA_CARD_PRODUCT_TOKEN',
},
];
}
export async function probeCardIssuers(): Promise<
Array<CardIssuerStatus & { probe?: { ok: boolean; detail: string } }>
> {
const status = getCardIssuersStatus();
const out: Array<CardIssuerStatus & { probe?: { ok: boolean; detail: string } }> = [];
for (const s of status) {
if (!s.configured) {
out.push({ ...s, probe: { ok: false, detail: 'credentials missing' } });
continue;
}
try {
const rail = new MarqetaMaltaRail();
const ping = await rail.ping();
out.push({ ...s, probe: ping });
} catch (e) {
out.push({
...s,
probe: { ok: false, detail: e instanceof Error ? e.message.slice(0, 200) : String(e) },
});
}
}
return out;
}
export async function issueMarqetaMaltaCard(payload: {
firstName: string;
lastName: string;
email?: string;
userToken?: string;
cardProductToken?: string;
revealSecrets?: boolean;
}): Promise<{
issuer: CardIssuerId;
userToken: string;
cardToken: string;
lastFour?: string;
panMasked?: string;
pan?: string;
cvv?: string;
expiry?: string;
state?: string;
}> {
const rail = new MarqetaMaltaRail();
const { user, card } = await rail.issueVirtualCard({
firstName: payload.firstName,
lastName: payload.lastName,
email: payload.email,
userToken: payload.userToken,
cardProductToken: payload.cardProductToken,
revealSecrets: payload.revealSecrets,
activate: true,
});
const lastFour = card.last_four;
const pan = card.pan;
return {
issuer: 'MARQETA_MALTA',
userToken: user.token,
cardToken: card.token,
lastFour,
panMasked: pan
? `${pan.slice(0, 6)}******${pan.slice(-4)}`
: lastFour
? `******${lastFour}`
: undefined,
pan: payload.revealSecrets ? pan : undefined,
cvv: payload.revealSecrets ? card.cvv_number : undefined,
expiry: card.expiration || card.expiration_time,
state: card.state,
};
}

View File

@@ -0,0 +1,227 @@
/**
* Marqeta Malta (EU) Core API — production card issuing.
* Docs: https://www.marqeta.com/docs/core-api/authentication
* Auth: HTTP Basic (application token : admin access token)
*/
import axios, { AxiosInstance } from 'axios';
export type MarqetaConfig = {
baseUrl: string;
applicationToken: string;
adminAccessToken: string;
cardProductToken?: string;
fundingSourceToken?: string;
programName: string;
region: string;
enabled: boolean;
};
export function loadMarqetaConfig(): MarqetaConfig {
const applicationToken = (
process.env.MARQETA_APPLICATION_TOKEN ||
process.env.MARQETA_APP_TOKEN ||
''
).trim();
const adminAccessToken = (
process.env.MARQETA_ADMIN_ACCESS_TOKEN ||
process.env.MARQETA_ADMIN_TOKEN ||
''
).trim();
// Customer-specific prod host (Malta EU program). Override via env.
const baseUrl = (
process.env.MARQETA_API_BASE_URL ||
process.env.MARQETA_MALTA_API_BASE_URL ||
'https://api.marqeta.com/v3'
).replace(/\/$/, '');
return {
baseUrl,
applicationToken,
adminAccessToken,
cardProductToken: (process.env.MARQETA_CARD_PRODUCT_TOKEN || '').trim() || undefined,
fundingSourceToken: (process.env.MARQETA_FUNDING_SOURCE_TOKEN || '').trim() || undefined,
programName: process.env.MARQETA_PROGRAM_NAME?.trim() || 'OMNL Malta Marqeta',
region: process.env.MARQETA_REGION?.trim() || 'MT',
enabled: Boolean(applicationToken && adminAccessToken) && process.env.MARQETA_ENABLED !== '0',
};
}
export type MarqetaUser = {
token: string;
first_name?: string;
last_name?: string;
email?: string;
status?: string;
};
export type MarqetaCard = {
token: string;
user_token: string;
card_product_token: string;
last_four?: string;
pan?: string;
cvv_number?: string;
expiration?: string;
expiration_time?: string;
state?: string;
instrument_type?: string;
};
export class MarqetaMaltaRail {
readonly config: MarqetaConfig;
private readonly http: AxiosInstance;
constructor(cfg = loadMarqetaConfig()) {
this.config = cfg;
const basic = Buffer.from(`${cfg.applicationToken}:${cfg.adminAccessToken}`).toString('base64');
this.http = axios.create({
baseURL: cfg.baseUrl,
timeout: 60000,
headers: {
Authorization: `Basic ${basic}`,
Accept: 'application/json',
'Content-Type': 'application/json',
},
});
}
isReady(): boolean {
return this.config.enabled && Boolean(this.config.applicationToken && this.config.adminAccessToken);
}
/** Smoke: list card products (or ping users count). */
async ping(): Promise<{ ok: boolean; detail: string }> {
if (!this.isReady()) return { ok: false, detail: 'credentials missing' };
try {
const { data, status } = await this.http.get('/cardproducts', {
params: { count: 5 },
validateStatus: () => true,
});
if (status >= 200 && status < 300) {
const count = Array.isArray(data?.data) ? data.data.length : data?.count ?? '?';
return { ok: true, detail: `cardproducts=${count}` };
}
// Fallback: users list
const u = await this.http.get('/users', { params: { count: 1 }, validateStatus: () => true });
if (u.status >= 200 && u.status < 300) {
return { ok: true, detail: `users_ok status=${u.status}` };
}
return {
ok: false,
detail: `HTTP ${status}: ${JSON.stringify(data).slice(0, 180)}`,
};
} catch (e) {
return { ok: false, detail: e instanceof Error ? e.message.slice(0, 200) : String(e) };
}
}
async createUser(payload: {
token?: string;
firstName: string;
lastName: string;
email?: string;
phone?: string;
notes?: string;
}): Promise<MarqetaUser> {
if (!this.isReady()) throw new Error('Marqeta Malta not configured');
const body: Record<string, unknown> = {
first_name: payload.firstName,
last_name: payload.lastName,
active: true,
notes: payload.notes || 'OMNL Z Online Bank · Malta Marqeta',
};
if (payload.token) body.token = payload.token;
if (payload.email) body.email = payload.email;
if (payload.phone) body.phone = payload.phone;
const { data, status } = await this.http.post('/users', body, { validateStatus: () => true });
if (status >= 400) {
throw new Error(`Marqeta createUser ${status}: ${JSON.stringify(data).slice(0, 300)}`);
}
return data as MarqetaUser;
}
async createCard(payload: {
userToken: string;
cardProductToken?: string;
token?: string;
showPan?: boolean;
showCvv?: boolean;
}): Promise<MarqetaCard> {
if (!this.isReady()) throw new Error('Marqeta Malta not configured');
const cardProductToken = payload.cardProductToken || this.config.cardProductToken;
if (!cardProductToken) throw new Error('MARQETA_CARD_PRODUCT_TOKEN required');
const qs: string[] = [];
if (payload.showPan) qs.push('show_pan=true');
if (payload.showCvv) qs.push('show_cvv_number=true');
const path = qs.length ? `/cards?${qs.join('&')}` : '/cards';
const body: Record<string, unknown> = {
user_token: payload.userToken,
card_product_token: cardProductToken,
};
if (payload.token) body.token = payload.token;
const { data, status } = await this.http.post(path, body, { validateStatus: () => true });
if (status >= 400) {
throw new Error(`Marqeta createCard ${status}: ${JSON.stringify(data).slice(0, 300)}`);
}
return data as MarqetaCard;
}
async transitionCard(cardToken: string, state: 'ACTIVE' | 'SUSPENDED' | 'TERMINATED', reason = 'OMNL'): Promise<unknown> {
if (!this.isReady()) throw new Error('Marqeta Malta not configured');
const { data, status } = await this.http.post(
'/cardtransitions',
{
card_token: cardToken,
channel: 'API',
state,
reason_code: '01',
reason,
},
{ validateStatus: () => true },
);
if (status >= 400) {
throw new Error(`Marqeta transition ${status}: ${JSON.stringify(data).slice(0, 300)}`);
}
return data;
}
/** Full production issue: user → virtual card (optionally reveal PAN/CVV). */
async issueVirtualCard(payload: {
firstName: string;
lastName: string;
email?: string;
userToken?: string;
cardToken?: string;
cardProductToken?: string;
revealSecrets?: boolean;
activate?: boolean;
}): Promise<{ user: MarqetaUser; card: MarqetaCard }> {
if (process.env.SETTLEMENT_ALLOW_MARQETA_PRODUCTION !== '1') {
throw new Error('SETTLEMENT_ALLOW_MARQETA_PRODUCTION=1 required for live Marqeta issue');
}
const user = await this.createUser({
token: payload.userToken,
firstName: payload.firstName,
lastName: payload.lastName,
email: payload.email,
});
const card = await this.createCard({
userToken: user.token,
cardProductToken: payload.cardProductToken,
token: payload.cardToken,
showPan: Boolean(payload.revealSecrets),
showCvv: Boolean(payload.revealSecrets),
});
if (payload.activate !== false && String(card.state || '').toUpperCase() !== 'ACTIVE') {
try {
await this.transitionCard(card.token, 'ACTIVE', 'OMNL activate on issue');
card.state = 'ACTIVE';
} catch {
// Card product may already activate_upon_issue
}
}
return { user, card };
}
}

View File

@@ -35,6 +35,11 @@ import {
probeExternalRails,
type ExternalRailId,
} from '../../adapters/external-banks-hub';
import {
getCardIssuersStatus,
issueMarqetaMaltaCard,
probeCardIssuers,
} from '../../adapters/card-issuers-hub';
import { requireApiKey } from '../../middleware/auth';
async function respondPublicMoneySupply(res: Response, officeId: number): Promise<void> {
@@ -485,6 +490,72 @@ export function createSettlementRouter(): Router {
}
});
router.get('/rails/cards', (_req, res) => {
res.json({
asOf: new Date().toISOString(),
issuers: getCardIssuersStatus(),
usage: {
issue: 'POST /rails/cards/issue (Marqeta Malta)',
probe: 'GET /rails/cards/probe',
smoke: 'node scripts/rails/smoke-marqeta-malta.mjs',
},
});
});
router.get('/rails/cards/probe', async (req, res) => {
if (!requireApiKey(req, res)) return;
try {
const issuers = await probeCardIssuers();
res.json({ asOf: new Date().toISOString(), issuers });
} catch (e) {
res.status(500).json({ error: e instanceof Error ? e.message : String(e) });
}
});
router.post('/rails/cards/issue', async (req, res) => {
if (!requireApiKey(req, res)) return;
try {
const firstName = String(req.body?.firstName || req.body?.first_name || '').trim();
const lastName = String(req.body?.lastName || req.body?.last_name || '').trim();
if (!firstName || !lastName) {
res.status(400).json({ error: 'firstName and lastName required' });
return;
}
const out = await issueMarqetaMaltaCard({
firstName,
lastName,
email: req.body?.email ? String(req.body.email) : undefined,
userToken: req.body?.userToken ? String(req.body.userToken) : undefined,
cardProductToken: req.body?.cardProductToken
? String(req.body.cardProductToken)
: undefined,
revealSecrets: Boolean(req.body?.revealPan || req.body?.revealSecrets),
});
res.status(201).json({ ok: true, ...out });
} catch (e) {
res.status(422).json({ error: e instanceof Error ? e.message : String(e) });
}
});
/** Marqeta webhook sink (auth via shared secret header when configured). */
router.post('/webhooks/marqeta', async (req, res) => {
const secret = (process.env.MARQETA_WEBHOOK_SECRET || '').trim();
if (secret) {
const hdr =
String(req.headers['x-marqeta-signature'] || req.headers['x-webhook-secret'] || '').trim();
if (hdr !== secret) {
res.status(401).json({ error: 'invalid webhook secret' });
return;
}
}
res.status(200).json({
ok: true,
received: true,
type: req.body?.type || req.body?.event || null,
asOf: new Date().toISOString(),
});
});
router.get('/tokens/m2', (_req, res) => {
try {
const fs = require('fs') as typeof import('fs');

View File

@@ -7,13 +7,15 @@ const rootEnv = path.resolve(__dirname, '../../../.env');
if (existsSync(rootEnv)) dotenv.config({ path: rootEnv });
dotenv.config();
/** Production rail secrets (gitignored) — Revolut / Wise / Binance */
/** Production rail / card-issuer secrets (gitignored) */
const repoRoot = path.resolve(__dirname, '../../..');
for (const rel of [
'secrets/revolut/credentials.env',
'secrets/wise/credentials.env',
'secrets/binance/credentials.env',
'secrets/marqeta/credentials.env',
'config/rails-revolut-wise-binance.env',
'config/rails-marqeta-malta.env',
]) {
const p = path.join(repoRoot, rel);
if (existsSync(p)) dotenv.config({ path: p, override: false });

View File

@@ -0,0 +1,71 @@
<!DOCTYPE html>
<html lang="en"><head>
<meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
<title>OMNL — Marqeta Malta card issuing</title>
<style>
:root { --bg:#071018; --fg:#e8eef5; --muted:#8fa3b8; --ok:#3dd68c; --warn:#f0b429; --bad:#f07178; --line:#1a2836; --accent:#c9a227; }
body { margin:0; font-family:"Segoe UI",system-ui,sans-serif; background:var(--bg); color:var(--fg); }
main { max-width:900px; margin:0 auto; padding:2rem 1.25rem; }
h1 { color:var(--accent); margin:0 0 .5rem; }
.badge { display:inline-block; margin:.2rem .35rem .2rem 0; padding:.25rem .65rem; border:1px solid var(--ok); color:var(--ok); border-radius:999px; font-size:.72rem; font-weight:700; }
.sub { color:var(--muted); margin:0 0 1.5rem; line-height:1.5; }
table { width:100%; border-collapse:collapse; }
th,td { text-align:left; padding:.7rem .45rem; border-bottom:1px solid var(--line); vertical-align:top; font-size:.9rem; }
th { color:var(--muted); font-size:.7rem; text-transform:uppercase; }
.ok { color:var(--ok); font-weight:600; }
.warn { color:var(--warn); }
.bad { color:var(--bad); }
code { font-size:.78rem; color:#c5d4e3; }
a { color:var(--accent); }
.note { margin-top:1.25rem; color:var(--muted); font-size:.85rem; line-height:1.5; }
</style></head>
<body><main>
<div>
<span class="badge">MARQETA MALTA</span>
<span class="badge">EU PRODUCTION</span>
<span class="badge">VISA ISSUING</span>
</div>
<h1>Marqeta Malta card issuer</h1>
<p class="sub">Production Core API for virtual Visa issue (Malta EU program). Credentials from <code>secrets/marqeta/credentials.env</code>. Live issue requires <code>SETTLEMENT_ALLOW_MARQETA_PRODUCTION=1</code>.</p>
<table>
<thead><tr><th>Issuer</th><th>Configured</th><th>Live gate</th><th>Card product</th><th>Base URL</th></tr></thead>
<tbody id="tbody"><tr><td colspan="5">Loading…</td></tr></tbody>
</table>
<p class="note">
Settlement: <code>GET /settlement/rails/cards</code> · <code>POST /settlement/rails/cards/issue</code><br>
Webhook: <code>POST /settlement/webhooks/marqeta</code><br>
Smoke: <code>node scripts/rails/smoke-marqeta-malta.mjs</code><br>
Cards UI: <a href="/zbank/cards">/zbank/cards</a> · theme <code>marqeta</code>
</p>
</main>
<script>
async function load() {
const tbody = document.getElementById('tbody');
const urls = [
'/settlement/rails/cards',
'/api/v1/settlement/rails/cards',
'/zbank/api/marqeta/status',
'http://127.0.0.1:3011/api/v1/settlement/rails/cards',
];
let data = null;
for (const u of urls) {
try {
const r = await fetch(u);
if (r.ok) { data = await r.json(); break; }
} catch {}
}
const issuers = data?.issuers || (data?.id ? [data] : null);
if (!issuers) {
tbody.innerHTML = `<tr><td colspan="5" class="warn">Card issuer endpoint unreachable — start settlement-middleware. Add secrets/marqeta/credentials.env</td></tr>`;
return;
}
tbody.innerHTML = issuers.map(r => {
const cfg = r.configured ? '<span class="ok">YES</span>' : '<span class="bad">NO</span>';
const live = r.liveIssueAllowed ? '<span class="ok">OPEN</span>' : '<span class="warn">CLOSED</span>';
const prod = r.cardProductSet ? '<span class="ok">SET</span>' : '<span class="warn">MISSING</span>';
return `<tr><td><strong>${r.name}</strong><br><code>${r.id}</code> · ${r.region || 'MT'}</td><td>${cfg}</td><td>${live}</td><td>${prod}</td><td><code>${r.baseUrl}</code></td></tr>`;
}).join('');
}
load();
</script>
</body></html>

View File

@@ -76,6 +76,7 @@
.face.front.theme-revolut { background: linear-gradient(135deg, #191c1f 0%, #2d3436 45%, #636e72 100%); }
.face.front.theme-wise { background: linear-gradient(135deg, #9fe870 0%, #2e7d32 50%, #1b5e20 100%); color:#0a0a0a; }
.face.front.theme-binance { background: linear-gradient(135deg, #f0b90b 0%, #c99400 45%, #3d2e00 100%); }
.face.front.theme-marqeta { background: linear-gradient(135deg, #0b1f3a 0%, #1a4a7a 45%, #c9a227 100%); }
.ex-name {
position: absolute; top: 1rem; left: 1.15rem;
font-size: .68rem; font-weight: 800; letter-spacing: .08em; text-transform: uppercase;
@@ -237,6 +238,7 @@
<div class="btn-row">
<button id="issue" type="button">Create Front + Back</button>
<button id="issueAll" type="button" class="secondary">Create All 3 CCY</button>
<button id="issueMarqeta" type="button" class="secondary">Issue Marqeta Malta</button>
</div>
<div class="btn-row">
<button id="issueRandom" type="button">Random Exchange Cards ×4</button>
@@ -253,6 +255,7 @@
</table>
<p style="margin-top:1rem;color:var(--muted);font-size:.85rem">
<a href="/zbank/cards">/zbank/cards</a> ·
<a href="/zbank/marqeta">Marqeta Malta</a> ·
<a href="/static/zbank-zardasht.html">accounts</a>
</p>
</div>
@@ -263,6 +266,7 @@ const tbody = document.getElementById('tbody');
const result = document.getElementById('result');
const issueBtn = document.getElementById('issue');
const issueAllBtn = document.getElementById('issueAll');
const issueMarqetaBtn = document.getElementById('issueMarqeta');
const issueRandomBtn = document.getElementById('issueRandom');
const issueRandom8Btn = document.getElementById('issueRandom8');
const sessionSecrets = {}; // panMasked -> { pan, cvv }
@@ -400,6 +404,36 @@ issueBtn.addEventListener('click', async () => {
}
});
issueMarqetaBtn.addEventListener('click', async () => {
issueMarqetaBtn.disabled = true;
result.classList.remove('show');
try {
const currency = document.getElementById('ccy').value;
const revealPan = document.getElementById('reveal').checked;
const r = await fetch('/zbank/api/cards/issue', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ currency, revealPan, exchange: 'marqeta', useMarqeta: true }),
});
const j = await r.json();
if (!r.ok) throw new Error(j.error || r.statusText);
const c = j.card;
if (c.panMasked && (c.pan || c.cvv)) sessionSecrets[c.panMasked] = { pan: c.pan, cvv: c.cvv };
result.classList.add('show');
result.innerHTML = `<span class="ok">MARQETA MALTA ISSUED</span> ${c.currency}<br>` +
`PAN masked: ${c.panMasked}<br>` +
(c.marqeta ? `cardToken: ${c.marqeta.cardToken}<br>userToken: ${c.marqeta.userToken}<br>` : '') +
(c.pan ? `PAN: ${c.pan}<br>CVV: ${c.cvv}<br>` : '') +
`Expiry: ${c.expiry}<br>IBAN: ${c.linkedIban}`;
await load();
} catch (e) {
result.classList.add('show');
result.textContent = 'Marqeta error: ' + (e && e.message ? e.message : e);
} finally {
issueMarqetaBtn.disabled = false;
}
});
issueAllBtn.addEventListener('click', async () => {
issueAllBtn.disabled = true;
result.classList.remove('show');

View File

@@ -37,6 +37,7 @@ import { MultiChainIndexer } from '../indexer/chain-indexer';
import { OmnlEventPoller } from '../indexer/omnl-event-poller';
import { getDatabasePool } from '../database/client';
import { issueCardForCurrency, issueRandomExchangeCards, loadCustomerRegistry, EXCHANGE_CATALOG } from '../lib/zbank-online-cards';
import { getMarqetaStatus } from '../lib/marqeta-malta';
import winston from 'winston';
// Setup logger
@@ -315,6 +316,22 @@ export class ApiServer {
res.type('html').send(readFileSync(railsHubPath, 'utf8'));
});
const marqetaRailsPath = path.join(__dirname, '../../public/rails-marqeta-malta.html');
this.app.get('/rails/marqeta-malta', (_req: Request, res: Response) => {
if (!existsSync(marqetaRailsPath)) {
res.status(404).type('text/plain').send('rails-marqeta-malta.html missing');
return;
}
res.type('html').send(readFileSync(marqetaRailsPath, 'utf8'));
});
this.app.get('/zbank/marqeta', (_req: Request, res: Response) => {
if (!existsSync(marqetaRailsPath)) {
res.status(404).type('text/plain').send('marqeta rails page missing');
return;
}
res.type('html').send(readFileSync(marqetaRailsPath, 'utf8'));
});
const repoRoot = path.resolve(__dirname, '../../../..');
this.app.get('/zbank/api/customer/zardasht', (_req: Request, res: Response) => {
try {
@@ -329,12 +346,16 @@ export class ApiServer {
}
});
this.app.post('/zbank/api/cards/issue', (req: Request, res: Response) => {
this.app.post('/zbank/api/cards/issue', async (req: Request, res: Response) => {
try {
const currency = String(req.body?.currency || 'USD').toUpperCase();
const revealPan = Boolean(req.body?.revealPan);
const exchange = req.body?.exchange ? String(req.body.exchange) : undefined;
const out = issueCardForCurrency(repoRoot, currency, revealPan, exchange);
const exchange = req.body?.exchange
? String(req.body.exchange)
: req.body?.useMarqeta
? 'marqeta'
: undefined;
const out = await issueCardForCurrency(repoRoot, currency, revealPan, exchange);
if (!out.ok) {
res.status(400).json({ error: out.error });
return;
@@ -349,11 +370,15 @@ export class ApiServer {
res.json({ exchanges: EXCHANGE_CATALOG });
});
this.app.post('/zbank/api/cards/random-exchanges', (req: Request, res: Response) => {
this.app.get('/zbank/api/marqeta/status', (_req: Request, res: Response) => {
res.json(getMarqetaStatus());
});
this.app.post('/zbank/api/cards/random-exchanges', async (req: Request, res: Response) => {
try {
const count = Math.min(8, Math.max(1, Number(req.body?.count || 4)));
const revealPan = Boolean(req.body?.revealPan);
const out = issueRandomExchangeCards(repoRoot, count, revealPan);
const out = await issueRandomExchangeCards(repoRoot, count, revealPan);
if (!out.ok) {
res.status(400).json({ error: out.error });
return;

View File

@@ -21,6 +21,16 @@ for (const p of rootEnvCandidates) {
}
}
dotenv.config();
// Marqeta Malta + other rail secrets (gitignored)
const secretCandidates = [
path.resolve(__dirname, '../../secrets/marqeta/credentials.env'), // from dist/
path.resolve(__dirname, '../../../secrets/marqeta/credentials.env'), // from src/
path.resolve(__dirname, '../../config/rails-marqeta-malta.env'),
path.resolve(__dirname, '../../../config/rails-marqeta-malta.env'),
];
for (const p of secretCandidates) {
if (existsSync(p)) dotenv.config({ path: p, override: false });
}
// Fill contract/token addresses from config/smart-contracts-master.json when not set (e.g. CUSDC_ADDRESS_138, CUSDT_ADDRESS_138)
try {
const loaderPath = path.resolve(__dirname, '../../../../config/contracts-loader.cjs');

View File

@@ -0,0 +1,147 @@
/**
* Marqeta Malta client for token-aggregation card issue path.
* Same env contract as settlement-middleware adapter.
*/
import { createHash } from 'node:crypto';
export type MarqetaTaConfig = {
baseUrl: string;
applicationToken: string;
adminAccessToken: string;
cardProductToken?: string;
enabled: boolean;
liveAllowed: boolean;
};
export function loadMarqetaTaConfig(): MarqetaTaConfig {
const applicationToken = (
process.env.MARQETA_APPLICATION_TOKEN ||
process.env.MARQETA_APP_TOKEN ||
''
).trim();
const adminAccessToken = (
process.env.MARQETA_ADMIN_ACCESS_TOKEN ||
process.env.MARQETA_ADMIN_TOKEN ||
''
).trim();
const baseUrl = (
process.env.MARQETA_API_BASE_URL ||
process.env.MARQETA_MALTA_API_BASE_URL ||
'https://api.marqeta.com/v3'
).replace(/\/$/, '');
return {
baseUrl,
applicationToken,
adminAccessToken,
cardProductToken: (process.env.MARQETA_CARD_PRODUCT_TOKEN || '').trim() || undefined,
enabled: Boolean(applicationToken && adminAccessToken) && process.env.MARQETA_ENABLED !== '0',
liveAllowed: process.env.SETTLEMENT_ALLOW_MARQETA_PRODUCTION === '1',
};
}
export function marqetaConfigured(): boolean {
const c = loadMarqetaTaConfig();
return c.enabled && c.liveAllowed && Boolean(c.cardProductToken);
}
export function getMarqetaStatus() {
const c = loadMarqetaTaConfig();
return {
id: 'MARQETA_MALTA',
name: process.env.MARQETA_PROGRAM_NAME || 'OMNL Malta Marqeta',
region: process.env.MARQETA_REGION || 'MT',
configured: Boolean(c.applicationToken && c.adminAccessToken),
liveIssueAllowed: c.enabled && c.liveAllowed,
cardProductSet: Boolean(c.cardProductToken),
baseUrl: c.baseUrl,
};
}
async function mqFetch(path: string, init: RequestInit = {}): Promise<{ status: number; data: any }> {
const c = loadMarqetaTaConfig();
const basic = Buffer.from(`${c.applicationToken}:${c.adminAccessToken}`).toString('base64');
const res = await fetch(`${c.baseUrl}${path}`, {
...init,
headers: {
Authorization: `Basic ${basic}`,
Accept: 'application/json',
'Content-Type': 'application/json',
...(init.headers || {}),
},
});
const text = await res.text();
let data: any;
try {
data = JSON.parse(text);
} catch {
data = { raw: text.slice(0, 200) };
}
return { status: res.status, data };
}
export async function issueMarqetaVirtualCard(opts: {
firstName: string;
lastName: string;
email?: string;
revealSecrets?: boolean;
}): Promise<{
userToken: string;
cardToken: string;
lastFour?: string;
pan?: string;
cvv?: string;
expiry?: string;
panMasked: string;
panToken: string;
state?: string;
}> {
if (!marqetaConfigured()) {
throw new Error('Marqeta Malta not live — set credentials + SETTLEMENT_ALLOW_MARQETA_PRODUCTION=1');
}
const c = loadMarqetaTaConfig();
const userRes = await mqFetch('/users', {
method: 'POST',
body: JSON.stringify({
first_name: opts.firstName,
last_name: opts.lastName,
email: opts.email,
active: true,
notes: 'OMNL Z Online Bank · Malta Marqeta',
}),
});
if (userRes.status >= 400) {
throw new Error(`Marqeta user ${userRes.status}: ${JSON.stringify(userRes.data).slice(0, 240)}`);
}
const userToken = String(userRes.data.token);
const qs = opts.revealSecrets ? '?show_pan=true&show_cvv_number=true' : '';
const cardRes = await mqFetch(`/cards${qs}`, {
method: 'POST',
body: JSON.stringify({
user_token: userToken,
card_product_token: c.cardProductToken,
}),
});
if (cardRes.status >= 400) {
throw new Error(`Marqeta card ${cardRes.status}: ${JSON.stringify(cardRes.data).slice(0, 240)}`);
}
const card = cardRes.data;
const lastFour = card.last_four ? String(card.last_four) : undefined;
const pan = card.pan ? String(card.pan) : undefined;
const panMasked = pan
? `${pan.slice(0, 6)}******${pan.slice(-4)}`
: lastFour
? `******${lastFour}`
: '******????';
const panToken = `mq_${createHash('sha256').update(String(card.token)).digest('hex').slice(0, 24)}`;
return {
userToken,
cardToken: String(card.token),
lastFour,
pan: opts.revealSecrets ? pan : undefined,
cvv: opts.revealSecrets ? card.cvv_number : undefined,
expiry: card.expiration || card.expiration_time,
panMasked,
panToken,
state: card.state,
};
}

View File

@@ -5,6 +5,7 @@
import { createHash, randomInt } from 'node:crypto';
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import path from 'node:path';
import { issueMarqetaVirtualCard, marqetaConfigured } from './marqeta-malta';
export type OnlineCard = {
id: string;
@@ -32,6 +33,14 @@ export type OnlineCard = {
theme: string;
bicHint: string;
};
/** Marqeta Malta production tokens when issued via Core API. */
marqeta?: {
userToken: string;
cardToken: string;
lastFour?: string;
state?: string;
issuer: 'MARQETA_MALTA';
};
/** Back-of-card panel (magstripe / signature / CVV area). */
back: {
magstripe: true;
@@ -56,6 +65,7 @@ export const EXCHANGE_CATALOG = [
{ id: 'revolut', name: 'Revolut Business', officeId: null, theme: 'revolut', bicHint: 'REVOGB2LXXX' },
{ id: 'wise', name: 'Wise Bank', officeId: null, theme: 'wise', bicHint: 'TRWIUS33XXX' },
{ id: 'binance', name: 'Binance Exchange', officeId: null, theme: 'binance', bicHint: 'BINANCE' },
{ id: 'marqeta', name: 'Marqeta Malta', officeId: null, theme: 'marqeta', bicHint: 'MARQMT2XXXX' },
] as const;
export type ExchangeDef = (typeof EXCHANGE_CATALOG)[number];
@@ -189,19 +199,19 @@ export function buildOnlineCard(opts: {
};
}
export function issueCardForCurrency(
export async function issueCardForCurrency(
repoRoot: string,
currency: string,
revealPan = false,
exchange?: ExchangeDef | string,
): { ok: true; card: OnlineCard; account: Record<string, unknown> } | { ok: false; error: string } {
): Promise<{ ok: true; card: OnlineCard; account: Record<string, unknown> } | { ok: false; error: string }> {
const reg = loadCustomerRegistry(repoRoot);
if (!reg) return { ok: false, error: 'Customer registry missing — run provision script first' };
const accounts = (reg.accounts as Array<Record<string, unknown>>) || [];
const acct = accounts.find((a) => String(a.currency).toUpperCase() === currency.toUpperCase());
if (!acct) return { ok: false, error: `No IBAN account for ${currency}` };
const customer = reg.customer as { displayName?: string };
const customer = reg.customer as { displayName?: string; email?: string };
let ex: ExchangeDef | undefined;
if (typeof exchange === 'string' && exchange) {
ex = EXCHANGE_CATALOG.find((e) => e.id === exchange.toLowerCase() || e.theme === exchange.toLowerCase());
@@ -209,13 +219,106 @@ export function issueCardForCurrency(
ex = exchange;
}
const card = buildOnlineCard({
currency: String(acct.currency),
iban: String(acct.iban),
accountNo: acct.fineractAccountNo ? String(acct.fineractAccountNo) : null,
nameOnCard: customer.displayName || 'CARDHOLDER',
exchange: ex,
});
// Prefer Marqeta Malta when live + (explicit marqeta theme OR default unbranded issue)
const useMarqeta = marqetaConfigured() && (!ex || ex.id === 'marqeta');
let card: OnlineCard & { pan?: string; cvv?: string };
if (useMarqeta) {
try {
const name = (customer.displayName || 'CARDHOLDER').trim();
const parts = name.split(/\s+/);
const firstName = parts[0] || 'Cardholder';
const lastName = parts.slice(1).join(' ') || 'OMNL';
const mq = await issueMarqetaVirtualCard({
firstName,
lastName,
email: customer.email,
revealSecrets: revealPan,
});
const nameOnCard = name.toUpperCase();
const exchangeDef = ex || EXCHANGE_CATALOG.find((e) => e.id === 'marqeta');
const id = `mqcard_${Date.now().toString(36)}_${randomInt(1000, 9999)}`;
card = {
id,
brand: 'Visa',
product: exchangeDef ? `${exchangeDef.name} Visa` : 'Marqeta Malta Visa',
formFactor: 'online',
currency: String(acct.currency),
linkedIban: String(acct.iban),
linkedAccountNo: acct.fineractAccountNo ? String(acct.fineractAccountNo) : null,
panMasked: mq.panMasked,
panToken: mq.panToken,
expiry: mq.expiry || `${String(new Date().getMonth() + 1).padStart(2, '0')}/${String(new Date().getFullYear() + 4).slice(-2)}`,
nameOnCard,
status: 'ACTIVE',
spendable: true,
onlineOnly: true,
atmEnabled: true,
contactless: true,
createdAt: new Date().toISOString(),
pan: mq.pan,
cvv: mq.cvv,
marqeta: {
userToken: mq.userToken,
cardToken: mq.cardToken,
lastFour: mq.lastFour,
state: mq.state,
issuer: 'MARQETA_MALTA',
},
...(exchangeDef
? {
exchange: {
id: exchangeDef.id,
name: exchangeDef.name,
officeId: exchangeDef.officeId,
theme: exchangeDef.theme,
bicHint: exchangeDef.bicHint,
},
}
: {}),
back: {
magstripe: true,
signatureStrip: true,
cvvPanel: true,
cvvMasked: '•••',
printedName: nameOnCard,
},
networks: {
visa: { status: 'ISSUED_MARQETA', note: 'Marqeta Malta EU production Visa' },
applePay: { status: 'TSP_READY', note: 'Provision via Marqeta digital wallet tokens' },
googlePay: { status: 'TSP_READY', note: 'Provision via Marqeta digital wallet tokens' },
unionPay: { status: 'PROVISION_READY', note: 'Dual-brand pending program config' },
westernUnion: { status: 'RAIL_MAPPED', note: 'WU via SWIFT/HYBX' },
atm: { status: 'NETWORK', note: 'ATM via Marqeta BIN / switch' },
ecommerce: { status: 'ACTIVE', note: 'CNP auth via Marqeta' },
exchange: {
status: 'MARQETA_MALTA',
note: 'Issuer: Marqeta Malta · linked IBAN balance on Fineract',
},
},
};
} catch (e) {
// If explicitly requested marqeta theme, fail hard; else fall back to local BIN
if (ex?.id === 'marqeta') {
return { ok: false, error: e instanceof Error ? e.message : String(e) };
}
card = buildOnlineCard({
currency: String(acct.currency),
iban: String(acct.iban),
accountNo: acct.fineractAccountNo ? String(acct.fineractAccountNo) : null,
nameOnCard: customer.displayName || 'CARDHOLDER',
exchange: ex,
});
}
} else {
card = buildOnlineCard({
currency: String(acct.currency),
iban: String(acct.iban),
accountNo: acct.fineractAccountNo ? String(acct.fineractAccountNo) : null,
nameOnCard: customer.displayName || 'CARDHOLDER',
exchange: ex,
});
}
const { pan, cvv, ...publicCard } = card;
const onlineCards = (Array.isArray(acct.onlineCards) ? acct.onlineCards : []) as OnlineCard[];
@@ -238,11 +341,11 @@ export function issueCardForCurrency(
}
/** Issue N randomly selected exchange-branded cards across funded IBANs. */
export function issueRandomExchangeCards(
export async function issueRandomExchangeCards(
repoRoot: string,
count = 4,
revealPan = false,
): { ok: true; cards: OnlineCard[] } | { ok: false; error: string } {
): Promise<{ ok: true; cards: OnlineCard[] } | { ok: false; error: string }> {
const reg = loadCustomerRegistry(repoRoot);
if (!reg) return { ok: false, error: 'Customer registry missing — run provision script first' };
const accounts = (reg.accounts as Array<Record<string, unknown>>) || [];
@@ -252,7 +355,7 @@ export function issueRandomExchangeCards(
const cards: OnlineCard[] = [];
for (const ex of exchanges) {
const currency = pickRandomCurrency(accounts as Array<{ currency?: string }>);
const issued = issueCardForCurrency(repoRoot, currency, revealPan, ex);
const issued = await issueCardForCurrency(repoRoot, currency, revealPan, ex);
if (!issued.ok) return { ok: false, error: issued.error };
cards.push(issued.card);
}