This commit is contained in:
104
src/pages/api/invoices/[id]/generate.ts
Normal file
104
src/pages/api/invoices/[id]/generate.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import type { APIRoute } from "astro";
|
||||
import { db } from "../../../../db";
|
||||
import {
|
||||
invoices,
|
||||
invoiceItems,
|
||||
clients,
|
||||
organizations,
|
||||
members,
|
||||
} from "../../../../db/schema";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
import { renderToStream } from "@ceereals/vue-pdf";
|
||||
import { createInvoiceDocument } from "../../../../pdf/generateInvoicePDF";
|
||||
|
||||
export const GET: APIRoute = async ({ params, locals }) => {
|
||||
try {
|
||||
const { id } = params;
|
||||
const user = locals.user;
|
||||
|
||||
if (!user || !id) {
|
||||
return new Response("Unauthorized", { status: 401 });
|
||||
}
|
||||
|
||||
// Fetch invoice with related data
|
||||
const invoiceResult = await db
|
||||
.select({
|
||||
invoice: invoices,
|
||||
client: clients,
|
||||
organization: organizations,
|
||||
})
|
||||
.from(invoices)
|
||||
.leftJoin(clients, eq(invoices.clientId, clients.id))
|
||||
.innerJoin(organizations, eq(invoices.organizationId, organizations.id))
|
||||
.where(eq(invoices.id, id))
|
||||
.get();
|
||||
|
||||
if (!invoiceResult) {
|
||||
return new Response("Invoice not found", { status: 404 });
|
||||
}
|
||||
|
||||
const { invoice, client, organization } = invoiceResult;
|
||||
|
||||
// Verify access
|
||||
const membership = await db
|
||||
.select()
|
||||
.from(members)
|
||||
.where(
|
||||
and(
|
||||
eq(members.userId, user.id),
|
||||
eq(members.organizationId, invoice.organizationId),
|
||||
),
|
||||
)
|
||||
.get();
|
||||
|
||||
if (!membership) {
|
||||
return new Response("Forbidden", { status: 403 });
|
||||
}
|
||||
|
||||
// Fetch items
|
||||
const items = await db
|
||||
.select()
|
||||
.from(invoiceItems)
|
||||
.where(eq(invoiceItems.invoiceId, invoice.id))
|
||||
.all();
|
||||
|
||||
if (!client) {
|
||||
return new Response("Client not found", { status: 404 });
|
||||
}
|
||||
|
||||
// Generate PDF using Vue PDF
|
||||
// Suppress verbose logging from PDF renderer
|
||||
const originalConsoleLog = console.log;
|
||||
console.log = () => {};
|
||||
|
||||
try {
|
||||
const pdfDocument = createInvoiceDocument({
|
||||
invoice,
|
||||
items,
|
||||
client,
|
||||
organization,
|
||||
});
|
||||
|
||||
const stream = await renderToStream(pdfDocument);
|
||||
|
||||
// Restore console.log
|
||||
console.log = originalConsoleLog;
|
||||
|
||||
const filename = `${invoice.type}_${invoice.number.replace(/[^a-zA-Z0-9]/g, "_")}.pdf`;
|
||||
|
||||
return new Response(stream as any, {
|
||||
headers: {
|
||||
"Content-Type": "application/pdf",
|
||||
"Content-Disposition": `attachment; filename="${filename}"`,
|
||||
},
|
||||
});
|
||||
} catch (pdfError) {
|
||||
// Restore console.log on error
|
||||
console.log = originalConsoleLog;
|
||||
throw pdfError;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error generating PDF:", error);
|
||||
return new Response("Error generating PDF", { status: 500 });
|
||||
}
|
||||
};
|
||||
88
src/pages/api/invoices/[id]/items/add.ts
Normal file
88
src/pages/api/invoices/[id]/items/add.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import type { APIRoute } from "astro";
|
||||
import { db } from "../../../../../db";
|
||||
import { invoiceItems, invoices, members } from "../../../../../db/schema";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
import { recalculateInvoiceTotals } from "../../../../../utils/invoice";
|
||||
|
||||
export const POST: APIRoute = async ({
|
||||
request,
|
||||
redirect,
|
||||
locals,
|
||||
params,
|
||||
}) => {
|
||||
const user = locals.user;
|
||||
if (!user) {
|
||||
return redirect("/login");
|
||||
}
|
||||
|
||||
const { id: invoiceId } = params;
|
||||
if (!invoiceId) {
|
||||
return new Response("Invoice ID required", { status: 400 });
|
||||
}
|
||||
|
||||
// Fetch invoice to verify existence and check status
|
||||
const invoice = await db
|
||||
.select()
|
||||
.from(invoices)
|
||||
.where(eq(invoices.id, invoiceId))
|
||||
.get();
|
||||
|
||||
if (!invoice) {
|
||||
return new Response("Invoice not found", { status: 404 });
|
||||
}
|
||||
|
||||
// Verify membership
|
||||
const membership = await db
|
||||
.select()
|
||||
.from(members)
|
||||
.where(
|
||||
and(
|
||||
eq(members.userId, user.id),
|
||||
eq(members.organizationId, invoice.organizationId)
|
||||
)
|
||||
)
|
||||
.get();
|
||||
|
||||
if (!membership) {
|
||||
return new Response("Unauthorized", { status: 401 });
|
||||
}
|
||||
|
||||
// Only allow editing if draft
|
||||
if (invoice.status !== "draft") {
|
||||
return new Response("Cannot edit a finalized invoice", { status: 400 });
|
||||
}
|
||||
|
||||
const formData = await request.formData();
|
||||
const description = formData.get("description") as string;
|
||||
const quantityStr = formData.get("quantity") as string;
|
||||
const unitPriceStr = formData.get("unitPrice") as string;
|
||||
|
||||
if (!description || !quantityStr || !unitPriceStr) {
|
||||
return new Response("Missing required fields", { status: 400 });
|
||||
}
|
||||
|
||||
const quantity = parseFloat(quantityStr);
|
||||
const unitPriceMajor = parseFloat(unitPriceStr);
|
||||
|
||||
// Convert to cents
|
||||
const unitPrice = Math.round(unitPriceMajor * 100);
|
||||
const amount = Math.round(quantity * unitPrice);
|
||||
|
||||
try {
|
||||
await db.insert(invoiceItems).values({
|
||||
invoiceId,
|
||||
description,
|
||||
quantity,
|
||||
unitPrice,
|
||||
amount,
|
||||
});
|
||||
|
||||
// Update invoice totals
|
||||
await recalculateInvoiceTotals(invoiceId);
|
||||
|
||||
return redirect(`/dashboard/invoices/${invoiceId}`);
|
||||
} catch (error) {
|
||||
console.error("Error adding invoice item:", error);
|
||||
return new Response("Internal Server Error", { status: 500 });
|
||||
}
|
||||
};
|
||||
84
src/pages/api/invoices/[id]/items/delete.ts
Normal file
84
src/pages/api/invoices/[id]/items/delete.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import type { APIRoute } from "astro";
|
||||
import { db } from "../../../../../db";
|
||||
import { invoiceItems, invoices, members } from "../../../../../db/schema";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
import { recalculateInvoiceTotals } from "../../../../../utils/invoice";
|
||||
|
||||
export const POST: APIRoute = async ({
|
||||
request,
|
||||
redirect,
|
||||
locals,
|
||||
params,
|
||||
}) => {
|
||||
const user = locals.user;
|
||||
if (!user) {
|
||||
return redirect("/login");
|
||||
}
|
||||
|
||||
const { id: invoiceId } = params;
|
||||
if (!invoiceId) {
|
||||
return new Response("Invoice ID required", { status: 400 });
|
||||
}
|
||||
|
||||
// Fetch invoice to verify existence and check status
|
||||
const invoice = await db
|
||||
.select()
|
||||
.from(invoices)
|
||||
.where(eq(invoices.id, invoiceId))
|
||||
.get();
|
||||
|
||||
if (!invoice) {
|
||||
return new Response("Invoice not found", { status: 404 });
|
||||
}
|
||||
|
||||
// Verify membership
|
||||
const membership = await db
|
||||
.select()
|
||||
.from(members)
|
||||
.where(
|
||||
and(
|
||||
eq(members.userId, user.id),
|
||||
eq(members.organizationId, invoice.organizationId)
|
||||
)
|
||||
)
|
||||
.get();
|
||||
|
||||
if (!membership) {
|
||||
return new Response("Unauthorized", { status: 401 });
|
||||
}
|
||||
|
||||
// Only allow editing if draft
|
||||
if (invoice.status !== "draft") {
|
||||
return new Response("Cannot edit a finalized invoice", { status: 400 });
|
||||
}
|
||||
|
||||
const formData = await request.formData();
|
||||
const itemId = formData.get("itemId") as string;
|
||||
|
||||
if (!itemId) {
|
||||
return new Response("Item ID required", { status: 400 });
|
||||
}
|
||||
|
||||
// Verify item belongs to invoice
|
||||
const item = await db
|
||||
.select()
|
||||
.from(invoiceItems)
|
||||
.where(and(eq(invoiceItems.id, itemId), eq(invoiceItems.invoiceId, invoiceId)))
|
||||
.get();
|
||||
|
||||
if (!item) {
|
||||
return new Response("Item not found", { status: 404 });
|
||||
}
|
||||
|
||||
try {
|
||||
await db.delete(invoiceItems).where(eq(invoiceItems.id, itemId));
|
||||
|
||||
// Update invoice totals
|
||||
await recalculateInvoiceTotals(invoiceId);
|
||||
|
||||
return redirect(`/dashboard/invoices/${invoiceId}`);
|
||||
} catch (error) {
|
||||
console.error("Error deleting invoice item:", error);
|
||||
return new Response("Internal Server Error", { status: 500 });
|
||||
}
|
||||
};
|
||||
76
src/pages/api/invoices/[id]/status.ts
Normal file
76
src/pages/api/invoices/[id]/status.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import type { APIRoute } from "astro";
|
||||
import { db } from "../../../../db";
|
||||
import { invoices, members } from "../../../../db/schema";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
|
||||
export const POST: APIRoute = async ({
|
||||
request,
|
||||
redirect,
|
||||
locals,
|
||||
params,
|
||||
}) => {
|
||||
const user = locals.user;
|
||||
if (!user) {
|
||||
return redirect("/login");
|
||||
}
|
||||
|
||||
const { id: invoiceId } = params;
|
||||
if (!invoiceId) {
|
||||
return new Response("Invoice ID required", { status: 400 });
|
||||
}
|
||||
|
||||
const formData = await request.formData();
|
||||
const status = formData.get("status") as string;
|
||||
|
||||
const validStatuses = [
|
||||
"draft",
|
||||
"sent",
|
||||
"paid",
|
||||
"void",
|
||||
"accepted",
|
||||
"declined",
|
||||
];
|
||||
|
||||
if (!status || !validStatuses.includes(status)) {
|
||||
return new Response("Invalid status", { status: 400 });
|
||||
}
|
||||
|
||||
// Fetch invoice to verify existence and check ownership
|
||||
const invoice = await db
|
||||
.select()
|
||||
.from(invoices)
|
||||
.where(eq(invoices.id, invoiceId))
|
||||
.get();
|
||||
|
||||
if (!invoice) {
|
||||
return new Response("Invoice not found", { status: 404 });
|
||||
}
|
||||
|
||||
// Verify membership
|
||||
const membership = await db
|
||||
.select()
|
||||
.from(members)
|
||||
.where(
|
||||
and(
|
||||
eq(members.userId, user.id),
|
||||
eq(members.organizationId, invoice.organizationId)
|
||||
)
|
||||
)
|
||||
.get();
|
||||
|
||||
if (!membership) {
|
||||
return new Response("Unauthorized", { status: 401 });
|
||||
}
|
||||
|
||||
try {
|
||||
await db
|
||||
.update(invoices)
|
||||
.set({ status: status as any })
|
||||
.where(eq(invoices.id, invoiceId));
|
||||
|
||||
return redirect(`/dashboard/invoices/${invoiceId}`);
|
||||
} catch (error) {
|
||||
console.error("Error updating invoice status:", error);
|
||||
return new Response("Internal Server Error", { status: 500 });
|
||||
}
|
||||
};
|
||||
87
src/pages/api/invoices/[id]/update.ts
Normal file
87
src/pages/api/invoices/[id]/update.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import type { APIRoute } from "astro";
|
||||
import { db } from "../../../../db";
|
||||
import { invoices, members } from "../../../../db/schema";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
import { recalculateInvoiceTotals } from "../../../../utils/invoice";
|
||||
|
||||
export const POST: APIRoute = async ({
|
||||
request,
|
||||
redirect,
|
||||
locals,
|
||||
params,
|
||||
}) => {
|
||||
const user = locals.user;
|
||||
if (!user) {
|
||||
return redirect("/login");
|
||||
}
|
||||
|
||||
const { id: invoiceId } = params;
|
||||
if (!invoiceId) {
|
||||
return new Response("Invoice ID required", { status: 400 });
|
||||
}
|
||||
|
||||
// Fetch invoice to verify existence
|
||||
const invoice = await db
|
||||
.select()
|
||||
.from(invoices)
|
||||
.where(eq(invoices.id, invoiceId))
|
||||
.get();
|
||||
|
||||
if (!invoice) {
|
||||
return new Response("Invoice not found", { status: 404 });
|
||||
}
|
||||
|
||||
// Verify membership
|
||||
const membership = await db
|
||||
.select()
|
||||
.from(members)
|
||||
.where(
|
||||
and(
|
||||
eq(members.userId, user.id),
|
||||
eq(members.organizationId, invoice.organizationId)
|
||||
)
|
||||
)
|
||||
.get();
|
||||
|
||||
if (!membership) {
|
||||
return new Response("Unauthorized", { status: 401 });
|
||||
}
|
||||
|
||||
const formData = await request.formData();
|
||||
const number = formData.get("number") as string;
|
||||
const currency = formData.get("currency") as string;
|
||||
const issueDateStr = formData.get("issueDate") as string;
|
||||
const dueDateStr = formData.get("dueDate") as string;
|
||||
const taxRateStr = formData.get("taxRate") as string;
|
||||
const notes = formData.get("notes") as string;
|
||||
|
||||
if (!number || !currency || !issueDateStr || !dueDateStr) {
|
||||
return new Response("Missing required fields", { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
const issueDate = new Date(issueDateStr);
|
||||
const dueDate = new Date(dueDateStr);
|
||||
const taxRate = taxRateStr ? parseFloat(taxRateStr) : 0;
|
||||
|
||||
await db
|
||||
.update(invoices)
|
||||
.set({
|
||||
number,
|
||||
currency,
|
||||
issueDate,
|
||||
dueDate,
|
||||
taxRate,
|
||||
notes: notes || null,
|
||||
})
|
||||
.where(eq(invoices.id, invoiceId));
|
||||
|
||||
// Recalculate totals in case tax rate changed
|
||||
await recalculateInvoiceTotals(invoiceId);
|
||||
|
||||
return redirect(`/dashboard/invoices/${invoiceId}`);
|
||||
} catch (error) {
|
||||
console.error("Error updating invoice:", error);
|
||||
return new Response("Internal Server Error", { status: 500 });
|
||||
}
|
||||
};
|
||||
74
src/pages/api/invoices/create.ts
Normal file
74
src/pages/api/invoices/create.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import type { APIRoute } from "astro";
|
||||
import { db } from "../../../db";
|
||||
import { invoices, members } from "../../../db/schema";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
|
||||
export const POST: APIRoute = async ({ request, redirect, locals, cookies }) => {
|
||||
const user = locals.user;
|
||||
if (!user) {
|
||||
return redirect("/login");
|
||||
}
|
||||
|
||||
const formData = await request.formData();
|
||||
const type = formData.get("type") as string;
|
||||
const clientId = formData.get("clientId") as string;
|
||||
const number = formData.get("number") as string;
|
||||
const issueDateStr = formData.get("issueDate") as string;
|
||||
const dueDateStr = formData.get("dueDate") as string;
|
||||
const currency = formData.get("currency") as string;
|
||||
|
||||
if (!type || !clientId || !number || !issueDateStr || !dueDateStr) {
|
||||
return new Response("Missing required fields", { status: 400 });
|
||||
}
|
||||
|
||||
// Get current team context
|
||||
const currentTeamId = cookies.get("currentTeamId")?.value;
|
||||
|
||||
// Verify membership
|
||||
const userMemberships = await db
|
||||
.select()
|
||||
.from(members)
|
||||
.where(eq(members.userId, user.id))
|
||||
.all();
|
||||
|
||||
if (userMemberships.length === 0) {
|
||||
return redirect("/dashboard");
|
||||
}
|
||||
|
||||
const membership = currentTeamId
|
||||
? userMemberships.find((m) => m.organizationId === currentTeamId)
|
||||
: userMemberships[0];
|
||||
|
||||
if (!membership) {
|
||||
return new Response("Unauthorized", { status: 401 });
|
||||
}
|
||||
|
||||
const organizationId = membership.organizationId;
|
||||
|
||||
try {
|
||||
const issueDate = new Date(issueDateStr);
|
||||
const dueDate = new Date(dueDateStr);
|
||||
|
||||
const [newInvoice] = await db
|
||||
.insert(invoices)
|
||||
.values({
|
||||
organizationId,
|
||||
clientId,
|
||||
number,
|
||||
type: type as "invoice" | "quote",
|
||||
status: "draft",
|
||||
issueDate,
|
||||
dueDate,
|
||||
currency: currency || "USD",
|
||||
subtotal: 0,
|
||||
taxAmount: 0,
|
||||
total: 0,
|
||||
})
|
||||
.returning();
|
||||
|
||||
return redirect(`/dashboard/invoices/${newInvoice.id}`);
|
||||
} catch (error) {
|
||||
console.error("Error creating invoice:", error);
|
||||
return new Response("Internal Server Error", { status: 500 });
|
||||
}
|
||||
};
|
||||
58
src/pages/api/invoices/delete.ts
Normal file
58
src/pages/api/invoices/delete.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import type { APIRoute } from "astro";
|
||||
import { db } from "../../../db";
|
||||
import { invoices, invoiceItems, members } from "../../../db/schema";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
|
||||
export const POST: APIRoute = async ({ request, redirect, locals }) => {
|
||||
const user = locals.user;
|
||||
if (!user) {
|
||||
return redirect("/login");
|
||||
}
|
||||
|
||||
const formData = await request.formData();
|
||||
const invoiceId = formData.get("id") as string;
|
||||
|
||||
if (!invoiceId) {
|
||||
return new Response("Invoice ID required", { status: 400 });
|
||||
}
|
||||
|
||||
// Fetch invoice to verify existence and check ownership
|
||||
const invoice = await db
|
||||
.select()
|
||||
.from(invoices)
|
||||
.where(eq(invoices.id, invoiceId))
|
||||
.get();
|
||||
|
||||
if (!invoice) {
|
||||
return new Response("Invoice not found", { status: 404 });
|
||||
}
|
||||
|
||||
// Verify membership
|
||||
const membership = await db
|
||||
.select()
|
||||
.from(members)
|
||||
.where(
|
||||
and(
|
||||
eq(members.userId, user.id),
|
||||
eq(members.organizationId, invoice.organizationId)
|
||||
)
|
||||
)
|
||||
.get();
|
||||
|
||||
if (!membership) {
|
||||
return new Response("Unauthorized", { status: 401 });
|
||||
}
|
||||
|
||||
try {
|
||||
// Delete invoice items first (manual cascade)
|
||||
await db.delete(invoiceItems).where(eq(invoiceItems.invoiceId, invoiceId));
|
||||
|
||||
// Delete the invoice
|
||||
await db.delete(invoices).where(eq(invoices.id, invoiceId));
|
||||
|
||||
return redirect("/dashboard/invoices");
|
||||
} catch (error) {
|
||||
console.error("Error deleting invoice:", error);
|
||||
return new Response("Internal Server Error", { status: 500 });
|
||||
}
|
||||
};
|
||||
@@ -12,6 +12,11 @@ export const POST: APIRoute = async ({ request, locals, redirect }) => {
|
||||
const formData = await request.formData();
|
||||
const organizationId = formData.get("organizationId") as string;
|
||||
const name = formData.get("name") as string;
|
||||
const street = formData.get("street") as string | null;
|
||||
const city = formData.get("city") as string | null;
|
||||
const state = formData.get("state") as string | null;
|
||||
const zip = formData.get("zip") as string | null;
|
||||
const country = formData.get("country") as string | null;
|
||||
|
||||
if (!organizationId || !name || name.trim().length === 0) {
|
||||
return new Response("Organization ID and name are required", {
|
||||
@@ -44,16 +49,23 @@ export const POST: APIRoute = async ({ request, locals, redirect }) => {
|
||||
);
|
||||
}
|
||||
|
||||
// Update organization name
|
||||
// Update organization information
|
||||
await db
|
||||
.update(organizations)
|
||||
.set({ name: name.trim() })
|
||||
.set({
|
||||
name: name.trim(),
|
||||
street: street?.trim() || null,
|
||||
city: city?.trim() || null,
|
||||
state: state?.trim() || null,
|
||||
zip: zip?.trim() || null,
|
||||
country: country?.trim() || null,
|
||||
})
|
||||
.where(eq(organizations.id, organizationId))
|
||||
.run();
|
||||
|
||||
return redirect("/dashboard/team/settings?success=org-name");
|
||||
} catch (error) {
|
||||
console.error("Error updating organization name:", error);
|
||||
return new Response("Failed to update organization name", { status: 500 });
|
||||
console.error("Error updating organization:", error);
|
||||
return new Response("Failed to update organization", { status: 500 });
|
||||
}
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user