diff --git a/apps/api/src/routes/invoices.ts b/apps/api/src/routes/invoices.ts index 0f65a34..2714be4 100644 --- a/apps/api/src/routes/invoices.ts +++ b/apps/api/src/routes/invoices.ts @@ -8,6 +8,7 @@ import { invoices, invoiceLineItems, invoiceTipSplits, + refunds, appointments, services, clients, @@ -125,8 +126,8 @@ const tipSplitSchema = z.object({ }) ).min(1).refine( (splits) => { - const total = splits.reduce((sum, s) => sum + s.sharePct, 0); - return Math.abs(total - 100) < 0.01; + const totalBps = splits.reduce((sum, s) => sum + Math.round(s.sharePct * 100), 0); + return totalBps === 10000; }, { message: "Split percentages must sum to 100" } ), @@ -170,12 +171,13 @@ invoicesRouter.post( } }); - const splits = await db - .select() - .from(invoiceTipSplits) - .where(eq(invoiceTipSplits.invoiceId, id)); + const [updatedInvoice] = await db.select().from(invoices).where(eq(invoices.id, id)); + const [lineItems, tipSplits] = await Promise.all([ + db.select().from(invoiceLineItems).where(eq(invoiceLineItems.invoiceId, id)), + db.select().from(invoiceTipSplits).where(eq(invoiceTipSplits.invoiceId, id)), + ]); - return c.json(splits, 201); + return c.json({ ...updatedInvoice, lineItems, tipSplits }, 201); } ); @@ -300,6 +302,13 @@ invoicesRouter.post("/from-appointment/:appointmentId", async (c) => { return c.json({ ...invoice, lineItems: [lineItem] }, 201); }); +const ALLOWED_TRANSITIONS: Record = { + draft: ["pending", "void"], + pending: ["draft", "paid", "void"], + paid: ["void"], + void: [], +}; + // Update invoice invoicesRouter.patch( "/:id", @@ -315,8 +324,14 @@ invoicesRouter.patch( .where(eq(invoices.id, id)); if (!current) return c.json({ error: "Not found" }, 404); - if (current.status === "void") { - return c.json({ error: "Cannot modify a voided invoice" }, 422); + if (body.status !== undefined) { + const allowed = ALLOWED_TRANSITIONS[current.status] ?? []; + if (!allowed.includes(body.status)) { + return c.json( + { error: `Invalid status transition from ${current.status} to ${body.status}` }, + 422 + ); + } } const update: Record = { ...body, updatedAt: new Date() }; @@ -354,6 +369,7 @@ import { processRefund } from "../services/payment.js"; const refundSchema = z.object({ amountCents: z.number().int().nonnegative().optional(), + idempotencyKey: z.string().max(255).optional(), }); invoicesRouter.post( @@ -379,9 +395,28 @@ invoicesRouter.post( return c.json({ error: "No Stripe payment intent found for this invoice" }, 422); } - const result = await processRefund(id, body.amountCents); - if (!result) return c.json({ error: "Refund failed" }, 500); + return await db.transaction(async (tx) => { + if (body.idempotencyKey) { + const [existing] = await tx + .select() + .from(refunds) + .where(eq(refunds.idempotencyKey, body.idempotencyKey)); + if (existing) { + return c.json({ refundId: existing.stripeRefundId }); + } + } - return c.json({ refundId: result.refundId }); + const result = await processRefund(id, body.amountCents); + if (!result) return c.json({ error: "Refund failed" }, 500); + + await tx.insert(refunds).values({ + invoiceId: id, + stripeRefundId: result.refundId, + idempotencyKey: body.idempotencyKey ?? null, + amountCents: body.amountCents ?? null, + }); + + return c.json({ refundId: result.refundId }); + }); } ); diff --git a/packages/db/migrations/0027_refunds.sql b/packages/db/migrations/0027_refunds.sql new file mode 100644 index 0000000..ba8d6ea --- /dev/null +++ b/packages/db/migrations/0027_refunds.sql @@ -0,0 +1,11 @@ +CREATE TABLE "refunds" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "invoice_id" uuid NOT NULL REFERENCES "invoices"("id") ON DELETE RESTRICT, + "stripe_refund_id" text NOT NULL, + "idempotency_key" text UNIQUE, + "amount_cents" integer, + "created_at" timestamp NOT NULL DEFAULT NOW() +); + +CREATE INDEX "idx_refunds_invoice_id" ON "refunds"("invoice_id"); +CREATE INDEX "idx_refunds_idempotency_key" ON "refunds"("idempotency_key"); diff --git a/packages/db/migrations/meta/_journal.json b/packages/db/migrations/meta/_journal.json index 96da64a..c5ad96e 100644 --- a/packages/db/migrations/meta/_journal.json +++ b/packages/db/migrations/meta/_journal.json @@ -190,6 +190,13 @@ "when": 1775568867192, "tag": "0026_stripe_payment", "breakpoints": true + }, + { + "idx": 27, + "version": "7", + "when": 1775655267192, + "tag": "0027_refunds", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/db/src/schema.ts b/packages/db/src/schema.ts index a5d6bfc..375bf75 100644 --- a/packages/db/src/schema.ts +++ b/packages/db/src/schema.ts @@ -300,6 +300,25 @@ export const invoiceTipSplits = pgTable( (t) => [index("idx_invoice_tip_splits_invoice_id").on(t.invoiceId)] ); +// Refund records with idempotency key support +export const refunds = pgTable( + "refunds", + { + id: uuid("id").primaryKey().defaultRandom(), + invoiceId: uuid("invoice_id") + .notNull() + .references(() => invoices.id, { onDelete: "restrict" }), + stripeRefundId: text("stripe_refund_id").notNull(), + idempotencyKey: text("idempotency_key").unique(), + amountCents: integer("amount_cents"), + createdAt: timestamp("created_at").notNull().defaultNow(), + }, + (t) => [ + index("idx_refunds_invoice_id").on(t.invoiceId), + index("idx_refunds_idempotency_key").on(t.idempotencyKey), + ] +); + // Tracks which reminder emails have been sent per appointment (prevents duplicates). // reminder_type values: "confirmation", "24h", "2h" export const reminderLogs = pgTable(