281 lines
8.5 KiB
JavaScript
281 lines
8.5 KiB
JavaScript
|
|
/**
|
||
|
|
* quiXzoom Frilans Payout — API Routes
|
||
|
|
* REST endpoints for multi-market payout
|
||
|
|
*/
|
||
|
|
|
||
|
|
import { Router } from 'express';
|
||
|
|
import { z } from 'zod';
|
||
|
|
import { getMarket, listMarkets, calculatePayout } from './markets.mjs';
|
||
|
|
import * as db from './db.mjs';
|
||
|
|
|
||
|
|
const router = Router();
|
||
|
|
|
||
|
|
// ── Validation Schemas ──────────────────────────────────────────────────────
|
||
|
|
|
||
|
|
const registerSchema = z.object({
|
||
|
|
country: z.string().length(2),
|
||
|
|
tax_handling: z.enum(['self', 'platform']),
|
||
|
|
personal_number: z.string().optional(),
|
||
|
|
payout_method: z.enum(['frilans_finans', 'stripe_connect', 'bank_transfer']),
|
||
|
|
});
|
||
|
|
|
||
|
|
const calculateSchema = z.object({
|
||
|
|
amount: z.number().positive(),
|
||
|
|
country: z.string().length(2),
|
||
|
|
tax_handling: z.enum(['self', 'platform']),
|
||
|
|
});
|
||
|
|
|
||
|
|
const payoutSchema = z.object({
|
||
|
|
amount: z.number().positive(),
|
||
|
|
country: z.string().length(2),
|
||
|
|
method: z.enum(['frilans_finans', 'stripe_connect', 'bank_transfer']),
|
||
|
|
});
|
||
|
|
|
||
|
|
// ── Middleware ──────────────────────────────────────────────────────────────
|
||
|
|
|
||
|
|
function validate(schema) {
|
||
|
|
return (req, res, next) => {
|
||
|
|
try {
|
||
|
|
schema.parse(req.body);
|
||
|
|
next();
|
||
|
|
} catch (error) {
|
||
|
|
res.status(400).json({ error: 'Validation failed', details: error.errors });
|
||
|
|
}
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
// ── Routes ──────────────────────────────────────────────────────────────────
|
||
|
|
|
||
|
|
// GET /api/aamos/frilans/markets
|
||
|
|
router.get('/markets', (req, res) => {
|
||
|
|
const markets = listMarkets().map(m => ({
|
||
|
|
country: m.country,
|
||
|
|
currency: m.currency,
|
||
|
|
language: m.language,
|
||
|
|
tax_rate: m.tax_rate,
|
||
|
|
platform_fee_pct: m.platform_fee_pct,
|
||
|
|
processing_fee_pct: m.processing_fee_pct,
|
||
|
|
min_payout: m.min_payout,
|
||
|
|
payout_schedule: m.payout_schedule,
|
||
|
|
frilans_available: m.frilans_available,
|
||
|
|
stripe_connect_available: m.stripe_connect_available,
|
||
|
|
bank_transfer_available: m.bank_transfer_available,
|
||
|
|
}));
|
||
|
|
|
||
|
|
res.json({ markets });
|
||
|
|
});
|
||
|
|
|
||
|
|
// GET /api/aamos/frilans/markets/:country
|
||
|
|
router.get('/markets/:country', (req, res) => {
|
||
|
|
const market = getMarket(req.params.country);
|
||
|
|
if (!market) {
|
||
|
|
return res.status(404).json({ error: 'Market not found' });
|
||
|
|
}
|
||
|
|
res.json({ market });
|
||
|
|
});
|
||
|
|
|
||
|
|
// POST /api/aamos/frilans/register
|
||
|
|
router.post('/register', validate(registerSchema), async (req, res) => {
|
||
|
|
try {
|
||
|
|
const { country, tax_handling, personal_number, payout_method } = req.body;
|
||
|
|
const userId = req.user?.id; // From auth middleware
|
||
|
|
|
||
|
|
const market = getMarket(country);
|
||
|
|
if (!market) {
|
||
|
|
return res.status(400).json({ error: 'Market not supported' });
|
||
|
|
}
|
||
|
|
|
||
|
|
// Check if method is available
|
||
|
|
if (payout_method === 'frilans_finans' && !market.frilans_available) {
|
||
|
|
return res.status(400).json({ error: 'Frilans not available in this market' });
|
||
|
|
}
|
||
|
|
|
||
|
|
const account = await db.createAccount({
|
||
|
|
userId,
|
||
|
|
country: market.country,
|
||
|
|
currency: market.currency,
|
||
|
|
taxHandling: tax_handling,
|
||
|
|
personalNumber: personal_number,
|
||
|
|
payoutMethod: payout_method,
|
||
|
|
});
|
||
|
|
|
||
|
|
res.status(201).json({
|
||
|
|
account_id: account.id,
|
||
|
|
status: account.status,
|
||
|
|
market: {
|
||
|
|
country: market.country,
|
||
|
|
currency: market.currency,
|
||
|
|
},
|
||
|
|
tax_rate: market.tax_rate,
|
||
|
|
platform_fee_pct: market.platform_fee_pct,
|
||
|
|
processing_fee_pct: market.processing_fee_pct,
|
||
|
|
});
|
||
|
|
} catch (error) {
|
||
|
|
res.status(500).json({ error: error.message });
|
||
|
|
}
|
||
|
|
});
|
||
|
|
|
||
|
|
// GET /api/aamos/frilans/account
|
||
|
|
router.get('/account', async (req, res) => {
|
||
|
|
try {
|
||
|
|
const userId = req.user?.id;
|
||
|
|
const account = await db.getAccountByUserId(userId);
|
||
|
|
|
||
|
|
if (!account) {
|
||
|
|
return res.status(404).json({ error: 'Account not found' });
|
||
|
|
}
|
||
|
|
|
||
|
|
res.json({ account });
|
||
|
|
} catch (error) {
|
||
|
|
res.status(500).json({ error: error.message });
|
||
|
|
}
|
||
|
|
});
|
||
|
|
|
||
|
|
// POST /api/aamos/frilans/calculate
|
||
|
|
router.post('/calculate', validate(calculateSchema), (req, res) => {
|
||
|
|
try {
|
||
|
|
const { amount, country, tax_handling } = req.body;
|
||
|
|
|
||
|
|
const market = getMarket(country);
|
||
|
|
if (!market) {
|
||
|
|
return res.status(400).json({ error: 'Market not supported' });
|
||
|
|
}
|
||
|
|
|
||
|
|
const calculation = calculatePayout(amount, market, tax_handling);
|
||
|
|
|
||
|
|
res.json({
|
||
|
|
...calculation,
|
||
|
|
breakdown: [
|
||
|
|
{ label: 'Gross Amount', amount: calculation.gross_amount, type: 'net' },
|
||
|
|
{ label: `Platform Fee (${(calculation.platform_fee_pct * 100).toFixed(0)}%)`, amount: calculation.platform_fee, type: 'fee' },
|
||
|
|
{ label: `Processing Fee (${(calculation.processing_fee_pct * 100).toFixed(1)}%)`, amount: calculation.processing_fee, type: 'fee' },
|
||
|
|
...(calculation.tax_amount > 0 ? [{
|
||
|
|
label: `Withholding Tax (${(calculation.tax_rate * 100).toFixed(0)}%)`,
|
||
|
|
amount: calculation.tax_amount,
|
||
|
|
type: 'tax',
|
||
|
|
}] : []),
|
||
|
|
{ label: 'Net Amount', amount: calculation.net_amount, type: 'net' },
|
||
|
|
],
|
||
|
|
});
|
||
|
|
} catch (error) {
|
||
|
|
res.status(500).json({ error: error.message });
|
||
|
|
}
|
||
|
|
});
|
||
|
|
|
||
|
|
// POST /api/aamos/frilans/payout
|
||
|
|
router.post('/payout', validate(payoutSchema), async (req, res) => {
|
||
|
|
try {
|
||
|
|
const { amount, country, method } = req.body;
|
||
|
|
const userId = req.user?.id;
|
||
|
|
|
||
|
|
const account = await db.getAccountByUserId(userId);
|
||
|
|
if (!account) {
|
||
|
|
return res.status(404).json({ error: 'Account not found' });
|
||
|
|
}
|
||
|
|
|
||
|
|
const market = getMarket(country);
|
||
|
|
if (!market) {
|
||
|
|
return res.status(400).json({ error: 'Market not supported' });
|
||
|
|
}
|
||
|
|
|
||
|
|
// Calculate
|
||
|
|
const calculation = calculatePayout(amount, market, account.tax_handling);
|
||
|
|
|
||
|
|
// Schedule date
|
||
|
|
const scheduledDate = getNextPayoutDate(market.payout_schedule);
|
||
|
|
|
||
|
|
// Create payout
|
||
|
|
const payout = await db.createPayout({
|
||
|
|
accountId: account.id,
|
||
|
|
amount: calculation.gross_amount,
|
||
|
|
netAmount: calculation.net_amount,
|
||
|
|
currency: calculation.currency,
|
||
|
|
platformFee: calculation.platform_fee,
|
||
|
|
processingFee: calculation.processing_fee,
|
||
|
|
taxAmount: calculation.tax_amount,
|
||
|
|
method,
|
||
|
|
scheduledDate,
|
||
|
|
});
|
||
|
|
|
||
|
|
res.status(201).json({
|
||
|
|
payout_id: payout.id,
|
||
|
|
status: payout.status,
|
||
|
|
amount: payout.amount,
|
||
|
|
net_amount: payout.net_amount,
|
||
|
|
currency: payout.currency,
|
||
|
|
scheduled_date: payout.scheduled_date,
|
||
|
|
method: payout.method,
|
||
|
|
});
|
||
|
|
} catch (error) {
|
||
|
|
res.status(500).json({ error: error.message });
|
||
|
|
}
|
||
|
|
});
|
||
|
|
|
||
|
|
// GET /api/aamos/frilans/payouts
|
||
|
|
router.get('/payouts', async (req, res) => {
|
||
|
|
try {
|
||
|
|
const userId = req.user?.id;
|
||
|
|
const account = await db.getAccountByUserId(userId);
|
||
|
|
|
||
|
|
if (!account) {
|
||
|
|
return res.status(404).json({ error: 'Account not found' });
|
||
|
|
}
|
||
|
|
|
||
|
|
const payouts = await db.listPayouts(account.id);
|
||
|
|
res.json({ payouts });
|
||
|
|
} catch (error) {
|
||
|
|
res.status(500).json({ error: error.message });
|
||
|
|
}
|
||
|
|
});
|
||
|
|
|
||
|
|
// GET /api/aamos/frilans/tax-summary
|
||
|
|
router.get('/tax-summary', async (req, res) => {
|
||
|
|
try {
|
||
|
|
const userId = req.user?.id;
|
||
|
|
const year = parseInt(req.query.year) || new Date().getFullYear();
|
||
|
|
|
||
|
|
const account = await db.getAccountByUserId(userId);
|
||
|
|
if (!account) {
|
||
|
|
return res.status(404).json({ error: 'Account not found' });
|
||
|
|
}
|
||
|
|
|
||
|
|
const summary = await db.getTaxSummary(account.id, year);
|
||
|
|
const market = getMarket(account.country);
|
||
|
|
|
||
|
|
res.json({
|
||
|
|
year,
|
||
|
|
country: account.country,
|
||
|
|
currency: account.currency,
|
||
|
|
...summary,
|
||
|
|
tax_rate: market?.tax_rate || 0,
|
||
|
|
});
|
||
|
|
} catch (error) {
|
||
|
|
res.status(500).json({ error: error.message });
|
||
|
|
}
|
||
|
|
});
|
||
|
|
|
||
|
|
// ── Helpers ─────────────────────────────────────────────────────────────────
|
||
|
|
|
||
|
|
function getNextPayoutDate(schedule) {
|
||
|
|
const now = new Date();
|
||
|
|
const date = new Date(now);
|
||
|
|
|
||
|
|
switch (schedule) {
|
||
|
|
case 'weekly':
|
||
|
|
const daysUntilFriday = (5 - now.getDay() + 7) % 7;
|
||
|
|
date.setDate(now.getDate() + (daysUntilFriday === 0 ? 7 : daysUntilFriday));
|
||
|
|
break;
|
||
|
|
case 'biweekly':
|
||
|
|
date.setDate(now.getDate() + 14);
|
||
|
|
break;
|
||
|
|
case 'monthly':
|
||
|
|
date.setMonth(now.getMonth() + 1);
|
||
|
|
date.setDate(0);
|
||
|
|
break;
|
||
|
|
}
|
||
|
|
|
||
|
|
return date.toISOString().split('T')[0];
|
||
|
|
}
|
||
|
|
|
||
|
|
export default router;
|