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
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:
19
config/rails-marqeta-malta.env.example
Normal file
19
config/rails-marqeta-malta.env.example
Normal 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
|
||||
19
config/secrets-templates/rails-marqeta-malta.env.example
Normal file
19
config/secrets-templates/rails-marqeta-malta.env.example
Normal 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
|
||||
87
scripts/rails/smoke-marqeta-malta.mjs
Normal file
87
scripts/rails/smoke-marqeta-malta.mjs
Normal 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);
|
||||
77
services/settlement-middleware/dist/adapters/card-issuers-hub.js
vendored
Normal file
77
services/settlement-middleware/dist/adapters/card-issuers-hub.js
vendored
Normal 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,
|
||||
};
|
||||
}
|
||||
172
services/settlement-middleware/dist/adapters/marqeta-malta.js
vendored
Normal file
172
services/settlement-middleware/dist/adapters/marqeta-malta.js
vendored
Normal 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;
|
||||
@@ -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');
|
||||
|
||||
4
services/settlement-middleware/dist/index.js
vendored
4
services/settlement-middleware/dist/index.js
vendored
@@ -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))
|
||||
|
||||
107
services/settlement-middleware/src/adapters/card-issuers-hub.ts
Normal file
107
services/settlement-middleware/src/adapters/card-issuers-hub.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
227
services/settlement-middleware/src/adapters/marqeta-malta.ts
Normal file
227
services/settlement-middleware/src/adapters/marqeta-malta.ts
Normal 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 };
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
|
||||
@@ -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 });
|
||||
|
||||
71
services/token-aggregation/public/rails-marqeta-malta.html
Normal file
71
services/token-aggregation/public/rails-marqeta-malta.html
Normal 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>
|
||||
@@ -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');
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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');
|
||||
|
||||
147
services/token-aggregation/src/lib/marqeta-malta.ts
Normal file
147
services/token-aggregation/src/lib/marqeta-malta.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user