diff --git a/config/rails-marqeta-malta.env.example b/config/rails-marqeta-malta.env.example new file mode 100644 index 0000000..d376df8 --- /dev/null +++ b/config/rails-marqeta-malta.env.example @@ -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 diff --git a/config/secrets-templates/rails-marqeta-malta.env.example b/config/secrets-templates/rails-marqeta-malta.env.example new file mode 100644 index 0000000..d376df8 --- /dev/null +++ b/config/secrets-templates/rails-marqeta-malta.env.example @@ -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 diff --git a/scripts/rails/smoke-marqeta-malta.mjs b/scripts/rails/smoke-marqeta-malta.mjs new file mode 100644 index 0000000..00a5ec1 --- /dev/null +++ b/scripts/rails/smoke-marqeta-malta.mjs @@ -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); diff --git a/services/settlement-middleware/dist/adapters/card-issuers-hub.js b/services/settlement-middleware/dist/adapters/card-issuers-hub.js new file mode 100644 index 0000000..e7d7818 --- /dev/null +++ b/services/settlement-middleware/dist/adapters/card-issuers-hub.js @@ -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, + }; +} diff --git a/services/settlement-middleware/dist/adapters/marqeta-malta.js b/services/settlement-middleware/dist/adapters/marqeta-malta.js new file mode 100644 index 0000000..8d0f98a --- /dev/null +++ b/services/settlement-middleware/dist/adapters/marqeta-malta.js @@ -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; diff --git a/services/settlement-middleware/dist/api/routes/settlement.js b/services/settlement-middleware/dist/api/routes/settlement.js index caab099..a92e6f5 100644 --- a/services/settlement-middleware/dist/api/routes/settlement.js +++ b/services/settlement-middleware/dist/api/routes/settlement.js @@ -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'); diff --git a/services/settlement-middleware/dist/index.js b/services/settlement-middleware/dist/index.js index 4349562..0824baf 100644 --- a/services/settlement-middleware/dist/index.js +++ b/services/settlement-middleware/dist/index.js @@ -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)) diff --git a/services/settlement-middleware/src/adapters/card-issuers-hub.ts b/services/settlement-middleware/src/adapters/card-issuers-hub.ts new file mode 100644 index 0000000..ca76c67 --- /dev/null +++ b/services/settlement-middleware/src/adapters/card-issuers-hub.ts @@ -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 +> { + const status = getCardIssuersStatus(); + const out: Array = []; + 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, + }; +} diff --git a/services/settlement-middleware/src/adapters/marqeta-malta.ts b/services/settlement-middleware/src/adapters/marqeta-malta.ts new file mode 100644 index 0000000..9eb4176 --- /dev/null +++ b/services/settlement-middleware/src/adapters/marqeta-malta.ts @@ -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 { + if (!this.isReady()) throw new Error('Marqeta Malta not configured'); + const body: Record = { + 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 { + 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 = { + 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 { + 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 }; + } +} diff --git a/services/settlement-middleware/src/api/routes/settlement.ts b/services/settlement-middleware/src/api/routes/settlement.ts index 83cdaf9..0687edf 100644 --- a/services/settlement-middleware/src/api/routes/settlement.ts +++ b/services/settlement-middleware/src/api/routes/settlement.ts @@ -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 { @@ -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'); diff --git a/services/settlement-middleware/src/index.ts b/services/settlement-middleware/src/index.ts index 8ab82ef..ede1f3a 100644 --- a/services/settlement-middleware/src/index.ts +++ b/services/settlement-middleware/src/index.ts @@ -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 }); diff --git a/services/token-aggregation/public/rails-marqeta-malta.html b/services/token-aggregation/public/rails-marqeta-malta.html new file mode 100644 index 0000000..dc314d7 --- /dev/null +++ b/services/token-aggregation/public/rails-marqeta-malta.html @@ -0,0 +1,71 @@ + + + +OMNL — Marqeta Malta card issuing + +
+
+ MARQETA MALTA + EU PRODUCTION + VISA ISSUING +
+

Marqeta Malta card issuer

+

Production Core API for virtual Visa issue (Malta EU program). Credentials from secrets/marqeta/credentials.env. Live issue requires SETTLEMENT_ALLOW_MARQETA_PRODUCTION=1.

+ + + +
IssuerConfiguredLive gateCard productBase URL
Loading…
+

+ Settlement: GET /settlement/rails/cards · POST /settlement/rails/cards/issue
+ Webhook: POST /settlement/webhooks/marqeta
+ Smoke: node scripts/rails/smoke-marqeta-malta.mjs
+ Cards UI: /zbank/cards · theme marqeta +

+
+ + diff --git a/services/token-aggregation/public/zbank-online-cards.html b/services/token-aggregation/public/zbank-online-cards.html index 5439e8d..58496da 100644 --- a/services/token-aggregation/public/zbank-online-cards.html +++ b/services/token-aggregation/public/zbank-online-cards.html @@ -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 @@
+
@@ -253,6 +255,7 @@

/zbank/cards · + Marqeta Malta · accounts

@@ -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 = `MARQETA MALTA ISSUED ${c.currency}
` + + `PAN masked: ${c.panMasked}
` + + (c.marqeta ? `cardToken: ${c.marqeta.cardToken}
userToken: ${c.marqeta.userToken}
` : '') + + (c.pan ? `PAN: ${c.pan}
CVV: ${c.cvv}
` : '') + + `Expiry: ${c.expiry}
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'); diff --git a/services/token-aggregation/src/api/server.ts b/services/token-aggregation/src/api/server.ts index b5abf97..11becf1 100644 --- a/services/token-aggregation/src/api/server.ts +++ b/services/token-aggregation/src/api/server.ts @@ -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; diff --git a/services/token-aggregation/src/index.ts b/services/token-aggregation/src/index.ts index 7e4876c..d4bb370 100644 --- a/services/token-aggregation/src/index.ts +++ b/services/token-aggregation/src/index.ts @@ -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'); diff --git a/services/token-aggregation/src/lib/marqeta-malta.ts b/services/token-aggregation/src/lib/marqeta-malta.ts new file mode 100644 index 0000000..c13f2bb --- /dev/null +++ b/services/token-aggregation/src/lib/marqeta-malta.ts @@ -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, + }; +} diff --git a/services/token-aggregation/src/lib/zbank-online-cards.ts b/services/token-aggregation/src/lib/zbank-online-cards.ts index a69ca85..d7e4fca 100644 --- a/services/token-aggregation/src/lib/zbank-online-cards.ts +++ b/services/token-aggregation/src/lib/zbank-online-cards.ts @@ -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 } | { ok: false; error: string } { +): Promise<{ ok: true; card: OnlineCard; account: Record } | { 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>) || []; 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>) || []; @@ -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); }