"use server"; import { createClient, createAdminClient } from "@/lib/supabase/server"; import { auth } from "@clerk/nextjs/server"; import { redirect } from "next/navigation"; import type { InvoiceStatus, InvoiceWithLineItems } from "@/lib/supabase/invoicing-types"; import { reportUsage } from "@/lib/supabase/stripe-actions"; import { sendEmail, invoiceNotificationTemplate } from "@/lib/email"; // ── Helpers ──────────────────────────────────────────────────── /** * Returns the organisation_id for the current user. * Throws if the user is not a member of any organisation. */ async function getOrganisationId(): Promise { const { userId } = await auth(); if (!userId) throw new Error("Unauthenticated"); const supabase = await createAdminClient(); const { data: member, error } = await supabase .from("organisation_members") .select("organisation_id") .eq("user_id", userId) .single(); if (error || !member) throw new Error("No organisation found"); return member.organisation_id; } // ── Fetch functions ──────────────────────────────────────────── export type InvoiceListFilter = "all" | "unpaid" | "paid" | "overdue"; export interface InvoiceRow { id: string; organisation_id: string; invoice_number: string; status: InvoiceStatus; currency: string; client_name: string; client_email: string | null; client_address: string | null; issue_date: string; due_date: string; amount: number | null; subtotal_cents: number; tax_total_cents: number; total_cents: number; notes: string | null; created_at: string; updated_at: string; created_by: string | null; } export async function fetchInvoices( filter: InvoiceListFilter = "all" ): Promise { const orgId = await getOrganisationId(); const supabase = await createAdminClient(); let query = supabase .from("invoices") .select("*") .eq("organisation_id", orgId) .order("created_at", { ascending: false }); switch (filter) { case "unpaid": query = query.in("status", ["draft", "sent"]); break; case "paid": query = query.eq("status", "paid"); break; case "overdue": query = query.eq("status", "overdue"); break; } const { data, error } = await query; if (error) throw error; return data as InvoiceRow[]; } export async function fetchInvoiceWithLineItems( invoiceId: string ): Promise { const orgId = await getOrganisationId(); const supabase = await createAdminClient(); // Fetch the invoice and verify org access const { data: invoice, error: invError } = await supabase .from("invoices") .select("*, invoice_line_items(*)") .eq("id", invoiceId) .eq("organisation_id", orgId) .single(); if (invError || !invoice) throw new Error("Invoice not found"); return { ...invoice, line_items: (invoice.invoice_line_items || []) as InvoiceWithLineItems["line_items"], }; } // ── Server actions (writes) ──────────────────────────────────── interface CreateInvoicePayload { client_name: string; client_email?: string; client_address?: string; issue_date: string; due_date: string; currency: string; notes?: string; line_items: { description: string; quantity: string; unit_price_cents: number; tax_rate: string; sort_order: number; }[]; } export async function createInvoice( payload: CreateInvoicePayload ): Promise<{ success: boolean; invoiceId?: string; error?: string }> { const { userId } = await auth(); if (!userId) return { success: false, error: "Unauthenticated" }; const orgId = await getOrganisationId(); const supabase = await createAdminClient(); // Compute totals let subtotal_cents = 0; let tax_total_cents = 0; const lineItems = payload.line_items.map((item) => { const qty = parseFloat(item.quantity); const lineTotalCents = Math.round(qty * item.unit_price_cents); const taxRate = parseFloat(item.tax_rate); const tax = Math.round((lineTotalCents * taxRate) / 100); subtotal_cents += lineTotalCents; tax_total_cents += tax; return { description: item.description, quantity: item.quantity, unit_price_cents: item.unit_price_cents, tax_rate: item.tax_rate, line_total_cents: lineTotalCents, sort_order: item.sort_order, }; }); const total_cents = subtotal_cents + tax_total_cents; // Insert invoice const { data: invoice, error: invError } = await supabase .from("invoices") .insert({ organisation_id: orgId, client_name: payload.client_name, client_email: payload.client_email || null, client_address: payload.client_address || null, issue_date: payload.issue_date, due_date: payload.due_date, currency: payload.currency, notes: payload.notes || null, created_by: userId, status: "draft", subtotal_cents, tax_total_cents, total_cents, }) .select("id") .single(); if (invError || !invoice) { return { success: false, error: invError?.message || "Failed to create invoice" }; } // Insert line items if (lineItems.length > 0) { const { error: lineError } = await supabase .from("invoice_line_items") .insert( lineItems.map((item) => ({ invoice_id: invoice.id, ...item, })) ); if (lineError) { // Rollback: delete the invoice we just created await supabase.from("invoices").delete().eq("id", invoice.id); return { success: false, error: lineError.message || "Failed to add line items" }; } } // Report usage for PAYG billing (non-blocking) reportUsage("invoices", 1).catch(() => {}); return { success: true, invoiceId: invoice.id }; } export async function updateInvoiceStatus( invoiceId: string, status: InvoiceStatus ): Promise<{ success: boolean; error?: string }> { const orgId = await getOrganisationId(); const supabase = await createAdminClient(); // Fetch invoice details before updating (for email notification) const { data: inv } = await supabase .from("invoices") .select("invoice_number, client_name, client_email, total_cents, amount, status, due_date, currency") .eq("id", invoiceId) .eq("organisation_id", orgId) .single(); const { error } = await supabase .from("invoices") .update({ status }) .eq("id", invoiceId) .eq("organisation_id", orgId); if (error) return { success: false, error: error.message }; // Send notification when invoice is sent if (status === "sent" && inv?.client_email) { const amountStr = inv.total_cents > 0 ? `$${(inv.total_cents / 100).toFixed(2)}` : inv.amount != null ? `$${inv.amount.toFixed(2)}` : "$0.00"; const dueDateStr = inv.due_date ? new Date(inv.due_date + "T00:00:00").toLocaleDateString("en-AU", { day: "numeric", month: "long", year: "numeric" }) : "N/A"; const template = invoiceNotificationTemplate( inv.client_name || "Client", inv.invoice_number || invoiceId.slice(0, 8), amountStr, dueDateStr ); await sendEmail({ to: [inv.client_email], subject: template.subject, html: template.html, }); } return { success: true }; } export async function deleteInvoice( invoiceId: string ): Promise<{ success: boolean; error?: string }> { const orgId = await getOrganisationId(); const supabase = await createAdminClient(); const { error } = await supabase .from("invoices") .delete() .eq("id", invoiceId) .eq("organisation_id", orgId); if (error) return { success: false, error: error.message }; return { success: true }; } /** * Lightweight fetch for invoice detail — used client-side via server action. */ export async function fetchInvoiceDetail( invoiceId: string ): Promise { const orgId = await getOrganisationId(); const supabase = await createAdminClient(); const { data, error } = await supabase .from("invoices") .select("*, invoice_line_items(*)") .eq("id", invoiceId) .eq("organisation_id", orgId) .single(); if (error || !data) return null; return { ...data, line_items: (data.invoice_line_items || []) as InvoiceWithLineItems["line_items"], }; } // ── Send invoice email ─────────────────────────────────────── export interface InvoiceToSend { id: string; invoice_number: string; client_name: string; client_email: string; total: string; } export interface SendInvoiceInput { invoice_number: string; client_name: string; client_email: string; total: string; subject: string; message: string; org_name: string; invoice_id?: string; sent_by?: string; } export async function sendInvoiceEmail( input: SendInvoiceInput ): Promise<{ success: boolean; error?: string }> { const { userId } = await auth(); const orgId = await getOrganisationId(); const supabase = await createAdminClient(); try { const { sendEmail } = await import("@/lib/email"); // Build the email with custom message + invoice details footer const html = `

