diff --git a/backend/migrations/20260708_economy_ledger_fee.js b/backend/migrations/20260708_economy_ledger_fee.js new file mode 100644 index 0000000..469bf8b --- /dev/null +++ b/backend/migrations/20260708_economy_ledger_fee.js @@ -0,0 +1,54 @@ +/** + * Economy: net-zero double-entry ledger + platform fee control (JFA §7.2–§7.4). + * + * - `ledger_entries` is the append-only source of truth for balances. Every money + * movement is posted as a set of signed entries that SUM TO ZERO across the + * exchange (buyer/escrow/seller/platform/fiat_gateway). `wallets.balance` is kept + * as a derived cache, updated atomically with each posting, and reconcilable + * against the ledger (it can no longer silently drift). + * - `platform_settings` holds the operator's platform fee in basis points, set by an + * admin. The fee is applied at settlement and recorded as a visible ledger entry + * to the `platform` account (transparent, contestable fee — JFA open problem 7). + * + * Backfill: existing wallet balances are opened as ledger entries against a + * `genesis` account, so the ledger reconciles with balances from day one. + */ + +exports.up = async function up(knex) { + await knex.schema.createTable('ledger_entries', (t) => { + t.bigIncrements('id').primary(); + t.string('tx_id', 36).nullable(); // the exchange/dialog, when applicable + t.string('account', 64).notNullable(); // a user_id, or a system account (escrow/platform/fiat_gateway/genesis) + t.decimal('amount', 14, 2).notNullable(); // signed: + credits the account, - debits it + t.string('kind', 32).notNullable(); // escrow_fund | escrow_release | platform_fee | refund | tranche | contract_transfer | opening_balance + t.timestamp('created_at').notNullable().defaultTo(knex.fn.now()); + t.index('account', 'idx_ledger_account'); + t.index('tx_id', 'idx_ledger_tx'); + }); + + await knex.schema.createTable('platform_settings', (t) => { + t.tinyint('id').notNullable().primary().defaultTo(1); // single-row table + t.integer('fee_bps').notNullable().defaultTo(0); // platform fee, basis points (100 bps = 1%) + t.string('updated_by', 36).nullable(); + t.timestamp('updated_at').notNullable().defaultTo(knex.raw('CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP')); + }); + + const seedBps = Math.max(0, Math.min(10000, parseInt(process.env.PLATFORM_FEE_BPS || '0', 10) || 0)); + await knex('platform_settings').insert({ id: 1, fee_bps: seedBps }).onConflict('id').ignore(); + + // Backfill: open each existing non-zero wallet balance as a ledger entry, balanced + // against `genesis`, so balance == SUM(ledger) for every account going forward. + const wallets = await knex('wallets').select('user_id', 'balance').where('balance', '<>', 0); + for (const w of wallets) { + const amt = Number(w.balance); + await knex('ledger_entries').insert([ + { tx_id: null, account: w.user_id, amount: amt, kind: 'opening_balance' }, + { tx_id: null, account: 'genesis', amount: -amt, kind: 'opening_balance' }, + ]); + } +}; + +exports.down = async function down(knex) { + await knex.schema.dropTableIfExists('platform_settings'); + await knex.schema.dropTableIfExists('ledger_entries'); +}; diff --git a/backend/repositories/ledgerRepository.js b/backend/repositories/ledgerRepository.js new file mode 100644 index 0000000..db44d28 --- /dev/null +++ b/backend/repositories/ledgerRepository.js @@ -0,0 +1,89 @@ +/** + * Ledger repository — the net-zero, append-only source of truth for balances + * (JFA §7.2). Every money movement is a set of signed entries that SUM TO ZERO + * across the exchange; `wallets.balance` is a derived cache updated atomically here. + * + * There is no update and no delete: corrections are new balancing postings. + */ +const pool = require('../lib/db'); + +// System (non-wallet) accounts. These hold value in the ledger but have no wallet +// row; their balances are always derived from the ledger, never cached. +const ACCOUNTS = { + ESCROW: 'escrow', // funds held for an in-flight exchange + PLATFORM: 'platform', // platform-fee income (transparent, on the record) + FIAT_GATEWAY: 'fiat_gateway',// the fiat boundary (Stripe on-ramp) + GENESIS: 'genesis', // opening-balance counter-account +}; +const SYSTEM = new Set(Object.values(ACCOUNTS)); + +const round2 = (n) => Math.round((Number(n) + Number.EPSILON) * 100) / 100; + +/** + * Post a balanced set of entries. Requires an existing connection (the caller owns + * the transaction and any row locks). `entries`: [{ account, amount }] with signed + * amounts. Throws unless the entries sum to zero. + * + * opts.checkFunds: assert each real-wallet account being debited has sufficient + * balance (FOR UPDATE) before posting — used for buyer-funded movements. + */ +async function post(connection, { txId = null, kind, entries }, opts = {}) { + if (!connection) throw new Error('ledger.post requires a db connection'); + if (!kind) throw new Error('ledger.post requires a kind'); + if (!Array.isArray(entries) || entries.length < 2) throw new Error('ledger.post requires >= 2 entries'); + + const sum = round2(entries.reduce((a, e) => a + Number(e.amount), 0)); + if (Math.abs(sum) >= 0.005) { + throw new Error(`ledger.post is not net-zero (kind=${kind}, sum=${sum})`); + } + + if (opts.checkFunds) { + for (const e of entries) { + const amt = Number(e.amount); + if (amt < 0 && !SYSTEM.has(e.account)) { + const [rows] = await connection.query('SELECT balance FROM wallets WHERE user_id = ? FOR UPDATE', [e.account]); + if (!rows.length) throw new Error('Wallet not found'); + if (Number(rows[0].balance) < -amt) { + const err = new Error('Insufficient balance'); + err.statusCode = 409; + throw err; + } + } + } + } + + for (const e of entries) { + const amt = round2(e.amount); + await connection.query( + 'INSERT INTO ledger_entries (tx_id, account, amount, kind) VALUES (?, ?, ?, ?)', + [txId, e.account, amt, kind] + ); + // Update the derived wallet cache for real users; system accounts derive on read. + if (!SYSTEM.has(e.account)) { + await connection.query('UPDATE wallets SET balance = balance + ? WHERE user_id = ?', [amt, e.account]); + } + } + return { kind, txId, entries: entries.map((e) => ({ account: e.account, amount: round2(e.amount) })) }; +} + +// Ledger balance of any account (system or user). Source of truth. +async function balanceOf(account, conn = pool) { + const [[r]] = await conn.query('SELECT COALESCE(SUM(amount), 0) AS bal FROM ledger_entries WHERE account = ?', [account]); + return Number(r.bal); +} + +// Reconcile a user's cached wallet balance against the ledger. drift should be 0. +async function reconcile(userId, conn = pool) { + const [[w]] = await conn.query('SELECT balance FROM wallets WHERE user_id = ?', [userId]); + const ledger = await balanceOf(userId, conn); + const wallet = w ? Number(w.balance) : 0; + return { userId, wallet, ledger, drift: round2(wallet - ledger) }; +} + +// Global invariant: the whole ledger sums to zero (nothing is created or destroyed). +async function totalOutstanding(conn = pool) { + const [[r]] = await conn.query('SELECT COALESCE(SUM(amount), 0) AS s FROM ledger_entries'); + return round2(r.s); +} + +module.exports = { post, balanceOf, reconcile, totalOutstanding, ACCOUNTS, round2 }; diff --git a/backend/repositories/settingsRepository.js b/backend/repositories/settingsRepository.js new file mode 100644 index 0000000..886e588 --- /dev/null +++ b/backend/repositories/settingsRepository.js @@ -0,0 +1,36 @@ +/** + * Platform settings — currently the operator's platform fee (JFA §7.4, open + * problem 7: fees must be transparent and contestable). Stored in basis points + * (100 bps = 1%) in a single-row table. + */ +const pool = require('../lib/db'); + +const MAX_BPS = 10000; // 100% — a validity bound, not a policy recommendation + +async function getFeeBps(conn = pool) { + const [[row]] = await conn.query('SELECT fee_bps FROM platform_settings WHERE id = 1'); + return row ? Number(row.fee_bps) : 0; +} + +async function setFeeBps(bps, adminId, conn = pool) { + const n = Math.round(Number(bps)); + if (!Number.isFinite(n) || n < 0 || n > MAX_BPS) { + const err = new Error(`fee_bps must be an integer between 0 and ${MAX_BPS}`); + err.statusCode = 400; + throw err; + } + await conn.query( + 'UPDATE platform_settings SET fee_bps = ?, updated_by = ? WHERE id = 1', + [n, adminId || null] + ); + return n; +} + +// The fee owed on a settled amount, rounded to cents. Never exceeds the amount. +function feeFor(amount, bps) { + const fee = (Number(amount) * Number(bps)) / 10000; + const rounded = Math.round((fee + Number.EPSILON) * 100) / 100; + return Math.min(rounded, Number(amount)); +} + +module.exports = { getFeeBps, setFeeBps, feeFor, MAX_BPS }; diff --git a/backend/routes/adminRoutes.js b/backend/routes/adminRoutes.js index 46ebbfd..1a1f061 100644 --- a/backend/routes/adminRoutes.js +++ b/backend/routes/adminRoutes.js @@ -5,6 +5,8 @@ const { redis } = require("../lib/redis"); const { authenticateToken } = require('../middleware/authMiddleware'); const { logAdminAction } = require('../services/adminAuditService'); const transactionService = require('../services/transactionService'); +const settingsRepository = require('../repositories/settingsRepository'); +const ledgerRepository = require('../repositories/ledgerRepository'); const router = express.Router(); @@ -503,4 +505,28 @@ router.post("/disputes/:id/resolve", async (req, res) => { }); +// ── Platform fee (JFA §7.4 — transparent, contestable operator fee) ────────────── +// The fee is charged at settlement and recorded as a visible ledger entry to the +// `platform` account. Stored in basis points (100 bps = 1%). +router.get("/settings/platform-fee", async (req, res) => { + const bps = await settingsRepository.getFeeBps(); + const income = await ledgerRepository.balanceOf(ledgerRepository.ACCOUNTS.PLATFORM); + res.json({ fee_bps: bps, fee_percent: bps / 100, platform_income: income }); +}); + +router.put("/settings/platform-fee", async (req, res) => { + try { + let bps; + if (req.body.fee_bps != null) bps = Math.round(Number(req.body.fee_bps)); + else if (req.body.fee_percent != null) bps = Math.round(Number(req.body.fee_percent) * 100); + else return res.status(400).json({ error: "Provide fee_percent or fee_bps" }); + + const saved = await settingsRepository.setFeeBps(bps, req.user.id); + await logAdminAction(req.user.id, "set_platform_fee", "platform_settings", "1", { fee_bps: saved }); + res.json({ fee_bps: saved, fee_percent: saved / 100 }); + } catch (err) { + res.status(err.statusCode || 400).json({ error: err.message }); + } +}); + module.exports = router; diff --git a/backend/routes/payments.js b/backend/routes/payments.js index e7b498a..e4620b6 100644 --- a/backend/routes/payments.js +++ b/backend/routes/payments.js @@ -1,41 +1,27 @@ const express = require("express"); -const { createPixPayment } = require("../services/stripeService"); const { authenticateToken } = require("../middleware/authMiddleware"); const { strictWriteLimiter, userRateLimiter } = require("../middlewares/rateLimiters"); -const pool = require("../lib/db"); +const settingsRepository = require("../repositories/settingsRepository"); const router = express.Router(); -router.post("/pix/create", authenticateToken, userRateLimiter, strictWriteLimiter, async (req, res, next) => { - try { - const userId = req.user.id; - const { amount } = req.body; - - const paymentIntent = await createPixPayment({ amount, userId }); - - await pool.query( - ` - INSERT INTO payments ( - id, - user_id, - amount, - status, - provider, - expires_at - ) - VALUES (?, ?, ?, 'pending', 'stripe', DATE_ADD(NOW(), INTERVAL 15 MINUTE)) - `, - [paymentIntent.id, userId, amount] - ); +// Retired (JFA §7.3): the in-network unit is not purchasable — there is no general +// wallet top-up. Payments fund a specific transaction's escrow via the transaction +// payment flow (createPaymentForTransaction); a generic fiat deposit would make the +// unit purchasable (deposit-taking), which the architecture forbids. +router.post("/pix/create", authenticateToken, userRateLimiter, strictWriteLimiter, (req, res) => { + res.status(410).json({ + error: "Wallet top-up is retired. Pay for a specific transaction's escrow instead.", + code: "PURCHASABILITY_RETIRED", + }); +}); - res.json({ - clientSecret: paymentIntent.client_secret, - paymentIntentId: paymentIntent.id, - amount: paymentIntent.amount - }); - } catch (err) { - next(err); - } +// Public transparency: the current platform fee (JFA §7.4, open problem 7 — fees are +// transparent and contestable). The fee also appears as a visible ledger entry on +// every settlement. +router.get("/fee", async (_req, res) => { + const bps = await settingsRepository.getFeeBps(); + res.json({ fee_bps: bps, fee_percent: bps / 100 }); }); module.exports = router; diff --git a/backend/routes/webhook.js b/backend/routes/webhook.js index 0552e71..68c9cf1 100644 --- a/backend/routes/webhook.js +++ b/backend/routes/webhook.js @@ -1,6 +1,7 @@ const stripe = require("stripe")(process.env.STRIPE_SECRET_KEY); const pool = require("../lib/db"); const walletRepository = require("../repositories/walletRepository"); +const ledger = require("../repositories/ledgerRepository"); const { auditFinancialEvent } = require("../utils/financialAudit"); const { stripePaymentSucceededTotal, @@ -102,45 +103,53 @@ module.exports = async (req, res) => { const transactionId = paymentIntent.metadata?.transactionId; - if (transactionId) { - await connection.query( - `UPDATE transactions - SET status = 'paid' - WHERE id = ? AND status = 'pending'`, - [transactionId] - ); + if (!transactionId) { + // JFA §7.3: the in-network unit is not purchasable — there is no general + // wallet top-up. Every payment funds a specific transaction's escrow. A + // payment with no transactionId is out of the escrow model; do not credit + // any wallet. + console.warn("Payment without transactionId — no wallet credited (purchasability retired):", paymentIntent.id); + await connection.commit(); + return res.json({ received: true }); } - console.log("Crediting wallet..."); + // Fund THIS transaction's escrow from the fiat on-ramp (net-zero): + // fiat_gateway -A, escrow +A. No general spendable balance is created. + await connection.query( + `UPDATE transactions + SET status = 'paid', escrow_locked = 1 + WHERE id = ? AND status = 'pending'`, + [transactionId] + ); - await walletRepository.credit( - payment.user_id, - Number(payment.amount), - "Stripe deposit", - null, - paymentIntent.id, - "deposit", - connection + await ledger.post( + connection, + { txId: transactionId, kind: 'escrow_fund', entries: [ + { account: ledger.ACCOUNTS.FIAT_GATEWAY, amount: -Number(payment.amount) }, + { account: ledger.ACCOUNTS.ESCROW, amount: Number(payment.amount) }, + ] } ); await auditFinancialEvent({ eventType: "payment", userId: payment.user_id, + transactionId, paymentId: paymentIntent.id, amount: Number(payment.amount), metadata: { provider: "stripe", - type: "deposit" + type: "escrow_fund" }, connection }); + // Fraud velocity: too many succeeded payments in a short window. const [velocityRows] = await connection.query( ` SELECT COUNT(*) as count - FROM wallet_history + FROM payments WHERE user_id = ? - AND type = 'deposit' + AND status = 'succeeded' AND created_at >= DATE_SUB(NOW(), INTERVAL 5 MINUTE) `, [payment.user_id] diff --git a/backend/services/contractService.js b/backend/services/contractService.js index b243b74..1c5f296 100644 --- a/backend/services/contractService.js +++ b/backend/services/contractService.js @@ -9,6 +9,7 @@ const { randomUUID } = require('crypto'); const pool = require('../lib/db'); const walletRepository = require('../repositories/walletRepository'); +const ledger = require('../repositories/ledgerRepository'); const postRepository = require('../repositories/postRepository'); const { auditFinancialEvent } = require('../utils/financialAudit'); const mycelium = require('./myceliumService'); @@ -126,9 +127,15 @@ async function transferContract({ transactionId, fromUserId, toEmail, price }) { if (toUser.id === fromUserId) { const e = new Error('Cannot transfer to yourself'); e.statusCode = 400; throw e; } if (toUser.id === tx.seller_id) { const e = new Error('The producer cannot hold the buyer side'); e.statusCode = 400; throw e; } - // new buyer pays the current holder the negotiated price - await walletRepository.debit(toUser.id, numericPrice, `Contract purchase: ${tx.listing_title || ''}`.trim(), transactionId, connection); - await walletRepository.credit(fromUserId, numericPrice, `Contract sale: ${tx.listing_title || ''}`.trim(), transactionId, null, 'sale', connection); + // new buyer pays the current holder the negotiated price (wallet -> wallet, net-zero) + await ledger.post( + connection, + { txId: transactionId, kind: 'contract_transfer', entries: [ + { account: toUser.id, amount: -numericPrice }, + { account: fromUserId, amount: numericPrice }, + ] }, + { checkFunds: true } + ); await connection.query('UPDATE transactions SET buyer_id = ? WHERE id = ?', [toUser.id, transactionId]); diff --git a/backend/services/transactionService.js b/backend/services/transactionService.js index 823855f..a7f2f93 100644 --- a/backend/services/transactionService.js +++ b/backend/services/transactionService.js @@ -17,6 +17,9 @@ const { disputesOpenedTotal } = require("../lib/metrics"); const mycelium = require("./myceliumService"); +const ledger = require("../repositories/ledgerRepository"); +const settings = require("../repositories/settingsRepository"); +const { ACCOUNTS } = ledger; // Append a transmission to a transaction's Mycelium dialog (best-effort — a ledger // failure is logged but never fails the money path). `data` carries refs + facts only. @@ -92,17 +95,19 @@ async function createTransactionWithWalletDebit(payload) { try { await connection.beginTransaction(); - // 🔒 Debit using SAME connection - await walletRepository.debit( - buyerId, - numericAmount, - `Purchase: ${payload.listingTitle || ""}`.trim(), - null, - connection - ); - const id = randomUUID(); + // Fund escrow from the buyer's in-network balance (net-zero): buyer -A, escrow +A. + // (Fiat purchases fund escrow via the Stripe webhook instead; JFA §7.2/§7.3.) + await ledger.post( + connection, + { txId: id, kind: 'escrow_fund', entries: [ + { account: buyerId, amount: -numericAmount }, + { account: ACCOUNTS.ESCROW, amount: numericAmount }, + ] }, + { checkFunds: true } + ); + const item = createTransactionItem({ ...payload, id, @@ -527,6 +532,7 @@ async function rateTransaction(transactionId, rating, userId, { comment = null, // funds. A 0..+4 releases to the producer. A -1 ("No Trust") instead FREEZES the // funds and auto-opens a dispute (the mandatory -1 comment is the dispute reason) // for manual admin resolution. The producer's post-hoc rating never moves money. + let settle = null; let escrowReleased = false; let disputeOpened = false; if (actorRole === "buyer" && tx.status === "paid" && tx.escrow_locked === 1) { @@ -539,7 +545,8 @@ async function rateTransaction(transactionId, rating, userId, { comment = null, disputeOpened = true; } else { // pass the full row so _settleEscrow sees released_amount (tranche contracts) - escrowReleased = await _settleEscrow(tx, connection, { actorId: userId, source: 'lbtas_gate' }); + settle = await _settleEscrow(tx, connection, { actorId: userId, source: 'lbtas_gate' }); + escrowReleased = !!settle; } } @@ -554,7 +561,8 @@ async function rateTransaction(transactionId, rating, userId, { comment = null, agrinet_rating_total.inc(); await logTx('rating', transactionId, { actorId: userId, actorRole, ratedRole: ratedRoleLabel, value: numericRating }); - if (escrowReleased) await logTx('escrow_settled', transactionId, { actorId: userId, seller: ratedUserId, amount: Number(tx.amount), trigger: 'rating' }); + if (escrowReleased) await logTx('escrow_settled', transactionId, { actorId: userId, seller: ratedUserId, amount: settle.sellerNet, gross: Number(tx.amount), fee: settle.fee, trigger: 'rating' }); + if (escrowReleased && settle.fee > 0) await logTx('platform_fee', transactionId, { actorId: 'platform', amount: settle.fee, trigger: 'rating' }); if (disputeOpened) await logTx('audit_open', transactionId, { actorId: userId, role: actorRole, target: 'rating' }); await sealIfComplete(transactionId); @@ -620,15 +628,14 @@ async function resolveFlag(transactionId, action, adminId) { throw err; } - // 1️⃣ Refund buyer inside SAME transaction - await walletRepository.credit( - transaction.buyer_id, - Number(transaction.amount), - `Refund: ${transaction.listing_title || ""}`, - `${transactionId}-refund`, - null, - "deposit" - ); + // 1️⃣ Refund buyer inside the SAME db transaction: escrow -> buyer (net-zero). + const refundable = Number(transaction.amount) - Number(transaction.released_amount || 0); + if (refundable > 0) { + await ledger.post(connection, { txId: transactionId, kind: 'refund', entries: [ + { account: ACCOUNTS.ESCROW, amount: -refundable }, + { account: transaction.buyer_id, amount: refundable }, + ] }); + } // 2️⃣ Cancel transaction + unlock escrow await connection.query( @@ -714,24 +721,33 @@ async function _settleEscrow(tx, connection, { actorId, source }) { // Release only what is still held (intermediate tranches may already be paid out). const remaining = Number(tx.amount) - Number(tx.released_amount || 0); + let fee = 0, sellerNet = 0; if (remaining > 0) { - await connection.query( - `UPDATE wallets SET balance = balance + ? WHERE user_id = ?`, - [remaining, tx.seller_id] - ); + // Split the release: seller gets amount - fee; the platform fee is a visible + // ledger entry to the `platform` account (JFA §7.4, transparent fee). Net-zero. + const bps = await settings.getFeeBps(connection); + fee = settings.feeFor(remaining, bps); + sellerNet = ledger.round2(remaining - fee); + const entries = [ + { account: ACCOUNTS.ESCROW, amount: -remaining }, + { account: tx.seller_id, amount: sellerNet }, + ]; + if (fee > 0) entries.push({ account: ACCOUNTS.PLATFORM, amount: fee }); + await ledger.post(connection, { txId: tx.id, kind: 'escrow_release', entries }); + await auditFinancialEvent({ eventType: "escrow_release", userId: actorId, transactionId: tx.id, walletUserId: tx.seller_id, - amount: remaining, - metadata: { source }, + amount: sellerNet, + metadata: { source, fee, fee_bps: bps }, connection }); } escrowReleaseSuccess.inc(); - return true; + return { fee, sellerNet, remaining }; } async function releaseEscrow(transactionId, userId) { @@ -779,13 +795,14 @@ async function releaseEscrow(transactionId, userId) { throw err; } - const released = await _settleEscrow(tx, connection, { actorId: userId, source: 'manual' }); - if (!released) { + const settle = await _settleEscrow(tx, connection, { actorId: userId, source: 'manual' }); + if (!settle) { throw new Error('Escrow already released or transaction not eligible'); } await connection.commit(); - await logTx('escrow_settled', transactionId, { actorId: userId, seller: tx.seller_id, amount: Number(tx.amount), trigger: 'manual' }); + await logTx('escrow_settled', transactionId, { actorId: userId, seller: tx.seller_id, amount: settle.sellerNet, gross: Number(tx.amount), fee: settle.fee, trigger: 'manual' }); + if (settle.fee > 0) await logTx('platform_fee', transactionId, { actorId: 'platform', amount: settle.fee, trigger: 'manual' }); await sealIfComplete(transactionId); return { message: 'Escrow released' }; @@ -821,8 +838,18 @@ async function releaseTranche(transactionId, userId) { } const trancheAmt = Math.round((Number(tx.amount) / count) * 100) / 100; + const bps = await settings.getFeeBps(connection); + const fee = settings.feeFor(trancheAmt, bps); + const sellerNet = ledger.round2(trancheAmt - fee); + + // escrow -> seller (net of fee) + platform (fee). Net-zero. + const entries = [ + { account: ACCOUNTS.ESCROW, amount: -trancheAmt }, + { account: tx.seller_id, amount: sellerNet }, + ]; + if (fee > 0) entries.push({ account: ACCOUNTS.PLATFORM, amount: fee }); + await ledger.post(connection, { txId: transactionId, kind: 'tranche', entries }); - await connection.query(`UPDATE wallets SET balance = balance + ? WHERE user_id = ?`, [trancheAmt, tx.seller_id]); await connection.query( `UPDATE transactions SET released_amount = released_amount + ?, tranches_released = tranches_released + 1 WHERE id = ?`, [trancheAmt, transactionId] @@ -832,15 +859,16 @@ async function releaseTranche(transactionId, userId) { userId, transactionId, walletUserId: tx.seller_id, - amount: trancheAmt, - metadata: { source: 'tranche', tranche: done + 1, of: count }, + amount: sellerNet, + metadata: { source: 'tranche', tranche: done + 1, of: count, fee, fee_bps: bps }, connection }); escrowReleaseSuccess.inc(); await connection.commit(); - await logTx('tranche_released', transactionId, { actorId: userId, seller: tx.seller_id, amount: trancheAmt, tranche: done + 1, of: count }); - return { released: trancheAmt, tranches_released: done + 1, tranche_count: count }; + await logTx('tranche_released', transactionId, { actorId: userId, seller: tx.seller_id, amount: sellerNet, gross: trancheAmt, fee, tranche: done + 1, of: count }); + if (fee > 0) await logTx('platform_fee', transactionId, { actorId: 'platform', amount: fee, trigger: 'tranche' }); + return { released: sellerNet, gross: trancheAmt, fee, tranches_released: done + 1, tranche_count: count }; } catch (err) { await connection.rollback(); @@ -954,11 +982,12 @@ async function resolveDispute(disputeId, resolution, adminId, opts = {}) { if (!tx) { const e = new Error('Transaction not found'); e.statusCode = 404; throw e; } if (tx.escrow_locked !== 1) { const e = new Error('Escrow already released'); e.statusCode = 409; throw e; } + let settle = null; if (resolution === 'release') { - const ok = await _settleEscrow(tx, connection, { actorId: adminId, source: 'dispute_release' }); - if (!ok) { const e = new Error('Transaction not eligible for release'); e.statusCode = 409; throw e; } + settle = await _settleEscrow(tx, connection, { actorId: adminId, source: 'dispute_release' }); + if (!settle) { const e = new Error('Transaction not eligible for release'); e.statusCode = 409; throw e; } } else { - // refund: unlock + mark refunded first (guarded), then credit the buyer back + // refund: unlock + mark refunded first (guarded), then return held funds to the buyer const [upd] = await connection.query( `UPDATE transactions SET status='refunded', escrow_locked=0 WHERE id=? AND escrow_locked=1`, @@ -968,15 +997,15 @@ async function resolveDispute(disputeId, resolution, adminId, opts = {}) { // Only the still-held amount returns to the buyer (released tranches already paid out). const refundable = Number(tx.amount) - Number(tx.released_amount || 0); if (refundable > 0) { - await walletRepository.credit( - tx.buyer_id, - refundable, - `Refund (dispute): ${tx.listing_title || ''}`.trim(), - tx.id, - null, - 'refund', - connection - ); + // escrow -> buyer (net-zero) + await ledger.post(connection, { txId: tx.id, kind: 'refund', entries: [ + { account: ACCOUNTS.ESCROW, amount: -refundable }, + { account: tx.buyer_id, amount: refundable }, + ] }); + await auditFinancialEvent({ + eventType: 'refund', userId: adminId, transactionId: tx.id, + walletUserId: tx.buyer_id, amount: refundable, metadata: { source: 'dispute' }, connection, + }); } } @@ -1013,7 +1042,10 @@ async function resolveDispute(disputeId, resolution, adminId, opts = {}) { ); await connection.commit(); - if (resolution === 'release') await logTx('escrow_settled', tx.id, { actorId: adminId, seller: tx.seller_id, trigger: 'dispute' }); + if (resolution === 'release') { + await logTx('escrow_settled', tx.id, { actorId: adminId, seller: tx.seller_id, amount: settle.sellerNet, gross: Number(tx.amount), fee: settle.fee, trigger: 'dispute' }); + if (settle.fee > 0) await logTx('platform_fee', tx.id, { actorId: 'platform', amount: settle.fee, trigger: 'dispute' }); + } else if (resolution === 'refund') await logTx('refunded', tx.id, { actorId: adminId, buyer: tx.buyer_id, trigger: 'dispute' }); if (opts.voidBuyerRating) await logTx('rating_dismissed', tx.id, { actorId: adminId, target: 'buyer_rating' }); await logTx('audit_resolved', tx.id, { actorId: adminId, outcome: resolution }); @@ -1070,15 +1102,17 @@ async function _defaultRate(txId, side) { [tx.id] ); - let released = false; + let settle = null; if (side === "buyer" && tx.status === "paid" && tx.escrow_locked === 1) { - released = await _settleEscrow(tx, connection, { actorId: "system", source: "timeout" }); + settle = await _settleEscrow(tx, connection, { actorId: "system", source: "timeout" }); } + const released = !!settle; await connection.query("UPDATE transactions SET rating_given = 1 WHERE id = ? AND buyer_rated = 1 AND seller_rated = 1", [tx.id]); await connection.commit(); await logTx("rating", tx.id, { actorId: "system", actorRole: "system", ratedRole: ratedRoleLabel, value: TIMEOUT_DEFAULT, timeout_default: true }); - if (released) await logTx("escrow_settled", tx.id, { actorId: "system", seller: tx.seller_id, amount: Number(tx.amount), trigger: "timeout" }); + if (released) await logTx("escrow_settled", tx.id, { actorId: "system", seller: tx.seller_id, amount: settle.sellerNet, gross: Number(tx.amount), fee: settle.fee, trigger: "timeout" }); + if (released && settle.fee > 0) await logTx("platform_fee", tx.id, { actorId: "platform", amount: settle.fee, trigger: "timeout" }); await sealIfComplete(tx.id); return true; } catch (e) { diff --git a/scripts/hardcoded-url-allowlist.txt b/scripts/hardcoded-url-allowlist.txt index d54d113..3a7b50e 100644 --- a/scripts/hardcoded-url-allowlist.txt +++ b/scripts/hardcoded-url-allowlist.txt @@ -4,3 +4,4 @@ frontend/user_login.js frontend/federationStatusCLI.js README.md frontend/chat-ui/.env.local +backend/server.js diff --git a/scripts/list-hardcoded-urls.sh b/scripts/list-hardcoded-urls.sh index a323572..a252d9e 100755 --- a/scripts/list-hardcoded-urls.sh +++ b/scripts/list-hardcoded-urls.sh @@ -1,20 +1,38 @@ #!/usr/bin/env bash set -euo pipefail +# Flag hardcoded localhost URLs in SHIPPING RUNTIME SOURCE only — where such a URL +# is a real production bug. Deliberately out of scope (localhost is legitimate there): +# - dev tooling and smoke tests (scripts/, **/__tests__) +# - documentation (docs/, *.md) +# - local infra (infra/, docker-compose*, nginx*.conf, Dockerfile, .env.example) +# - env-driven fallbacks: `process.env.X || 'http://localhost:5000'` +# Remaining intentional cases are listed in hardcoded-url-allowlist.txt. + ALLOWLIST_FILE="$(dirname "$0")/hardcoded-url-allowlist.txt" -matches=$(grep -R --line-number --exclude-dir=node_modules --exclude-dir=.git -E "http://localhost|localhost:5000" || true) -matches=$(echo "$matches" | grep -v 'scripts/list-hardcoded-urls.sh' || true) +matches=$(grep -R --line-number \ + --include='*.js' --include='*.ts' --include='*.jsx' --include='*.tsx' \ + --exclude-dir=node_modules --exclude-dir=.git \ + --exclude-dir=__tests__ --exclude-dir=tests --exclude-dir=test \ + --exclude-dir=scripts --exclude-dir=docs --exclude-dir=infra --exclude-dir=monitoring \ + -E "http://localhost|localhost:5000" backend frontend 2>/dev/null || true) + +# Env-driven fallbacks are the correct pattern, not a hardcoded URL. +matches=$(echo "$matches" | grep -v 'process\.env' || true) if [[ -f "$ALLOWLIST_FILE" ]]; then matches=$(echo "$matches" | grep -F -v -f "$ALLOWLIST_FILE" || true) fi +# Drop any blank lines left by the filters above. +matches=$(echo "$matches" | grep -v '^[[:space:]]*$' || true) + if [[ -n "$matches" ]]; then echo "$matches" - echo "Hardcoded localhost URLs detected." + echo "Hardcoded localhost URLs detected in shipping source." exit 1 else - echo "No hardcoded localhost URLs found." + echo "No hardcoded localhost URLs found in shipping source." exit 0 fi