initial commit: rocket.new export of broadcastbeat
This commit is contained in:
43
src/app/api/accounting/clients/route.ts
Normal file
43
src/app/api/accounting/clients/route.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { createClient } from '@/lib/supabase/server';
|
||||
import { requireRmpAdmin } from '@/lib/accounting/rmpService';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const auth = await requireRmpAdmin();
|
||||
if (!auth) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
|
||||
const supabase = await createClient();
|
||||
const { searchParams } = new URL(request.url);
|
||||
const status = searchParams.get('status') ?? 'active';
|
||||
const search = searchParams.get('search') ?? '';
|
||||
|
||||
let query = supabase.from('rmp_clients').select('*').order('company_name');
|
||||
if (status && status !== 'all') query = query.eq('status', status);
|
||||
if (search) query = query.ilike('company_name', `%${search}%`);
|
||||
|
||||
const { data, error } = await query;
|
||||
if (error) return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
|
||||
return NextResponse.json({ clients: data ?? [] });
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const auth = await requireRmpAdmin();
|
||||
if (!auth) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
|
||||
const supabase = await createClient();
|
||||
const body = await request.json();
|
||||
|
||||
const { data, error } = await supabase.from('rmp_clients').insert({
|
||||
company_name: body.company_name,
|
||||
contact_name: body.contact_name,
|
||||
contact_email: body.contact_email,
|
||||
contact_phone: body.contact_phone,
|
||||
billing_address: body.billing_address,
|
||||
notes: body.notes,
|
||||
status: 'active',
|
||||
}).select().single();
|
||||
|
||||
if (error) return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
return NextResponse.json({ client: data });
|
||||
}
|
||||
19
src/app/api/accounting/commissions/[id]/mark-paid/route.ts
Normal file
19
src/app/api/accounting/commissions/[id]/mark-paid/route.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { createClient } from '@/lib/supabase/server';
|
||||
import { requireRmpAdmin } from '@/lib/accounting/rmpService';
|
||||
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const auth = await requireRmpAdmin();
|
||||
if (!auth) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
|
||||
const { id } = await params;
|
||||
const supabase = await createClient();
|
||||
|
||||
const { error } = await supabase.from('rmp_commissions').update({ paid: true, paid_date: new Date().toISOString().split('T')[0] }).eq('id', id);
|
||||
if (error) return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
26
src/app/api/accounting/commissions/route.ts
Normal file
26
src/app/api/accounting/commissions/route.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { createClient } from '@/lib/supabase/server';
|
||||
import { requireRmpAdmin } from '@/lib/accounting/rmpService';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const auth = await requireRmpAdmin();
|
||||
if (!auth) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
|
||||
const supabase = await createClient();
|
||||
const { searchParams } = new URL(request.url);
|
||||
const year = parseInt(searchParams.get('year') ?? String(new Date().getFullYear()));
|
||||
const staff_id = searchParams.get('staff_id');
|
||||
|
||||
let query = supabase
|
||||
.from('rmp_commissions')
|
||||
.select('*, rmp_sales_staff(full_name), rmp_invoices(invoice_number)')
|
||||
.eq('year', year)
|
||||
.order('created_at', { ascending: false });
|
||||
|
||||
if (staff_id) query = query.eq('staff_id', staff_id);
|
||||
|
||||
const { data, error } = await query;
|
||||
if (error) return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
|
||||
return NextResponse.json({ commissions: data ?? [] });
|
||||
}
|
||||
39
src/app/api/accounting/commissions/summary/route.ts
Normal file
39
src/app/api/accounting/commissions/summary/route.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { createClient } from '@/lib/supabase/server';
|
||||
import { requireRmpAdmin } from '@/lib/accounting/rmpService';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const auth = await requireRmpAdmin();
|
||||
if (!auth) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
|
||||
const supabase = await createClient();
|
||||
const { searchParams } = new URL(request.url);
|
||||
const year = parseInt(searchParams.get('year') ?? String(new Date().getFullYear()));
|
||||
|
||||
const { data: staff } = await supabase.from('rmp_sales_staff').select('id, full_name').eq('status', 'active');
|
||||
const { data: tiers } = await supabase.from('rmp_commission_tiers').select('*').eq('effective_year', year).order('tier_order');
|
||||
const { data: comms } = await supabase.from('rmp_commissions').select('*').eq('year', year);
|
||||
|
||||
const summary = (staff ?? []).map((s: any) => {
|
||||
const staffComms = (comms ?? []).filter((c: any) => c.staff_id === s.id);
|
||||
const staffTiers = (tiers ?? []).filter((t: any) => t.staff_id === s.id);
|
||||
const ytd_sales_cents = staffComms.reduce((sum: number, c: any) => sum + (c.sale_amount_cents ?? 0), 0);
|
||||
const earned_cents = staffComms.reduce((sum: number, c: any) => sum + (c.commission_amount_cents ?? 0), 0);
|
||||
const outstanding_cents = staffComms.filter((c: any) => !c.paid).reduce((sum: number, c: any) => sum + (c.commission_amount_cents ?? 0), 0);
|
||||
|
||||
// Find current tier
|
||||
let current_tier = 'Tier 1';
|
||||
let next_threshold_cents = null;
|
||||
for (const tier of staffTiers) {
|
||||
if (!tier.threshold_cents || ytd_sales_cents < tier.threshold_cents) {
|
||||
current_tier = `Tier ${tier.tier_order} (${(tier.rate * 100).toFixed(0)}%)`;
|
||||
next_threshold_cents = tier.threshold_cents;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return { staff_id: s.id, full_name: s.full_name, ytd_sales_cents, earned_cents, outstanding_cents, current_tier, next_threshold_cents };
|
||||
});
|
||||
|
||||
return NextResponse.json({ summary });
|
||||
}
|
||||
88
src/app/api/accounting/dashboard/route.ts
Normal file
88
src/app/api/accounting/dashboard/route.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { createClient } from '@/lib/supabase/server';
|
||||
import { requireRmpAdmin } from '@/lib/accounting/rmpService';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const auth = await requireRmpAdmin();
|
||||
if (!auth) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
|
||||
const supabase = await createClient();
|
||||
const now = new Date();
|
||||
const yearStart = `${now.getFullYear()}-01-01`;
|
||||
const monthStart = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-01`;
|
||||
|
||||
// Revenue YTD (paid invoices)
|
||||
const { data: revenueYtd } = await supabase
|
||||
.from('rmp_invoices')
|
||||
.select('amount_cents')
|
||||
.eq('status', 'paid')
|
||||
.gte('paid_date', yearStart);
|
||||
|
||||
const revenue_ytd = (revenueYtd ?? []).reduce((s: number, r: any) => s + (r.amount_cents ?? 0), 0);
|
||||
|
||||
// Expenses YTD
|
||||
const { data: expensesYtd } = await supabase
|
||||
.from('rmp_expenses')
|
||||
.select('amount_cents')
|
||||
.gte('expense_date', yearStart);
|
||||
|
||||
const expenses_ytd = (expensesYtd ?? []).reduce((s: number, r: any) => s + (r.amount_cents ?? 0), 0);
|
||||
|
||||
// Outstanding invoices
|
||||
const { data: outstanding } = await supabase
|
||||
.from('rmp_invoices')
|
||||
.select('amount_cents')
|
||||
.in('status', ['pending', 'overdue']);
|
||||
|
||||
const outstanding_total = (outstanding ?? []).reduce((s: number, r: any) => s + (r.amount_cents ?? 0), 0);
|
||||
|
||||
// Unreconciled transactions
|
||||
const { count: unreconciled } = await supabase
|
||||
.from('rmp_bank_transactions')
|
||||
.select('*', { count: 'exact', head: true })
|
||||
.eq('reconciled', false);
|
||||
|
||||
// Commissions outstanding
|
||||
const { data: commOutstanding } = await supabase
|
||||
.from('rmp_commissions')
|
||||
.select('commission_amount_cents')
|
||||
.eq('paid', false);
|
||||
|
||||
const commissions_outstanding = (commOutstanding ?? []).reduce((s: number, r: any) => s + (r.commission_amount_cents ?? 0), 0);
|
||||
|
||||
// Site revenue breakdown
|
||||
const { data: siteInvoices } = await supabase
|
||||
.from('rmp_invoices')
|
||||
.select('site, amount_cents, status, paid_date')
|
||||
.gte('created_at', yearStart);
|
||||
|
||||
const sites = ['broadcastbeat', 'avbeat', 'backlotbeat', 'broadcastengineering'];
|
||||
const siteRevenue = sites.map(site => {
|
||||
const siteData = (siteInvoices ?? []).filter((i: any) => i.site === site);
|
||||
const revenue_ytd = siteData.filter((i: any) => i.status === 'paid').reduce((s: number, r: any) => s + (r.amount_cents ?? 0), 0);
|
||||
const revenue_mtd = siteData.filter((i: any) => i.status === 'paid' && i.paid_date >= monthStart).reduce((s: number, r: any) => s + (r.amount_cents ?? 0), 0);
|
||||
const outstanding = siteData.filter((i: any) => ['pending', 'overdue'].includes(i.status)).reduce((s: number, r: any) => s + (r.amount_cents ?? 0), 0);
|
||||
return { site, revenue_ytd, revenue_mtd, outstanding, active_orders: 0 };
|
||||
});
|
||||
|
||||
// Top clients
|
||||
const { data: clientInvoices } = await supabase
|
||||
.from('rmp_invoices')
|
||||
.select('client_id, amount_cents, rmp_clients(company_name)')
|
||||
.eq('status', 'paid')
|
||||
.gte('paid_date', yearStart);
|
||||
|
||||
const clientMap: Record<string, any> = {};
|
||||
for (const inv of (clientInvoices ?? [])) {
|
||||
if (!inv.client_id) continue;
|
||||
if (!clientMap[inv.client_id]) clientMap[inv.client_id] = { id: inv.client_id, company_name: (inv as any).rmp_clients?.company_name, revenue_ytd: 0, active_orders: 0 };
|
||||
clientMap[inv.client_id].revenue_ytd += inv.amount_cents ?? 0;
|
||||
}
|
||||
const topClients = Object.values(clientMap).sort((a: any, b: any) => b.revenue_ytd - a.revenue_ytd).slice(0, 5);
|
||||
|
||||
return NextResponse.json({
|
||||
stats: { revenue_ytd, expenses_ytd, net_profit: revenue_ytd - expenses_ytd, outstanding: outstanding_total, unreconciled: unreconciled ?? 0, commissions_outstanding },
|
||||
siteRevenue,
|
||||
topClients,
|
||||
});
|
||||
}
|
||||
42
src/app/api/accounting/documents/route.ts
Normal file
42
src/app/api/accounting/documents/route.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { createClient } from '@/lib/supabase/server';
|
||||
import { requireRmpAdmin } from '@/lib/accounting/rmpService';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const auth = await requireRmpAdmin();
|
||||
if (!auth) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
|
||||
const supabase = await createClient();
|
||||
const { searchParams } = new URL(request.url);
|
||||
const type = searchParams.get('type');
|
||||
const year = searchParams.get('year');
|
||||
|
||||
let query = supabase.from('rmp_documents').select('*').order('uploaded_at', { ascending: false });
|
||||
if (type) query = query.eq('document_type', type);
|
||||
if (year) query = query.eq('year', parseInt(year));
|
||||
|
||||
const { data, error } = await query;
|
||||
if (error) return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
|
||||
return NextResponse.json({ documents: data ?? [] });
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const auth = await requireRmpAdmin();
|
||||
if (!auth) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
|
||||
const supabase = await createClient();
|
||||
const body = await request.json();
|
||||
|
||||
const { data, error } = await supabase.from('rmp_documents').insert({
|
||||
document_type: body.document_type,
|
||||
title: body.title,
|
||||
year: body.year || null,
|
||||
person: body.person || null,
|
||||
file_url: body.file_url || null,
|
||||
notes: body.notes || null,
|
||||
}).select().single();
|
||||
|
||||
if (error) return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
return NextResponse.json({ document: data });
|
||||
}
|
||||
44
src/app/api/accounting/expenses/route.ts
Normal file
44
src/app/api/accounting/expenses/route.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { createClient } from '@/lib/supabase/server';
|
||||
import { requireRmpAdmin } from '@/lib/accounting/rmpService';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const auth = await requireRmpAdmin();
|
||||
if (!auth) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
|
||||
const supabase = await createClient();
|
||||
const { searchParams } = new URL(request.url);
|
||||
const category = searchParams.get('category');
|
||||
const reconciled = searchParams.get('reconciled');
|
||||
|
||||
let query = supabase.from('rmp_expenses').select('*').order('expense_date', { ascending: false });
|
||||
if (category) query = query.eq('category', category);
|
||||
if (reconciled !== null && reconciled !== '') query = query.eq('reconciled', reconciled === 'true');
|
||||
|
||||
const { data, error } = await query;
|
||||
if (error) return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
|
||||
return NextResponse.json({ expenses: data ?? [] });
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const auth = await requireRmpAdmin();
|
||||
if (!auth) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
|
||||
const supabase = await createClient();
|
||||
const body = await request.json();
|
||||
|
||||
const { data, error } = await supabase.from('rmp_expenses').insert({
|
||||
vendor: body.vendor || null,
|
||||
amount_cents: body.amount_cents,
|
||||
currency: body.currency ?? 'USD',
|
||||
category: body.category,
|
||||
description: body.description || null,
|
||||
expense_date: body.expense_date,
|
||||
receipt_source: body.receipt_source ?? 'manual',
|
||||
reconciled: false,
|
||||
}).select().single();
|
||||
|
||||
if (error) return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
return NextResponse.json({ expense: data });
|
||||
}
|
||||
29
src/app/api/accounting/invoices/[id]/mark-paid/route.ts
Normal file
29
src/app/api/accounting/invoices/[id]/mark-paid/route.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
import { requireRmpAdmin, markInvoicePaid } from '@/lib/accounting/rmpService';
|
||||
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const auth = await requireRmpAdmin();
|
||||
if (!auth) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
|
||||
const { id } = await params;
|
||||
const body = await request.json();
|
||||
|
||||
const result = await markInvoicePaid(id, body.method, body.paid_date, body.wire_reference, body.stripe_payment_id);
|
||||
|
||||
if (!result.ok) return NextResponse.json({ error: result.error }, { status: 400 });
|
||||
|
||||
// Notify
|
||||
try {
|
||||
await fetch(`${process.env.NEXT_PUBLIC_SITE_URL}/api/accounting/notify-payment`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ invoice_id: id }),
|
||||
});
|
||||
} catch {}
|
||||
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
51
src/app/api/accounting/invoices/route.ts
Normal file
51
src/app/api/accounting/invoices/route.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { createClient } from '@/lib/supabase/server';
|
||||
import { requireRmpAdmin, generateInvoiceNumber } from '@/lib/accounting/rmpService';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const auth = await requireRmpAdmin();
|
||||
if (!auth) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
|
||||
const supabase = await createClient();
|
||||
const { searchParams } = new URL(request.url);
|
||||
const status = searchParams.get('status');
|
||||
|
||||
let query = supabase
|
||||
.from('rmp_invoices')
|
||||
.select('*, rmp_clients(company_name), rmp_orders(internal_order_number), rmp_sales_staff(full_name)')
|
||||
.order('created_at', { ascending: false });
|
||||
|
||||
if (status) query = query.eq('status', status);
|
||||
|
||||
const { data, error } = await query;
|
||||
if (error) return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
|
||||
return NextResponse.json({ invoices: data ?? [] });
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const auth = await requireRmpAdmin();
|
||||
if (!auth) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
|
||||
const supabase = await createClient();
|
||||
const body = await request.json();
|
||||
const invoice_number = await generateInvoiceNumber();
|
||||
|
||||
const { data, error } = await supabase.from('rmp_invoices').insert({
|
||||
order_id: body.order_id || null,
|
||||
client_id: body.client_id || null,
|
||||
staff_id: body.staff_id || null,
|
||||
invoice_number,
|
||||
site: body.site,
|
||||
amount_cents: body.amount_cents,
|
||||
currency: body.currency ?? 'USD',
|
||||
status: 'pending',
|
||||
issue_date: body.issue_date || new Date().toISOString().split('T')[0],
|
||||
due_date: body.due_date || null,
|
||||
is_staff_sale: body.is_staff_sale ?? false,
|
||||
notes: body.notes || null,
|
||||
}).select().single();
|
||||
|
||||
if (error) return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
return NextResponse.json({ invoice: data });
|
||||
}
|
||||
38
src/app/api/accounting/notify-payment/route.ts
Normal file
38
src/app/api/accounting/notify-payment/route.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { createClient } from '@/lib/supabase/server';
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const supabase = await createClient();
|
||||
const body = await request.json();
|
||||
const { invoice_id } = body;
|
||||
|
||||
const notifyEmail = process.env.NOTIFY_EMAIL;
|
||||
const notifyPhone = process.env.NOTIFY_PHONE;
|
||||
|
||||
let message = 'Payment received';
|
||||
if (invoice_id) {
|
||||
const { data: inv } = await supabase.from('rmp_invoices').select('invoice_number, amount_cents').eq('id', invoice_id).single();
|
||||
if (inv) message = `Payment received for invoice ${inv.invoice_number}: $${((inv.amount_cents ?? 0) / 100).toFixed(2)}`;
|
||||
}
|
||||
|
||||
// Log notification
|
||||
await supabase.from('rmp_notifications').insert({ type: 'payment_notify', message, invoice_id: invoice_id ?? null });
|
||||
|
||||
// Email notification (stub — SMTP configured)
|
||||
if (notifyEmail) {
|
||||
try {
|
||||
await fetch(`${process.env.NEXT_PUBLIC_SITE_URL}/api/marketplace/send-email`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ to: notifyEmail, subject: 'RMP Payment Received', text: message }),
|
||||
});
|
||||
} catch {}
|
||||
}
|
||||
|
||||
// SMS via Twilio — stub until credentials added
|
||||
if (notifyPhone && process.env.TWILIO_ACCOUNT_SID) {
|
||||
// TODO: Add Twilio SMS when credentials are configured
|
||||
}
|
||||
|
||||
return NextResponse.json({ ok: true, message });
|
||||
}
|
||||
59
src/app/api/accounting/orders/route.ts
Normal file
59
src/app/api/accounting/orders/route.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { createClient } from '@/lib/supabase/server';
|
||||
import { requireRmpAdmin, generateOrderNumber } from '@/lib/accounting/rmpService';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const auth = await requireRmpAdmin();
|
||||
if (!auth) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
|
||||
const supabase = await createClient();
|
||||
const { searchParams } = new URL(request.url);
|
||||
const site = searchParams.get('site');
|
||||
const status = searchParams.get('status');
|
||||
const client = searchParams.get('client');
|
||||
|
||||
let query = supabase
|
||||
.from('rmp_orders')
|
||||
.select('*, rmp_clients(company_name), rmp_products(name), rmp_sales_staff(full_name)')
|
||||
.order('created_at', { ascending: false });
|
||||
|
||||
if (site) query = query.eq('site', site);
|
||||
if (status) query = query.eq('status', status);
|
||||
if (client) query = query.ilike('rmp_clients.company_name', `%${client}%`);
|
||||
|
||||
const { data, error } = await query;
|
||||
if (error) return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
|
||||
return NextResponse.json({ orders: data ?? [] });
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const auth = await requireRmpAdmin();
|
||||
if (!auth) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
|
||||
const supabase = await createClient();
|
||||
const body = await request.json();
|
||||
const internal_order_number = await generateOrderNumber();
|
||||
|
||||
const { data, error } = await supabase.from('rmp_orders').insert({
|
||||
client_id: body.client_id || null,
|
||||
staff_id: body.staff_id || null,
|
||||
po_number: body.po_number || null,
|
||||
internal_order_number,
|
||||
site: body.site,
|
||||
product_id: body.product_id || null,
|
||||
ad_unit: body.ad_unit || null,
|
||||
description: body.description || null,
|
||||
start_date: body.start_date || null,
|
||||
end_date: body.end_date || null,
|
||||
total_amount_cents: body.total_amount_cents || null,
|
||||
currency: body.currency ?? 'USD',
|
||||
invoicing_schedule: body.invoicing_schedule ?? 'single',
|
||||
next_invoice_date: body.next_invoice_date || null,
|
||||
notes: body.notes || null,
|
||||
status: 'active',
|
||||
}).select().single();
|
||||
|
||||
if (error) return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
return NextResponse.json({ order: data });
|
||||
}
|
||||
20
src/app/api/accounting/products/[id]/route.ts
Normal file
20
src/app/api/accounting/products/[id]/route.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { createClient } from '@/lib/supabase/server';
|
||||
import { requireRmpAdmin } from '@/lib/accounting/rmpService';
|
||||
|
||||
export async function PATCH(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const auth = await requireRmpAdmin();
|
||||
if (!auth) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
|
||||
const { id } = await params;
|
||||
const supabase = await createClient();
|
||||
const body = await request.json();
|
||||
|
||||
const { error } = await supabase.from('rmp_products').update(body).eq('id', id);
|
||||
if (error) return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
42
src/app/api/accounting/products/route.ts
Normal file
42
src/app/api/accounting/products/route.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { createClient } from '@/lib/supabase/server';
|
||||
import { requireRmpAdmin } from '@/lib/accounting/rmpService';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const auth = await requireRmpAdmin();
|
||||
if (!auth) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
|
||||
const supabase = await createClient();
|
||||
const { searchParams } = new URL(request.url);
|
||||
const site = searchParams.get('site');
|
||||
|
||||
let query = supabase.from('rmp_products').select('*').order('site').order('sort_order');
|
||||
if (site) query = query.eq('site', site);
|
||||
|
||||
const { data, error } = await query;
|
||||
if (error) return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
|
||||
return NextResponse.json({ products: data ?? [] });
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const auth = await requireRmpAdmin();
|
||||
if (!auth) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
|
||||
const supabase = await createClient();
|
||||
const body = await request.json();
|
||||
|
||||
const { data, error } = await supabase.from('rmp_products').insert({
|
||||
site: body.site,
|
||||
product_type: body.product_type,
|
||||
name: body.name,
|
||||
description: body.description || null,
|
||||
dimensions: body.dimensions || null,
|
||||
duration_days: body.duration_days || null,
|
||||
sort_order: body.sort_order ?? 0,
|
||||
active: true,
|
||||
}).select().single();
|
||||
|
||||
if (error) return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
return NextResponse.json({ product: data });
|
||||
}
|
||||
92
src/app/api/accounting/reconciliation/route.ts
Normal file
92
src/app/api/accounting/reconciliation/route.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { createClient } from '@/lib/supabase/server';
|
||||
import { requireRmpAdmin } from '@/lib/accounting/rmpService';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const auth = await requireRmpAdmin();
|
||||
if (!auth) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
|
||||
const supabase = await createClient();
|
||||
const { data: transactions, error } = await supabase
|
||||
.from('rmp_bank_transactions')
|
||||
.select('*')
|
||||
.order('transaction_date', { ascending: false })
|
||||
.limit(200);
|
||||
|
||||
if (error) return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
|
||||
let matched = (transactions ?? []).filter((t: any) => t.reconciled).length;
|
||||
let unmatched = (transactions ?? []).filter((t: any) => !t.reconciled).length;
|
||||
|
||||
return NextResponse.json({ transactions: transactions ?? [], summary: { matched, unmatched } });
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const auth = await requireRmpAdmin();
|
||||
if (!auth) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
|
||||
const supabase = await createClient();
|
||||
const body = await request.json();
|
||||
const csv: string = body.csv ?? '';
|
||||
|
||||
const lines = csv.split('\n').filter(Boolean);
|
||||
const headers = lines[0]?.split(',').map((h: string) => h.trim().toLowerCase().replace(/"/g, '')) ?? [];
|
||||
|
||||
const rows = lines.slice(1).map((line: string) => {
|
||||
const vals = line.split(',').map((v: string) => v.trim().replace(/"/g, ''));
|
||||
const row: Record<string, string> = {};
|
||||
headers.forEach((h: string, i: number) => { row[h] = vals[i] ?? ''; });
|
||||
return row;
|
||||
});
|
||||
|
||||
let matched = 0;
|
||||
let unmatched = 0;
|
||||
|
||||
for (const row of rows) {
|
||||
const date = row['date'] || row['transaction date'] || row['trans date'];
|
||||
const desc = row['description'] || row['memo'] || row['payee'];
|
||||
const amountStr = row['amount'] || row['debit'] || row['credit'];
|
||||
const amount = parseFloat(amountStr?.replace(/[$,]/g, '') ?? '0');
|
||||
if (!date || isNaN(amount)) continue;
|
||||
|
||||
const amount_cents = Math.round(Math.abs(amount) * 100);
|
||||
const type = amount >= 0 ? 'credit' : 'debit';
|
||||
|
||||
// Try to auto-match to invoice
|
||||
let matched_invoice_id = null;
|
||||
if (type === 'credit') {
|
||||
const { data: invoices } = await supabase
|
||||
.from('rmp_invoices')
|
||||
.select('id')
|
||||
.eq('amount_cents', amount_cents)
|
||||
.eq('status', 'pending')
|
||||
.gte('due_date', new Date(new Date(date).getTime() - 3 * 86400000).toISOString().split('T')[0])
|
||||
.lte('due_date', new Date(new Date(date).getTime() + 3 * 86400000).toISOString().split('T')[0])
|
||||
.limit(1);
|
||||
|
||||
if (invoices?.[0]) {
|
||||
matched_invoice_id = invoices[0].id;
|
||||
matched++;
|
||||
} else {
|
||||
unmatched++;
|
||||
}
|
||||
}
|
||||
|
||||
await supabase.from('rmp_bank_transactions').insert({
|
||||
transaction_date: date,
|
||||
description: desc,
|
||||
amount_cents: type === 'debit' ? -amount_cents : amount_cents,
|
||||
type,
|
||||
matched_invoice_id,
|
||||
reconciled: !!matched_invoice_id,
|
||||
raw_csv_row: row,
|
||||
});
|
||||
|
||||
// If matched, mark invoice paid
|
||||
if (matched_invoice_id) {
|
||||
await supabase.from('rmp_invoices').update({ status: 'paid', paid_date: date, payment_method: 'wire' }).eq('id', matched_invoice_id);
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({ ok: true, summary: { matched, unmatched } });
|
||||
}
|
||||
49
src/app/api/accounting/reports/route.ts
Normal file
49
src/app/api/accounting/reports/route.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { createClient } from '@/lib/supabase/server';
|
||||
import { requireRmpAdmin } from '@/lib/accounting/rmpService';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const auth = await requireRmpAdmin();
|
||||
if (!auth) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
|
||||
const supabase = await createClient();
|
||||
const { searchParams } = new URL(request.url);
|
||||
const start = searchParams.get('start') ?? `${new Date().getFullYear()}-01-01`;
|
||||
const end = searchParams.get('end') ?? new Date().toISOString().split('T')[0];
|
||||
|
||||
const [invoicesRes, expensesRes] = await Promise.all([
|
||||
supabase.from('rmp_invoices').select('site, amount_cents, client_id, rmp_clients(company_name)').eq('status', 'paid').gte('paid_date', start).lte('paid_date', end),
|
||||
supabase.from('rmp_expenses').select('category, amount_cents').gte('expense_date', start).lte('expense_date', end),
|
||||
]);
|
||||
|
||||
const invoices = invoicesRes.data ?? [];
|
||||
const expenses = expensesRes.data ?? [];
|
||||
|
||||
const total_revenue_cents = invoices.reduce((s: number, i: any) => s + (i.amount_cents ?? 0), 0);
|
||||
const total_expenses_cents = expenses.reduce((s: number, e: any) => s + (e.amount_cents ?? 0), 0);
|
||||
|
||||
// By site
|
||||
const siteMap: Record<string, number> = {};
|
||||
for (const inv of invoices) {
|
||||
siteMap[inv.site] = (siteMap[inv.site] ?? 0) + (inv.amount_cents ?? 0);
|
||||
}
|
||||
const by_site = Object.entries(siteMap).map(([site, revenue_cents]) => ({ site, revenue_cents })).sort((a, b) => b.revenue_cents - a.revenue_cents);
|
||||
|
||||
// Top clients
|
||||
const clientMap: Record<string, any> = {};
|
||||
for (const inv of invoices) {
|
||||
if (!inv.client_id) continue;
|
||||
if (!clientMap[inv.client_id]) clientMap[inv.client_id] = { id: inv.client_id, company_name: (inv as any).rmp_clients?.company_name, revenue_cents: 0 };
|
||||
clientMap[inv.client_id].revenue_cents += inv.amount_cents ?? 0;
|
||||
}
|
||||
const top_clients = Object.values(clientMap).sort((a: any, b: any) => b.revenue_cents - a.revenue_cents).slice(0, 10);
|
||||
|
||||
// By expense category
|
||||
const catMap: Record<string, number> = {};
|
||||
for (const exp of expenses) {
|
||||
catMap[exp.category ?? 'other'] = (catMap[exp.category ?? 'other'] ?? 0) + (exp.amount_cents ?? 0);
|
||||
}
|
||||
const by_category = Object.entries(catMap).map(([category, amount_cents]) => ({ category, amount_cents })).sort((a, b) => b.amount_cents - a.amount_cents);
|
||||
|
||||
return NextResponse.json({ total_revenue_cents, total_expenses_cents, net_profit_cents: total_revenue_cents - total_expenses_cents, by_site, top_clients, by_category });
|
||||
}
|
||||
66
src/app/api/accounting/staff/route.ts
Normal file
66
src/app/api/accounting/staff/route.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { createClient } from '@/lib/supabase/server';
|
||||
import { requireRmpAdmin } from '@/lib/accounting/rmpService';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const auth = await requireRmpAdmin();
|
||||
if (!auth) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
|
||||
const supabase = await createClient();
|
||||
|
||||
const { data: staff, error } = await supabase
|
||||
.from('rmp_sales_staff')
|
||||
.select('*')
|
||||
.order('full_name');
|
||||
|
||||
if (error) return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
|
||||
// Attach YTD commission data
|
||||
const year = new Date().getFullYear();
|
||||
const enriched = await Promise.all((staff ?? []).map(async (s: any) => {
|
||||
const { data: comms } = await supabase
|
||||
.from('rmp_commissions')
|
||||
.select('commission_amount_cents, paid')
|
||||
.eq('staff_id', s.id)
|
||||
.eq('year', year);
|
||||
|
||||
const commission_ytd = (comms ?? []).reduce((sum: number, c: any) => sum + (c.commission_amount_cents ?? 0), 0);
|
||||
const commission_outstanding = (comms ?? []).filter((c: any) => !c.paid).reduce((sum: number, c: any) => sum + (c.commission_amount_cents ?? 0), 0);
|
||||
return { ...s, commission_ytd, commission_outstanding };
|
||||
}));
|
||||
|
||||
return NextResponse.json({ staff: enriched });
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const auth = await requireRmpAdmin();
|
||||
if (!auth) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
|
||||
const supabase = await createClient();
|
||||
const body = await request.json();
|
||||
|
||||
const { data: staffMember, error } = await supabase.from('rmp_sales_staff').insert({
|
||||
full_name: body.full_name,
|
||||
email: body.email || null,
|
||||
phone: body.phone || null,
|
||||
notes: body.notes || null,
|
||||
status: 'active',
|
||||
}).select().single();
|
||||
|
||||
if (error) return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
|
||||
// Insert commission tiers
|
||||
if (body.tiers?.length > 0) {
|
||||
await supabase.from('rmp_commission_tiers').insert(
|
||||
body.tiers.map((t: any) => ({
|
||||
staff_id: staffMember.id,
|
||||
effective_year: t.effective_year,
|
||||
tier_order: t.tier_order,
|
||||
threshold_cents: t.threshold_cents,
|
||||
rate: t.rate,
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({ staff: staffMember });
|
||||
}
|
||||
51
src/app/api/accounting/stripe-webhook/route.ts
Normal file
51
src/app/api/accounting/stripe-webhook/route.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { createClient } from '@/lib/supabase/server';
|
||||
|
||||
|
||||
// Stub — Stripe credentials will be added later
|
||||
export async function POST(request: NextRequest) {
|
||||
const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET_RMP;
|
||||
if (!webhookSecret) {
|
||||
return NextResponse.json({ error: 'Stripe not configured' }, { status: 503 });
|
||||
}
|
||||
|
||||
// TODO: Verify Stripe webhook signature when STRIPE_WEBHOOK_SECRET_RMP is set
|
||||
// const sig = request.headers.get('stripe-signature');
|
||||
// const event = stripe.webhooks.constructEvent(body, sig, webhookSecret);
|
||||
|
||||
const body = await request.json();
|
||||
const eventType = body?.type;
|
||||
|
||||
if (eventType === 'payment_intent.succeeded') {
|
||||
const supabase = await createClient();
|
||||
const stripeId = body?.data?.object?.id;
|
||||
const amountCents = body?.data?.object?.amount;
|
||||
|
||||
if (stripeId && amountCents) {
|
||||
const { data: invoice } = await supabase
|
||||
.from('rmp_invoices')
|
||||
.select('id')
|
||||
.eq('amount_cents', amountCents)
|
||||
.eq('status', 'pending')
|
||||
.limit(1)
|
||||
.single();
|
||||
|
||||
if (invoice) {
|
||||
await supabase.from('rmp_invoices').update({
|
||||
status: 'paid',
|
||||
payment_method: 'stripe',
|
||||
stripe_payment_id: stripeId,
|
||||
paid_date: new Date().toISOString().split('T')[0],
|
||||
}).eq('id', invoice.id);
|
||||
|
||||
await supabase.from('rmp_notifications').insert({
|
||||
type: 'stripe_payment',
|
||||
message: `Stripe payment received: ${stripeId}`,
|
||||
invoice_id: invoice.id,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({ received: true });
|
||||
}
|
||||
Reference in New Issue
Block a user