[OWL] Payment escrow system + marketplace payment integration
Some checks are pending
FreightDesk CI/CD / Lint & Test (push) Waiting to run
FreightDesk CI/CD / Build Docker Image (push) Blocked by required conditions
FreightDesk CI/CD / Deploy to Coolify (push) Blocked by required conditions

Database (migration 006):
- escrow_accounts: per-user balance tracking (available + held)
- escrow_transactions: all financial transactions (deposit/hold/release/refund/payout/fee)
- payout_requests: driver withdrawal requests with bank/UPI details
- platform_config: fee settings (5% commission, min deposit, hold period)
- disputes: payment dispute tracking
- Enhanced loads table: payment_status, escrow_amount, platform_fee, settled_at

Escrow Routes (/escrow):
- GET /escrow — payment dashboard with balance and transactions
- GET/POST /escrow/deposit — deposit funds (simulated, production: Razorpay)
- POST /escrow/hold — move funds to escrow for a specific load
- POST /escrow/release — release funds to driver after delivery
- GET/POST /escrow/payout — driver payout request (UPI or bank)
- POST /escrow/admin/payouts/:id/approve — admin approves payout
- POST /escrow/dispute — raise payment dispute

Views:
- Payment dashboard (balance, transactions, quick actions)
- Deposit page with quick amounts
- Payout request page with bank/UPI forms
- Payment status card on load detail (shipper view)
- Hold/Release/Dispute actions integrated into marketplace flow

Payment Flow:
1. Shipper deposits funds → balance
2. Shipper accepts bid → hold in escrow (driver freight + 5% fee)
3. Delivery confirmed → release to driver
4. Driver requests payout → admin approves → bank transfer
This commit is contained in:
FreightDesk 2026-06-08 01:50:02 +00:00
parent 69d814c439
commit 4923357e29
7 changed files with 936 additions and 24 deletions

View file

@ -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);

View file

