4d7f00be1f
- Add src/types/billing.ts with Payment, Coupon, CreditTransaction, Invoice types - Cast all Supabase query results through 'unknown' for untyped billing tables - All routes now build cleanly with strict TypeScript checking Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
205 lines
6.4 KiB
TypeScript
205 lines
6.4 KiB
TypeScript
import { NextResponse } from "next/server";
|
|
import { getSupabaseAdmin } from "@/lib/admin";
|
|
import { requireAdmin } from "@/lib/apiAuth";
|
|
import { sendEmail } from "@/lib/email";
|
|
import { invoiceEmail } from "@/lib/email-templates";
|
|
import type { Invoice } from "@/types/billing";
|
|
|
|
export async function GET(request: Request) {
|
|
const authResult = await requireAdmin(request);
|
|
if (authResult instanceof NextResponse) return authResult;
|
|
|
|
const supabase = getSupabaseAdmin();
|
|
const { searchParams } = new URL(request.url);
|
|
const page = parseInt(searchParams.get("page") || "1");
|
|
const limit = parseInt(searchParams.get("limit") || "20");
|
|
const orgId = searchParams.get("orgId");
|
|
const status = searchParams.get("status");
|
|
const offset = (page - 1) * limit;
|
|
|
|
try {
|
|
let query = supabase
|
|
.from("invoices")
|
|
.select(
|
|
"*, organization:organizations(id, name, billing_email), creator:users!invoices_created_by_fkey(id, email, name)",
|
|
{ count: "exact" }
|
|
)
|
|
.order("created_at", { ascending: false })
|
|
.range(offset, offset + limit - 1);
|
|
|
|
if (orgId) query = query.eq("organization_id", orgId);
|
|
if (status) query = query.eq("status", status);
|
|
|
|
const { data, count, error } = await query;
|
|
if (error) throw error;
|
|
|
|
const invoices = (data || []) as unknown as Invoice[];
|
|
|
|
return NextResponse.json({
|
|
invoices,
|
|
pagination: { page, limit, total: count || 0, pages: Math.ceil((count || 0) / limit) },
|
|
});
|
|
} catch (error) {
|
|
console.error("Error fetching invoices:", error);
|
|
return NextResponse.json({ error: "Failed to fetch invoices" }, { status: 500 });
|
|
}
|
|
}
|
|
|
|
export async function POST(request: Request) {
|
|
const authResult = await requireAdmin(request);
|
|
if (authResult instanceof NextResponse) return authResult;
|
|
|
|
const supabase = getSupabaseAdmin();
|
|
|
|
try {
|
|
const body = await request.json();
|
|
const { organization_id, amount, currency, items, due_date, notes, status: invoiceStatus } = body;
|
|
|
|
if (!organization_id || amount === undefined || !items?.length) {
|
|
return NextResponse.json(
|
|
{ error: "organization_id, amount, and items are required" },
|
|
{ status: 400 }
|
|
);
|
|
}
|
|
|
|
// Generate invoice number: INV-YYYYMM-XXXX
|
|
const now = new Date();
|
|
const prefix = `INV-${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, "0")}`;
|
|
const { count } = await supabase
|
|
.from("invoices")
|
|
.select("id", { count: "exact", head: true })
|
|
.like("invoice_number", `${prefix}%`);
|
|
const seq = String((count || 0) + 1).padStart(4, "0");
|
|
|
|
const { data, error } = await supabase
|
|
.from("invoices")
|
|
.insert({
|
|
invoice_number: `${prefix}-${seq}`,
|
|
organization_id,
|
|
amount: Math.round(amount),
|
|
currency: currency || "EUR",
|
|
status: invoiceStatus || "draft",
|
|
items,
|
|
due_date: due_date || null,
|
|
notes,
|
|
created_by: authResult.userId,
|
|
})
|
|
.select("*, organization:organizations(id, name, billing_email)")
|
|
.single();
|
|
|
|
if (error) throw error;
|
|
const invoice = data as unknown as Invoice;
|
|
|
|
return NextResponse.json({ invoice });
|
|
} catch (error) {
|
|
console.error("Error creating invoice:", error);
|
|
return NextResponse.json({ error: "Failed to create invoice" }, { status: 500 });
|
|
}
|
|
}
|
|
|
|
export async function PATCH(request: Request) {
|
|
const authResult = await requireAdmin(request);
|
|
if (authResult instanceof NextResponse) return authResult;
|
|
|
|
const supabase = getSupabaseAdmin();
|
|
|
|
try {
|
|
const body = await request.json();
|
|
const { id, action, ...updates } = body;
|
|
|
|
if (!id) {
|
|
return NextResponse.json({ error: "Invoice id is required" }, { status: 400 });
|
|
}
|
|
|
|
// Send invoice via email
|
|
if (action === "send") {
|
|
const { data } = await supabase
|
|
.from("invoices")
|
|
.select("*, organization:organizations(id, name, billing_email)")
|
|
.eq("id", id)
|
|
.single();
|
|
|
|
const invoice = data as unknown as Invoice | null;
|
|
|
|
if (!invoice) {
|
|
return NextResponse.json({ error: "Invoice not found" }, { status: 404 });
|
|
}
|
|
|
|
const org = invoice.organization;
|
|
const recipientEmail = org?.billing_email;
|
|
|
|
if (!recipientEmail) {
|
|
return NextResponse.json(
|
|
{ error: "Organization has no billing email configured" },
|
|
{ status: 400 }
|
|
);
|
|
}
|
|
|
|
const appUrl = process.env.NEXT_PUBLIC_APP_URL || "http://localhost:3000";
|
|
const template = invoiceEmail({
|
|
organizationName: org?.name || "Unknown",
|
|
invoiceNumber: invoice.invoice_number,
|
|
amount: invoice.amount,
|
|
currency: invoice.currency,
|
|
dueDate: invoice.due_date,
|
|
items: (invoice.items) || [],
|
|
dashboardUrl: `${appUrl}/dashboard/settings`,
|
|
});
|
|
|
|
const sent = await sendEmail({
|
|
to: recipientEmail,
|
|
subject: template.subject,
|
|
html: template.html,
|
|
text: template.text,
|
|
});
|
|
|
|
if (!sent) {
|
|
return NextResponse.json({ error: "Failed to send email" }, { status: 500 });
|
|
}
|
|
|
|
await supabase
|
|
.from("invoices")
|
|
.update({ status: "sent", updated_at: new Date().toISOString() })
|
|
.eq("id", id);
|
|
|
|
return NextResponse.json({ success: true, message: "Invoice sent" });
|
|
}
|
|
|
|
// Mark as paid
|
|
if (action === "mark_paid") {
|
|
const { data, error } = await supabase
|
|
.from("invoices")
|
|
.update({ status: "paid", paid_at: new Date().toISOString(), updated_at: new Date().toISOString() })
|
|
.eq("id", id)
|
|
.select()
|
|
.single();
|
|
|
|
if (error) throw error;
|
|
const invoice = data as unknown as Invoice;
|
|
return NextResponse.json({ invoice });
|
|
}
|
|
|
|
// Generic update
|
|
const allowedFields = ["status", "amount", "items", "due_date", "notes"];
|
|
const safeUpdates: Record<string, unknown> = { updated_at: new Date().toISOString() };
|
|
for (const key of allowedFields) {
|
|
if (key in updates) safeUpdates[key] = updates[key];
|
|
}
|
|
|
|
const { data, error } = await supabase
|
|
.from("invoices")
|
|
.update(safeUpdates)
|
|
.eq("id", id)
|
|
.select()
|
|
.single();
|
|
|
|
if (error) throw error;
|
|
const invoice = data as unknown as Invoice;
|
|
|
|
return NextResponse.json({ invoice });
|
|
} catch (error) {
|
|
console.error("Error updating invoice:", error);
|
|
return NextResponse.json({ error: "Failed to update invoice" }, { status: 500 });
|
|
}
|
|
}
|