Skip to content

Commit 7ac33b2

Browse files
authored
Merge pull request #667 from pragmaticAweds/issue-640-invoice-line-items
feat(routes-b): add invoice line-items CRUD (#640)
2 parents 36bc7b5 + 4920763 commit 7ac33b2

4 files changed

Lines changed: 823 additions & 0 deletions

File tree

Lines changed: 244 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,244 @@
1+
import crypto from 'node:crypto'
2+
3+
export const MAX_ITEMS_PER_INVOICE = 50
4+
export const MAX_NAME_LENGTH = 200
5+
6+
export type LineItem = {
7+
id: string
8+
name: string
9+
quantity: number
10+
unitPrice: number
11+
taxRate: number
12+
createdAt: string
13+
updatedAt: string
14+
}
15+
16+
export type LineItemTotals = {
17+
subtotal: number
18+
taxTotal: number
19+
total: number
20+
}
21+
22+
type NewItemInput = { name: string; quantity: number; unitPrice: number; taxRate: number }
23+
type ItemPatch = Partial<NewItemInput>
24+
25+
export type ItemValidationResult =
26+
| { ok: true; value: NewItemInput }
27+
| { ok: false; error: string }
28+
29+
export type ItemPatchValidationResult =
30+
| { ok: true; value: ItemPatch }
31+
| { ok: false; error: string }
32+
33+
const itemStore = new Map<string, LineItem[]>()
34+
35+
function isPositiveNumber(value: unknown): value is number {
36+
return typeof value === 'number' && Number.isFinite(value) && value > 0
37+
}
38+
39+
function isPositiveInteger(value: unknown): value is number {
40+
return typeof value === 'number' && Number.isInteger(value) && value > 0
41+
}
42+
43+
function isValidTaxRate(value: unknown): value is number {
44+
return typeof value === 'number' && Number.isFinite(value) && value >= 0 && value <= 1
45+
}
46+
47+
function isValidName(value: unknown): value is string {
48+
return typeof value === 'string' && value.trim().length > 0 && value.length <= MAX_NAME_LENGTH
49+
}
50+
51+
function roundCents(value: number): number {
52+
return Math.round(value * 100) / 100
53+
}
54+
55+
export function validateNewItem(body: unknown): ItemValidationResult {
56+
if (!body || typeof body !== 'object') {
57+
return { ok: false, error: 'Item body must be an object' }
58+
}
59+
const { name, quantity, unitPrice, taxRate } = body as Record<string, unknown>
60+
61+
if (!isValidName(name)) {
62+
return { ok: false, error: `name must be a non-empty string (max ${MAX_NAME_LENGTH} chars)` }
63+
}
64+
if (!isPositiveInteger(quantity)) {
65+
return { ok: false, error: 'quantity must be a positive integer' }
66+
}
67+
if (!isPositiveNumber(unitPrice)) {
68+
return { ok: false, error: 'unitPrice must be a positive number' }
69+
}
70+
if (!isValidTaxRate(taxRate)) {
71+
return { ok: false, error: 'taxRate must be a number between 0 and 1' }
72+
}
73+
74+
return {
75+
ok: true,
76+
value: { name: (name as string).trim(), quantity, unitPrice, taxRate },
77+
}
78+
}
79+
80+
export function validateItemPatch(body: unknown): ItemPatchValidationResult {
81+
if (!body || typeof body !== 'object') {
82+
return { ok: false, error: 'Patch body must be an object' }
83+
}
84+
const { name, quantity, unitPrice, taxRate } = body as Record<string, unknown>
85+
const out: ItemPatch = {}
86+
87+
if (name !== undefined) {
88+
if (!isValidName(name)) {
89+
return { ok: false, error: `name must be a non-empty string (max ${MAX_NAME_LENGTH} chars)` }
90+
}
91+
out.name = (name as string).trim()
92+
}
93+
if (quantity !== undefined) {
94+
if (!isPositiveInteger(quantity)) {
95+
return { ok: false, error: 'quantity must be a positive integer' }
96+
}
97+
out.quantity = quantity
98+
}
99+
if (unitPrice !== undefined) {
100+
if (!isPositiveNumber(unitPrice)) {
101+
return { ok: false, error: 'unitPrice must be a positive number' }
102+
}
103+
out.unitPrice = unitPrice
104+
}
105+
if (taxRate !== undefined) {
106+
if (!isValidTaxRate(taxRate)) {
107+
return { ok: false, error: 'taxRate must be a number between 0 and 1' }
108+
}
109+
out.taxRate = taxRate
110+
}
111+
112+
if (Object.keys(out).length === 0) {
113+
return { ok: false, error: 'No fields to update' }
114+
}
115+
116+
return { ok: true, value: out }
117+
}
118+
119+
export function listLineItems(invoiceId: string): LineItem[] {
120+
const stored = itemStore.get(invoiceId)
121+
return stored ? stored.map(item => ({ ...item })) : []
122+
}
123+
124+
export function getLineItem(invoiceId: string, itemId: string): LineItem | null {
125+
const stored = itemStore.get(invoiceId)
126+
if (!stored) return null
127+
const found = stored.find(item => item.id === itemId)
128+
return found ? { ...found } : null
129+
}
130+
131+
export function computeTotals(items: LineItem[]): LineItemTotals {
132+
let subtotal = 0
133+
let taxTotal = 0
134+
for (const item of items) {
135+
const lineSubtotal = item.quantity * item.unitPrice
136+
subtotal += lineSubtotal
137+
taxTotal += lineSubtotal * item.taxRate
138+
}
139+
subtotal = roundCents(subtotal)
140+
taxTotal = roundCents(taxTotal)
141+
return {
142+
subtotal,
143+
taxTotal,
144+
total: roundCents(subtotal + taxTotal),
145+
}
146+
}
147+
148+
export class InvoiceItemCapError extends Error {
149+
code = 'INVOICE_ITEM_CAP'
150+
constructor() {
151+
super(`An invoice can have at most ${MAX_ITEMS_PER_INVOICE} items`)
152+
}
153+
}
154+
155+
export class LineItemNotFoundError extends Error {
156+
code = 'LINE_ITEM_NOT_FOUND'
157+
constructor() {
158+
super('Line item not found')
159+
}
160+
}
161+
162+
type Plan<T> = { totals: LineItemTotals; commit: () => void } & T
163+
164+
export function planAddItem(invoiceId: string, input: NewItemInput): Plan<{ item: LineItem }> {
165+
const existing = itemStore.get(invoiceId) ?? []
166+
if (existing.length >= MAX_ITEMS_PER_INVOICE) {
167+
throw new InvoiceItemCapError()
168+
}
169+
170+
const now = new Date().toISOString()
171+
const item: LineItem = {
172+
id: crypto.randomUUID(),
173+
name: input.name,
174+
quantity: input.quantity,
175+
unitPrice: input.unitPrice,
176+
taxRate: input.taxRate,
177+
createdAt: now,
178+
updatedAt: now,
179+
}
180+
const next = [...existing, item]
181+
const totals = computeTotals(next)
182+
183+
return {
184+
item: { ...item },
185+
totals,
186+
commit: () => {
187+
itemStore.set(invoiceId, next)
188+
},
189+
}
190+
}
191+
192+
export function planPatchItem(
193+
invoiceId: string,
194+
itemId: string,
195+
patch: ItemPatch,
196+
): Plan<{ item: LineItem }> {
197+
const existing = itemStore.get(invoiceId)
198+
if (!existing) throw new LineItemNotFoundError()
199+
const idx = existing.findIndex(item => item.id === itemId)
200+
if (idx < 0) throw new LineItemNotFoundError()
201+
202+
const updated: LineItem = {
203+
...existing[idx],
204+
...patch,
205+
updatedAt: new Date().toISOString(),
206+
}
207+
const next = existing.slice()
208+
next[idx] = updated
209+
const totals = computeTotals(next)
210+
211+
return {
212+
item: { ...updated },
213+
totals,
214+
commit: () => {
215+
itemStore.set(invoiceId, next)
216+
},
217+
}
218+
}
219+
220+
export function planRemoveItem(invoiceId: string, itemId: string): Plan<Record<string, never>> {
221+
const existing = itemStore.get(invoiceId)
222+
if (!existing) throw new LineItemNotFoundError()
223+
const idx = existing.findIndex(item => item.id === itemId)
224+
if (idx < 0) throw new LineItemNotFoundError()
225+
226+
const next = existing.slice()
227+
next.splice(idx, 1)
228+
const totals = computeTotals(next)
229+
230+
return {
231+
totals,
232+
commit: () => {
233+
if (next.length === 0) {
234+
itemStore.delete(invoiceId)
235+
} else {
236+
itemStore.set(invoiceId, next)
237+
}
238+
},
239+
}
240+
}
241+
242+
export function resetInvoiceLineItemsStore(): void {
243+
itemStore.clear()
244+
}
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
import { withRequestId } from '../../../../_lib/with-request-id'
2+
import { NextRequest, NextResponse } from 'next/server'
3+
import { prisma } from '@/lib/db'
4+
import { verifyAuthToken } from '@/lib/auth'
5+
import {
6+
LineItemNotFoundError,
7+
planPatchItem,
8+
planRemoveItem,
9+
validateItemPatch,
10+
} from '../../../../_lib/invoice-line-items'
11+
12+
async function authorizeInvoiceAccess(request: NextRequest, invoiceId: string) {
13+
const authToken = request.headers.get('authorization')?.replace('Bearer ', '')
14+
const claims = await verifyAuthToken(authToken || '')
15+
if (!claims) {
16+
return { ok: false as const, response: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) }
17+
}
18+
19+
const user = await prisma.user.findUnique({ where: { privyId: claims.userId } })
20+
if (!user) {
21+
return { ok: false as const, response: NextResponse.json({ error: 'User not found' }, { status: 404 }) }
22+
}
23+
24+
const invoice = await prisma.invoice.findUnique({
25+
where: { id: invoiceId },
26+
select: { id: true, userId: true, status: true },
27+
})
28+
if (!invoice) {
29+
return { ok: false as const, response: NextResponse.json({ error: 'Invoice not found' }, { status: 404 }) }
30+
}
31+
if (invoice.userId !== user.id) {
32+
return { ok: false as const, response: NextResponse.json({ error: 'Forbidden' }, { status: 403 }) }
33+
}
34+
35+
return { ok: true as const, user, invoice }
36+
}
37+
38+
async function PATCHHandler(
39+
request: NextRequest,
40+
{ params }: { params: Promise<{ id: string; itemId: string }> },
41+
) {
42+
const { id, itemId } = await params
43+
const auth = await authorizeInvoiceAccess(request, id)
44+
if (!auth.ok) return auth.response
45+
46+
if (auth.invoice.status !== 'pending') {
47+
return NextResponse.json(
48+
{ error: 'Only pending invoices can be edited', code: 'INVOICE_NOT_EDITABLE' },
49+
{ status: 422 },
50+
)
51+
}
52+
53+
const body = await request.json().catch(() => null)
54+
const validated = validateItemPatch(body)
55+
if (!validated.ok) {
56+
return NextResponse.json({ error: validated.error }, { status: 400 })
57+
}
58+
59+
let plan
60+
try {
61+
plan = planPatchItem(id, itemId, validated.value)
62+
} catch (error) {
63+
if (error instanceof LineItemNotFoundError) {
64+
return NextResponse.json({ error: error.message }, { status: 404 })
65+
}
66+
throw error
67+
}
68+
69+
await prisma.$transaction(async (tx) => {
70+
await tx.invoice.update({
71+
where: { id },
72+
data: { amount: plan.totals.total },
73+
})
74+
})
75+
plan.commit()
76+
77+
return NextResponse.json({ item: plan.item, totals: plan.totals })
78+
}
79+
80+
async function DELETEHandler(
81+
request: NextRequest,
82+
{ params }: { params: Promise<{ id: string; itemId: string }> },
83+
) {
84+
const { id, itemId } = await params
85+
const auth = await authorizeInvoiceAccess(request, id)
86+
if (!auth.ok) return auth.response
87+
88+
if (auth.invoice.status !== 'pending') {
89+
return NextResponse.json(
90+
{ error: 'Only pending invoices can be edited', code: 'INVOICE_NOT_EDITABLE' },
91+
{ status: 422 },
92+
)
93+
}
94+
95+
let plan
96+
try {
97+
plan = planRemoveItem(id, itemId)
98+
} catch (error) {
99+
if (error instanceof LineItemNotFoundError) {
100+
return NextResponse.json({ error: error.message }, { status: 404 })
101+
}
102+
throw error
103+
}
104+
105+
await prisma.$transaction(async (tx) => {
106+
await tx.invoice.update({
107+
where: { id },
108+
data: { amount: plan.totals.total },
109+
})
110+
})
111+
plan.commit()
112+
113+
return NextResponse.json({ totals: plan.totals })
114+
}
115+
116+
export const PATCH = withRequestId(PATCHHandler)
117+
export const DELETE = withRequestId(DELETEHandler)

0 commit comments

Comments
 (0)