* feat(invoices): choose the first invoice date on recurring schedules A yearly or quarterly recurring schedule had no way to say which month it bills in: the dialog exposed interval and day of month only, so a yearly schedule created in September always fired in September. The phase of a schedule is fully defined by its first run date, which the table already stores as next_run_date and the create API already accepted as start_date but nothing exposed. - Dialog: new date field (first invoice date on create, next invoice date on edit), prefilled with the next natural occurrence so the default is "no offset"; kept in step with day of month both ways; shows the following three run dates so the phase is visible. Sent as start_date on create and as next_run_date on edit only when the user actually re-phased. - API: create validates start_date (on the day_of_month grid, not in the past); update accepts next_run_date (on the grid for the effective day, strictly after today in Stockholm) and lets it win over the automatic recompute a day change or reactivation does. - Staged operations / MCP: start_date documented as the phase; update tool gains next_run_date. Commit executor rejects off-grid dates and rolls a date that went stale before approval forward on its own grid. - lib/invoices/recurring-run-date.ts: pure, client-safe grid helpers shared by the dialog, the routes, the executors and the cron service. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PqkDCm4nhPZda2ft5WpRNC * fix(invoices): validate schedule dates at MCP staging, use Stockholm's calendar in the dialog Resolves the skeptic and CI findings on #2338 in one pass: - MCP staging tools now apply the same grid and past/future rules as the routes to start_date and next_run_date, so the preview a human approves is exactly what the commit executor writes (previously an off-grid date staged fine and failed at approval, and a past next_run_date was rolled to another date silently). - The dialog computes today and the default first invoice date in Europe/Stockholm instead of the browser's zone, matching the server; getStockholmDateHour moved to the client-safe module and is re-exported from the service. - gnubok_update_recurring_schedule description trimmed under the 280-char limit while keeping the clamping and Stockholm phrases the registration test requires. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PqkDCm4nhPZda2ft5WpRNC --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
335 lines
12 KiB
TypeScript
335 lines
12 KiB
TypeScript
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, findCall } = 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('rejects auto_send when the customer has no email', async () => {
|
|
// customer lookup: exists but without email
|
|
enqueue({ data: { id: '550e8400-e29b-41d4-a716-446655440000', email: null }, error: 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: true,
|
|
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(400)
|
|
expect(body.type).toBe('validation_error')
|
|
})
|
|
|
|
it('rejects a malformed dimensions bag with 400', 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,
|
|
// Key must be a SIE dim number: 'projekt' is not.
|
|
default_dimensions: { projekt: 'P001' },
|
|
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(400)
|
|
expect(body.type).toBe('validation_error')
|
|
})
|
|
|
|
it('persists schedule and item dimension bags', async () => {
|
|
// The queued mock's chain proxy discards call args by design, so capture
|
|
// .insert/.update payloads per table with a thin wrapper.
|
|
const inserted: Record<string, unknown[]> = {}
|
|
const originalFrom = mockSupabase.from.getMockImplementation()!
|
|
mockSupabase.from.mockImplementation((table: string) => {
|
|
const chain = originalFrom(table) as object
|
|
return new Proxy(chain, {
|
|
get(target, prop, receiver) {
|
|
if (prop === 'insert') {
|
|
return (rows: unknown) => {
|
|
;(inserted[table] ??= []).push(rows)
|
|
return (Reflect.get(target, prop, receiver) as (r: unknown) => unknown)(rows)
|
|
}
|
|
}
|
|
return Reflect.get(target, prop, receiver)
|
|
},
|
|
})
|
|
})
|
|
|
|
const createdSchedule = { id: 's-1', name: 'Acme retainer' }
|
|
enqueue({ data: { id: '550e8400-e29b-41d4-a716-446655440000' }, error: null })
|
|
enqueue({ data: createdSchedule, error: null })
|
|
enqueue({ data: null, error: null })
|
|
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,
|
|
default_dimensions: { '1': 'KS1', '6': 'P001' },
|
|
items: [
|
|
{
|
|
description: 'Konsultarvode',
|
|
quantity: 10,
|
|
unit: 'tim',
|
|
unit_price: 1200,
|
|
dimensions: { '6': 'P002' },
|
|
},
|
|
{ description: 'Resor', quantity: 1, unit: 'st', unit_price: 500 },
|
|
],
|
|
},
|
|
})
|
|
const response = await POST(request, { params: Promise.resolve({}) })
|
|
const { status } = await parseJsonResponse(response)
|
|
expect(status).toBe(201)
|
|
|
|
expect(inserted['recurring_invoice_schedules'][0]).toMatchObject({
|
|
default_dimensions: { '1': 'KS1', '6': 'P001' },
|
|
})
|
|
const itemRows = inserted['recurring_invoice_schedule_items'][0] as Array<
|
|
Record<string, unknown>
|
|
>
|
|
expect(itemRows[0].dimensions).toEqual({ '6': 'P002' })
|
|
expect(itemRows[1].dimensions).toEqual({})
|
|
})
|
|
|
|
it('rejects a start_date that is not on the day_of_month grid', async () => {
|
|
enqueue({ data: { id: '550e8400-e29b-41d4-a716-446655440000' }, error: null })
|
|
|
|
const request = createMockRequest('/api/invoices/recurring', {
|
|
method: 'POST',
|
|
body: {
|
|
customer_id: '550e8400-e29b-41d4-a716-446655440000',
|
|
name: 'Årsavgift',
|
|
day_of_month: 15,
|
|
interval_months: 12,
|
|
start_date: '2999-02-14',
|
|
items: [{ description: 'Licens', quantity: 1, unit: 'st', unit_price: 12000 }],
|
|
},
|
|
})
|
|
const response = await POST(request, { params: Promise.resolve({}) })
|
|
const { status, body } = await parseJsonResponse<{ type: string; error: string }>(response)
|
|
expect(status).toBe(400)
|
|
expect(body.type).toBe('validation_error')
|
|
expect(body.error).toMatch(/day_of_month/)
|
|
expect(findCall('recurring_invoice_schedules', 'insert')).toBeUndefined()
|
|
})
|
|
|
|
it('rejects a start_date in the past', async () => {
|
|
enqueue({ data: { id: '550e8400-e29b-41d4-a716-446655440000' }, error: null })
|
|
|
|
const request = createMockRequest('/api/invoices/recurring', {
|
|
method: 'POST',
|
|
body: {
|
|
customer_id: '550e8400-e29b-41d4-a716-446655440000',
|
|
name: 'Årsavgift',
|
|
day_of_month: 15,
|
|
interval_months: 12,
|
|
start_date: '2020-02-15',
|
|
items: [{ description: 'Licens', quantity: 1, unit: 'st', unit_price: 12000 }],
|
|
},
|
|
})
|
|
const response = await POST(request, { params: Promise.resolve({}) })
|
|
const { status, body } = await parseJsonResponse<{ type: string; error: string }>(response)
|
|
expect(status).toBe(400)
|
|
expect(body.error).toMatch(/past/)
|
|
expect(findCall('recurring_invoice_schedules', 'insert')).toBeUndefined()
|
|
})
|
|
|
|
it('uses an explicit start_date as the first run so a yearly schedule keeps its month', async () => {
|
|
const createdSchedule = {
|
|
id: 's-2',
|
|
company_id: 'company-1',
|
|
customer_id: '550e8400-e29b-41d4-a716-446655440000',
|
|
name: 'Årsavgift',
|
|
day_of_month: 15,
|
|
interval_months: 12,
|
|
next_run_date: '2999-02-15',
|
|
status: 'active',
|
|
}
|
|
enqueue({ data: { id: '550e8400-e29b-41d4-a716-446655440000' }, error: null })
|
|
enqueue({ data: createdSchedule, error: null })
|
|
enqueue({ data: null, error: null })
|
|
enqueue({ data: { ...createdSchedule, items: [] }, error: null })
|
|
|
|
const request = createMockRequest('/api/invoices/recurring', {
|
|
method: 'POST',
|
|
body: {
|
|
customer_id: '550e8400-e29b-41d4-a716-446655440000',
|
|
name: 'Årsavgift',
|
|
day_of_month: 15,
|
|
interval_months: 12,
|
|
start_date: '2999-02-15',
|
|
items: [{ description: 'Licens', quantity: 1, unit: 'st', unit_price: 12000 }],
|
|
},
|
|
})
|
|
const response = await POST(request, { params: Promise.resolve({}) })
|
|
const { status } = await parseJsonResponse<{ data: { id: string } }>(response)
|
|
expect(status).toBe(201)
|
|
const insertArgs = findCall('recurring_invoice_schedules', 'insert')
|
|
expect(insertArgs?.[0]).toMatchObject({ next_run_date: '2999-02-15', interval_months: 12 })
|
|
})
|
|
|
|
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')
|
|
})
|
|
})
|