[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 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;
|
||||||
|
|
|
||||||
|
|
@ -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() }));
|
||||||
|
|
|
||||||
|
|
@ -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">💰 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 -->
|
<!-- 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') %>
|
||||||
|
|
|
||||||
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