[OWL] Payment escrow system + marketplace payment integration
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:
parent
69d814c439
commit
4923357e29
7 changed files with 936 additions and 24 deletions
122
supabase/migrations/006_payment_escrow.sql
Normal file
122
supabase/migrations/006_payment_escrow.sql
Normal 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);
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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() }));
|
||||
|
|
|
|||
|
|
@ -60,6 +60,49 @@
|
|||
</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">💰 Payment & 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;">₹ <%= (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;">₹ <%= 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;">₹ <%= 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">⚠ 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 ₹ <%= 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">✔ 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">✔ 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">⚠ This load has a payment dispute. Admin will review.</div>
|
||||
<% } %>
|
||||
</div>
|
||||
</div>
|
||||
<% } %>
|
||||
|
||||
<!-- Driver: Bid Section -->
|
||||
<% if (userRole === 'driver') { %>
|
||||
<div class="card mb-3">
|
||||
|
|
@ -208,6 +251,24 @@ document.getElementById('bidForm')?.addEventListener('submit', async (e) => {
|
|||
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>
|
||||
|
||||
<%- include('../partials/portal-footer') %>
|
||||
|
|
|
|||
78
webapp/src/views/pages/payments/deposit.ejs
Normal file
78
webapp/src/views/pages/payments/deposit.ejs
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
<%- include('../partials/portal-header', { activeMenu: 'payments' }) %>
|
||||
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h1 class="page-title">💰 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">← 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 (₹) *</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)">₹ 1,000</button>
|
||||
<button type="button" class="btn btn-sm btn-outline" onclick="setAmount(5000)">₹ 5,000</button>
|
||||
<button type="button" class="btn btn-sm btn-outline" onclick="setAmount(10000)">₹ 10,000</button>
|
||||
<button type="button" class="btn btn-sm btn-outline" onclick="setAmount(25000)">₹ 25,000</button>
|
||||
<button type="button" class="btn btn-sm btn-outline" onclick="setAmount(50000)">₹ 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 %> (₹ <%= 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;">
|
||||
₹ <%= ((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;">
|
||||
₹ <%= (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') %>
|
||||
101
webapp/src/views/pages/payments/index.ejs
Normal file
101
webapp/src/views/pages/payments/index.ejs
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
<%- include('../partials/portal-header', { activeMenu: 'payments' }) %>
|
||||
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h1 class="page-title">💰 Payment & 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;">
|
||||
₹ <%= ((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;">
|
||||
₹ <%= (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;">
|
||||
₹ <%= ((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;">
|
||||
₹ <%= ((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' ? '+' : '-' %>
|
||||
₹ <%= (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') %>
|
||||
112
webapp/src/views/pages/payments/payout.ejs
Normal file
112
webapp/src/views/pages/payments/payout.ejs
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
<%- include('../partials/portal-header', { activeMenu: 'payments' }) %>
|
||||
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h1 class="page-title">💰 Request Payout</h1>
|
||||
<p class="page-subtitle">Withdraw your earnings to bank account or UPI</p>
|
||||
</div>
|
||||
<a href="/escrow" class="btn btn-outline">← 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;">
|
||||
₹ <%= ((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 (₹) *</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;">₹ <%= (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') %>
|
||||
Loading…
Reference in a new issue