From e211ab31be44428e72379c23209ed414b15eb571 Mon Sep 17 00:00:00 2001 From: Mattsson <111893710+mattssonn@users.noreply.github.com> Date: Tue, 19 May 2026 13:48:32 +0200 Subject: [PATCH] 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 --- app/(dashboard)/invoices/new/page.tsx | 1 + app/(dashboard)/invoices/page.tsx | 38 +- .../invoices/recurring/new/page.tsx | 383 ++++++++++++++ app/(dashboard)/invoices/recurring/page.tsx | 211 ++++++++ app/api/invoices/preview-pdf/route.ts | 5 +- app/api/invoices/recurring/[id]/route.ts | 188 +++++++ .../recurring/__tests__/route.test.ts | 148 ++++++ app/api/invoices/recurring/cron/route.ts | 137 +++++ app/api/invoices/recurring/route.ts | 144 ++++++ components/settings/ApiKeysPanel.tsx | 150 +++--- docker-compose.yml | 1 + docker-entrypoint.sh | 3 +- docker/crontab.hosted | 1 + docker/crontab.self-hosted | 1 + lib/api/schemas.ts | 50 ++ lib/events/types.ts | 11 + .../recurring-schedule-service.test.ts | 65 +++ lib/invoices/pdf-template.tsx | 5 +- lib/invoices/recurring-schedule-service.ts | 469 ++++++++++++++++++ ...0518150000_recurring_invoice_schedules.sql | 143 ++++++ types/index.ts | 51 ++ vercel.json | 4 + 22 files changed, 2133 insertions(+), 76 deletions(-) create mode 100644 app/(dashboard)/invoices/recurring/new/page.tsx create mode 100644 app/(dashboard)/invoices/recurring/page.tsx create mode 100644 app/api/invoices/recurring/[id]/route.ts create mode 100644 app/api/invoices/recurring/__tests__/route.test.ts create mode 100644 app/api/invoices/recurring/cron/route.ts create mode 100644 app/api/invoices/recurring/route.ts create mode 100644 lib/invoices/__tests__/recurring-schedule-service.test.ts create mode 100644 lib/invoices/recurring-schedule-service.ts create mode 100644 supabase/migrations/20260518150000_recurring_invoice_schedules.sql diff --git a/app/(dashboard)/invoices/new/page.tsx b/app/(dashboard)/invoices/new/page.tsx index 84e14764..88562f4d 100644 --- a/app/(dashboard)/invoices/new/page.tsx +++ b/app/(dashboard)/invoices/new/page.tsx @@ -411,6 +411,7 @@ export default function NewInvoicePage() { your_reference: pendingData.your_reference, our_reference: pendingData.our_reference, notes: pendingData.notes, + invoice_number: numberPreview, }), }) diff --git a/app/(dashboard)/invoices/page.tsx b/app/(dashboard)/invoices/page.tsx index dc10fc46..924d32cc 100644 --- a/app/(dashboard)/invoices/page.tsx +++ b/app/(dashboard)/invoices/page.tsx @@ -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() { - - ) : ( - - ) + {canWrite ? ( + + + + ) : ( + + )} + } /> diff --git a/app/(dashboard)/invoices/recurring/new/page.tsx b/app/(dashboard)/invoices/recurring/new/page.tsx new file mode 100644 index 00000000..20f687d8 --- /dev/null +++ b/app/(dashboard)/invoices/recurring/new/page.tsx @@ -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 + +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([]) + const [isSubmitting, setIsSubmitting] = useState(false) + + const { + register, + control, + handleSubmit, + watch, + formState: { errors }, + } = useForm({ + 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 ( +
+ + + Tillbaka + + + + +
+ + + Schema + + +
+ + + {errors.name && ( +

{errors.name.message}

+ )} +
+ +
+ + ( + + )} + /> + {errors.customer_id && ( +

{errors.customer_id.message}

+ )} +
+ +
+
+ + +

+ 29-31 körs sista dagen i kortare månader. +

+
+
+ + +
+
+ + ( + + )} + /> +
+
+ +
+
+ ( + field.onChange(e.target.checked)} + className="mt-1 h-4 w-4" + /> + )} + /> +
+ +

+ När markerad: fakturan skickas med e-post till kunden direkt vid + skapande. Annars skapas den som utkast för manuell granskning. +

+
+
+
+
+
+ + + + Rader + + + {fields.map((field, index) => ( +
+
+ +
+
+ +
+
+ ( + + )} + /> +
+
+ +
+
+ +
+
+ ))} + +
+ Delsumma exkl. moms: {formatCurrency(subtotal, watchCurrency)} +
+
+
+ + + + Övrigt + + +
+
+ + +
+
+ + +
+
+
+ +