initial commit: rocket.new export of broadcastbeat
This commit is contained in:
317
src/lib/billing/billingService.ts
Normal file
317
src/lib/billing/billingService.ts
Normal 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;
|
||||
}
|
||||
148
src/lib/billing/moonInvoiceClient.ts
Normal file
148
src/lib/billing/moonInvoiceClient.ts
Normal 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' };
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user