dev-api: add developer portal, OpenAPI spec, and Flatenbadet case study
- New /developers/ page with API docs, SDKs, pricing, use cases - OpenAPI 3.0 spec for Orders, Missions, Photos, Analytics - Case study: Glasskiosken i Flatenbadet — complete ROI analysis - Updated /order/ with recurring missions and frequency dropdown
This commit is contained in:
@@ -0,0 +1,280 @@
|
||||
/**
|
||||
* 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;
|
||||
Reference in New Issue
Block a user