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:
@@ -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 },
|
||||
)
|
||||
Reference in New Issue
Block a user