${input.subject}

${input.message}

Invoice: ${input.invoice_number}
Amount: ${input.total}
Client: ${input.client_name || "N/A"}

Sent from ${input.org_name} via Vela.

`; const result = await sendEmail({ to: [input.client_email], subject: input.subject, html, }); if ("error" in result) { // Log failed email await supabase.from("sent_emails").insert({ organisation_id: orgId, sent_by: input.sent_by || userId, to_email: input.client_email, subject: input.subject, body_preview: input.message.slice(0, 200), related_type: "invoice", related_id: input.invoice_id || null, status: "failed", }); return { success: false, error: result.error }; } // Log sent email await supabase.from("sent_emails").insert({ organisation_id: orgId, sent_by: input.sent_by || userId, to_email: input.client_email, subject: input.subject, body_preview: input.message.slice(0, 200), related_type: "invoice", related_id: input.invoice_id || null, status: "sent", }); return { success: true }; } catch (e: any) { return { success: false, error: e.message || "Failed to send email" }; } } export async function fetchSentEmails(limit = 50): Promise { const { userId } = await auth(); if (!userId) throw new Error("Unauthenticated"); const supabase = await createAdminClient(); const { data: member } = await supabase .from("organisation_members") .select("organisation_id, role") .eq("user_id", userId) .single(); if (!member) throw new Error("No organisation found"); let query = supabase .from("sent_emails") .select("*, sender:profiles(full_name)") .eq("organisation_id", member.organisation_id) .order("sent_at", { ascending: false }) .limit(limit); // Non-admins only see their own sent emails if (member.role !== "owner" && member.role !== "admin") { query = query.eq("sent_by", userId); } const { data, error } = await query; if (error) throw error; return data || []; } export async function getOrgName(): Promise { const orgId = await getOrganisationId(); const supabase = await createAdminClient(); const { data } = await supabase .from("organisations") .select("name") .eq("id", orgId) .single(); return data?.name || "Vela"; }