initial commit: rocket.new export of broadcastbeat

This commit is contained in:
Ryan Salazar
2026-05-07 16:39:17 +00:00
commit 70aa1ad46e
302 changed files with 60710 additions and 0 deletions

View File

@@ -0,0 +1,317 @@
/**
* Billing Service Layer
* Reads always come from local Supabase cache.
* Writes go to MoonInvoice API (if mooninvoice mode) + local cache.
* BILLING_MODE env var controls the active data source.
*/
import { createClient } from '@supabase/supabase-js';
const supabase = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{ db: { schema: process.env.NEXT_PUBLIC_SUPABASE_SCHEMA || 'bb' } }
);
export function getBillingMode(): 'mooninvoice' | 'native' {
return (process.env.BILLING_MODE as 'mooninvoice' | 'native') || 'mooninvoice';
}
// ─── Companies ───────────────────────────────────────────────────────────────
export async function getCompanies() {
const { data, error } = await supabase
.from('billing_companies')
.select('*')
.eq('is_active', true)
.order('name');
if (error) throw error;
return data;
}
export async function getCompanyById(id: string) {
const { data, error } = await supabase
.from('billing_companies')
.select('*')
.eq('id', id)
.single();
if (error) throw error;
return data;
}
export async function updateCompany(id: string, updates: Record<string, any>) {
const { data, error } = await supabase
.from('billing_companies')
.update({ ...updates, updated_at: new Date().toISOString() })
.eq('id', id)
.select()
.single();
if (error) throw error;
return data;
}
// ─── Clients ─────────────────────────────────────────────────────────────────
export async function getClients(companyId?: string) {
let query = supabase
.from('billing_clients')
.select('*, billing_companies(name, invoice_prefix)')
.order('name');
if (companyId) query = query.eq('company_id', companyId);
const { data, error } = await query;
if (error) throw error;
return data;
}
export async function getClientById(id: string) {
const { data, error } = await supabase
.from('billing_clients')
.select('*, billing_companies(name, invoice_prefix)')
.eq('id', id)
.single();
if (error) throw error;
return data;
}
export async function upsertClient(client: Record<string, any>) {
const { data, error } = await supabase
.from('billing_clients')
.upsert({ ...client, updated_at: new Date().toISOString() })
.select()
.single();
if (error) throw error;
return data;
}
// ─── Invoices ────────────────────────────────────────────────────────────────
export async function getInvoices(filters: {
companyId?: string;
status?: string;
clientId?: string;
dateFrom?: string;
dateTo?: string;
search?: string;
}) {
let query = supabase
.from('billing_invoices')
.select('*, billing_clients(name, organization), billing_companies(name, invoice_prefix)')
.order('created_at', { ascending: false });
if (filters.companyId) query = query.eq('company_id', filters.companyId);
if (filters.status && filters.status !== 'all') query = query.eq('status', filters.status);
if (filters.clientId) query = query.eq('client_id', filters.clientId);
if (filters.dateFrom) query = query.gte('issue_date', filters.dateFrom);
if (filters.dateTo) query = query.lte('issue_date', filters.dateTo);
const { data, error } = await query;
if (error) throw error;
if (filters.search) {
const s = filters.search.toLowerCase();
return data?.filter(
(inv: any) =>
inv.invoice_number?.toLowerCase().includes(s) ||
inv.billing_clients?.name?.toLowerCase().includes(s)
);
}
return data;
}
export async function getInvoiceById(id: string) {
const { data, error } = await supabase
.from('billing_invoices')
.select(
'*, billing_clients(*), billing_companies(*), billing_line_items(*)'
)
.eq('id', id)
.single();
if (error) throw error;
return data;
}
export async function upsertInvoice(invoice: Record<string, any>, lineItems?: Record<string, any>[]) {
const { data, error } = await supabase
.from('billing_invoices')
.upsert({ ...invoice, updated_at: new Date().toISOString() })
.select()
.single();
if (error) throw error;
if (lineItems && data) {
// Delete existing line items and re-insert
await supabase.from('billing_line_items').delete().eq('invoice_id', data.id);
if (lineItems.length > 0) {
const { error: liError } = await supabase
.from('billing_line_items')
.insert(lineItems.map((li) => ({ ...li, invoice_id: data.id })));
if (liError) throw liError;
}
}
return data;
}
export async function updateInvoiceStatus(id: string, status: string) {
const updates: Record<string, any> = { status, updated_at: new Date().toISOString() };
if (status === 'paid') updates.paid_at = new Date().toISOString();
const { data, error } = await supabase
.from('billing_invoices')
.update(updates)
.eq('id', id)
.select()
.single();
if (error) throw error;
return data;
}
// ─── Payments ────────────────────────────────────────────────────────────────
export async function getPayments(companyId?: string) {
let query = supabase
.from('billing_payments')
.select('*, billing_invoices(invoice_number, company_id), billing_clients(name)')
.order('payment_date', { ascending: false });
if (companyId) {
query = query.eq('billing_invoices.company_id', companyId);
}
const { data, error } = await query;
if (error) throw error;
return data;
}
export async function insertPayment(payment: Record<string, any>) {
const { data, error } = await supabase
.from('billing_payments')
.insert(payment)
.select()
.single();
if (error) throw error;
return data;
}
// ─── Dashboard stats ─────────────────────────────────────────────────────────
export async function getDashboardStats(companyId?: string) {
const now = new Date();
const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1).toISOString().split('T')[0];
const startOfLastMonth = new Date(now.getFullYear(), now.getMonth() - 1, 1).toISOString().split('T')[0];
const endOfLastMonth = new Date(now.getFullYear(), now.getMonth(), 0).toISOString().split('T')[0];
const in7Days = new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000).toISOString().split('T')[0];
const today = now.toISOString().split('T')[0];
let base = supabase.from('billing_invoices').select('*');
if (companyId) base = base.eq('company_id', companyId);
const { data: allInvoices } = await base;
const invoices = allInvoices || [];
const revenueThisMonth = invoices
.filter((i: any) => i.status === 'paid' && i.paid_at >= startOfMonth)
.reduce((s: number, i: any) => s + Number(i.total), 0);
const revenueLastMonth = invoices
.filter((i: any) => i.status === 'paid' && i.paid_at >= startOfLastMonth && i.paid_at <= endOfLastMonth)
.reduce((s: number, i: any) => s + Number(i.total), 0);
const outstanding = invoices.filter((i: any) => ['sent', 'viewed'].includes(i.status));
const overdue = invoices.filter((i: any) => i.status === 'overdue' || (i.due_date < today && !['paid', 'cancelled', 'draft'].includes(i.status)));
const paidThisMonth = invoices.filter((i: any) => i.status === 'paid' && i.paid_at >= startOfMonth);
const upcoming = invoices.filter((i: any) => i.due_date >= today && i.due_date <= in7Days && !['paid', 'cancelled'].includes(i.status));
return {
revenueThisMonth,
revenueLastMonth,
revenueChange: revenueLastMonth > 0 ? ((revenueThisMonth - revenueLastMonth) / revenueLastMonth) * 100 : 0,
outstandingCount: outstanding.length,
outstandingAmount: outstanding.reduce((s: number, i: any) => s + Number(i.total) - Number(i.amount_paid), 0),
overdueCount: overdue.length,
overdueAmount: overdue.reduce((s: number, i: any) => s + Number(i.total) - Number(i.amount_paid), 0),
paidThisMonthAmount: paidThisMonth.reduce((s: number, i: any) => s + Number(i.total), 0),
upcomingCount: upcoming.length,
upcomingAmount: upcoming.reduce((s: number, i: any) => s + Number(i.total) - Number(i.amount_paid), 0),
};
}
export async function getMonthlyRevenue(companyId?: string, months = 12) {
const result: { month: string; revenue: number }[] = [];
const now = new Date();
for (let i = months - 1; i >= 0; i--) {
const d = new Date(now.getFullYear(), now.getMonth() - i, 1);
const start = d.toISOString().split('T')[0];
const end = new Date(d.getFullYear(), d.getMonth() + 1, 0).toISOString().split('T')[0];
let query = supabase
.from('billing_invoices')
.select('total')
.eq('status', 'paid')
.gte('paid_at', start)
.lte('paid_at', end);
if (companyId) query = query.eq('company_id', companyId);
const { data } = await query;
const revenue = (data || []).reduce((s: number, r: any) => s + Number(r.total), 0);
result.push({
month: d.toLocaleString('default', { month: 'short', year: '2-digit' }),
revenue,
});
}
return result;
}
// ─── Billing settings ────────────────────────────────────────────────────────
export async function getBillingSettings() {
const { data, error } = await supabase
.from('billing_settings')
.select('*')
.limit(1)
.single();
if (error && error.code !== 'PGRST116') throw error;
return data;
}
export async function updateBillingSettings(updates: Record<string, any>) {
const existing = await getBillingSettings();
if (existing) {
const { data, error } = await supabase
.from('billing_settings')
.update({ ...updates, updated_at: new Date().toISOString() })
.eq('id', existing.id)
.select()
.single();
if (error) throw error;
return data;
} else {
const { data, error } = await supabase
.from('billing_settings')
.insert(updates)
.select()
.single();
if (error) throw error;
return data;
}
}
// ─── Sync log ────────────────────────────────────────────────────────────────
export async function logSync(entry: {
sync_type: string;
company_id?: string;
records_synced?: number;
errors?: any;
status: 'running' | 'completed' | 'failed';
started_at?: string;
completed_at?: string;
}) {
const { data, error } = await supabase
.from('billing_sync_log')
.insert(entry)
.select()
.single();
if (error) throw error;
return data;
}

