Files
beenvoice-web/src/server/api/routers/payments.ts
T
soconnor f0c34160df 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>
2026-05-10 17:17:58 -04:00

104 lines
3.4 KiB
TypeScript

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 };
}),
});