UI/settings api mcp (#524)

* feat(voucher): add create voucher and correct entry previews; update commit methods

* feat: add support for pending operations in API key scopes and OAuth client management

- Introduced new API key scopes for reading and approving pending operations.
- Updated the scope groups to include pending operations.
- Added new tools for listing and managing pending operations.
- Implemented OAuth client registration and revocation endpoints.
- Created a UI panel for managing OAuth clients, including registration and revocation.
- Added tests for pending operations tools and OAuth allowlist functionality.
- Implemented a database migration for OAuth client registrations with appropriate policies and constraints.

* feat: Implement OAuth client registration rate limiting and enhance security measures

- Added IP-based rate limiting to the OAuth client registration endpoint to prevent enumeration attacks.
- Introduced a service-role client for allowlist lookups, ensuring trust boundaries are maintained.
- Updated error responses to be uniform across different types of redirect URI validation failures.
- Enhanced tests to reflect changes in OAuth scope handling, ensuring fallback to read-only scopes when no scopes are provided.
- Improved handling of high-risk pending operations, requiring explicit confirmation for approvals.
- Added audit logging for OAuth client revocations and pending operation approvals/rejections to maintain a security audit trail.
- Refactored API key scope management to include default read-only scopes for OAuth-issued keys and added segregation-of-duties checks.

* feat: add recurring invoice scheduling functionality

- Implemented recurring invoice schedules with a new database schema.
- Created API routes for managing recurring invoices (GET and POST).
- Added cron job to automatically generate invoices based on schedules.
- Developed service functions for computing next run dates and executing schedules.
- Added tests for the new functionality, including validation and success cases.
- Introduced error handling for various scenarios in the invoice creation process.

* feat: refine VAT rate validation and enhance recurring invoice handling
This commit is contained in:
Mattsson
2026-05-19 13:48:32 +02:00
committed by GitHub
parent 16164ea14c
commit e211ab31be
22 changed files with 2133 additions and 76 deletions
+1
View File
@@ -411,6 +411,7 @@ export default function NewInvoicePage() {
your_reference: pendingData.your_reference,
our_reference: pendingData.our_reference,
notes: pendingData.notes,
invoice_number: numberPreview,
}),
})
+23 -15
View File
@@ -15,7 +15,7 @@ import { formatCurrency, formatDate } from '@/lib/utils'
import { cn } from '@/lib/utils'
import { invoiceNumberDisplay } from '@/lib/invoices/display'
import { getDisplayTotal } from '@/lib/invoices/rounding'
import { Plus, Search, Receipt, Lock } from 'lucide-react'
import { Plus, Search, Receipt, Lock, Repeat } from 'lucide-react'
import { EmptyInvoices, EmptyState } from '@/components/ui/empty-state'
import { useCompany } from '@/contexts/CompanyContext'
import { useCanWrite } from '@/lib/hooks/use-can-write'
@@ -138,22 +138,30 @@ export default function InvoicesPage() {
<PageHeader
title="Fakturor"
action={
canWrite ? (
<Link href="/invoices/new">
<Button>
<Plus className="mr-2 h-4 w-4" />
Ny faktura
<div className="flex gap-2">
<Link href="/invoices/recurring">
<Button variant="secondary">
<Repeat className="mr-2 h-4 w-4" />
Återkommande
</Button>
</Link>
) : (
<Button
disabled
title="Du har endast läsbehörighet i detta företag"
>
<Lock className="mr-2 h-4 w-4" />
Ny faktura
</Button>
)
{canWrite ? (
<Link href="/invoices/new">
<Button>
<Plus className="mr-2 h-4 w-4" />
Ny faktura
</Button>
</Link>
) : (
<Button
disabled
title="Du har endast läsbehörighet i detta företag"
>
<Lock className="mr-2 h-4 w-4" />
Ny faktura
</Button>
)}
</div>
}
/>
@@ -0,0 +1,383 @@
'use client'
import { useState, useEffect } from 'react'
import { useRouter } from 'next/navigation'
import Link from 'next/link'
import { createClient } from '@/lib/supabase/client'
import { useForm, useFieldArray, Controller } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Textarea } from '@/components/ui/textarea'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { PageHeader } from '@/components/ui/page-header'
import { useToast } from '@/components/ui/use-toast'
import { useCompany } from '@/contexts/CompanyContext'
import { ArrowLeft, Plus, Trash2 } from 'lucide-react'
import type { Customer, Currency } from '@/types'
import { formatCurrency } from '@/lib/utils'
const itemSchema = z.object({
description: z.string().min(1, 'Beskrivning krävs'),
quantity: z.number().min(0.01, 'Minst 0.01'),
unit: z.string().min(1, 'Enhet krävs'),
unit_price: z.number(),
vat_rate: z
.union([z.literal(0), z.literal(6), z.literal(12), z.literal(25)])
.nullable()
.optional(),
})
const schema = z.object({
customer_id: z.string().uuid('Välj en kund'),
name: z.string().min(1, 'Namn krävs'),
day_of_month: z.number().int().min(1).max(31),
payment_terms_days: z.number().int().min(0).max(90),
currency: z.enum(['SEK', 'EUR', 'USD', 'GBP', 'NOK', 'DKK']),
auto_send: z.boolean(),
your_reference: z.string().optional(),
our_reference: z.string().optional(),
notes: z.string().optional(),
items: z.array(itemSchema).min(1, 'Minst en rad krävs'),
})
type FormData = z.infer<typeof schema>
const currencies: Currency[] = ['SEK', 'EUR', 'USD', 'GBP', 'NOK', 'DKK']
const units = ['st', 'tim', 'dag', 'månad', 'km', 'kg']
export default function NewRecurringSchedulePage() {
const router = useRouter()
const { toast } = useToast()
const { company } = useCompany()
const supabase = createClient()
const [customers, setCustomers] = useState<Customer[]>([])
const [isSubmitting, setIsSubmitting] = useState(false)
const {
register,
control,
handleSubmit,
watch,
formState: { errors },
} = useForm<FormData>({
resolver: zodResolver(schema),
defaultValues: {
customer_id: '',
name: '',
day_of_month: 15,
payment_terms_days: 30,
currency: 'SEK',
auto_send: false,
items: [{ description: '', quantity: 1, unit: 'st', unit_price: 0, vat_rate: 25 }],
},
})
const { fields, append, remove } = useFieldArray({ control, name: 'items' })
useEffect(() => {
if (!company) return
supabase
.from('customers')
.select('*')
.eq('company_id', company.id)
.order('name')
.then(({ data }) => setCustomers(data ?? []))
}, [company])
async function onSubmit(data: FormData) {
setIsSubmitting(true)
try {
const res = await fetch('/api/invoices/recurring', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
})
if (!res.ok) {
const body = await res.json().catch(() => ({}))
throw new Error(body.error || 'Kunde inte skapa schema')
}
toast({ title: 'Schema skapat' })
router.push('/invoices/recurring')
} catch (err) {
toast({
title: 'Kunde inte skapa schema',
description: err instanceof Error ? err.message : undefined,
variant: 'destructive',
})
} finally {
setIsSubmitting(false)
}
}
const items = watch('items')
const watchCurrency = watch('currency')
const subtotalRaw = items.reduce(
(sum, it) => sum + (it.quantity || 0) * (it.unit_price || 0),
0,
)
// Round to öre using the project monetary rule, then format.
const subtotal = Math.round(subtotalRaw * 100) / 100
return (
<div className="space-y-8">
<Link
href="/invoices/recurring"
className="inline-flex items-center text-sm text-muted-foreground hover:text-foreground"
>
<ArrowLeft className="h-4 w-4 mr-1" />
Tillbaka
</Link>
<PageHeader title="Nytt återkommande schema" />
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
<Card>
<CardHeader>
<CardTitle className="text-base">Schema</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div>
<Label htmlFor="name">Namn</Label>
<Input
id="name"
placeholder="t.ex. Månadsretainer Acme AB"
{...register('name')}
/>
{errors.name && (
<p className="text-sm text-destructive mt-1">{errors.name.message}</p>
)}
</div>
<div>
<Label htmlFor="customer_id">Kund</Label>
<Controller
control={control}
name="customer_id"
render={({ field }) => (
<Select value={field.value} onValueChange={field.onChange}>
<SelectTrigger id="customer_id">
<SelectValue placeholder="Välj kund" />
</SelectTrigger>
<SelectContent>
{customers.map((c) => (
<SelectItem key={c.id} value={c.id}>
{c.name}
</SelectItem>
))}
</SelectContent>
</Select>
)}
/>
{errors.customer_id && (
<p className="text-sm text-destructive mt-1">{errors.customer_id.message}</p>
)}
</div>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
<div>
<Label htmlFor="day_of_month">Dag i månaden</Label>
<Input
id="day_of_month"
type="number"
min={1}
max={31}
className="tabular-nums"
{...register('day_of_month', { valueAsNumber: true })}
/>
<p className="text-xs text-muted-foreground mt-1">
29-31 körs sista dagen i kortare månader.
</p>
</div>
<div>
<Label htmlFor="payment_terms_days">Betalningsvillkor (dagar)</Label>
<Input
id="payment_terms_days"
type="number"
min={0}
max={90}
className="tabular-nums"
{...register('payment_terms_days', { valueAsNumber: true })}
/>
</div>
<div>
<Label htmlFor="currency">Valuta</Label>
<Controller
control={control}
name="currency"
render={({ field }) => (
<Select value={field.value} onValueChange={field.onChange}>
<SelectTrigger id="currency">
<SelectValue />
</SelectTrigger>
<SelectContent>
{currencies.map((c) => (
<SelectItem key={c} value={c}>
{c}
</SelectItem>
))}
</SelectContent>
</Select>
)}
/>
</div>
</div>
<div className="rounded-lg border border-border p-4">
<div className="flex items-start gap-3">
<Controller
control={control}
name="auto_send"
render={({ field }) => (
<input
type="checkbox"
id="auto_send"
checked={field.value}
onChange={(e) => field.onChange(e.target.checked)}
className="mt-1 h-4 w-4"
/>
)}
/>
<div className="flex-1">
<Label htmlFor="auto_send" className="font-medium">
Skapa och skicka automatiskt
</Label>
<p className="text-sm text-muted-foreground mt-1">
När markerad: fakturan skickas med e-post till kunden direkt vid
skapande. Annars skapas den som utkast för manuell granskning.
</p>
</div>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base">Rader</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{fields.map((field, index) => (
<div
key={field.id}
className="grid grid-cols-12 gap-2 items-start"
>
<div className="col-span-12 sm:col-span-5">
<Input
placeholder="Beskrivning"
{...register(`items.${index}.description`)}
/>
</div>
<div className="col-span-3 sm:col-span-2">
<Input
type="number"
step="0.01"
placeholder="Antal"
className="tabular-nums"
{...register(`items.${index}.quantity`, { valueAsNumber: true })}
/>
</div>
<div className="col-span-3 sm:col-span-1">
<Controller
control={control}
name={`items.${index}.unit`}
render={({ field }) => (
<Select value={field.value} onValueChange={field.onChange}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{units.map((u) => (
<SelectItem key={u} value={u}>
{u}
</SelectItem>
))}
</SelectContent>
</Select>
)}
/>
</div>
<div className="col-span-4 sm:col-span-3">
<Input
type="number"
step="0.01"
placeholder="à-pris"
className="tabular-nums"
{...register(`items.${index}.unit_price`, { valueAsNumber: true })}
/>
</div>
<div className="col-span-2 sm:col-span-1">
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => fields.length > 1 && remove(index)}
aria-label="Ta bort rad"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
))}
<Button
type="button"
variant="secondary"
size="sm"
onClick={() =>
append({ description: '', quantity: 1, unit: 'st', unit_price: 0, vat_rate: 25 })
}
>
<Plus className="mr-2 h-4 w-4" />
Lägg till rad
</Button>
<div className="pt-2 text-sm text-muted-foreground tabular-nums">
Delsumma exkl. moms: {formatCurrency(subtotal, watchCurrency)}
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base">Övrigt</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<Label htmlFor="your_reference">Er referens</Label>
<Input id="your_reference" {...register('your_reference')} />
</div>
<div>
<Label htmlFor="our_reference">Vår referens</Label>
<Input id="our_reference" {...register('our_reference')} />
</div>
</div>
<div>
<Label htmlFor="notes">Anteckningar (skrivs på varje faktura)</Label>
<Textarea id="notes" rows={3} {...register('notes')} />
</div>
</CardContent>
</Card>
<div className="flex justify-end gap-2">
<Link href="/invoices/recurring">
<Button type="button" variant="secondary">
Avbryt
</Button>
</Link>
<Button type="submit" disabled={isSubmitting}>
{isSubmitting ? 'Skapar...' : 'Skapa schema'}
</Button>
</div>
</form>
</div>
)
}
+211
View File
@@ -0,0 +1,211 @@
'use client'
import { useState, useEffect } from 'react'
import Link from 'next/link'
import { useRouter } from 'next/navigation'
import { Button } from '@/components/ui/button'
import { Card, CardContent } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { PageHeader } from '@/components/ui/page-header'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import { EmptyState } from '@/components/ui/empty-state'
import { useToast } from '@/components/ui/use-toast'
import { useCanWrite } from '@/lib/hooks/use-can-write'
import { formatDate } from '@/lib/utils'
import { Plus, Repeat, Lock, AlertTriangle } from 'lucide-react'
import type { RecurringInvoiceSchedule, Customer } from '@/types'
type ScheduleRow = RecurringInvoiceSchedule & {
customer?: Pick<Customer, 'id' | 'name' | 'email'>
}
export default function RecurringInvoicesPage() {
const [schedules, setSchedules] = useState<ScheduleRow[]>([])
const [isLoading, setIsLoading] = useState(true)
const { canWrite } = useCanWrite()
const { toast } = useToast()
const router = useRouter()
async function fetchSchedules() {
setIsLoading(true)
try {
const res = await fetch('/api/invoices/recurring')
if (!res.ok) throw new Error('failed')
const json = await res.json()
setSchedules(json.data ?? [])
} catch {
toast({
title: 'Kunde inte ladda återkommande fakturor',
description: 'Kontrollera din anslutning och försök igen.',
variant: 'destructive',
})
}
setIsLoading(false)
}
useEffect(() => {
fetchSchedules()
}, [])
async function togglePause(s: ScheduleRow) {
const next = s.status === 'active' ? 'paused' : 'active'
const res = await fetch(`/api/invoices/recurring/${s.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ status: next }),
})
if (res.ok) {
toast({
title: next === 'paused' ? 'Schema pausat' : 'Schema återaktiverat',
})
fetchSchedules()
} else {
toast({
title: 'Kunde inte uppdatera schema',
variant: 'destructive',
})
}
}
async function deleteSchedule(s: ScheduleRow) {
if (!confirm(`Ta bort schemat "${s.name}"? Redan skapade fakturor påverkas inte.`)) {
return
}
const res = await fetch(`/api/invoices/recurring/${s.id}`, { method: 'DELETE' })
if (res.ok) {
toast({ title: 'Schema borttaget' })
fetchSchedules()
} else {
toast({ title: 'Kunde inte ta bort schema', variant: 'destructive' })
}
}
return (
<div className="space-y-8">
<PageHeader
title="Återkommande fakturor"
action={
canWrite ? (
<Link href="/invoices/recurring/new">
<Button>
<Plus className="mr-2 h-4 w-4" />
Nytt schema
</Button>
</Link>
) : (
<Button disabled title="Du har endast läsbehörighet i detta företag">
<Lock className="mr-2 h-4 w-4" />
Nytt schema
</Button>
)
}
/>
{isLoading ? (
<Card>
<CardContent className="py-12 text-center text-sm text-muted-foreground">
Laddar...
</CardContent>
</Card>
) : schedules.length === 0 ? (
<Card>
<CardContent className="p-0">
<EmptyState
icon={Repeat}
title="Inga återkommande fakturor"
description="Skapa ett schema för att automatiskt fakturera kunder på en bestämd dag varje månad."
actionLabel={canWrite ? 'Nytt schema' : undefined}
actionHref={canWrite ? '/invoices/recurring/new' : undefined}
/>
</CardContent>
</Card>
) : (
<Card>
<CardContent className="p-0">
<Table>
<TableHeader>
<TableRow>
<TableHead>Namn</TableHead>
<TableHead>Kund</TableHead>
<TableHead className="tabular-nums">Dag</TableHead>
<TableHead className="tabular-nums">Nästa körning</TableHead>
<TableHead>Status</TableHead>
<TableHead className="tabular-nums text-right">Skapade</TableHead>
<TableHead className="text-right">Åtgärder</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{schedules.map((s) => (
<TableRow
key={s.id}
className="cursor-pointer"
onClick={() => router.push(`/invoices/recurring/${s.id}`)}
>
<TableCell className="font-medium">
<div className="flex items-center gap-2">
{s.name}
{s.last_run_warning && (
<AlertTriangle
className="h-4 w-4 text-warning-foreground"
aria-label={s.last_run_warning}
/>
)}
</div>
</TableCell>
<TableCell className="text-muted-foreground">
{s.customer?.name ?? '—'}
</TableCell>
<TableCell className="tabular-nums">{s.day_of_month}</TableCell>
<TableCell className="tabular-nums">{formatDate(s.next_run_date)}</TableCell>
<TableCell>
{s.status === 'active' ? (
<Badge variant="success">Aktiv</Badge>
) : (
<Badge variant="secondary">Pausad</Badge>
)}
</TableCell>
<TableCell className="tabular-nums text-right">
{s.generated_count}
</TableCell>
<TableCell className="text-right">
<div
className="flex justify-end gap-2"
onClick={(e) => e.stopPropagation()}
>
{canWrite && (
<>
<Button
variant="secondary"
size="sm"
onClick={() => togglePause(s)}
>
{s.status === 'active' ? 'Pausa' : 'Aktivera'}
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => deleteSchedule(s)}
>
Ta bort
</Button>
</>
)}
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
)}
</div>
)
}
+3 -2
View File
@@ -24,7 +24,7 @@ export async function POST(request: Request) {
const companyId = await requireCompanyId(supabase, user.id)
const body = await request.json()
const { customer_id, invoice_date, due_date, delivery_date, currency, items, your_reference, our_reference, notes, document_type } = body
const { customer_id, invoice_date, due_date, delivery_date, currency, items, your_reference, our_reference, notes, document_type, invoice_number } = body
if (!customer_id || !items || items.length === 0) {
return NextResponse.json({ error: 'Kunduppgifter och rader krävs' }, { status: 400 })
@@ -90,7 +90,7 @@ export async function POST(request: Request) {
id: 'preview',
user_id: user.id,
customer_id,
invoice_number: 'FÖRHANDSGRANSKNING',
invoice_number: typeof invoice_number === 'string' && invoice_number.trim() ? invoice_number : 'FÖRHANDSGRANSKNING',
invoice_date: invoice_date || new Date().toISOString().split('T')[0],
due_date: due_date || new Date().toISOString().split('T')[0],
delivery_date: delivery_date || null,
@@ -127,6 +127,7 @@ export async function POST(request: Request) {
customer: customer as Customer,
items: invoiceItems,
company: company as CompanySettings,
isPreview: true,
})
)
+188
View File
@@ -0,0 +1,188 @@
import { NextResponse } from 'next/server'
import { ensureInitialized } from '@/lib/init'
import { withRouteContext } from '@/lib/api/with-route-context'
import { errorResponse } from '@/lib/errors/get-structured-error'
import { UpdateRecurringScheduleSchema } from '@/lib/api/schemas'
ensureInitialized()
export const GET = withRouteContext(
'recurring_invoice.get',
async (_request, ctx, { params }: { params: Promise<{ id: string }> }) => {
const { id } = await params
const { supabase, companyId, log } = ctx
const { data, error } = await supabase
.from('recurring_invoice_schedules')
.select('*, customer:customers(*), items:recurring_invoice_schedule_items(*)')
.eq('id', id)
.eq('company_id', companyId)
.single()
if (error || !data) {
log.warn('recurring schedule not found', { scheduleId: id })
return NextResponse.json(
{ error: 'Schedule not found', type: 'not_found' },
{ status: 404 },
)
}
return NextResponse.json({ data })
},
)
export const PATCH = withRouteContext(
'recurring_invoice.update',
async (request, ctx, { params }: { params: Promise<{ id: string }> }) => {
const { id } = await params
const { supabase, companyId, log, requestId } = ctx
let rawBody: unknown
try {
rawBody = await request.json()
} catch {
return NextResponse.json(
{ error: 'Invalid JSON in request body', type: 'validation_error' },
{ status: 400 },
)
}
const parsed = UpdateRecurringScheduleSchema.safeParse(rawBody)
if (!parsed.success) {
return NextResponse.json(
{
error: 'Validation failed',
type: 'validation_error',
errors: parsed.error.issues.map((i) => ({
field: i.path.join('.'),
message: i.message,
code: i.code,
})),
},
{ status: 400 },
)
}
const input = parsed.data
const { items, ...scheduleFields } = input
// Only forward fields the user actually supplied.
const updateRow: Record<string, unknown> = {}
for (const [k, v] of Object.entries(scheduleFields)) {
if (v !== undefined) updateRow[k] = v
}
if (Object.keys(updateRow).length > 0) {
const { error: updateError } = await supabase
.from('recurring_invoice_schedules')
.update(updateRow)
.eq('id', id)
.eq('company_id', companyId)
if (updateError) {
log.error('failed to update recurring schedule', updateError)
return errorResponse(updateError, log, { requestId })
}
}
if (items) {
// Replace items wholesale. Cheaper than diffing for a small list and
// matches how the UI form sends the full list back on every save.
const { data: existing } = await supabase
.from('recurring_invoice_schedules')
.select('id')
.eq('id', id)
.eq('company_id', companyId)
.single()
if (!existing) {
return NextResponse.json(
{ error: 'Schedule not found', type: 'not_found' },
{ status: 404 },
)
}
// Snapshot existing rows so we can restore them if the insert fails.
// Without this, a failed replace would leave the schedule with zero
// items and every subsequent cron run would throw "schedule has no
// items", silently skipping billing dates.
const { data: previousItems } = await supabase
.from('recurring_invoice_schedule_items')
.select('sort_order, description, quantity, unit, unit_price, vat_rate')
.eq('schedule_id', id)
await supabase
.from('recurring_invoice_schedule_items')
.delete()
.eq('schedule_id', id)
const itemRows = items.map((item, idx) => ({
schedule_id: id,
sort_order: idx,
description: item.description,
quantity: item.quantity,
unit: item.unit,
unit_price: item.unit_price,
vat_rate: item.vat_rate ?? null,
}))
const { error: itemsError } = await supabase
.from('recurring_invoice_schedule_items')
.insert(itemRows)
if (itemsError) {
log.error('failed to replace schedule items', itemsError)
// Restore the snapshot so the schedule stays valid for the cron.
if (previousItems && previousItems.length > 0) {
const restoreRows = previousItems.map((row) => ({
schedule_id: id,
sort_order: row.sort_order,
description: row.description,
quantity: row.quantity,
unit: row.unit,
unit_price: row.unit_price,
vat_rate: row.vat_rate,
}))
const { error: restoreError } = await supabase
.from('recurring_invoice_schedule_items')
.insert(restoreRows)
if (restoreError) {
log.error(
'failed to restore schedule items after failed replace — schedule may be left empty',
restoreError,
{ scheduleId: id },
)
}
}
return errorResponse(itemsError, log, { requestId })
}
}
const { data: complete } = await supabase
.from('recurring_invoice_schedules')
.select('*, customer:customers(*), items:recurring_invoice_schedule_items(*)')
.eq('id', id)
.eq('company_id', companyId)
.single()
return NextResponse.json({ data: complete })
},
{ requireWrite: true },
)
export const DELETE = withRouteContext(
'recurring_invoice.delete',
async (_request, ctx, { params }: { params: Promise<{ id: string }> }) => {
const { id } = await params
const { supabase, companyId, log, requestId } = ctx
// Items cascade-delete via FK ON DELETE CASCADE.
const { error } = await supabase
.from('recurring_invoice_schedules')
.delete()
.eq('id', id)
.eq('company_id', companyId)
if (error) {
log.error('failed to delete recurring schedule', error)
return errorResponse(error, log, { requestId })
}
return NextResponse.json({ success: true })
},
{ requireWrite: true },
)
@@ -0,0 +1,148 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import {
createMockRequest,
parseJsonResponse,
createQueuedMockSupabase,
} from '@/tests/helpers'
import { eventBus } from '@/lib/events'
const { supabase: mockSupabase, enqueue, reset } = createQueuedMockSupabase()
vi.mock('@/lib/supabase/server', () => ({
createClient: () => Promise.resolve(mockSupabase),
}))
vi.mock('@/lib/init', () => ({
ensureInitialized: vi.fn(),
}))
vi.mock('@/lib/company/context', () => ({
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
}))
vi.mock('@/lib/auth/require-write', () => ({
requireWritePermission: vi.fn().mockResolvedValue({ ok: true }),
}))
import { GET, POST } from '../route'
const mockUser = { id: 'user-1', email: 'test@test.se' }
describe('GET /api/invoices/recurring', () => {
beforeEach(() => {
vi.clearAllMocks()
reset()
eventBus.clear()
mockSupabase.auth.getUser.mockResolvedValue({ data: { user: mockUser } })
})
it('returns 401 when not authenticated', async () => {
mockSupabase.auth.getUser.mockResolvedValue({ data: { user: null } })
const response = await GET(createMockRequest('/api/invoices/recurring'), { params: Promise.resolve({}) })
const { status } = await parseJsonResponse(response)
expect(status).toBe(401)
})
it('returns schedule list', async () => {
const schedules = [
{ id: 's-1', name: 'Acme retainer', day_of_month: 15, status: 'active' },
]
enqueue({ data: schedules, error: null })
const response = await GET(createMockRequest('/api/invoices/recurring'), { params: Promise.resolve({}) })
const { status, body } = await parseJsonResponse<{ data: unknown[] }>(response)
expect(status).toBe(200)
expect(body.data).toEqual(schedules)
})
})
describe('POST /api/invoices/recurring', () => {
beforeEach(() => {
vi.clearAllMocks()
reset()
eventBus.clear()
mockSupabase.auth.getUser.mockResolvedValue({ data: { user: mockUser } })
})
it('returns 400 on validation error (missing items)', async () => {
const request = createMockRequest('/api/invoices/recurring', {
method: 'POST',
body: {
customer_id: '550e8400-e29b-41d4-a716-446655440000',
name: 'Test',
day_of_month: 15,
payment_terms_days: 30,
currency: 'SEK',
auto_send: false,
items: [],
},
})
const response = await POST(request, { params: Promise.resolve({}) })
const { status, body } = await parseJsonResponse<{ type: string }>(response)
expect(status).toBe(400)
expect(body.type).toBe('validation_error')
})
it('returns 404 when customer does not exist', async () => {
enqueue({ data: null, error: null }) // customer lookup → null
const request = createMockRequest('/api/invoices/recurring', {
method: 'POST',
body: {
customer_id: '550e8400-e29b-41d4-a716-446655440000',
name: 'Test',
day_of_month: 15,
payment_terms_days: 30,
currency: 'SEK',
auto_send: false,
items: [
{ description: 'Service', quantity: 1, unit: 'st', unit_price: 1000 },
],
},
})
const response = await POST(request, { params: Promise.resolve({}) })
const { status, body } = await parseJsonResponse<{ type: string }>(response)
expect(status).toBe(404)
expect(body.type).toBe('not_found')
})
it('creates a schedule on the happy path', async () => {
const createdSchedule = {
id: 's-1',
company_id: 'company-1',
user_id: 'user-1',
customer_id: '550e8400-e29b-41d4-a716-446655440000',
name: 'Acme retainer',
day_of_month: 15,
next_run_date: '2026-05-15',
status: 'active',
}
// 1. customer lookup ok
enqueue({ data: { id: '550e8400-e29b-41d4-a716-446655440000' }, error: null })
// 2. schedule insert returns the row
enqueue({ data: createdSchedule, error: null })
// 3. items insert ok
enqueue({ data: null, error: null })
// 4. final re-fetch
enqueue({ data: { ...createdSchedule, items: [] }, error: null })
const request = createMockRequest('/api/invoices/recurring', {
method: 'POST',
body: {
customer_id: '550e8400-e29b-41d4-a716-446655440000',
name: 'Acme retainer',
day_of_month: 15,
payment_terms_days: 30,
currency: 'SEK',
auto_send: false,
items: [
{ description: 'Konsultarvode', quantity: 10, unit: 'tim', unit_price: 1200 },
],
},
})
const response = await POST(request, { params: Promise.resolve({}) })
const { status, body } = await parseJsonResponse<{ data: { id: string } }>(response)
expect(status).toBe(201)
expect(body.data.id).toBe('s-1')
})
})
+137
View File
@@ -0,0 +1,137 @@
import { NextResponse } from 'next/server'
import { ensureInitialized } from '@/lib/init'
import { withCronContext } from '@/lib/api/with-cron-context'
import { createServiceClient } from '@/lib/supabase/server'
import {
executeRecurringSchedule,
computeNextRunDate,
} from '@/lib/invoices/recurring-schedule-service'
import type {
RecurringInvoiceSchedule,
RecurringInvoiceScheduleItem,
} from '@/types'
ensureInitialized()
type DueSchedule = RecurringInvoiceSchedule & { items: RecurringInvoiceScheduleItem[] }
/**
* GET /api/invoices/recurring/cron — daily 06:30 UTC.
*
* Spawns invoices for every active schedule whose next_run_date is today or
* earlier. Each schedule runs in isolated try/catch so a failure on one
* doesn't block the rest. On success: bump next_run_date, last_run_at,
* last_invoice_id, generated_count. On failure: leave next_run_date alone so
* tomorrow's run retries; pause the schedule only if the same error recurs
* across days (out of scope for v1 — let the user investigate).
*/
export const GET = withCronContext('cron.recurring_invoices', async (_request, ctx) => {
const supabase = createServiceClient()
const today = new Date()
const todayIso = today.toISOString().slice(0, 10)
const { data: due, error } = await supabase
.from('recurring_invoice_schedules')
.select('*, items:recurring_invoice_schedule_items(*)')
.eq('status', 'active')
.lte('next_run_date', todayIso)
if (error) {
ctx.log.error('failed to load due recurring schedules', error)
return NextResponse.json(
{ success: false, error: error.message },
{ status: 500 },
)
}
const schedules = (due ?? []) as DueSchedule[]
ctx.log.info('recurring invoice cron starting', {
dueCount: schedules.length,
todayIso,
})
type RunResult = {
scheduleId: string
invoiceId?: string
invoiceNumber?: string | null
autoSent?: boolean
warning?: string | null
skipped?: boolean
skipReason?: string
error?: string
}
const results: RunResult[] = []
const summary = await ctx.forEach('schedule', schedules, async (schedule, itemCtx) => {
// Idempotency: skip if already ran today. Protects against cron retries
// within the same UTC day; cheaper than a Postgres advisory lock and the
// window we're protecting (one row, ~seconds) is tiny.
if (schedule.last_run_at) {
const lastRunDay = schedule.last_run_at.slice(0, 10)
if (lastRunDay >= todayIso) {
itemCtx.log.info('schedule already ran today; skipping')
results.push({
scheduleId: schedule.id,
skipped: true,
skipReason: 'already_ran_today',
})
return
}
}
const result = await executeRecurringSchedule(supabase, schedule, today)
const nextRunDate = computeNextRunDate(today, schedule.day_of_month)
const { error: updateError } = await supabase
.from('recurring_invoice_schedules')
.update({
next_run_date: nextRunDate,
last_run_at: today.toISOString(),
last_invoice_id: result.invoiceId,
last_run_warning: result.warning,
generated_count: schedule.generated_count + 1,
})
.eq('id', schedule.id)
.eq('company_id', schedule.company_id)
if (updateError) {
// The invoice exists. If we don't mark the schedule as ran, tomorrow's
// cron would spawn a duplicate. Surface this loudly.
itemCtx.log.error(
'invoice created but failed to update schedule — manual cleanup may be needed',
updateError,
{ scheduleId: schedule.id, invoiceId: result.invoiceId },
)
throw new Error(
`schedule update failed after invoice ${result.invoiceId} created: ${updateError.message}`,
)
}
results.push({
scheduleId: schedule.id,
invoiceId: result.invoiceId,
invoiceNumber: result.invoiceNumber,
autoSent: result.autoSent,
warning: result.warning,
})
})
ctx.log.info('recurring invoice cron summary', {
total: summary.total,
succeeded: summary.succeeded,
failed: summary.failed,
})
return NextResponse.json({
success: true,
total: summary.total,
succeeded: summary.succeeded,
failed: summary.failed,
failures: summary.failures,
results,
})
})
export const POST = GET
+144
View File
@@ -0,0 +1,144 @@
import { NextResponse } from 'next/server'
import { ensureInitialized } from '@/lib/init'
import { withRouteContext } from '@/lib/api/with-route-context'
import { errorResponse } from '@/lib/errors/get-structured-error'
import { CreateRecurringScheduleSchema } from '@/lib/api/schemas'
import { computeInitialRunDate } from '@/lib/invoices/recurring-schedule-service'
ensureInitialized()
export const GET = withRouteContext(
'recurring_invoice.list',
async (_request, ctx) => {
const { supabase, companyId, log, requestId } = ctx
const { data, error } = await supabase
.from('recurring_invoice_schedules')
.select('*, customer:customers(id,name,email), items:recurring_invoice_schedule_items(*)')
.eq('company_id', companyId)
.order('created_at', { ascending: false })
if (error) {
log.error('failed to list recurring schedules', error)
return errorResponse(error, log, { requestId })
}
return NextResponse.json({ data })
},
)
export const POST = withRouteContext(
'recurring_invoice.create',
async (request, ctx) => {
const { user, supabase, companyId, log, requestId } = ctx
let rawBody: unknown
try {
rawBody = await request.json()
} catch {
return NextResponse.json(
{ error: 'Invalid JSON in request body', type: 'validation_error' },
{ status: 400 },
)
}
const parsed = CreateRecurringScheduleSchema.safeParse(rawBody)
if (!parsed.success) {
log.warn('recurring schedule validation failed', {
issueCount: parsed.error.issues.length,
})
return NextResponse.json(
{
error: 'Validation failed',
type: 'validation_error',
errors: parsed.error.issues.map((i) => ({
field: i.path.join('.'),
message: i.message,
code: i.code,
})),
},
{ status: 400 },
)
}
const input = parsed.data
// Verify the customer belongs to this company (defense in depth + clearer
// 404 than the FK violation we'd otherwise get).
const { data: customer } = await supabase
.from('customers')
.select('id')
.eq('id', input.customer_id)
.eq('company_id', companyId)
.maybeSingle()
if (!customer) {
return NextResponse.json(
{ error: 'Customer not found', type: 'not_found' },
{ status: 404 },
)
}
const nextRunDate = computeInitialRunDate(
new Date(),
input.day_of_month,
input.start_date,
)
const { data: schedule, error: insertError } = await supabase
.from('recurring_invoice_schedules')
.insert({
company_id: companyId,
user_id: user.id,
customer_id: input.customer_id,
name: input.name,
day_of_month: input.day_of_month,
payment_terms_days: input.payment_terms_days,
currency: input.currency,
your_reference: input.your_reference ?? null,
our_reference: input.our_reference ?? null,
notes: input.notes ?? null,
auto_send: input.auto_send,
next_run_date: nextRunDate,
status: 'active',
})
.select()
.single()
if (insertError || !schedule) {
log.error('failed to insert recurring schedule', insertError)
return errorResponse(insertError ?? new Error('insert failed'), log, { requestId })
}
const itemRows = input.items.map((item, idx) => ({
schedule_id: schedule.id,
sort_order: idx,
description: item.description,
quantity: item.quantity,
unit: item.unit,
unit_price: item.unit_price,
vat_rate: item.vat_rate ?? null,
}))
const { error: itemsError } = await supabase
.from('recurring_invoice_schedule_items')
.insert(itemRows)
if (itemsError) {
// Roll back the parent so a half-created schedule doesn't ship.
await supabase
.from('recurring_invoice_schedules')
.delete()
.eq('id', schedule.id)
.eq('company_id', companyId)
log.error('failed to insert schedule items; rolled back schedule', itemsError)
return errorResponse(itemsError, log, { requestId })
}
const { data: complete } = await supabase
.from('recurring_invoice_schedules')
.select('*, customer:customers(id,name,email), items:recurring_invoice_schedule_items(*)')
.eq('id', schedule.id)
.single()
return NextResponse.json({ data: complete }, { status: 201 })
},
{ requireWrite: true },
)