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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 54 additions & 0 deletions backend/migrations/20260708_economy_ledger_fee.js
Original file line number Diff line number Diff line change
@@ -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');
};
89 changes: 89 additions & 0 deletions backend/repositories/ledgerRepository.js
Original file line number Diff line number Diff line change
@@ -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 };
36 changes: 36 additions & 0 deletions backend/repositories/settingsRepository.js
Original file line number Diff line number Diff line change
@@ -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 };
26 changes: 26 additions & 0 deletions backend/routes/adminRoutes.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down Expand Up @@ -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;
48 changes: 17 additions & 31 deletions backend/routes/payments.js
Original file line number Diff line number Diff line change
@@ -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;
47 changes: 28 additions & 19 deletions backend/routes/webhook.js
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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]
Expand Down
13 changes: 10 additions & 3 deletions backend/services/contractService.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -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]);

Expand Down
Loading
Loading