feat: add recurring invoices, public links, time tracker, payments, reminders
- Recurring invoices: schedule-based auto-generation with CRUD UI at /dashboard/invoices/recurring and POST /api/cron/generate-recurring cron endpoint - Public invoice link: generate/revoke shareable /i/[token] page for unauthenticated clients with PDF download - Live time tracker: localStorage-persisted timer widget in invoice editor that appends a line item on stop (rounds to nearest 0.25h) - Partial payment tracking: record payments per invoice, auto-mark paid when fully covered, balance due display - Send reminder: email reminder via Resend with custom message dialog and last-sent indicator Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,103 @@
|
||||
import { z } from "zod";
|
||||
import { eq, sum } from "drizzle-orm";
|
||||
import { createTRPCRouter, protectedProcedure } from "../trpc";
|
||||
import { invoicePayments, invoices } from "~/server/db/schema";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
|
||||
const PAYMENT_METHODS = ["cash", "check", "bank_transfer", "credit_card", "paypal", "other"] as const;
|
||||
|
||||
export const paymentsRouter = createTRPCRouter({
|
||||
getByInvoice: protectedProcedure
|
||||
.input(z.object({ invoiceId: z.string() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
const invoice = await ctx.db.query.invoices.findFirst({
|
||||
where: eq(invoices.id, input.invoiceId),
|
||||
});
|
||||
if (invoice?.createdById !== ctx.session.user.id) {
|
||||
throw new TRPCError({ code: "NOT_FOUND" });
|
||||
}
|
||||
return ctx.db.query.invoicePayments.findMany({
|
||||
where: eq(invoicePayments.invoiceId, input.invoiceId),
|
||||
orderBy: (p, { desc }) => [desc(p.date)],
|
||||
});
|
||||
}),
|
||||
|
||||
create: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
invoiceId: z.string(),
|
||||
amount: z.number().positive(),
|
||||
date: z.date(),
|
||||
method: z.enum(PAYMENT_METHODS).default("other"),
|
||||
notes: z.string().max(500).optional(),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const invoice = await ctx.db.query.invoices.findFirst({
|
||||
where: eq(invoices.id, input.invoiceId),
|
||||
});
|
||||
if (invoice?.createdById !== ctx.session.user.id) {
|
||||
throw new TRPCError({ code: "NOT_FOUND" });
|
||||
}
|
||||
|
||||
await ctx.db.insert(invoicePayments).values({
|
||||
invoiceId: input.invoiceId,
|
||||
amount: input.amount,
|
||||
currency: invoice.currency,
|
||||
date: input.date,
|
||||
method: input.method,
|
||||
notes: input.notes ?? null,
|
||||
createdById: ctx.session.user.id,
|
||||
});
|
||||
|
||||
// Auto-mark paid if total payments >= invoice total
|
||||
const [totals] = await ctx.db
|
||||
.select({ paid: sum(invoicePayments.amount) })
|
||||
.from(invoicePayments)
|
||||
.where(eq(invoicePayments.invoiceId, input.invoiceId));
|
||||
|
||||
const totalPaid = Number(totals?.paid ?? 0);
|
||||
if (totalPaid >= invoice.totalAmount && invoice.status !== "paid") {
|
||||
await ctx.db
|
||||
.update(invoices)
|
||||
.set({ status: "paid" })
|
||||
.where(eq(invoices.id, input.invoiceId));
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
}),
|
||||
|
||||
delete: protectedProcedure
|
||||
.input(z.object({ id: z.string() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const payment = await ctx.db.query.invoicePayments.findFirst({
|
||||
where: eq(invoicePayments.id, input.id),
|
||||
});
|
||||
if (!payment) throw new TRPCError({ code: "NOT_FOUND" });
|
||||
|
||||
const invoice = await ctx.db.query.invoices.findFirst({
|
||||
where: eq(invoices.id, payment.invoiceId),
|
||||
});
|
||||
if (invoice?.createdById !== ctx.session.user.id) {
|
||||
throw new TRPCError({ code: "FORBIDDEN" });
|
||||
}
|
||||
|
||||
await ctx.db.delete(invoicePayments).where(eq(invoicePayments.id, input.id));
|
||||
|
||||
// Re-check paid status after deletion
|
||||
const [totals] = await ctx.db
|
||||
.select({ paid: sum(invoicePayments.amount) })
|
||||
.from(invoicePayments)
|
||||
.where(eq(invoicePayments.invoiceId, payment.invoiceId));
|
||||
|
||||
const totalPaid = Number(totals?.paid ?? 0);
|
||||
if (totalPaid < invoice.totalAmount && invoice.status === "paid") {
|
||||
await ctx.db
|
||||
.update(invoices)
|
||||
.set({ status: "sent" })
|
||||
.where(eq(invoices.id, payment.invoiceId));
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
}),
|
||||
});
|
||||
Reference in New Issue
Block a user