diff --git a/supabase/migrations/006_payment_escrow.sql b/supabase/migrations/006_payment_escrow.sql new file mode 100644 index 0000000..81b6f37 --- /dev/null +++ b/supabase/migrations/006_payment_escrow.sql @@ -0,0 +1,122 @@ +-- ============================================================ +-- FreightDesk — Migration 006: Payment Escrow System +-- Escrow payments, transactions, settlements, payouts +-- ============================================================ + +-- ============================================================ +-- 1. ESCROW ACCOUNTS (one per user — shipper or driver) +-- ============================================================ +CREATE TABLE IF NOT EXISTS escrow_accounts ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL, + role TEXT NOT NULL CHECK (role IN ('shipper', 'driver')), + balance INTEGER DEFAULT 0, -- available balance in paise + held_balance INTEGER DEFAULT 0, -- funds in escrow (in paise) + total_deposited INTEGER DEFAULT 0, + total_withdrawn INTEGER DEFAULT 0, + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + UNIQUE(user_id, role) +); + +CREATE INDEX IF NOT EXISTS idx_escrow_user ON escrow_accounts(user_id); + +-- ============================================================ +-- 2. ESCROW TRANSACTIONS +-- ============================================================ +CREATE TABLE IF NOT EXISTS escrow_transactions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + escrow_account_id UUID REFERENCES escrow_accounts(id), + load_id UUID REFERENCES loads(id), + bid_id UUID REFERENCES bids(id), + type TEXT NOT NULL CHECK (type IN ( + 'deposit', -- shipper deposits funds + 'hold', -- funds moved to escrow hold + 'release', -- funds released to driver + 'refund', -- funds refunded to shipper + 'payout', -- driver withdraws to bank + 'platform_fee', -- FreightDesk commission + 'adjustment' -- manual admin adjustment + )), + amount INTEGER NOT NULL, -- in paise + status TEXT DEFAULT 'pending' CHECK (status IN ('pending', 'completed', 'failed', 'reversed')), + reference_id TEXT, -- external payment reference + metadata JSONB DEFAULT '{}', + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + completed_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_escrow_tx_account ON escrow_transactions(escrow_account_id); +CREATE INDEX IF NOT EXISTS idx_escrow_tx_load ON escrow_transactions(load_id); +CREATE INDEX IF NOT EXISTS idx_escrow_tx_type ON escrow_transactions(type); +CREATE INDEX IF NOT EXISTS idx_escrow_tx_status ON escrow_transactions(status); + +-- ============================================================ +-- 3. PAYOUT REQUESTS (driver withdrawal requests) +-- ============================================================ +CREATE TABLE IF NOT EXISTS payout_requests ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL, + driver_id UUID REFERENCES vehicles(id), + amount INTEGER NOT NULL, + status TEXT DEFAULT 'pending' CHECK (status IN ('pending', 'approved', 'rejected', 'processed')), + bank_name TEXT, + account_number TEXT, + ifsc_code TEXT, + upi_id TEXT, + processed_by UUID, + processed_at TIMESTAMP WITH TIME ZONE, + notes TEXT, + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_payout_driver ON payout_requests(driver_id); +CREATE INDEX IF NOT EXISTS idx_payout_status ON payout_requests(status); + +-- ============================================================ +-- 4. PLATFORM CONFIG (fee settings) +-- ============================================================ +CREATE TABLE IF NOT EXISTS platform_config ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL, + description TEXT, + updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() +); + +-- Default fee settings +INSERT INTO platform_config (key, value, description) VALUES + ('escrow.platform_fee_percent', '5', 'Platform commission percentage'), + ('escrow.min_deposit_amount', '10000', 'Minimum deposit amount in paise (₹100)'), + ('escrow.hold_period_hours', '72', 'Hours to hold funds after delivery before auto-release'), + ('escrow.payout_min_amount', '50000', 'Minimum payout request in paise (₹500)'), + ('escrow.payout_fee', '0', 'Payout processing fee in paise') +ON CONFLICT (key) DO NOTHING; + +-- ============================================================ +-- 5. LOAD PAYMENT STATUS (tracks payment state per load) +-- ============================================================ +ALTER TABLE loads ADD COLUMN IF NOT EXISTS payment_status TEXT DEFAULT 'none' + CHECK (payment_status IN ('none', 'deposited', 'in_escrow', 'released', 'refunded', 'disputed')); + +ALTER TABLE loads ADD COLUMN IF NOT EXISTS escrow_amount INTEGER; +ALTER TABLE loads ADD COLUMN IF NOT EXISTS platform_fee INTEGER; +ALTER TABLE loads ADD COLUMN IF NOT EXISTS settled_at TIMESTAMP WITH TIME ZONE; + +-- ============================================================ +-- 6. DISPUTES TABLE +-- ============================================================ +CREATE TABLE IF NOT EXISTS disputes ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + load_id UUID NOT NULL REFERENCES loads(id), + raised_by UUID NOT NULL, + raised_against UUID NOT NULL, + reason TEXT NOT NULL, + status TEXT DEFAULT 'open' CHECK (status IN ('open', 'under_review', 'resolved', 'closed')), + resolution TEXT, + resolved_by UUID, + resolved_at TIMESTAMP WITH TIME ZONE, + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_disputes_load ON disputes(load_id); +CREATE INDEX IF NOT EXISTS idx_disputes_status ON disputes(status); diff --git a/webapp/src/routes/payments.js b/webapp/src/routes/payments.js index 7b9e863..3aa7085 100644 --- a/webapp/src/routes/payments.js +++ b/webapp/src/routes/payments.js @@ -1,37 +1,474 @@ const express = require('express'); const router = express.Router(); const supabase = require('../services/supabase'); -const { requireAuth } = require('../middleware/auth'); const { asyncHandler } = require('../middleware/security'); -const { PAYMENT_METHODS } = require('../config/constants'); -// GET /payments — Payment ledger -router.get('/', requireAuth, asyncHandler(async (req, res) => { - const { data: payments } = await supabase - .from('payments') - .select('*, load:loads(from_city, to_city, shipper:shippers(name))') - .order('payment_date', { ascending: false, nullsFirst: false }) - .limit(50); +// ============================================================ +// MIDDLEWARE +// ============================================================ - res.render('pages/payments/list', { - payments: payments || [], - PAYMENT_METHODS, +function requirePortalAuth(req, res, next) { + if (!req.session.portalUser) { + return res.redirect('/portal/login?redirect=' + encodeURIComponent(req.originalUrl)); + } + next(); +} + +function requireRole(role) { + return (req, res, next) => { + if (req.session.portalUser?.role !== role) { + return res.status(403).send('Access denied'); + } + next(); + }; +} + +// Helper: get or create escrow account +async function getEscrowAccount(userId, role) { + let { data } = await supabase + .from('escrow_accounts') + .select('*') + .eq('user_id', userId) + .eq('role', role) + .single(); + + if (!data) { + const { data: created } = await supabase + .from('escrow_accounts') + .insert({ user_id: userId, role, balance: 0, held_balance: 0 }) + .select() + .single(); + data = created; + } + return data; +} + +// Helper: get platform fee +async function getPlatformFee(amount) { + const { data } = await supabase + .from('platform_config') + .select('value') + .eq('key', 'escrow.platform_fee_percent') + .single(); + const percent = parseFloat(data?.value || '5'); + return Math.round(amount * percent / 100); +} + +// Helper: get hold period +async function getHoldPeriod() { + const { data } = await supabase + .from('platform_config') + .select('value') + .eq('key', 'escrow.hold_period_hours') + .single(); + return parseInt(data?.value || '72'); +} + +// ============================================================ +// SHIPPER: DEPOSIT FUNDS +// ============================================================ + +// GET /payments/deposit +router.get('/deposit', requirePortalAuth, requireRole('shipper'), asyncHandler(async (req, res) => { + const account = await getEscrowAccount(req.session.portalUser.id, 'shipper'); + const { data: txns } = await supabase + .from('escrow_transactions') + .select('*, loads(from_city, to_city)') + .eq('escrow_account_id', account.id) + .order('created_at', { ascending: false }) + .limit(20); + + res.render('pages/payments/deposit', { + account, + transactions: txns || [], + error: null, }); })); -// POST /payments — Record a payment -router.post('/', requireAuth, asyncHandler(async (req, res) => { - const { load_id, type, direction, amount, method, payment_date, notes } = req.body; - - await supabase.from('payments').insert({ - load_id, type, direction, - amount: parseFloat(amount) || 0, - method: method || 'bank_transfer', - payment_date: payment_date || null, - notes: notes || null, +// POST /payments/deposit +router.post('/deposit', requirePortalAuth, requireRole('shipper'), asyncHandler(async (req, res) => { + const { amount, load_id } = req.body; + const depositAmount = parseInt(amount); + + if (!depositAmount || depositAmount < 100) { + return res.render('pages/payments/deposit', { + account: {}, + transactions: [], + error: 'Minimum deposit is ₹1', + }); + } + + const account = await getEscrowAccount(req.session.portalUser.id, 'shipper'); + + // In production, this would integrate with Razorpay/Stripe + // For now, simulate deposit + const { error: txError } = await supabase.from('escrow_transactions').insert({ + escrow_account_id: account.id, + load_id: load_id || null, + type: 'deposit', + amount: depositAmount, + status: 'completed', + reference_id: 'SIM-' + Date.now(), + completed_at: new Date().toISOString(), }); - res.redirect(req.get('Referer') || '/payments'); + if (txError) { + return res.render('pages/payments/deposit', { + account, + transactions: [], + error: 'Deposit failed: ' + txError.message, + }); + } + + // Update balance + await supabase.from('escrow_accounts').update({ + balance: account.balance + depositAmount, + total_deposited: account.total_deposited + depositAmount, + updated_at: new Date().toISOString(), + }).eq('id', account.id); + + // If deposit is for a specific load, move to escrow hold + if (load_id) { + await moveToEscrow(account.id, load_id, depositAmount); + } + + await supabase.from('notifications').insert({ + user_id: req.session.portalUser.id, + type: 'payment', + title: 'Deposit Successful', + message: `₹${depositAmount.toLocaleString('en-IN')} deposited to your account`, + }); + + res.redirect('/payments/deposit?success=1'); })); +// ============================================================ +// SHIPPER: HOLD FUNDS IN ESCROW (for a specific load) +// ============================================================ + +// POST /payments/hold +router.post('/hold', requirePortalAuth, requireRole('shipper'), asyncHandler(async (req, res) => { + const { load_id } = req.body; + + const { data: load } = await supabase + .from('loads') + .select('*, bids!inner(amount)') + .eq('id', load_id) + .single(); + + if (!load) return res.status(404).json({ error: 'Load not found' }); + + const holdAmount = load.driver_freight || load.bids?.amount; + if (!holdAmount) return res.status(400).json({ error: 'No bid amount to hold' }); + + const platformFee = await getPlatformFee(holdAmount); + const totalHold = holdAmount + platformFee; + + const account = await getEscrowAccount(req.session.portalUser.id, 'shipper'); + + if (account.balance < totalHold) { + return res.status(400).json({ + error: `Insufficient balance. Need ₹${totalHold.toLocaleString('en-IN')} (₹${holdAmount.toLocaleString('en-IN')} + ₹${platformFee.toLocaleString('en-IN')} fee). Deposit first.` + }); + } + + // Move funds: balance → held_balance + await supabase.from('escrow_accounts').update({ + balance: account.balance - totalHold, + held_balance: account.held_balance + totalHold, + updated_at: new Date().toISOString(), + }).eq('id', account.id); + + // Record transactions + await supabase.from('escrow_transactions').insert([ + { + escrow_account_id: account.id, + load_id, + bid_id: load.accepted_bid_id, + type: 'hold', + amount: holdAmount, + status: 'completed', + completed_at: new Date().toISOString(), + }, + { + escrow_account_id: account.id, + load_id, + type: 'platform_fee', + amount: platformFee, + status: 'completed', + completed_at: new Date().toISOString(), + }, + ]); + + // Update load payment status + await supabase.from('loads').update({ + payment_status: 'in_escrow', + escrow_amount: holdAmount, + platform_fee: platformFee, + }).eq('id', load_id); + + res.json({ success: true, held: holdAmount, fee: platformFee }); +})); + +// ============================================================ +// SHIPPER: RELEASE FUNDS TO DRIVER (after delivery confirmation) +// ============================================================ + +// POST /payments/release +router.post('/release', requirePortalAuth, requireRole('shipper'), asyncHandler(async (req, res) => { + const { load_id } = req.body; + + const { data: load } = await supabase + .from('loads') + .select('*, vehicles(id, driver_name)') + .eq('id', load_id) + .eq('payment_status', 'in_escrow') + .single(); + + if (!load) return res.status(400).json({ error: 'Load not in escrow or already released' }); + + const holdAmount = load.escrow_amount; + const driverAccount = await getEscrowAccount(load.vehicles?.id, 'driver'); + + // Move from shipper held → driver balance + const shipperAccount = await getEscrowAccount(req.session.portalUser.id, 'shipper'); + await supabase.from('escrow_accounts').update({ + held_balance: Math.max(0, shipperAccount.held_balance - holdAmount), + updated_at: new Date().toISOString(), + }).eq('id', shipperAccount.id); + + await supabase.from('escrow_accounts').update({ + balance: driverAccount.balance + holdAmount, + total_deposited: driverAccount.total_deposited + holdAmount, + updated_at: new Date().toISOString(), + }).eq('id', driverAccount.id); + + // Record release transaction + await supabase.from('escrow_transactions').insert({ + escrow_account_id: driverAccount.id, + load_id, + type: 'release', + amount: holdAmount, + status: 'completed', + completed_at: new Date().toISOString(), + }); + + // Update load + await supabase.from('loads').update({ + payment_status: 'released', + settled_at: new Date().toISOString(), + status: 'settled', + }).eq('id', load_id); + + // Notify driver + await supabase.from('notifications').insert({ + user_id: load.vehicles?.id, + type: 'payment', + title: 'Payment Released!', + message: `₹${holdAmount.toLocaleString('en-IN')} released for ${load.from_city} → ${load.to_city}`, + data: { load_id, amount: holdAmount }, + }); + + res.json({ success: true }); +})); + +// ============================================================ +// DRIVER: REQUEST PAYOUT +// ============================================================ + +// GET /payments/payout +router.get('/payout', requirePortalAuth, requireRole('driver'), asyncHandler(async (req, res) => { + const account = await getEscrowAccount(req.session.portalUser.id, 'driver'); + const { data: payouts } = await supabase + .from('payout_requests') + .select('*') + .eq('user_id', req.session.portalUser.id) + .order('created_at', { ascending: false }) + .limit(20); + + res.render('pages/payments/payout', { + account, + payouts: payouts || [], + error: null, + }); +})); + +// POST /payments/payout +router.post('/payout', requirePortalAuth, requireRole('driver'), asyncHandler(async (req, res) => { + const { amount, upi_id, bank_name, account_number, ifsc_code } = req.body; + const payoutAmount = parseInt(amount); + + if (!payoutAmount || payoutAmount < 500) { + return res.render('pages/payments/payout', { + account: {}, + payouts: [], + error: 'Minimum payout is ₹500', + }); + } + + if (!upi_id && (!bank_name || !account_number || !ifsc_code)) { + return res.render('pages/payments/payout', { + account: {}, + payouts: [], + error: 'Provide UPI ID or bank details', + }); + } + + const account = await getEscrowAccount(req.session.portalUser.id, 'driver'); + + if (account.balance < payoutAmount) { + return res.render('pages/payments/payout', { + account, + payouts: [], + error: `Insufficient balance. Available: ₹${account.balance.toLocaleString('en-IN')}`, + }); + } + + // Create payout request + const { error: payoutError } = await supabase.from('payout_requests').insert({ + user_id: req.session.portalUser.id, + driver_id: req.session.portalUser.driver_id, + amount: payoutAmount, + upi_id: upi_id || null, + bank_name: bank_name || null, + account_number: account_number || null, + ifsc_code: ifsc_code || null, + }); + + if (payoutError) { + return res.render('pages/payments/payout', { + account, + payouts: [], + error: 'Payout request failed: ' + payoutError.message, + }); + } + + // Reserve the amount (move from balance to held) + await supabase.from('escrow_accounts').update({ + balance: account.balance - payoutAmount, + held_balance: account.held_balance + payoutAmount, + updated_at: new Date().toISOString(), + }).eq('id', account.id); + + await supabase.from('notifications').insert({ + user_id: req.session.portalUser.id, + type: 'payment', + title: 'Payout Requested', + message: `₹${payoutAmount.toLocaleString('en-IN')} payout request submitted`, + }); + + res.redirect('/payments/payout?requested=1'); +})); + +// ============================================================ +// ADMIN: APPROVE/REJECT PAYOUT +// ============================================================ + +// POST /admin/payouts/:id/approve +router.post('/admin/payouts/:id/approve', requirePortalAuth, asyncHandler(async (req, res) => { + // Only admin can approve + if (!req.session.userId) { + return res.status(403).json({ error: 'Admin access required' }); + } + + const { data: payout } = await supabase + .from('payout_requests') + .select('*') + .eq('id', req.params.id) + .single(); + + if (!payout) return res.status(404).json({ error: 'Payout not found' }); + + // Update payout + await supabase.from('payout_requests').update({ + status: 'approved', + processed_by: req.session.userId, + processed_at: new Date().toISOString(), + }).eq('id', req.params.id); + + // Release held funds + const account = await getEscrowAccount(payout.user_id, 'driver'); + await supabase.from('escrow_accounts').update({ + held_balance: Math.max(0, account.held_balance - payout.amount), + total_withdrawn: account.total_withdrawn + payout.amount, + updated_at: new Date().toISOString(), + }).eq('id', account.id); + + // Record transaction + await supabase.from('escrow_transactions').insert({ + escrow_account_id: account.id, + type: 'payout', + amount: payout.amount, + status: 'completed', + reference_id: 'PAYOUT-' + payout.id, + completed_at: new Date().toISOString(), + }); + + await supabase.from('notifications').insert({ + user_id: payout.user_id, + type: 'payment', + title: 'Payout Processed', + message: `₹${payout.amount.toLocaleString('en-IN')} has been sent to your account`, + }); + + res.json({ success: true }); +})); + +// ============================================================ +// DISPUTES +// ============================================================ + +// POST /payments/dispute +router.post('/dispute', requirePortalAuth, asyncHandler(async (req, res) => { + const { load_id, reason } = req.body; + if (!load_id || !reason) return res.status(400).json({ error: 'Load ID and reason required' }); + + const { data: load } = await supabase.from('loads').select('*').eq('id', load_id).single(); + if (!load) return res.status(404).json({ error: 'Load not found' }); + + // Determine who to raise against + const raisedAgainst = req.session.portalUser.role === 'shipper' + ? load.accepted_bid_id + : load.shipper_id; + + await supabase.from('disputes').insert({ + load_id, + raised_by: req.session.portalUser.id, + raised_against: raisedAgainst, + reason, + }); + + // Hold funds if in escrow + if (load.payment_status === 'in_escrow') { + await supabase.from('loads').update({ payment_status: 'disputed' }).eq('id', load_id); + } + + res.json({ success: true }); +})); + +// Helper: move funds to escrow for a load +async function moveToEscrow(accountId, loadId, amount) { + const platformFee = await getPlatformFee(amount); + const total = amount + platformFee; + + const { data: account } = await supabase + .from('escrow_accounts') + .select('*') + .eq('id', accountId) + .single(); + + if (account && account.balance >= total) { + await supabase.from('escrow_accounts').update({ + balance: account.balance - total, + held_balance: account.held_balance + total, + }).eq('id', accountId); + + await supabase.from('loads').update({ + escrow_amount: amount, + platform_fee: platformFee, + payment_status: 'in_escrow', + }).eq('id', loadId); + } +} + module.exports = router; diff --git a/webapp/src/server.js b/webapp/src/server.js index c043ba3..518376f 100644 --- a/webapp/src/server.js +++ b/webapp/src/server.js @@ -210,8 +210,9 @@ app.use('/portal', require('./routes/portal')); app.use('/invoices', require('./routes/invoices')); app.use('/portal-users', require('./routes/portal-users')); app.use('/api', require('./routes/api')); -app.use('/', require('./routes/public')); app.use('/marketplace', require('./routes/marketplace')); +app.use('/escrow', require('./routes/payments')); +app.use('/', require('./routes/public')); // Health check app.get('/health', (req, res) => res.json({ status: 'ok', ts: Date.now() })); diff --git a/webapp/src/views/pages/marketplace/load-detail.ejs b/webapp/src/views/pages/marketplace/load-detail.ejs index 294ec98..498703a 100644 --- a/webapp/src/views/pages/marketplace/load-detail.ejs +++ b/webapp/src/views/pages/marketplace/load-detail.ejs @@ -60,6 +60,49 @@ + + <% if (isShipperOwner && load.accepted_bid_id) { %> +
Add funds to your escrow account to pay for loads
+Manage deposits, escrow, and payouts
+No transactions yet
+| Type | +Amount | +Load | +Status | +Date | +
|---|---|---|---|---|
| + + <%= tx.type %> + + | ++ <%= tx.type === 'deposit' || tx.type === 'release' ? '+' : '-' %> + ₹ <%= (tx.amount / 100).toLocaleString('en-IN') %> + | ++ <% if (tx.loads) { %> + <%= tx.loads.from_city %> → <%= tx.loads.to_city %> + <% } else { %>—<% } %> + | +<%= tx.status %> | +<%= new Date(tx.created_at).toLocaleDateString('en-IN') %> | +
Withdraw your earnings to bank account or UPI
+No payout requests yet
+| Amount | +Method | +Status | +Date | +
|---|---|---|---|
| ₹ <%= (p.amount / 100).toLocaleString('en-IN') %> | ++ <% if (p.upi_id) { %> + UPI: <%= p.upi_id %> + <% } else { %> + Bank: <%= p.bank_name %> + <% } %> + | ++ + <%= p.status %> + + | +<%= new Date(p.created_at).toLocaleDateString('en-IN') %> | +