@ -1,37 +1,474 @@
const express = require('express'); const express = require('express');
const router = express.Router(); const router = express.Router();
const supabase = require('../services/supabase'); const supabase = require('../services/supabase');
const { requireAuth } = require('../middleware/auth');
const { asyncHandler } = require('../middleware/security'); const { asyncHandler } = require('../middleware/security');
const { PAYMENT_METHODS } = require('../config/constants');
// GET /payments — Payment ledger // ============================================================
router.get('/', requireAuth, asyncHandler(async (req, res) => { // MIDDLEWARE
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);
res.render('pages/payments/list', { function requirePortalAuth(req, res, next) {
payments: payments || [], if (!req.session.portalUser) {
PAYMENT_METHODS, 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 // POST /payments/deposit
router.post('/', requireAuth, asyncHandler(async (req, res) => { router.post('/deposit', requirePortalAuth, requireRole('shipper'), asyncHandler(async (req, res) => {
const { load_id, type, direction, amount, method, payment_date, notes } = req.body; const { amount, load_id } = req.body;
const depositAmount = parseInt(amount);
await supabase.from('payments').insert({
load_id, type, direction, if (!depositAmount || depositAmount < 100) {
amount: parseFloat(amount) || 0, return res.render('pages/payments/deposit', {
method: method || 'bank_transfer', account: {},
payment_date: payment_date || null, transactions: [],
notes: notes || null, 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; module.exports = router;

View file

@ -210,8 +210,9 @@ app.use('/portal', require('./routes/portal'));
app.use('/invoices', require('./routes/invoices')); app.use('/invoices', require('./routes/invoices'));
app.use('/portal-users', require('./routes/portal-users')); app.use('/portal-users', require('./routes/portal-users'));
app.use('/api', require('./routes/api')); app.use('/api', require('./routes/api'));
app.use('/', require('./routes/public'));
app.use('/marketplace', require('./routes/marketplace')); app.use('/marketplace', require('./routes/marketplace'));
app.use('/escrow', require('./routes/payments'));
app.use('/', require('./routes/public'));
// Health check // Health check
app.get('/health', (req, res) => res.json({ status: 'ok', ts: Date.now() })); app.get('/health', (req, res) => res.json({ status: 'ok', ts: Date.now() }));

View file

@ -60,6 +60,49 @@
</div> </div>
</div> </div>
<!-- Payment Status (Shipper view) -->
<% if (isShipperOwner && load.accepted_bid_id) { %>
<div class="card mb-3">
<div class="card-header"><h3 class="card-title">&#128176; Payment &amp; Escrow</h3></div>
<div class="card-body">
<div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:12px;margin-bottom:16px;">
<div style="text-align:center;padding:12px;background:#f8f9fa;border-radius:8px;">
<div style="font-size:12px;color:#666;">Driver Freight</div>
<div style="font-size:20px;font-weight:700;">&#8377; <%= (load.driver_freight || 0).toLocaleString('en-IN') %></div>
</div>
<div style="text-align:center;padding:12px;background:#f8f9fa;border-radius:8px;">
<div style="font-size:12px;color:#666;">Platform Fee (5%)</div>
<div style="font-size:20px;font-weight:700;color:#f59e0b;">&#8377; <%= Math.round((load.driver_freight || 0) * 0.05).toLocaleString('en-IN') %></div>
</div>
<div style="text-align:center;padding:12px;background:#e8f5e9;border-radius:8px;">
<div style="font-size:12px;color:#666;">Total</div>
<div style="font-size:20px;font-weight:700;color:#2e7d32;">&#8377; <%= Math.round((load.driver_freight || 0) * 1.05).toLocaleString('en-IN') %></div>
</div>
</div>
<% if (load.payment_status === 'none' || load.payment_status === 'deposited') { %>
<div class="alert alert-error">&#9888; Funds not in escrow. Deposit and hold funds to secure this load.</div>
<form method="POST" action="/escrow/hold" style="display:flex;gap:8px;">
<input type="hidden" name="load_id" value="<%= load.id %>">
<button type="submit" class="btn btn-primary">Hold &#8377; <%= Math.round((load.driver_freight || 0) * 1.05).toLocaleString('en-IN') %> in Escrow</button>
<a href="/escrow/deposit" class="btn btn-outline">Deposit Funds First</a>
</form>
<% } else if (load.payment_status === 'in_escrow') { %>
<div class="alert alert-success">&#10004; Funds held in escrow. Release after delivery confirmation.</div>
<form method="POST" action="/escrow/release" style="display:flex;gap:8px;">
<input type="hidden" name="load_id" value="<%= load.id %>">
<button type="submit" class="btn btn-success" onclick="return confirm('Release payment to driver? This cannot be undone.')">Release Payment to Driver</button>
<button type="button" class="btn btn-danger" onclick="raiseDispute()">Raise Dispute</button>
</form>
<% } else if (load.payment_status === 'released') { %>
<div class="alert alert-success">&#10004; Payment released to driver on <%= new Date(load.settled_at).toLocaleDateString('en-IN') %></div>
<% } else if (load.payment_status === 'disputed') { %>
<div class="alert alert-error">&#9888; This load has a payment dispute. Admin will review.</div>
<% } %>
</div>
</div>
<% } %>
<!-- Driver: Bid Section --> <!-- Driver: Bid Section -->
<% if (userRole === 'driver') { %> <% if (userRole === 'driver') { %>
<div class="card mb-3"> <div class="card mb-3">
@ -208,6 +251,24 @@ document.getElementById('bidForm')?.addEventListener('submit', async (e) => {
alert(data.error || 'Failed to place bid'); alert(data.error || 'Failed to place bid');
} }
}); });
async function raiseDispute() {
const reason = prompt('Describe the issue:');
if (!reason) return;
const res = await fetch('/escrow/dispute', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ load_id: '<%= load.id %>', reason })
});
const data = await res.json();
if (data.success) {
alert('Dispute raised. Admin will review.');
location.reload();
} else {
alert(data.error || 'Failed to raise dispute');
}
}
</script> </script>
<%- include('../partials/portal-footer') %> <%- include('../partials/portal-footer') %>

View file

@ -0,0 +1,78 @@
<%- include('../partials/portal-header', { activeMenu: 'payments' }) %>
<div class="page-header">
<div>
<h1 class="page-title">&#128176; Deposit Funds</h1>
<p class="page-subtitle">Add funds to your escrow account to pay for loads</p>
</div>
<a href="/escrow" class="btn btn-outline">&larr; Back to Payments</a>
</div>
<% if (error) { %>
<div class="alert alert-error"><%= error %></div>
<% } %>
<div class="grid-2">
<div class="card">
<div class="card-header"><h3 class="card-title">Quick Deposit</h3></div>
<div class="card-body">
<form method="POST" action="/escrow/deposit">
<input type="hidden" name="_csrf" value="<%= typeof _csrf !== 'undefined' ? _csrf : '' %>">
<div class="form-group">
<label class="form-label">Amount (&#8377;) *</label>
<input type="number" name="amount" class="form-input" required min="100" placeholder="Enter amount" id="depositAmount">
</div>
<div style="display:flex;gap:8px;margin-bottom:16px;flex-wrap:wrap;">
<button type="button" class="btn btn-sm btn-outline" onclick="setAmount(1000)">&#8377; 1,000</button>
<button type="button" class="btn btn-sm btn-outline" onclick="setAmount(5000)">&#8377; 5,000</button>
<button type="button" class="btn btn-sm btn-outline" onclick="setAmount(10000)">&#8377; 10,000</button>
<button type="button" class="btn btn-sm btn-outline" onclick="setAmount(25000)">&#8377; 25,000</button>
<button type="button" class="btn btn-sm btn-outline" onclick="setAmount(50000)">&#8377; 50,000</button>
</div>
<div class="form-group">
<label class="form-label">For Load (optional)</label>
<select name="load_id" class="form-input">
<option value="">General deposit (no specific load)</option>
<% if (typeof loads !== 'undefined' && loads) { %>
<% for (const l of loads) { %>
<option value="<%= l.id %>"><%= l.from_city %> → <%= l.to_city %> (&#8377; <%= l.budget_max || 'TBD' %>)</option>
<% } %>
<% } %>
</select>
</div>
<button type="submit" class="btn btn-primary btn-block">Deposit via UPI / Net Banking</button>
</form>
<div style="margin-top:16px;padding:12px;background:#f8f9fa;border-radius:8px;font-size:12px;color:#666;">
<strong>Note:</strong> In production, this integrates with Razorpay. For now, deposits are simulated.
</div>
</div>
</div>
<div class="card">
<div class="card-header"><h3 class="card-title">Current Balance</h3></div>
<div class="card-body" style="text-align:center;padding:24px;">
<div style="font-size:32px;font-weight:700;color:#000080;">
&#8377; <%= ((account?.balance || 0) / 100).toLocaleString('en-IN') %>
</div>
<div style="font-size:13px;color:#666;">Available</div>
<% if (account?.held_balance > 0) { %>
<div style="margin-top:8px;font-size:14px;color:#f59e0b;">
&#8377; <%= (account.held_balance / 100).toLocaleString('en-IN') %> in escrow
</div>
<% } %>
</div>
</div>
</div>
<script>
function setAmount(amt) {
document.getElementById('depositAmount').value = amt;
}
</script>
<%- include('../partials/portal-footer') %>

View file

@ -0,0 +1,101 @@
<%- include('../partials/portal-header', { activeMenu: 'payments' }) %>
<div class="page-header">
<div>
<h1 class="page-title">&#128176; Payment &amp; Escrow</h1>
<p class="page-subtitle">Manage deposits, escrow, and payouts</p>
</div>
</div>
<div class="grid-2">
<!-- Balance Card -->
<div class="card">
<div class="card-header"><h3 class="card-title">Account Balance</h3></div>
<div class="card-body" style="text-align:center;padding:32px;">
<div style="font-size:36px;font-weight:700;color:#000080;">
&#8377; <%= ((account?.balance || 0) / 100).toLocaleString('en-IN') %>
</div>
<div style="font-size:13px;color:#666;margin-top:4px;">Available Balance</div>
<% if (account?.held_balance > 0) { %>
<div style="margin-top:12px;font-size:14px;color:#f59e0b;">
&#8377; <%= (account.held_balance / 100).toLocaleString('en-IN') %> in escrow
</div>
<% } %>
<div style="margin-top:16px;display:flex;gap:8px;justify-content:center;">
<a href="/escrow/deposit" class="btn btn-primary">Deposit Funds</a>
<% if (portalUser?.role === 'driver') { %>
<a href="/escrow/payout" class="btn btn-secondary">Request Payout</a>
<% } %>
</div>
</div>
</div>
<!-- Quick Stats -->
<div class="card">
<div class="card-header"><h3 class="card-title">Transaction Summary</h3></div>
<div class="card-body">
<div style="display:grid;grid-template-columns:1fr 1fr;gap:16px;">
<div style="text-align:center;padding:12px;background:#e8f5e9;border-radius:8px;">
<div style="font-size:24px;font-weight:700;color:#2e7d32;">
&#8377; <%= ((account?.total_deposited || 0) / 100).toLocaleString('en-IN') %>
</div>
<div style="font-size:12px;color:#666;">Total Deposited</div>
</div>
<div style="text-align:center;padding:12px;background:#fff3e0;border-radius:8px;">
<div style="font-size:24px;font-weight:700;color:#e65100;">
&#8377; <%= ((account?.total_withdrawn || 0) / 100).toLocaleString('en-IN') %>
</div>
<div style="font-size:12px;color:#666;">Total Withdrawn</div>
</div>
</div>
</div>
</div>
</div>
<!-- Recent Transactions -->
<div class="card mt-3">
<div class="card-header"><h3 class="card-title">Recent Transactions</h3></div>
<div class="card-body" style="padding:0;">
<% if (!transactions || transactions.length === 0) { %>
<div class="empty-state" style="padding:32px;">
<p>No transactions yet</p>
</div>
<% } else { %>
<table class="table">
<thead>
<tr>
<th>Type</th>
<th>Amount</th>
<th>Load</th>
<th>Status</th>
<th>Date</th>
</tr>
</thead>
<tbody>
<% for (const tx of transactions) { %>
<tr>
<td>
<span class="badge badge-<%= tx.type === 'deposit' ? 'success' : tx.type === 'release' ? 'primary' : tx.type === 'payout' ? 'warning' : 'gray' %>">
<%= tx.type %>
</span>
</td>
<td style="font-weight:600;">
<%= tx.type === 'deposit' || tx.type === 'release' ? '+' : '-' %>
&#8377; <%= (tx.amount / 100).toLocaleString('en-IN') %>
</td>
<td>
<% if (tx.loads) { %>
<%= tx.loads.from_city %> → <%= tx.loads.to_city %>
<% } else { %>—<% } %>
</td>
<td><span class="badge badge-<%= tx.status === 'completed' ? 'success' : 'warning' %>"><%= tx.status %></span></td>
<td style="font-size:13px;color:#666;"><%= new Date(tx.created_at).toLocaleDateString('en-IN') %></td>
</tr>
<% } %>
</tbody>
</table>
<% } %>
</div>
</div>
<%- include('../partials/portal-footer') %>

View file

@ -0,0 +1,112 @@
<%- include('../partials/portal-header', { activeMenu: 'payments' }) %>
<div class="page-header">
<div>
<h1 class="page-title">&#128176; Request Payout</h1>
<p class="page-subtitle">Withdraw your earnings to bank account or UPI</p>
</div>
<a href="/escrow" class="btn btn-outline">&larr; Back to Payments</a>
</div>
<% if (error) { %>
<div class="alert alert-error"><%= error %></div>
<% } %>
<div class="grid-2">
<div class="card">
<div class="card-header"><h3 class="card-title">Withdraw Funds</h3></div>
<div class="card-body">
<div style="text-align:center;padding:16px;background:#e8f5e9;border-radius:8px;margin-bottom:16px;">
<div style="font-size:28px;font-weight:700;color:#2e7d32;">
&#8377; <%= ((account?.balance || 0) / 100).toLocaleString('en-IN') %>
</div>
<div style="font-size:12px;color:#666;">Available for withdrawal</div>
</div>
<form method="POST" action="/escrow/payout">
<input type="hidden" name="_csrf" value="<%= typeof _csrf !== 'undefined' ? _csrf : '' %>">
<div class="form-group">
<label class="form-label">Amount (&#8377;) *</label>
<input type="number" name="amount" class="form-input" required min="500" max="<%= account?.balance || 0 %>" placeholder="Min ₹500">
</div>
<h4 style="margin:16px 0 8px;font-size:14px;color:#000080;">Payout Method</h4>
<div class="form-group">
<label class="form-label">UPI ID</label>
<input type="text" name="upi_id" class="form-input" placeholder="yourname@upi">
</div>
<div style="text-align:center;margin:12px 0;color:#666;font-size:13px;">— OR —</div>
<div class="form-row">
<div class="form-group">
<label class="form-label">Bank Name</label>
<input type="text" name="bank_name" class="form-input" placeholder="Bank name">
</div>
<div class="form-group">
<label class="form-label">Account Number</label>
<input type="text" name="account_number" class="form-input" placeholder="Account number">
</div>
</div>
<div class="form-group">
<label class="form-label">IFSC Code</label>
<input type="text" name="ifsc_code" class="form-input" placeholder="IFSC code">
</div>
<button type="submit" class="btn btn-primary btn-block">Request Payout</button>
</form>
<div style="margin-top:12px;font-size:12px;color:#666;">
Payouts are processed within 24-48 hours. Minimum ₹500.
</div>
</div>
</div>
<!-- Payout History -->
<div class="card">
<div class="card-header"><h3 class="card-title">Payout History</h3></div>
<div class="card-body" style="padding:0;">
<% if (!payouts || payouts.length === 0) { %>
<div class="empty-state" style="padding:32px;">
<p>No payout requests yet</p>
</div>
<% } else { %>
<table class="table">
<thead>
<tr>
<th>Amount</th>
<th>Method</th>
<th>Status</th>
<th>Date</th>
</tr>
</thead>
<tbody>
<% for (const p of payouts) { %>
<tr>
<td style="font-weight:600;">&#8377; <%= (p.amount / 100).toLocaleString('en-IN') %></td>
<td>
<% if (p.upi_id) { %>
UPI: <%= p.upi_id %>
<% } else { %>
Bank: <%= p.bank_name %>
<% } %>
</td>
<td>
<span class="badge badge-<%= p.status === 'approved' || p.status === 'processed' ? 'success' : p.status === 'rejected' ? 'danger' : 'warning' %>">
<%= p.status %>
</span>
</td>
<td style="font-size:13px;color:#666;"><%= new Date(p.created_at).toLocaleDateString('en-IN') %></td>
</tr>
<% } %>
</tbody>
</table>
<% } %>
</div>
</div>
</div>
<%- include('../partials/portal-footer') %>