View File

@@ -0,0 +1,148 @@
/**
* MoonInvoice API Client
* Direct REST calls using stored env vars.
* All reads go through local cache (billing tables).
* Only writes and scheduled syncs hit the Moon API.
*/
const MOON_BASE_URL = 'https://api.mooninvoice.com/api_mi/public';
export type MoonCompany = 'rmp' | 'jtr';
function getApiKey(company: MoonCompany): string {
const key =
company === 'rmp'
? process.env.MOONINVOICE_API_KEY_RMP
: process.env.MOONINVOICE_API_KEY_JTR;
if (!key) throw new Error(`MoonInvoice API key not configured for ${company.toUpperCase()}`);
return key;
}
async function moonFetch(
company: MoonCompany,
path: string,
options: RequestInit = {}
): Promise<any> {
const apiKey = getApiKey(company);
const url = `${MOON_BASE_URL}${path}`;
const res = await fetch(url, {
...options,
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${apiKey}`,
...(options.headers || {}),
},
});
if (!res.ok) {
const text = await res.text().catch(() => '');
throw new Error(`MoonInvoice API error ${res.status}: ${text}`);
}
return res.json();
}
// ─── Contacts ────────────────────────────────────────────────────────────────
export async function moonGetContacts(company: MoonCompany, page = 1): Promise<any> {
return moonFetch(company, `/contact_list?page=${page}`);
}
export async function moonCreateContact(company: MoonCompany, data: Record<string, any>): Promise<any> {
return moonFetch(company, '/add_contact', {
method: 'POST',
body: JSON.stringify(data),
});
}
export async function moonUpdateContact(
company: MoonCompany,
contactId: string,
data: Record<string, any>
): Promise<any> {
return moonFetch(company, `/update_contact/${contactId}`, {
method: 'PUT',
body: JSON.stringify(data),
});
}
// ─── Invoices ────────────────────────────────────────────────────────────────
export async function moonGetInvoices(
company: MoonCompany,
page = 1,
status?: string
): Promise<any> {
const params = new URLSearchParams({ page: String(page) });
if (status) params.set('status', status);
return moonFetch(company, `/invoice_list?${params}`);
}
export async function moonGetInvoice(company: MoonCompany, invoiceId: string): Promise<any> {
return moonFetch(company, `/invoice_detail/${invoiceId}`);
}
export async function moonCreateInvoice(
company: MoonCompany,
data: Record<string, any>
): Promise<any> {
return moonFetch(company, '/add_invoice', {
method: 'POST',
body: JSON.stringify(data),
});
}
export async function moonUpdateInvoice(
company: MoonCompany,
invoiceId: string,
data: Record<string, any>
): Promise<any> {
return moonFetch(company, `/update_invoice/${invoiceId}`, {
method: 'PUT',
body: JSON.stringify(data),
});
}
export async function moonSendInvoice(company: MoonCompany, invoiceId: string): Promise<any> {
return moonFetch(company, `/send_invoice/${invoiceId}`, { method: 'POST' });
}
export async function moonRecordPayment(
company: MoonCompany,
invoiceId: string,
data: Record<string, any>
): Promise<any> {
return moonFetch(company, `/record_payment/${invoiceId}`, {
method: 'POST',
body: JSON.stringify(data),
});
}
export async function moonGetInvoicePdf(company: MoonCompany, invoiceId: string): Promise<Response> {
const apiKey = getApiKey(company);
return fetch(`${MOON_BASE_URL}/invoice_pdf/${invoiceId}`, {
headers: { Authorization: `Bearer ${apiKey}` },
});
}
export async function moonDeleteInvoice(company: MoonCompany, invoiceId: string): Promise<any> {
return moonFetch(company, `/delete_invoice/${invoiceId}`, { method: 'DELETE' });
}
// ─── Payments ────────────────────────────────────────────────────────────────
export async function moonGetPayments(company: MoonCompany, page = 1): Promise<any> {
return moonFetch(company, `/payment_list?page=${page}`);
}
// ─── Test connection ─────────────────────────────────────────────────────────
export async function moonTestConnection(company: MoonCompany): Promise<{ ok: boolean; message: string }> {
try {
await moonGetContacts(company, 1);
return { ok: true, message: 'Connection successful' };
} catch (err: any) {
return { ok: false, message: err.message || 'Connection failed' };
}
}