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:
@@ -411,6 +411,7 @@ export default function NewInvoicePage() {
|
||||
your_reference: pendingData.your_reference,
|
||||
our_reference: pendingData.our_reference,
|
||||
notes: pendingData.notes,
|
||||
invoice_number: numberPreview,
|
||||
}),
|
||||
})
|
||||
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
)
|
||||
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
@@ -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 },
|
||||
)
|
||||
@@ -18,6 +18,7 @@ import { Checkbox } from '@/components/ui/checkbox'
|
||||
import { DestructiveConfirmDialog, useDestructiveConfirm } from '@/components/ui/destructive-confirm-dialog'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import { Loader2, Plus, Copy, Check, Trash2, Key, ChevronDown } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { getBranding } from '@/lib/branding/service'
|
||||
import type { ApiKeyScope } from '@/lib/auth/api-keys'
|
||||
|
||||
@@ -180,6 +181,48 @@ function CopyBlock({ text }: { text: string }) {
|
||||
)
|
||||
}
|
||||
|
||||
function ScopeCard({
|
||||
entry,
|
||||
checked,
|
||||
onCheckedChange,
|
||||
}: {
|
||||
entry: ScopeEntry
|
||||
checked: boolean
|
||||
onCheckedChange: (checked: boolean) => void
|
||||
}) {
|
||||
const dashIdx = entry.label.indexOf(' — ')
|
||||
const verb = dashIdx > 0 ? entry.label.slice(0, dashIdx) : entry.label
|
||||
const description = dashIdx > 0 ? entry.label.slice(dashIdx + 3) : ''
|
||||
|
||||
return (
|
||||
<label
|
||||
className={cn(
|
||||
'flex min-h-[68px] cursor-pointer flex-col gap-1 rounded-md border p-2 transition-colors',
|
||||
checked
|
||||
? 'border-foreground/30 bg-secondary'
|
||||
: 'border-border hover:bg-secondary/60'
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
checked={checked}
|
||||
onCheckedChange={onCheckedChange}
|
||||
className="shrink-0"
|
||||
/>
|
||||
<span className="flex-1 text-xs font-medium text-foreground">{verb}</span>
|
||||
<span className="shrink-0 text-[10px] tabular-nums text-muted-foreground">
|
||||
{entry.tools > 0 ? `${entry.tools} verktyg` : 'REST'}
|
||||
</span>
|
||||
</div>
|
||||
{description && (
|
||||
<p className="ml-6 line-clamp-2 text-[11px] leading-snug text-muted-foreground">
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
</label>
|
||||
)
|
||||
}
|
||||
|
||||
export function ApiKeysPanel() {
|
||||
const { toast } = useToast()
|
||||
const { dialogProps: revokeDialogProps, confirm: confirmRevoke } = useDestructiveConfirm()
|
||||
@@ -331,7 +374,7 @@ export function ApiKeysPanel() {
|
||||
: `${scopeCount} behörigheter`}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 mt-1">
|
||||
<div className="mt-1 flex flex-wrap items-center gap-x-3 gap-y-1">
|
||||
<code className="text-xs text-muted-foreground font-mono">
|
||||
{key.key_prefix}...
|
||||
</code>
|
||||
@@ -433,14 +476,14 @@ export function ApiKeysPanel() {
|
||||
|
||||
{/* Create key dialog */}
|
||||
<Dialog open={showCreateDialog} onOpenChange={setShowCreateDialog}>
|
||||
<DialogContent>
|
||||
<DialogContent className="max-w-[calc(100vw-2rem)] rounded-2xl p-4 sm:max-w-3xl sm:p-6">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Skapa API-nyckel</DialogTitle>
|
||||
<DialogDescription>
|
||||
Ge nyckeln ett namn så du vet vad den används till.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="key-name">Namn</Label>
|
||||
<Input
|
||||
@@ -451,63 +494,58 @@ export function ApiKeysPanel() {
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleCreate()}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Behörigheter</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Välj vad nyckeln ska ha åtkomst till.
|
||||
</p>
|
||||
<div className="space-y-3 pt-1">
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-baseline justify-between gap-3">
|
||||
<div className="space-y-1">
|
||||
<Label>Behörigheter</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Välj vad nyckeln ska ha åtkomst till.
|
||||
</p>
|
||||
</div>
|
||||
<span className="shrink-0 text-xs tabular-nums text-muted-foreground">
|
||||
{newKeyScopes.size} av {ALL_SCOPES.length} valda
|
||||
</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
{SCOPE_GROUPS.map((group) => (
|
||||
<div key={group.domain} className="space-y-1.5">
|
||||
<p className="text-sm font-medium">{group.label}</p>
|
||||
<div className="space-y-1 pl-1">
|
||||
<div key={group.domain} className="space-y-2">
|
||||
<h4 className="text-sm font-medium">{group.label}</h4>
|
||||
<div className="space-y-2 px-2">
|
||||
{group.read && (
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<Checkbox
|
||||
checked={newKeyScopes.has(group.read.scope)}
|
||||
onCheckedChange={(checked) => {
|
||||
setNewKeyScopes((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (checked) {
|
||||
next.add(group.read!.scope)
|
||||
} else {
|
||||
next.delete(group.read!.scope)
|
||||
// Remove write too — write without read makes no sense
|
||||
if (group.write) next.delete(group.write.scope)
|
||||
}
|
||||
return next
|
||||
})
|
||||
}}
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">{group.read.label}</span>
|
||||
<span className="text-[10px] tabular-nums text-muted-foreground/60">
|
||||
{group.read.tools > 0 ? `${group.read.tools} verktyg` : 'REST'}
|
||||
</span>
|
||||
</label>
|
||||
<ScopeCard
|
||||
entry={group.read}
|
||||
checked={newKeyScopes.has(group.read.scope)}
|
||||
onCheckedChange={(checked) => {
|
||||
setNewKeyScopes((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (checked) {
|
||||
next.add(group.read!.scope)
|
||||
} else {
|
||||
next.delete(group.read!.scope)
|
||||
if (group.write) next.delete(group.write.scope)
|
||||
}
|
||||
return next
|
||||
})
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{group.write && (
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<Checkbox
|
||||
checked={newKeyScopes.has(group.write.scope)}
|
||||
onCheckedChange={(checked) => {
|
||||
setNewKeyScopes((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (checked) {
|
||||
next.add(group.write!.scope)
|
||||
// Auto-check read when write is checked (when read exists)
|
||||
if (group.read) next.add(group.read.scope)
|
||||
} else {
|
||||
next.delete(group.write!.scope)
|
||||
}
|
||||
return next
|
||||
})
|
||||
}}
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">{group.write.label}</span>
|
||||
<span className="text-[10px] tabular-nums text-muted-foreground/60">
|
||||
{group.write.tools > 0 ? `${group.write.tools} verktyg` : 'REST'}
|
||||
</span>
|
||||
</label>
|
||||
<ScopeCard
|
||||
entry={group.write}
|
||||
checked={newKeyScopes.has(group.write.scope)}
|
||||
onCheckedChange={(checked) => {
|
||||
setNewKeyScopes((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (checked) {
|
||||
next.add(group.write!.scope)
|
||||
if (group.read) next.add(group.read.scope)
|
||||
} else {
|
||||
next.delete(group.write!.scope)
|
||||
}
|
||||
return next
|
||||
})
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -26,4 +26,5 @@ services:
|
||||
- APP_URL=http://app:3000
|
||||
volumes:
|
||||
- ./docker/crontab.self-hosted:/etc/supercronic/crontab:ro
|
||||
init: true
|
||||
restart: unless-stopped
|
||||
|
||||
@@ -30,13 +30,14 @@ fi
|
||||
# Replace build-time placeholder sentinels with runtime env vars in static JS bundles.
|
||||
# This allows a single pre-built image to work with any Supabase project.
|
||||
if [ -d /app/.next/static ]; then
|
||||
find /app/.next/static -name '*.js' -exec sed -i \
|
||||
find /app/.next -type f \( -name '*.js' -o -name '*.html' -o -name '*.rsc' -o -name '*.meta' -o -name '*.body' \) -exec sed -i \
|
||||
-e "s|__NEXT_PUBLIC_SUPABASE_URL__|${NEXT_PUBLIC_SUPABASE_URL}|g" \
|
||||
-e "s|__NEXT_PUBLIC_SUPABASE_ANON_KEY__|${NEXT_PUBLIC_SUPABASE_ANON_KEY}|g" \
|
||||
-e "s|__NEXT_PUBLIC_APP_URL__|${NEXT_PUBLIC_APP_URL}|g" \
|
||||
-e "s|__NEXT_PUBLIC_VAPID_PUBLIC_KEY__|${NEXT_PUBLIC_VAPID_PUBLIC_KEY:-}|g" \
|
||||
-e "s|__NEXT_PUBLIC_SELF_HOSTED__|${NEXT_PUBLIC_SELF_HOSTED:-true}|g" \
|
||||
-e "s|__NEXT_PUBLIC_REQUIRE_MFA__|${NEXT_PUBLIC_REQUIRE_MFA:-false}|g" \
|
||||
-e "s|__NEXT_PUBLIC_BRANDING_APP_NAME__|${NEXT_PUBLIC_BRANDING_APP_NAME:-Gnubok}|g" \
|
||||
{} +
|
||||
fi
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
0 5 * * * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/extensions/ext/enable-banking/sync/cron
|
||||
0 6 * * * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/deadlines/status/cron
|
||||
0 8 * * * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/invoices/reminders/cron
|
||||
30 6 * * * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/invoices/recurring/cron
|
||||
0 0 2 1 * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/tax-deadlines/cron
|
||||
0 2 * * * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/events/cleanup/cron
|
||||
0 3 * * 0 curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/documents/verify/cron
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
0 6 * * * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/deadlines/status/cron
|
||||
0 8 * * * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/invoices/reminders/cron
|
||||
30 6 * * * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/invoices/recurring/cron
|
||||
0 0 2 1 * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/tax-deadlines/cron
|
||||
0 2 * * * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/events/cleanup/cron
|
||||
0 3 * * 0 curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/documents/verify/cron
|
||||
|
||||
@@ -177,6 +177,56 @@ export const CreateCreditNoteSchema = z.object({
|
||||
reason: z.string().optional(),
|
||||
})
|
||||
|
||||
// ============================================================
|
||||
// Recurring invoice schedule schemas
|
||||
// ============================================================
|
||||
|
||||
// Swedish VAT rates per ML 17 kap 24§ p.9 — null means "use customer default
|
||||
// from getAvailableVatRates". Any other value would produce a non-compliant
|
||||
// invoice (buyer cannot deduct ingående moms). Cron-time validation against
|
||||
// the customer's allowed set still runs in executeRecurringSchedule.
|
||||
export const RecurringScheduleItemSchema = z.object({
|
||||
description: z.string().min(1, 'Item description is required'),
|
||||
quantity: z.number().positive('Quantity must be positive'),
|
||||
unit: z.string().min(1, 'Unit is required').default('st'),
|
||||
unit_price: z.number(),
|
||||
vat_rate: z
|
||||
.union([z.literal(0), z.literal(6), z.literal(12), z.literal(25)])
|
||||
.nullable()
|
||||
.optional(),
|
||||
})
|
||||
|
||||
export const CreateRecurringScheduleSchema = z.object({
|
||||
customer_id: uuid,
|
||||
name: z.string().min(1, 'Schedule name is required').max(200),
|
||||
day_of_month: z.number().int().min(1).max(31),
|
||||
payment_terms_days: z.number().int().min(0).max(90).default(30),
|
||||
currency: CurrencySchema.default('SEK'),
|
||||
your_reference: z.string().optional(),
|
||||
our_reference: z.string().optional(),
|
||||
notes: z.string().optional(),
|
||||
auto_send: z.boolean().default(false),
|
||||
// Optional: when to first run. Defaults to next occurrence of day_of_month
|
||||
// (today if day_of_month === today, otherwise next month).
|
||||
start_date: isoDate.optional(),
|
||||
items: z.array(RecurringScheduleItemSchema).min(1, 'At least one item is required'),
|
||||
})
|
||||
|
||||
export const UpdateRecurringScheduleSchema = z.object({
|
||||
customer_id: uuid.optional(),
|
||||
name: z.string().min(1).max(200).optional(),
|
||||
day_of_month: z.number().int().min(1).max(31).optional(),
|
||||
payment_terms_days: z.number().int().min(0).max(90).optional(),
|
||||
currency: CurrencySchema.optional(),
|
||||
your_reference: z.string().nullable().optional(),
|
||||
our_reference: z.string().nullable().optional(),
|
||||
notes: z.string().nullable().optional(),
|
||||
auto_send: z.boolean().optional(),
|
||||
status: z.enum(['active', 'paused']).optional(),
|
||||
// Replace all items if provided. Omit to keep existing items unchanged.
|
||||
items: z.array(RecurringScheduleItemSchema).min(1).optional(),
|
||||
})
|
||||
|
||||
export const MarkInvoicePaidSchema = z.object({
|
||||
payment_date: isoDate.optional(),
|
||||
exchange_rate_difference: z.number().optional(),
|
||||
|
||||
@@ -32,6 +32,17 @@ export type CoreEvent =
|
||||
| { type: 'invoice.sent'; payload: { invoice: Invoice; userId: string; companyId: string } }
|
||||
| { type: 'invoice.paid'; payload: { invoice: Invoice; paymentAmount: number; paymentDate: string; userId: string; companyId: string } }
|
||||
| { type: 'credit_note.created'; payload: { creditNote: CreditNote; userId: string; companyId: string } }
|
||||
// Recurring invoices — emitted by the daily cron after a schedule spawns
|
||||
// an invoice. `autoSent` tells observers whether the email also went out
|
||||
// (false means it was created as draft for manual review).
|
||||
| { type: 'recurring_invoice.executed'; payload: {
|
||||
scheduleId: string
|
||||
invoice: Invoice
|
||||
autoSent: boolean
|
||||
warning: string | null
|
||||
userId: string
|
||||
companyId: string
|
||||
} }
|
||||
// Banking
|
||||
| { type: 'transaction.synced'; payload: { transactions: Transaction[]; userId: string; companyId: string } }
|
||||
| { type: 'transaction.categorized'; payload: { transaction: Transaction; account: string; taxCode: string; userId: string; companyId: string } }
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import {
|
||||
computeNextRunDate,
|
||||
computeInitialRunDate,
|
||||
} from '@/lib/invoices/recurring-schedule-service'
|
||||
|
||||
describe('computeNextRunDate', () => {
|
||||
it('advances day 15 from January to February', () => {
|
||||
const result = computeNextRunDate(new Date(Date.UTC(2026, 0, 15)), 15)
|
||||
expect(result).toBe('2026-02-15')
|
||||
})
|
||||
|
||||
it('clamps day 31 to last day of February (non-leap)', () => {
|
||||
// 2027 February has 28 days.
|
||||
const result = computeNextRunDate(new Date(Date.UTC(2027, 0, 31)), 31)
|
||||
expect(result).toBe('2027-02-28')
|
||||
})
|
||||
|
||||
it('clamps day 31 to last day of February in a leap year', () => {
|
||||
const result = computeNextRunDate(new Date(Date.UTC(2028, 0, 31)), 31)
|
||||
expect(result).toBe('2028-02-29')
|
||||
})
|
||||
|
||||
it('rolls into the next year correctly', () => {
|
||||
const result = computeNextRunDate(new Date(Date.UTC(2026, 11, 15)), 15)
|
||||
expect(result).toBe('2027-01-15')
|
||||
})
|
||||
|
||||
it('clamps day 31 to 30 in 30-day months (April)', () => {
|
||||
const result = computeNextRunDate(new Date(Date.UTC(2026, 2, 31)), 31)
|
||||
expect(result).toBe('2026-04-30')
|
||||
})
|
||||
|
||||
it('rejects invalid day_of_month', () => {
|
||||
expect(() => computeNextRunDate(new Date(), 0)).toThrow()
|
||||
expect(() => computeNextRunDate(new Date(), 32)).toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('computeInitialRunDate', () => {
|
||||
it('picks this month when day_of_month is in the future', () => {
|
||||
const today = new Date(Date.UTC(2026, 4, 5)) // 2026-05-05
|
||||
expect(computeInitialRunDate(today, 15)).toBe('2026-05-15')
|
||||
})
|
||||
|
||||
it('picks today when day_of_month === today', () => {
|
||||
const today = new Date(Date.UTC(2026, 4, 15))
|
||||
expect(computeInitialRunDate(today, 15)).toBe('2026-05-15')
|
||||
})
|
||||
|
||||
it('picks next month when day_of_month is in the past', () => {
|
||||
const today = new Date(Date.UTC(2026, 4, 20))
|
||||
expect(computeInitialRunDate(today, 15)).toBe('2026-06-15')
|
||||
})
|
||||
|
||||
it('honours start_date override', () => {
|
||||
const today = new Date(Date.UTC(2026, 4, 20))
|
||||
expect(computeInitialRunDate(today, 15, '2027-01-01')).toBe('2027-01-01')
|
||||
})
|
||||
|
||||
it('clamps day 31 in February when picking this-month', () => {
|
||||
const today = new Date(Date.UTC(2027, 1, 10)) // 2027-02-10, Feb has 28 days
|
||||
expect(computeInitialRunDate(today, 31)).toBe('2027-02-28')
|
||||
})
|
||||
})
|
||||
@@ -316,9 +316,10 @@ interface InvoicePDFProps {
|
||||
items: InvoiceItem[]
|
||||
company: CompanySettings
|
||||
originalInvoiceNumber?: string
|
||||
isPreview?: boolean
|
||||
}
|
||||
|
||||
export function InvoicePDF({ invoice, customer, items, company, originalInvoiceNumber }: InvoicePDFProps) {
|
||||
export function InvoicePDF({ invoice, customer, items, company, originalInvoiceNumber, isPreview }: InvoicePDFProps) {
|
||||
const isCreditNote = !!invoice.credited_invoice_id
|
||||
|
||||
// Check if items have mixed VAT rates
|
||||
@@ -360,7 +361,7 @@ export function InvoicePDF({ invoice, customer, items, company, originalInvoiceN
|
||||
: 'Detta utkast har makulerats och är inte ett giltigt fakturaunderlag.'}
|
||||
</Text>
|
||||
</View>
|
||||
) : (invoice.status === 'draft' || !invoice.invoice_number) && (
|
||||
) : isPreview ? null : (invoice.status === 'draft' || !invoice.invoice_number) && (
|
||||
<View style={styles.draftBanner}>
|
||||
<Text style={styles.draftBannerTitle}>UTKAST – inte en giltig faktura</Text>
|
||||
<Text style={styles.draftBannerText}>
|
||||
|
||||
@@ -0,0 +1,469 @@
|
||||
/**
|
||||
* Recurring invoice schedule service.
|
||||
*
|
||||
* Two public functions:
|
||||
* - executeRecurringSchedule: spawn one invoice from a schedule, optionally
|
||||
* sending it. Used by the daily cron and by a manual "run now" admin
|
||||
* action.
|
||||
* - computeNextRunDate: pure date helper. Given today + day_of_month, return
|
||||
* the next date the schedule should run. Day-of-month values >28 are
|
||||
* clamped to the last day of shorter months; the schedule keeps its
|
||||
* original day_of_month so it jumps back in months that have it.
|
||||
*/
|
||||
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
import { eventBus } from '@/lib/events'
|
||||
import { getVatRules, getAvailableVatRates } from '@/lib/invoices/vat-rules'
|
||||
import { fetchExchangeRate, convertToSEK } from '@/lib/currency/riksbanken'
|
||||
import { ensureInvoiceNumber } from '@/lib/invoices/ensure-invoice-number'
|
||||
import { createInvoiceJournalEntry } from '@/lib/bookkeeping/invoice-entries'
|
||||
import { renderToBuffer } from '@react-pdf/renderer'
|
||||
import { InvoicePDF } from '@/lib/invoices/pdf-template'
|
||||
import { getEmailService } from '@/lib/email/service'
|
||||
import {
|
||||
generateInvoiceEmailHtml,
|
||||
generateInvoiceEmailText,
|
||||
generateInvoiceEmailSubject,
|
||||
} from '@/lib/email/invoice-templates'
|
||||
import { uploadDocument } from '@/lib/core/documents/document-service'
|
||||
import { createLogger } from '@/lib/logger'
|
||||
import type {
|
||||
Invoice,
|
||||
InvoiceItem,
|
||||
Customer,
|
||||
CompanySettings,
|
||||
RecurringInvoiceSchedule,
|
||||
RecurringInvoiceScheduleItem,
|
||||
} from '@/types'
|
||||
|
||||
const log = createLogger('invoices/recurring-schedule-service')
|
||||
|
||||
export interface ExecuteResult {
|
||||
invoiceId: string
|
||||
invoiceNumber: string | null
|
||||
autoSent: boolean
|
||||
warning: string | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Last day of the month for the given year/month (1-indexed month).
|
||||
* Used to clamp day_of_month values >28 in shorter months.
|
||||
*/
|
||||
function lastDayOfMonth(year: number, monthIndex0: number): number {
|
||||
// Day 0 of next month = last day of this month.
|
||||
return new Date(Date.UTC(year, monthIndex0 + 1, 0)).getUTCDate()
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the next run date for a schedule given a reference date and the
|
||||
* stored day_of_month. The reference is always interpreted in UTC to avoid
|
||||
* timezone surprises around the day boundary in Vercel cron.
|
||||
*
|
||||
* Rules:
|
||||
* - If reference is the same as a valid day_of_month occurrence, returns
|
||||
* NEXT month's occurrence (callers compute the FIRST run via
|
||||
* computeInitialRunDate).
|
||||
* - Day 29-31 in shorter months clamps to that month's last day.
|
||||
* - The schedule's stored day_of_month is unchanged — caller passes it in.
|
||||
*/
|
||||
export function computeNextRunDate(reference: Date, dayOfMonth: number): string {
|
||||
if (dayOfMonth < 1 || dayOfMonth > 31) {
|
||||
throw new Error(`invalid day_of_month: ${dayOfMonth}`)
|
||||
}
|
||||
const refY = reference.getUTCFullYear()
|
||||
const refM = reference.getUTCMonth()
|
||||
// Advance to the next month.
|
||||
const nextM = refM + 1
|
||||
const nextYear = refY + Math.floor(nextM / 12)
|
||||
const nextMonth = ((nextM % 12) + 12) % 12
|
||||
const clamped = Math.min(dayOfMonth, lastDayOfMonth(nextYear, nextMonth))
|
||||
const yyyy = nextYear.toString().padStart(4, '0')
|
||||
const mm = (nextMonth + 1).toString().padStart(2, '0')
|
||||
const dd = clamped.toString().padStart(2, '0')
|
||||
return `${yyyy}-${mm}-${dd}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the initial next_run_date when a schedule is created.
|
||||
* - If start_date is given, use it.
|
||||
* - Else, if today's day-of-month <= schedule day_of_month (clamped to this
|
||||
* month's last day), pick this month's occurrence.
|
||||
* - Otherwise pick next month's occurrence.
|
||||
*/
|
||||
export function computeInitialRunDate(
|
||||
today: Date,
|
||||
dayOfMonth: number,
|
||||
startDate?: string,
|
||||
): string {
|
||||
if (startDate) return startDate
|
||||
if (dayOfMonth < 1 || dayOfMonth > 31) {
|
||||
throw new Error(`invalid day_of_month: ${dayOfMonth}`)
|
||||
}
|
||||
const y = today.getUTCFullYear()
|
||||
const m = today.getUTCMonth()
|
||||
const todayDay = today.getUTCDate()
|
||||
const thisMonthDay = Math.min(dayOfMonth, lastDayOfMonth(y, m))
|
||||
if (todayDay <= thisMonthDay) {
|
||||
const yyyy = y.toString().padStart(4, '0')
|
||||
const mm = (m + 1).toString().padStart(2, '0')
|
||||
const dd = thisMonthDay.toString().padStart(2, '0')
|
||||
return `${yyyy}-${mm}-${dd}`
|
||||
}
|
||||
return computeNextRunDate(today, dayOfMonth)
|
||||
}
|
||||
|
||||
/**
|
||||
* Spawn one invoice from a schedule. Always creates the invoice; auto_send
|
||||
* additionally renders + emails + flips status + creates JE + archives PDF.
|
||||
*
|
||||
* Idempotency: caller must check schedule.last_run_at >= today before calling
|
||||
* to prevent double-spawn on cron retries within the same UTC day.
|
||||
*/
|
||||
export async function executeRecurringSchedule(
|
||||
supabase: SupabaseClient,
|
||||
schedule: RecurringInvoiceSchedule & { items: RecurringInvoiceScheduleItem[] },
|
||||
today: Date = new Date(),
|
||||
): Promise<ExecuteResult> {
|
||||
const opLog = log.child({ scheduleId: schedule.id, companyId: schedule.company_id })
|
||||
|
||||
// 1. Load customer to resolve VAT rules.
|
||||
const { data: customer, error: customerErr } = await supabase
|
||||
.from('customers')
|
||||
.select('*')
|
||||
.eq('id', schedule.customer_id)
|
||||
.eq('company_id', schedule.company_id)
|
||||
.single<Customer>()
|
||||
|
||||
if (customerErr || !customer) {
|
||||
throw new Error(`customer not found for schedule ${schedule.id}`)
|
||||
}
|
||||
|
||||
const vatRules = getVatRules(customer.customer_type, customer.vat_number_validated)
|
||||
const availableRates = getAvailableVatRates(customer.customer_type, customer.vat_number_validated)
|
||||
const allowedRates = new Set(availableRates.map((r) => r.rate))
|
||||
|
||||
// 2. Compute amounts (mirrors POST /api/invoices).
|
||||
const items = (schedule.items || []).slice().sort((a, b) => a.sort_order - b.sort_order)
|
||||
if (items.length === 0) {
|
||||
throw new Error(`schedule ${schedule.id} has no items`)
|
||||
}
|
||||
|
||||
const subtotal = items.reduce((sum, it) => sum + it.quantity * it.unit_price, 0)
|
||||
let vatAmount = 0
|
||||
for (const item of items) {
|
||||
const itemRate = item.vat_rate != null ? item.vat_rate : vatRules.rate
|
||||
if (!allowedRates.has(itemRate)) {
|
||||
throw new Error(
|
||||
`VAT rate ${itemRate}% not allowed for customer type ${customer.customer_type}`,
|
||||
)
|
||||
}
|
||||
const lineTotal = item.quantity * item.unit_price
|
||||
vatAmount += Math.round((lineTotal * itemRate) / 100 * 100) / 100
|
||||
}
|
||||
const total = subtotal + vatAmount
|
||||
|
||||
const uniqueRates = new Set(items.map((it) => (it.vat_rate != null ? it.vat_rate : vatRules.rate)))
|
||||
const isMixedRate = uniqueRates.size > 1
|
||||
|
||||
// 3. Dates: invoice_date = today (UTC), due_date = +payment_terms_days.
|
||||
const yyyy = today.getUTCFullYear().toString().padStart(4, '0')
|
||||
const mm = (today.getUTCMonth() + 1).toString().padStart(2, '0')
|
||||
const dd = today.getUTCDate().toString().padStart(2, '0')
|
||||
const invoiceDate = `${yyyy}-${mm}-${dd}`
|
||||
const due = new Date(Date.UTC(today.getUTCFullYear(), today.getUTCMonth(), today.getUTCDate()))
|
||||
due.setUTCDate(due.getUTCDate() + schedule.payment_terms_days)
|
||||
const dueDate = due.toISOString().slice(0, 10)
|
||||
|
||||
// 4. Foreign currency: fetch exchange rate.
|
||||
let exchangeRate: number | null = null
|
||||
let exchangeRateDate: string | null = null
|
||||
let subtotalSek: number | null = null
|
||||
let vatAmountSek: number | null = null
|
||||
let totalSek: number | null = null
|
||||
if (schedule.currency !== 'SEK') {
|
||||
const rateData = await fetchExchangeRate(schedule.currency)
|
||||
if (rateData) {
|
||||
exchangeRate = rateData.rate
|
||||
exchangeRateDate = rateData.date
|
||||
subtotalSek = convertToSEK(subtotal, exchangeRate)
|
||||
vatAmountSek = convertToSEK(vatAmount, exchangeRate)
|
||||
totalSek = convertToSEK(total, exchangeRate)
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Insert invoice header.
|
||||
const { data: invoice, error: invoiceError } = await supabase
|
||||
.from('invoices')
|
||||
.insert({
|
||||
user_id: schedule.user_id,
|
||||
company_id: schedule.company_id,
|
||||
customer_id: schedule.customer_id,
|
||||
invoice_number: null,
|
||||
invoice_date: invoiceDate,
|
||||
due_date: dueDate,
|
||||
delivery_date: null,
|
||||
currency: schedule.currency,
|
||||
exchange_rate: exchangeRate,
|
||||
exchange_rate_date: exchangeRateDate,
|
||||
subtotal,
|
||||
subtotal_sek: subtotalSek,
|
||||
vat_amount: vatAmount,
|
||||
vat_amount_sek: vatAmountSek,
|
||||
total,
|
||||
total_sek: totalSek,
|
||||
remaining_amount: total,
|
||||
vat_treatment: vatRules.treatment,
|
||||
vat_rate: isMixedRate ? null : (uniqueRates.values().next().value ?? vatRules.rate),
|
||||
moms_ruta: vatRules.momsRuta,
|
||||
reverse_charge_text: vatRules.reverseChargeText || null,
|
||||
your_reference: schedule.your_reference,
|
||||
our_reference: schedule.our_reference,
|
||||
notes: schedule.notes,
|
||||
document_type: 'invoice',
|
||||
})
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (invoiceError || !invoice) {
|
||||
throw new Error(`failed to insert invoice from schedule: ${invoiceError?.message ?? 'unknown'}`)
|
||||
}
|
||||
|
||||
// 6. Insert items.
|
||||
const itemRows = items.map((item, index) => {
|
||||
const itemRate = item.vat_rate != null ? item.vat_rate : vatRules.rate
|
||||
const lineTotal = item.quantity * item.unit_price
|
||||
const itemVat = Math.round((lineTotal * itemRate) / 100 * 100) / 100
|
||||
return {
|
||||
invoice_id: invoice.id,
|
||||
sort_order: index,
|
||||
description: item.description,
|
||||
quantity: item.quantity,
|
||||
unit: item.unit,
|
||||
unit_price: item.unit_price,
|
||||
line_total: lineTotal,
|
||||
vat_rate: itemRate,
|
||||
vat_amount: itemVat,
|
||||
}
|
||||
})
|
||||
const { error: itemsError } = await supabase.from('invoice_items').insert(itemRows)
|
||||
if (itemsError) {
|
||||
// Hard-delete is safe here only because step 5 inserted invoice_number: null
|
||||
// — no F-series slot has been consumed yet (step 7 calls ensureInvoiceNumber).
|
||||
// Once a number is assigned, the soft-cancel path in step 7 must be used to
|
||||
// preserve the sequence per BFL 5 kap 6§ / ML 17 kap 24§.
|
||||
await supabase.from('invoices').delete().eq('id', invoice.id)
|
||||
throw new Error(`failed to insert invoice items: ${itemsError.message}`)
|
||||
}
|
||||
|
||||
// 7. Allocate F-series number.
|
||||
try {
|
||||
await ensureInvoiceNumber(supabase, schedule.company_id, invoice as Invoice)
|
||||
} catch (err) {
|
||||
// Soft-cancel to preserve the F-series sequence (ML 17 kap 24§).
|
||||
await supabase
|
||||
.from('invoices')
|
||||
.update({ status: 'cancelled' })
|
||||
.eq('id', invoice.id)
|
||||
.eq('company_id', schedule.company_id)
|
||||
.eq('status', 'draft')
|
||||
throw new Error(
|
||||
`failed to assign invoice number: ${err instanceof Error ? err.message : String(err)}`,
|
||||
)
|
||||
}
|
||||
|
||||
// 8. Re-fetch with relations so downstream PDF/email/event have full data.
|
||||
const { data: completeInvoice } = await supabase
|
||||
.from('invoices')
|
||||
.select('*, customer:customers(*), items:invoice_items(*)')
|
||||
.eq('id', invoice.id)
|
||||
.single()
|
||||
|
||||
if (!completeInvoice) {
|
||||
throw new Error('failed to reload created invoice')
|
||||
}
|
||||
|
||||
// Always emit invoice.created so existing consumers (event_log, etc.) see it.
|
||||
await eventBus.emit({
|
||||
type: 'invoice.created',
|
||||
payload: {
|
||||
invoice: completeInvoice as Invoice,
|
||||
companyId: schedule.company_id,
|
||||
userId: schedule.user_id,
|
||||
},
|
||||
})
|
||||
|
||||
let autoSent = false
|
||||
let warning: string | null = null
|
||||
|
||||
// 9. Auto-send path. If anything below fails, we keep the invoice (now a
|
||||
// numbered draft) and surface a Swedish warning on the schedule — the
|
||||
// user can manually send from /invoices/[id].
|
||||
if (schedule.auto_send) {
|
||||
try {
|
||||
autoSent = await sendInvoiceFromSchedule(
|
||||
supabase,
|
||||
schedule.company_id,
|
||||
schedule.user_id,
|
||||
completeInvoice as Invoice & { customer: Customer; items: InvoiceItem[] },
|
||||
)
|
||||
if (!autoSent) {
|
||||
warning = 'Auto-utskick misslyckades — fakturan finns som utkast och kan skickas manuellt.'
|
||||
}
|
||||
} catch (err) {
|
||||
opLog.error('auto-send failed for recurring schedule', err as Error, {
|
||||
invoiceId: invoice.id,
|
||||
})
|
||||
warning = `Auto-utskick misslyckades: ${err instanceof Error ? err.message : 'okänt fel'}`
|
||||
}
|
||||
}
|
||||
|
||||
await eventBus.emit({
|
||||
type: 'recurring_invoice.executed',
|
||||
payload: {
|
||||
scheduleId: schedule.id,
|
||||
invoice: completeInvoice as Invoice,
|
||||
autoSent,
|
||||
warning,
|
||||
companyId: schedule.company_id,
|
||||
userId: schedule.user_id,
|
||||
},
|
||||
})
|
||||
|
||||
return {
|
||||
invoiceId: invoice.id,
|
||||
invoiceNumber: (completeInvoice as Invoice).invoice_number,
|
||||
autoSent,
|
||||
warning,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Render PDF + send email + flip status + create JE + archive PDF.
|
||||
* Mirrors /api/invoices/[id]/send/route.ts but inline so we don't depend on
|
||||
* the route's auth chain. Returns true if email was sent successfully.
|
||||
*/
|
||||
async function sendInvoiceFromSchedule(
|
||||
supabase: SupabaseClient,
|
||||
companyId: string,
|
||||
userId: string,
|
||||
invoice: Invoice & { customer: Customer; items: InvoiceItem[] },
|
||||
): Promise<boolean> {
|
||||
const emailService = getEmailService()
|
||||
if (!emailService.isConfigured()) {
|
||||
log.warn('email service not configured; recurring schedule cannot auto-send', {
|
||||
invoiceId: invoice.id,
|
||||
})
|
||||
return false
|
||||
}
|
||||
if (!invoice.customer.email) {
|
||||
log.warn('customer has no email; recurring schedule cannot auto-send', {
|
||||
invoiceId: invoice.id,
|
||||
customerId: invoice.customer.id,
|
||||
})
|
||||
return false
|
||||
}
|
||||
|
||||
const { data: company } = await supabase
|
||||
.from('company_settings')
|
||||
.select('*')
|
||||
.eq('company_id', companyId)
|
||||
.single<CompanySettings>()
|
||||
|
||||
if (!company) {
|
||||
throw new Error('company settings missing — cannot send invoice')
|
||||
}
|
||||
|
||||
const items = (invoice.items || []).slice().sort((a, b) => a.sort_order - b.sort_order)
|
||||
|
||||
// Render PDF with status overridden to 'sent' so the customer doesn't
|
||||
// receive a "UTKAST" stamp.
|
||||
const pdfBuffer = await renderToBuffer(
|
||||
InvoicePDF({
|
||||
invoice: { ...invoice, status: 'sent' as const },
|
||||
customer: invoice.customer,
|
||||
items,
|
||||
company,
|
||||
}),
|
||||
)
|
||||
|
||||
const emailData = { invoice, customer: invoice.customer, company }
|
||||
const filename = `faktura-${invoice.invoice_number}.pdf`
|
||||
const ccAddress = company.email || undefined
|
||||
|
||||
const result = await emailService.sendEmail({
|
||||
to: invoice.customer.email,
|
||||
cc: ccAddress,
|
||||
subject: generateInvoiceEmailSubject(emailData),
|
||||
html: generateInvoiceEmailHtml(emailData),
|
||||
text: generateInvoiceEmailText(emailData),
|
||||
replyTo: company.email || undefined,
|
||||
fromName: company.company_name ?? undefined,
|
||||
attachments: [
|
||||
{ filename, content: pdfBuffer, contentType: 'application/pdf' },
|
||||
],
|
||||
})
|
||||
|
||||
if (!result.success) {
|
||||
log.error(
|
||||
'email provider failed in recurring schedule auto-send',
|
||||
new Error(result.error || 'unknown'),
|
||||
{ invoiceId: invoice.id },
|
||||
)
|
||||
return false
|
||||
}
|
||||
|
||||
// Email delivered — flip status, create JE, archive PDF. Treat downstream
|
||||
// failures as warnings (don't unsend the email).
|
||||
await supabase
|
||||
.from('invoices')
|
||||
.update({ status: 'sent' })
|
||||
.eq('id', invoice.id)
|
||||
.eq('company_id', companyId)
|
||||
|
||||
const accountingMethod = (company as { accounting_method?: string }).accounting_method
|
||||
let journalEntryId: string | undefined
|
||||
if (!accountingMethod || accountingMethod === 'accrual') {
|
||||
try {
|
||||
const journalEntry = await createInvoiceJournalEntry(
|
||||
supabase,
|
||||
companyId,
|
||||
userId,
|
||||
invoice,
|
||||
company.entity_type,
|
||||
)
|
||||
if (journalEntry) {
|
||||
journalEntryId = journalEntry.id
|
||||
await supabase
|
||||
.from('invoices')
|
||||
.update({ journal_entry_id: journalEntry.id })
|
||||
.eq('id', invoice.id)
|
||||
}
|
||||
} catch (err) {
|
||||
log.error('failed to create journal entry for recurring invoice', err as Error, {
|
||||
invoiceId: invoice.id,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const pdfArrayBuffer = new Uint8Array(pdfBuffer).buffer as ArrayBuffer
|
||||
await uploadDocument(
|
||||
supabase,
|
||||
userId,
|
||||
companyId,
|
||||
{ name: filename, buffer: pdfArrayBuffer, type: 'application/pdf' },
|
||||
{ upload_source: 'system', journal_entry_id: journalEntryId },
|
||||
)
|
||||
} catch (err) {
|
||||
log.error('failed to archive recurring invoice PDF', err as Error, {
|
||||
invoiceId: invoice.id,
|
||||
})
|
||||
}
|
||||
|
||||
await eventBus.emit({
|
||||
type: 'invoice.sent',
|
||||
payload: { invoice, companyId, userId },
|
||||
})
|
||||
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
-- Migration: recurring_invoice_schedules — Återkommande fakturor (v1)
|
||||
--
|
||||
-- Why this exists: Users with subscription-style billing (retainers, hyror,
|
||||
-- abonnemang) repeatedly create the same invoice on a fixed day each month.
|
||||
-- This table stores an invoice template plus a monthly cadence. A daily cron
|
||||
-- (/api/invoices/recurring/cron) finds schedules whose next_run_date <= today
|
||||
-- and spawns a real invoice via the standard invoice creation pipeline.
|
||||
--
|
||||
-- Scope v1 (locked in via planning):
|
||||
-- - Monthly cadence only (day_of_month 1-31; clamped to last day of month
|
||||
-- in cron's computeNextRunDate, schedule retains original day_of_month).
|
||||
-- - No end_date / max_runs — schedule runs until user pauses or deletes.
|
||||
-- - Per-schedule auto_send flag: true = create + send email immediately,
|
||||
-- false = create as draft for manual review.
|
||||
|
||||
-- ============================================================
|
||||
-- recurring_invoice_schedules — the template + cadence
|
||||
-- ============================================================
|
||||
|
||||
CREATE TABLE public.recurring_invoice_schedules (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
company_id UUID NOT NULL REFERENCES public.companies(id) ON DELETE CASCADE,
|
||||
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
|
||||
-- Customer is RESTRICT so deleting a customer with active schedules raises a
|
||||
-- clear FK error rather than silently nuking the schedules. Surface as a
|
||||
-- Swedish error via lib/errors/get-error-message.ts on the customer delete
|
||||
-- API route; the user then pauses/deletes the schedule first.
|
||||
customer_id UUID NOT NULL REFERENCES public.customers(id) ON DELETE RESTRICT,
|
||||
-- Human-readable name shown in the list view (e.g. "Månadsretainer Acme AB").
|
||||
name TEXT NOT NULL CHECK (length(name) > 0),
|
||||
-- Day of month (1-31). Values >28 are clamped to the last day of shorter
|
||||
-- months by computeNextRunDate; the original day_of_month is preserved so
|
||||
-- a 31-day schedule jumps back to 31 in months that have it.
|
||||
day_of_month SMALLINT NOT NULL CHECK (day_of_month BETWEEN 1 AND 31),
|
||||
-- Payment terms (days). due_date = invoice_date + payment_terms_days.
|
||||
-- Net-30 is the SME default; 0-90 covers practical range without being
|
||||
-- arbitrary.
|
||||
payment_terms_days SMALLINT NOT NULL DEFAULT 30 CHECK (payment_terms_days BETWEEN 0 AND 90),
|
||||
currency TEXT NOT NULL DEFAULT 'SEK',
|
||||
-- Free-text fields mirroring the manual invoice form, applied to each
|
||||
-- generated faktura.
|
||||
your_reference TEXT,
|
||||
our_reference TEXT,
|
||||
notes TEXT,
|
||||
-- false: create as draft so the user reviews + sends manually.
|
||||
-- true: render PDF, send via email extension, flip status to 'sent',
|
||||
-- create journal entry on accrual. If email extension not configured
|
||||
-- or customer has no email, falls back to draft + sets
|
||||
-- last_run_warning.
|
||||
auto_send BOOLEAN NOT NULL DEFAULT false,
|
||||
status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'paused')),
|
||||
-- Date the schedule should next produce an invoice. Cron filter:
|
||||
-- next_run_date <= today AND status='active'. Recomputed after every
|
||||
-- successful run.
|
||||
next_run_date DATE NOT NULL,
|
||||
-- last_run_at + last_invoice_id provide idempotency. Cron skips if
|
||||
-- last_run_at::date >= today, so retries within the same UTC day don't
|
||||
-- double-spawn.
|
||||
last_run_at TIMESTAMPTZ,
|
||||
last_invoice_id UUID REFERENCES public.invoices(id) ON DELETE SET NULL,
|
||||
-- Free-text Swedish warning surfaced in the UI when the most recent run
|
||||
-- couldn't fully complete (e.g. email extension disabled). Cleared on
|
||||
-- next successful run.
|
||||
last_run_warning TEXT,
|
||||
generated_count INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_ris_company ON public.recurring_invoice_schedules (company_id);
|
||||
CREATE INDEX idx_ris_customer ON public.recurring_invoice_schedules (customer_id);
|
||||
-- Partial index for cron's primary query: active schedules due to run.
|
||||
CREATE INDEX idx_ris_due ON public.recurring_invoice_schedules (next_run_date)
|
||||
WHERE status = 'active';
|
||||
|
||||
ALTER TABLE public.recurring_invoice_schedules ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
CREATE POLICY "recurring_invoice_schedules_select" ON public.recurring_invoice_schedules
|
||||
FOR SELECT USING (company_id IN (SELECT public.user_company_ids()));
|
||||
CREATE POLICY "recurring_invoice_schedules_insert" ON public.recurring_invoice_schedules
|
||||
FOR INSERT WITH CHECK (company_id IN (SELECT public.user_company_ids()));
|
||||
CREATE POLICY "recurring_invoice_schedules_update" ON public.recurring_invoice_schedules
|
||||
FOR UPDATE USING (company_id IN (SELECT public.user_company_ids()))
|
||||
WITH CHECK (company_id IN (SELECT public.user_company_ids()));
|
||||
CREATE POLICY "recurring_invoice_schedules_delete" ON public.recurring_invoice_schedules
|
||||
FOR DELETE USING (company_id IN (SELECT public.user_company_ids()));
|
||||
|
||||
CREATE TRIGGER recurring_invoice_schedules_updated_at
|
||||
BEFORE UPDATE ON public.recurring_invoice_schedules
|
||||
FOR EACH ROW EXECUTE FUNCTION public.update_updated_at_column();
|
||||
|
||||
-- ============================================================
|
||||
-- recurring_invoice_schedule_items — template line items
|
||||
-- ============================================================
|
||||
|
||||
CREATE TABLE public.recurring_invoice_schedule_items (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
schedule_id UUID NOT NULL REFERENCES public.recurring_invoice_schedules(id) ON DELETE CASCADE,
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
description TEXT NOT NULL CHECK (length(description) > 0),
|
||||
quantity NUMERIC(12, 4) NOT NULL CHECK (quantity > 0),
|
||||
unit TEXT NOT NULL DEFAULT 'st',
|
||||
unit_price NUMERIC(14, 2) NOT NULL,
|
||||
-- NULL = inherit the customer's default VAT rate at spawn time. The cron
|
||||
-- resolves this via lib/invoices/vat-rules.ts so a customer who later
|
||||
-- becomes VAT-validated picks up the new rate automatically on the next
|
||||
-- run.
|
||||
vat_rate NUMERIC(5, 2) CHECK (vat_rate IS NULL OR (vat_rate >= 0 AND vat_rate <= 100)),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_risi_schedule ON public.recurring_invoice_schedule_items (schedule_id, sort_order);
|
||||
|
||||
ALTER TABLE public.recurring_invoice_schedule_items ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
-- Items inherit access from the parent schedule via EXISTS-join so we
|
||||
-- don't have to duplicate company_id on the child rows.
|
||||
CREATE POLICY "recurring_invoice_schedule_items_select" ON public.recurring_invoice_schedule_items
|
||||
FOR SELECT USING (EXISTS (
|
||||
SELECT 1 FROM public.recurring_invoice_schedules s
|
||||
WHERE s.id = schedule_id
|
||||
AND s.company_id IN (SELECT public.user_company_ids())
|
||||
));
|
||||
CREATE POLICY "recurring_invoice_schedule_items_insert" ON public.recurring_invoice_schedule_items
|
||||
FOR INSERT WITH CHECK (EXISTS (
|
||||
SELECT 1 FROM public.recurring_invoice_schedules s
|
||||
WHERE s.id = schedule_id
|
||||
AND s.company_id IN (SELECT public.user_company_ids())
|
||||
));
|
||||
CREATE POLICY "recurring_invoice_schedule_items_update" ON public.recurring_invoice_schedule_items
|
||||
FOR UPDATE USING (EXISTS (
|
||||
SELECT 1 FROM public.recurring_invoice_schedules s
|
||||
WHERE s.id = schedule_id
|
||||
AND s.company_id IN (SELECT public.user_company_ids())
|
||||
));
|
||||
CREATE POLICY "recurring_invoice_schedule_items_delete" ON public.recurring_invoice_schedule_items
|
||||
FOR DELETE USING (EXISTS (
|
||||
SELECT 1 FROM public.recurring_invoice_schedules s
|
||||
WHERE s.id = schedule_id
|
||||
AND s.company_id IN (SELECT public.user_company_ids())
|
||||
));
|
||||
|
||||
NOTIFY pgrst, 'reload schema';
|
||||
@@ -707,6 +707,57 @@ export interface InvoiceItem {
|
||||
created_at: string
|
||||
}
|
||||
|
||||
// Recurring Invoice Schedule (template + monthly cadence)
|
||||
export type RecurringInvoiceScheduleStatus = 'active' | 'paused'
|
||||
|
||||
export interface RecurringInvoiceSchedule {
|
||||
id: string
|
||||
company_id: string
|
||||
user_id: string
|
||||
customer_id: string
|
||||
|
||||
name: string
|
||||
|
||||
// Monthly cadence, day-of-month 1-31. Clamped to last day of month in
|
||||
// shorter months (handled by computeNextRunDate).
|
||||
day_of_month: number
|
||||
payment_terms_days: number
|
||||
|
||||
currency: Currency
|
||||
your_reference: string | null
|
||||
our_reference: string | null
|
||||
notes: string | null
|
||||
|
||||
auto_send: boolean
|
||||
status: RecurringInvoiceScheduleStatus
|
||||
|
||||
next_run_date: string
|
||||
last_run_at: string | null
|
||||
last_invoice_id: string | null
|
||||
last_run_warning: string | null
|
||||
generated_count: number
|
||||
|
||||
created_at: string
|
||||
updated_at: string
|
||||
|
||||
// Relations
|
||||
customer?: Customer
|
||||
items?: RecurringInvoiceScheduleItem[]
|
||||
}
|
||||
|
||||
export interface RecurringInvoiceScheduleItem {
|
||||
id: string
|
||||
schedule_id: string
|
||||
sort_order: number
|
||||
description: string
|
||||
quantity: number
|
||||
unit: string
|
||||
unit_price: number
|
||||
// null = inherit customer's default VAT rate at spawn time
|
||||
vat_rate: number | null
|
||||
created_at: string
|
||||
}
|
||||
|
||||
// Tax Rates (reference table)
|
||||
export interface TaxRate {
|
||||
id: string
|
||||
|
||||
@@ -8,6 +8,10 @@
|
||||
"path": "/api/invoices/reminders/cron",
|
||||
"schedule": "0 8 * * *"
|
||||
},
|
||||
{
|
||||
"path": "/api/invoices/recurring/cron",
|
||||
"schedule": "30 6 * * *"
|
||||
},
|
||||
{
|
||||
"path": "/api/tax-deadlines/cron",
|
||||
"schedule": "0 0 2 1 *"
|
||||
|
||||
Reference in New Issue
Block a user