diff --git a/app/api/bookkeeping/account-balances/route.ts b/app/api/bookkeeping/account-balances/route.ts index ae06165e..c7281574 100644 --- a/app/api/bookkeeping/account-balances/route.ts +++ b/app/api/bookkeeping/account-balances/route.ts @@ -120,7 +120,8 @@ export async function GET(request: Request) { query = query.neq('journal_entry_id', obEntryId) } - return query.range(from, to) + // Stable total order for correct paging (see fetch-all.ts). + return query.order('id', { ascending: true }).range(from, to) }) } catch (err) { log.error('period activity lookup failed', { diff --git a/app/api/v1/companies/[companyId]/compliance/check/route.ts b/app/api/v1/companies/[companyId]/compliance/check/route.ts index 4cb26eaa..41c1dfd1 100644 --- a/app/api/v1/companies/[companyId]/compliance/check/route.ts +++ b/app/api/v1/companies/[companyId]/compliance/check/route.ts @@ -20,7 +20,7 @@ import { z } from 'zod' import type { SupabaseClient } from '@supabase/supabase-js' import { ok } from '@/lib/api/v1/response' -import { registerEndpoint } from '@/lib/api/v1/registry' +import { registerEndpoint, dataEnvelope } from '@/lib/api/v1/registry' import { withApiV1 } from '@/lib/api/v1/with-api-v1' import { v1ErrorResponse, v1ErrorResponseFromCode } from '@/lib/api/v1/errors' import { ownsFiscalPeriod } from '@/lib/api/v1/owns-fiscal-period' @@ -234,7 +234,7 @@ registerEndpoint({ idempotent: true, reversible: false, dryRunSupported: false, - response: { success: ComplianceCheckResponse }, + response: { success: dataEnvelope(ComplianceCheckResponse) }, }) export const GET = withApiV1<{ params: Promise<{ companyId: string }> }>( diff --git a/app/api/v1/companies/[companyId]/customers/[id]/route.ts b/app/api/v1/companies/[companyId]/customers/[id]/route.ts index 6baf8a0c..2699ac20 100644 --- a/app/api/v1/companies/[companyId]/customers/[id]/route.ts +++ b/app/api/v1/companies/[companyId]/customers/[id]/route.ts @@ -16,7 +16,7 @@ import { z } from 'zod' import { noContent, ok } from '@/lib/api/v1/response' import { dryRunPreview } from '@/lib/api/v1/dry-run' import { parseExpand } from '@/lib/api/v1/expand' -import { registerEndpoint } from '@/lib/api/v1/registry' +import { registerEndpoint, dataEnvelope, NoBodyResponse } from '@/lib/api/v1/registry' import { withApiV1 } from '@/lib/api/v1/with-api-v1' import { v1ErrorResponse, v1ErrorResponseFromCode } from '@/lib/api/v1/errors' import { UpdateCustomerSchema } from '@/lib/api/schemas' @@ -100,7 +100,7 @@ registerEndpoint({ idempotent: true, reversible: false, dryRunSupported: false, - response: { success: CustomerDetail }, + response: { success: dataEnvelope(CustomerDetail) }, }) export const GET = withApiV1<{ params: Promise<{ companyId: string; id: string }> }>( @@ -244,7 +244,7 @@ registerEndpoint({ reversible: true, dryRunSupported: true, request: { body: UpdateCustomerSchema }, - response: { success: CustomerDetail }, + response: { success: dataEnvelope(CustomerDetail) }, }) const CUSTOMER_UPDATE_RESPONSE_COLUMNS = CUSTOMER_DETAIL_COLUMNS @@ -438,7 +438,7 @@ registerEndpoint({ idempotent: true, reversible: true, dryRunSupported: true, - response: { success: z.object({}) }, + response: { success: NoBodyResponse }, }) export const DELETE = withApiV1<{ params: Promise<{ companyId: string; id: string }> }>( diff --git a/app/api/v1/companies/[companyId]/customers/bulk-create/route.ts b/app/api/v1/companies/[companyId]/customers/bulk-create/route.ts index ce8231e2..832704cf 100644 --- a/app/api/v1/companies/[companyId]/customers/bulk-create/route.ts +++ b/app/api/v1/companies/[companyId]/customers/bulk-create/route.ts @@ -17,7 +17,7 @@ import { z } from 'zod' import type { SupabaseClient } from '@supabase/supabase-js' import { ok } from '@/lib/api/v1/response' import { dryRunPreview } from '@/lib/api/v1/dry-run' -import { registerEndpoint } from '@/lib/api/v1/registry' +import { registerEndpoint, dataEnvelope } from '@/lib/api/v1/registry' import { withApiV1 } from '@/lib/api/v1/with-api-v1' import { v1ErrorResponseFromCode } from '@/lib/api/v1/errors' import { CreateCustomerSchema } from '@/lib/api/schemas' @@ -99,7 +99,7 @@ registerEndpoint({ reversible: true, dryRunSupported: true, request: { body: BulkCreateRequest }, - response: { success: BulkCreateResponse }, + response: { success: dataEnvelope(BulkCreateResponse) }, }) interface ResultItem { diff --git a/app/api/v1/companies/[companyId]/customers/route.ts b/app/api/v1/companies/[companyId]/customers/route.ts index 4cf47a4c..ab6bdcb0 100644 --- a/app/api/v1/companies/[companyId]/customers/route.ts +++ b/app/api/v1/companies/[companyId]/customers/route.ts @@ -16,7 +16,7 @@ import { encodeDefaultCursor, parsePaginationParams, } from '@/lib/api/v1/pagination' -import { registerEndpoint, listEnvelope } from '@/lib/api/v1/registry' +import { registerEndpoint, listEnvelope, dataEnvelope } from '@/lib/api/v1/registry' import { withApiV1 } from '@/lib/api/v1/with-api-v1' import { v1ErrorResponse, v1ErrorResponseFromCode } from '@/lib/api/v1/errors' import { CreateCustomerSchema } from '@/lib/api/schemas' @@ -301,7 +301,7 @@ registerEndpoint({ reversible: true, dryRunSupported: true, request: { body: CreateCustomerSchema }, - response: { success: CustomerCreated }, + response: { success: dataEnvelope(CustomerCreated) }, }) export const POST = withApiV1<{ params: Promise<{ companyId: string }> }>( diff --git a/app/api/v1/companies/[companyId]/documents/[id]/download/route.ts b/app/api/v1/companies/[companyId]/documents/[id]/download/route.ts index a6e62017..8202ecf5 100644 --- a/app/api/v1/companies/[companyId]/documents/[id]/download/route.ts +++ b/app/api/v1/companies/[companyId]/documents/[id]/download/route.ts @@ -13,7 +13,7 @@ import { z } from 'zod' import { ok } from '@/lib/api/v1/response' -import { registerEndpoint } from '@/lib/api/v1/registry' +import { registerEndpoint, dataEnvelope } from '@/lib/api/v1/registry' import { withApiV1 } from '@/lib/api/v1/with-api-v1' import { v1ErrorResponse, v1ErrorResponseFromCode } from '@/lib/api/v1/errors' import { eventBus } from '@/lib/events' @@ -77,7 +77,7 @@ registerEndpoint({ idempotent: true, reversible: false, dryRunSupported: false, - response: { success: DocumentDownloadResponse }, + response: { success: dataEnvelope(DocumentDownloadResponse) }, }) export const GET = withApiV1<{ params: Promise<{ companyId: string; id: string }> }>( diff --git a/app/api/v1/companies/[companyId]/documents/[id]/link/route.ts b/app/api/v1/companies/[companyId]/documents/[id]/link/route.ts index e386d738..5ca71406 100644 --- a/app/api/v1/companies/[companyId]/documents/[id]/link/route.ts +++ b/app/api/v1/companies/[companyId]/documents/[id]/link/route.ts @@ -18,7 +18,7 @@ import { z } from 'zod' import { ok } from '@/lib/api/v1/response' import { dryRunPreview } from '@/lib/api/v1/dry-run' -import { registerEndpoint } from '@/lib/api/v1/registry' +import { registerEndpoint, dataEnvelope } from '@/lib/api/v1/registry' import { withApiV1 } from '@/lib/api/v1/with-api-v1' import { v1ErrorResponse, v1ErrorResponseFromCode } from '@/lib/api/v1/errors' import { linkToJournalEntry } from '@/lib/core/documents/document-service' @@ -71,7 +71,7 @@ registerEndpoint({ reversible: false, dryRunSupported: true, request: { body: Body }, - response: { success: DocumentLinkedResponse }, + response: { success: dataEnvelope(DocumentLinkedResponse) }, }) export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string }> }>( diff --git a/app/api/v1/companies/[companyId]/documents/route.ts b/app/api/v1/companies/[companyId]/documents/route.ts index 564a164a..4b3f2236 100644 --- a/app/api/v1/companies/[companyId]/documents/route.ts +++ b/app/api/v1/companies/[companyId]/documents/route.ts @@ -31,7 +31,7 @@ import { z } from 'zod' import { created } from '@/lib/api/v1/response' -import { registerEndpoint } from '@/lib/api/v1/registry' +import { registerEndpoint, dataEnvelope } from '@/lib/api/v1/registry' import { withApiV1 } from '@/lib/api/v1/with-api-v1' import { v1ErrorResponseFromCode } from '@/lib/api/v1/errors' import { @@ -110,7 +110,7 @@ registerEndpoint({ reversible: false, dryRunSupported: false, request: { body: MultipartBodySchema, contentType: 'multipart/form-data' }, - response: { success: DocumentUploaded }, + response: { success: dataEnvelope(DocumentUploaded) }, }) export const POST = withApiV1<{ params: Promise<{ companyId: string }> }>( diff --git a/app/api/v1/companies/[companyId]/employees/[id]/route.ts b/app/api/v1/companies/[companyId]/employees/[id]/route.ts index 9d76eab3..a3b91581 100644 --- a/app/api/v1/companies/[companyId]/employees/[id]/route.ts +++ b/app/api/v1/companies/[companyId]/employees/[id]/route.ts @@ -20,7 +20,7 @@ import { z } from 'zod' import { ok, noContent } from '@/lib/api/v1/response' import { dryRunPreview } from '@/lib/api/v1/dry-run' -import { registerEndpoint } from '@/lib/api/v1/registry' +import { registerEndpoint, dataEnvelope, NoBodyResponse } from '@/lib/api/v1/registry' import { withApiV1 } from '@/lib/api/v1/with-api-v1' import { v1ErrorResponse, v1ErrorResponseFromCode } from '@/lib/api/v1/errors' import { UpdateEmployeeSchema } from '@/lib/api/schemas' @@ -136,7 +136,7 @@ registerEndpoint({ idempotent: true, reversible: false, dryRunSupported: false, - response: { success: EmployeeDetail }, + response: { success: dataEnvelope(EmployeeDetail) }, }) export const GET = withApiV1<{ params: Promise<{ companyId: string; id: string }> }>( @@ -201,7 +201,7 @@ registerEndpoint({ request: { body: UpdateEmployeeSchema }, // Write responses mask personnummer (GDPR Art.5(1)(c)) — only the GET // drill-in returns the full value. Symmetric with the POST response. - response: { success: EmployeeWriteResponse }, + response: { success: dataEnvelope(EmployeeWriteResponse) }, }) export const PATCH = withApiV1<{ params: Promise<{ companyId: string; id: string }> }>( @@ -402,7 +402,7 @@ registerEndpoint({ idempotent: true, reversible: true, dryRunSupported: true, - response: { success: z.object({}) }, + response: { success: NoBodyResponse }, }) export const DELETE = withApiV1<{ params: Promise<{ companyId: string; id: string }> }>( diff --git a/app/api/v1/companies/[companyId]/employees/route.ts b/app/api/v1/companies/[companyId]/employees/route.ts index 5c132cdb..a1629d5d 100644 --- a/app/api/v1/companies/[companyId]/employees/route.ts +++ b/app/api/v1/companies/[companyId]/employees/route.ts @@ -24,7 +24,7 @@ import { encodeDefaultCursor, parsePaginationParams, } from '@/lib/api/v1/pagination' -import { registerEndpoint, listEnvelope } from '@/lib/api/v1/registry' +import { registerEndpoint, listEnvelope, dataEnvelope } from '@/lib/api/v1/registry' import { withApiV1 } from '@/lib/api/v1/with-api-v1' import { v1ErrorResponse, v1ErrorResponseFromCode } from '@/lib/api/v1/errors' import { CreateEmployeeSchema } from '@/lib/api/schemas' @@ -318,7 +318,7 @@ registerEndpoint({ reversible: true, dryRunSupported: true, request: { body: CreateEmployeeSchema }, - response: { success: EmployeeCreated }, + response: { success: dataEnvelope(EmployeeCreated) }, }) const EMPLOYEE_RESPONSE_COLUMNS = diff --git a/app/api/v1/companies/[companyId]/fiscal-periods/[id]/close/route.ts b/app/api/v1/companies/[companyId]/fiscal-periods/[id]/close/route.ts index 131c66ba..bf26f668 100644 --- a/app/api/v1/companies/[companyId]/fiscal-periods/[id]/close/route.ts +++ b/app/api/v1/companies/[companyId]/fiscal-periods/[id]/close/route.ts @@ -11,7 +11,7 @@ import { z } from 'zod' import { ok } from '@/lib/api/v1/response' -import { registerEndpoint } from '@/lib/api/v1/registry' +import { registerEndpoint, dataEnvelope } from '@/lib/api/v1/registry' import { withApiV1 } from '@/lib/api/v1/with-api-v1' import { v1ErrorResponseFromCode } from '@/lib/api/v1/errors' import { closePeriod } from '@/lib/core/bookkeeping/period-service' @@ -49,7 +49,7 @@ registerEndpoint({ idempotent: true, reversible: false, dryRunSupported: false, - response: { success: PeriodClosedResponse }, + response: { success: dataEnvelope(PeriodClosedResponse) }, }) export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string }> }>( diff --git a/app/api/v1/companies/[companyId]/fiscal-periods/[id]/currency-revaluation/route.ts b/app/api/v1/companies/[companyId]/fiscal-periods/[id]/currency-revaluation/route.ts index 72fd642b..d80e0b0b 100644 --- a/app/api/v1/companies/[companyId]/fiscal-periods/[id]/currency-revaluation/route.ts +++ b/app/api/v1/companies/[companyId]/fiscal-periods/[id]/currency-revaluation/route.ts @@ -12,7 +12,7 @@ import { z } from 'zod' import { accepted } from '@/lib/api/v1/response' -import { registerEndpoint } from '@/lib/api/v1/registry' +import { registerEndpoint, dataEnvelope } from '@/lib/api/v1/registry' import { withApiV1 } from '@/lib/api/v1/with-api-v1' import { v1ErrorResponseFromCode } from '@/lib/api/v1/errors' import { ownsFiscalPeriod } from '@/lib/api/v1/owns-fiscal-period' @@ -59,7 +59,7 @@ registerEndpoint({ reversible: true, dryRunSupported: false, request: { body: Body }, - response: { success: RevaluationAccepted }, + response: { success: dataEnvelope(RevaluationAccepted) }, }) export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string }> }>( diff --git a/app/api/v1/companies/[companyId]/fiscal-periods/[id]/lock/route.ts b/app/api/v1/companies/[companyId]/fiscal-periods/[id]/lock/route.ts index 9086a203..619589e6 100644 --- a/app/api/v1/companies/[companyId]/fiscal-periods/[id]/lock/route.ts +++ b/app/api/v1/companies/[companyId]/fiscal-periods/[id]/lock/route.ts @@ -8,7 +8,7 @@ import { z } from 'zod' import { ok } from '@/lib/api/v1/response' -import { registerEndpoint } from '@/lib/api/v1/registry' +import { registerEndpoint, dataEnvelope } from '@/lib/api/v1/registry' import { withApiV1 } from '@/lib/api/v1/with-api-v1' import { v1ErrorResponseFromCode } from '@/lib/api/v1/errors' import { lockPeriod } from '@/lib/core/bookkeeping/period-service' @@ -46,7 +46,7 @@ registerEndpoint({ idempotent: true, reversible: true, dryRunSupported: false, - response: { success: PeriodLockedResponse }, + response: { success: dataEnvelope(PeriodLockedResponse) }, }) export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string }> }>( diff --git a/app/api/v1/companies/[companyId]/fiscal-periods/[id]/opening-balances/route.ts b/app/api/v1/companies/[companyId]/fiscal-periods/[id]/opening-balances/route.ts index 74711f9b..b5563ac5 100644 --- a/app/api/v1/companies/[companyId]/fiscal-periods/[id]/opening-balances/route.ts +++ b/app/api/v1/companies/[companyId]/fiscal-periods/[id]/opening-balances/route.ts @@ -12,7 +12,7 @@ import { z } from 'zod' import { ok } from '@/lib/api/v1/response' -import { registerEndpoint } from '@/lib/api/v1/registry' +import { registerEndpoint, dataEnvelope } from '@/lib/api/v1/registry' import { withApiV1 } from '@/lib/api/v1/with-api-v1' import { v1ErrorResponseFromCode } from '@/lib/api/v1/errors' import { ownsFiscalPeriod } from '@/lib/api/v1/owns-fiscal-period' @@ -56,7 +56,7 @@ registerEndpoint({ reversible: true, dryRunSupported: false, request: { body: Body }, - response: { success: OpeningBalancesResponse }, + response: { success: dataEnvelope(OpeningBalancesResponse) }, }) export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string }> }>( diff --git a/app/api/v1/companies/[companyId]/fiscal-periods/[id]/year-end/route.ts b/app/api/v1/companies/[companyId]/fiscal-periods/[id]/year-end/route.ts index 5cecf192..4f181641 100644 --- a/app/api/v1/companies/[companyId]/fiscal-periods/[id]/year-end/route.ts +++ b/app/api/v1/companies/[companyId]/fiscal-periods/[id]/year-end/route.ts @@ -15,7 +15,7 @@ import { z } from 'zod' import { accepted } from '@/lib/api/v1/response' -import { registerEndpoint } from '@/lib/api/v1/registry' +import { registerEndpoint, dataEnvelope } from '@/lib/api/v1/registry' import { withApiV1 } from '@/lib/api/v1/with-api-v1' import { v1ErrorResponseFromCode } from '@/lib/api/v1/errors' import { ownsFiscalPeriod } from '@/lib/api/v1/owns-fiscal-period' @@ -62,7 +62,7 @@ registerEndpoint({ idempotent: true, reversible: false, dryRunSupported: false, - response: { success: YearEndAcceptedResponse }, + response: { success: dataEnvelope(YearEndAcceptedResponse) }, }) export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string }> }>( diff --git a/app/api/v1/companies/[companyId]/imports/bank/route.ts b/app/api/v1/companies/[companyId]/imports/bank/route.ts index 55a88576..a8d03d40 100644 --- a/app/api/v1/companies/[companyId]/imports/bank/route.ts +++ b/app/api/v1/companies/[companyId]/imports/bank/route.ts @@ -21,7 +21,7 @@ import { z } from 'zod' import { accepted } from '@/lib/api/v1/response' -import { registerEndpoint } from '@/lib/api/v1/registry' +import { registerEndpoint, dataEnvelope } from '@/lib/api/v1/registry' import { withApiV1 } from '@/lib/api/v1/with-api-v1' import { v1ErrorResponseFromCode } from '@/lib/api/v1/errors' import { @@ -83,7 +83,7 @@ registerEndpoint({ reversible: false, dryRunSupported: false, request: { contentType: 'multipart/form-data' }, - response: { success: BankImportAccepted }, + response: { success: dataEnvelope(BankImportAccepted) }, }) export const POST = withApiV1<{ params: Promise<{ companyId: string }> }>( diff --git a/app/api/v1/companies/[companyId]/imports/sie/route.ts b/app/api/v1/companies/[companyId]/imports/sie/route.ts index 36f471f1..6ad6e63a 100644 --- a/app/api/v1/companies/[companyId]/imports/sie/route.ts +++ b/app/api/v1/companies/[companyId]/imports/sie/route.ts @@ -24,7 +24,7 @@ import { z } from 'zod' import { accepted } from '@/lib/api/v1/response' -import { registerEndpoint } from '@/lib/api/v1/registry' +import { registerEndpoint, dataEnvelope } from '@/lib/api/v1/registry' import { withApiV1 } from '@/lib/api/v1/with-api-v1' import { v1ErrorResponseFromCode } from '@/lib/api/v1/errors' import { @@ -94,7 +94,7 @@ registerEndpoint({ reversible: false, dryRunSupported: false, request: { contentType: 'multipart/form-data' }, - response: { success: SieImportAccepted }, + response: { success: dataEnvelope(SieImportAccepted) }, }) export const POST = withApiV1<{ params: Promise<{ companyId: string }> }>( diff --git a/app/api/v1/companies/[companyId]/invoices/[id]/credit/route.ts b/app/api/v1/companies/[companyId]/invoices/[id]/credit/route.ts index 24507be7..4ab30094 100644 --- a/app/api/v1/companies/[companyId]/invoices/[id]/credit/route.ts +++ b/app/api/v1/companies/[companyId]/invoices/[id]/credit/route.ts @@ -27,7 +27,7 @@ import { z } from 'zod' import { created } from '@/lib/api/v1/response' import { dryRunPreview } from '@/lib/api/v1/dry-run' -import { registerEndpoint } from '@/lib/api/v1/registry' +import { registerEndpoint, dataEnvelope } from '@/lib/api/v1/registry' import { withApiV1 } from '@/lib/api/v1/with-api-v1' import { v1ErrorResponse, v1ErrorResponseFromCode } from '@/lib/api/v1/errors' import { createCreditNoteJournalEntry } from '@/lib/bookkeeping/invoice-entries' @@ -96,7 +96,7 @@ registerEndpoint({ reversible: false, dryRunSupported: true, request: { body: CreditNoteRequest }, - response: { success: CreditNoteCreated }, + response: { success: dataEnvelope(CreditNoteCreated) }, }) export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string }> }>( diff --git a/app/api/v1/companies/[companyId]/invoices/[id]/mark-paid/route.ts b/app/api/v1/companies/[companyId]/invoices/[id]/mark-paid/route.ts index 1adad292..b9019c32 100644 --- a/app/api/v1/companies/[companyId]/invoices/[id]/mark-paid/route.ts +++ b/app/api/v1/companies/[companyId]/invoices/[id]/mark-paid/route.ts @@ -29,7 +29,7 @@ import { z } from 'zod' import { ok } from '@/lib/api/v1/response' import { dryRunPreview } from '@/lib/api/v1/dry-run' -import { registerEndpoint } from '@/lib/api/v1/registry' +import { registerEndpoint, dataEnvelope } from '@/lib/api/v1/registry' import { withApiV1 } from '@/lib/api/v1/with-api-v1' import { v1ErrorResponse, v1ErrorResponseFromCode } from '@/lib/api/v1/errors' import { MarkInvoicePaidSchema } from '@/lib/api/schemas' @@ -99,7 +99,7 @@ registerEndpoint({ reversible: false, dryRunSupported: true, request: { body: MarkInvoicePaidSchema }, - response: { success: InvoiceMarkPaidResponse }, + response: { success: dataEnvelope(InvoiceMarkPaidResponse) }, }) export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string }> }>( diff --git a/app/api/v1/companies/[companyId]/invoices/[id]/mark-sent/route.ts b/app/api/v1/companies/[companyId]/invoices/[id]/mark-sent/route.ts index e14da941..5194c3b4 100644 --- a/app/api/v1/companies/[companyId]/invoices/[id]/mark-sent/route.ts +++ b/app/api/v1/companies/[companyId]/invoices/[id]/mark-sent/route.ts @@ -36,7 +36,7 @@ import { z } from 'zod' import { ok } from '@/lib/api/v1/response' import { dryRunPreview } from '@/lib/api/v1/dry-run' -import { registerEndpoint } from '@/lib/api/v1/registry' +import { registerEndpoint, dataEnvelope } from '@/lib/api/v1/registry' import { withApiV1 } from '@/lib/api/v1/with-api-v1' import { v1ErrorResponse, v1ErrorResponseFromCode } from '@/lib/api/v1/errors' import { createInvoiceJournalEntry } from '@/lib/bookkeeping/invoice-entries' @@ -97,7 +97,7 @@ registerEndpoint({ idempotent: true, reversible: false, dryRunSupported: true, - response: { success: InvoiceMarkSentResponse }, + response: { success: dataEnvelope(InvoiceMarkSentResponse) }, }) export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string }> }>( diff --git a/app/api/v1/companies/[companyId]/invoices/[id]/route.ts b/app/api/v1/companies/[companyId]/invoices/[id]/route.ts index b2ea882b..4a9d47b0 100644 --- a/app/api/v1/companies/[companyId]/invoices/[id]/route.ts +++ b/app/api/v1/companies/[companyId]/invoices/[id]/route.ts @@ -16,7 +16,7 @@ import { z } from 'zod' import { ok } from '@/lib/api/v1/response' import { dryRunPreview } from '@/lib/api/v1/dry-run' import { parseExpand } from '@/lib/api/v1/expand' -import { registerEndpoint } from '@/lib/api/v1/registry' +import { registerEndpoint, dataEnvelope } from '@/lib/api/v1/registry' import { withApiV1 } from '@/lib/api/v1/with-api-v1' import { v1ErrorResponse, v1ErrorResponseFromCode } from '@/lib/api/v1/errors' @@ -108,7 +108,7 @@ registerEndpoint({ idempotent: true, reversible: false, dryRunSupported: false, - response: { success: InvoiceDetail }, + response: { success: dataEnvelope(InvoiceDetail) }, }) export const GET = withApiV1<{ params: Promise<{ companyId: string; id: string }> }>( @@ -210,7 +210,7 @@ registerEndpoint({ reversible: true, dryRunSupported: true, request: { body: V1PatchDraftInvoiceSchema }, - response: { success: InvoiceDetail }, + response: { success: dataEnvelope(InvoiceDetail) }, }) const INVOICE_PATCH_RESPONSE_COLUMNS = diff --git a/app/api/v1/companies/[companyId]/invoices/[id]/send/route.ts b/app/api/v1/companies/[companyId]/invoices/[id]/send/route.ts index fb7ba17a..316fb074 100644 --- a/app/api/v1/companies/[companyId]/invoices/[id]/send/route.ts +++ b/app/api/v1/companies/[companyId]/invoices/[id]/send/route.ts @@ -39,7 +39,7 @@ import { z } from 'zod' import { renderToBuffer } from '@react-pdf/renderer' import { ok } from '@/lib/api/v1/response' import { dryRunPreview } from '@/lib/api/v1/dry-run' -import { registerEndpoint } from '@/lib/api/v1/registry' +import { registerEndpoint, dataEnvelope } from '@/lib/api/v1/registry' import { withApiV1 } from '@/lib/api/v1/with-api-v1' import { v1ErrorResponse, v1ErrorResponseFromCode } from '@/lib/api/v1/errors' import { InvoicePDF } from '@/lib/invoices/pdf-template' @@ -113,7 +113,7 @@ registerEndpoint({ idempotent: true, reversible: false, dryRunSupported: true, - response: { success: InvoiceSendResponse }, + response: { success: dataEnvelope(InvoiceSendResponse) }, }) export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string }> }>( diff --git a/app/api/v1/companies/[companyId]/invoices/bulk-create/route.ts b/app/api/v1/companies/[companyId]/invoices/bulk-create/route.ts index 4a9d22c5..7877fa96 100644 --- a/app/api/v1/companies/[companyId]/invoices/bulk-create/route.ts +++ b/app/api/v1/companies/[companyId]/invoices/bulk-create/route.ts @@ -36,7 +36,7 @@ import { z } from 'zod' import type { SupabaseClient } from '@supabase/supabase-js' import { ok } from '@/lib/api/v1/response' import { dryRunPreview } from '@/lib/api/v1/dry-run' -import { registerEndpoint } from '@/lib/api/v1/registry' +import { registerEndpoint, dataEnvelope } from '@/lib/api/v1/registry' import { withApiV1 } from '@/lib/api/v1/with-api-v1' import { v1ErrorResponseFromCode } from '@/lib/api/v1/errors' import { CreateInvoiceSchema } from '@/lib/api/schemas' @@ -128,7 +128,7 @@ registerEndpoint({ reversible: true, dryRunSupported: true, request: { body: BulkCreateRequest }, - response: { success: BulkCreateResponse }, + response: { success: dataEnvelope(BulkCreateResponse) }, }) interface ResultItem { diff --git a/app/api/v1/companies/[companyId]/invoices/route.ts b/app/api/v1/companies/[companyId]/invoices/route.ts index 9735192b..02bad7cf 100644 --- a/app/api/v1/companies/[companyId]/invoices/route.ts +++ b/app/api/v1/companies/[companyId]/invoices/route.ts @@ -23,7 +23,7 @@ import { parsePaginationParams, } from '@/lib/api/v1/pagination' import { parseExpand } from '@/lib/api/v1/expand' -import { registerEndpoint, listEnvelope } from '@/lib/api/v1/registry' +import { registerEndpoint, listEnvelope, dataEnvelope } from '@/lib/api/v1/registry' import { withApiV1 } from '@/lib/api/v1/with-api-v1' import { v1ErrorResponse, v1ErrorResponseFromCode } from '@/lib/api/v1/errors' import { CreateInvoiceSchema } from '@/lib/api/schemas' @@ -372,7 +372,7 @@ registerEndpoint({ reversible: true, dryRunSupported: true, request: { body: CreateInvoiceSchema }, - response: { success: InvoiceCreated }, + response: { success: dataEnvelope(InvoiceCreated) }, }) export const POST = withApiV1<{ params: Promise<{ companyId: string }> }>( diff --git a/app/api/v1/companies/[companyId]/journal-entries/[id]/commit/route.ts b/app/api/v1/companies/[companyId]/journal-entries/[id]/commit/route.ts index bfbe46f0..06b0bfb9 100644 --- a/app/api/v1/companies/[companyId]/journal-entries/[id]/commit/route.ts +++ b/app/api/v1/companies/[companyId]/journal-entries/[id]/commit/route.ts @@ -15,7 +15,7 @@ import { z } from 'zod' import { ok } from '@/lib/api/v1/response' import { dryRunPreview } from '@/lib/api/v1/dry-run' -import { registerEndpoint } from '@/lib/api/v1/registry' +import { registerEndpoint, dataEnvelope } from '@/lib/api/v1/registry' import { withApiV1 } from '@/lib/api/v1/with-api-v1' import { v1ErrorResponse, v1ErrorResponseFromCode } from '@/lib/api/v1/errors' import { commitEntry, getNextVoucherNumber } from '@/lib/bookkeeping/engine' @@ -59,7 +59,7 @@ registerEndpoint({ idempotent: true, reversible: true, dryRunSupported: true, - response: { success: JournalEntryCommitted }, + response: { success: dataEnvelope(JournalEntryCommitted) }, }) export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string }> }>( diff --git a/app/api/v1/companies/[companyId]/journal-entries/[id]/correct/route.ts b/app/api/v1/companies/[companyId]/journal-entries/[id]/correct/route.ts index 85837014..0ef61553 100644 --- a/app/api/v1/companies/[companyId]/journal-entries/[id]/correct/route.ts +++ b/app/api/v1/companies/[companyId]/journal-entries/[id]/correct/route.ts @@ -16,7 +16,7 @@ import { z } from 'zod' import { ok } from '@/lib/api/v1/response' import { dryRunPreview } from '@/lib/api/v1/dry-run' -import { registerEndpoint } from '@/lib/api/v1/registry' +import { registerEndpoint, dataEnvelope } from '@/lib/api/v1/registry' import { withApiV1 } from '@/lib/api/v1/with-api-v1' import { v1ErrorResponse, v1ErrorResponseFromCode } from '@/lib/api/v1/errors' import { checkPeriodLock } from '@/lib/api/v1/check-period-lock' @@ -76,7 +76,7 @@ registerEndpoint({ reversible: false, dryRunSupported: true, request: { body: CorrectJournalEntrySchema }, - response: { success: JournalEntryCorrected }, + response: { success: dataEnvelope(JournalEntryCorrected) }, }) export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string }> }>( diff --git a/app/api/v1/companies/[companyId]/journal-entries/[id]/reverse/route.ts b/app/api/v1/companies/[companyId]/journal-entries/[id]/reverse/route.ts index 9c24e2d2..47ca1772 100644 --- a/app/api/v1/companies/[companyId]/journal-entries/[id]/reverse/route.ts +++ b/app/api/v1/companies/[companyId]/journal-entries/[id]/reverse/route.ts @@ -14,7 +14,7 @@ import { z } from 'zod' import { ok } from '@/lib/api/v1/response' import { dryRunPreview } from '@/lib/api/v1/dry-run' -import { registerEndpoint } from '@/lib/api/v1/registry' +import { registerEndpoint, dataEnvelope } from '@/lib/api/v1/registry' import { withApiV1 } from '@/lib/api/v1/with-api-v1' import { v1ErrorResponse, v1ErrorResponseFromCode } from '@/lib/api/v1/errors' import { checkPeriodLock } from '@/lib/api/v1/check-period-lock' @@ -68,7 +68,7 @@ registerEndpoint({ reversible: false, dryRunSupported: true, request: { body: ReverseRequest }, - response: { success: JournalEntryReversed }, + response: { success: dataEnvelope(JournalEntryReversed) }, }) export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string }> }>( diff --git a/app/api/v1/companies/[companyId]/journal-entries/[id]/route.ts b/app/api/v1/companies/[companyId]/journal-entries/[id]/route.ts index 46e7d9fd..afc3ca18 100644 --- a/app/api/v1/companies/[companyId]/journal-entries/[id]/route.ts +++ b/app/api/v1/companies/[companyId]/journal-entries/[id]/route.ts @@ -7,7 +7,7 @@ import { z } from 'zod' import { ok } from '@/lib/api/v1/response' -import { registerEndpoint } from '@/lib/api/v1/registry' +import { registerEndpoint, dataEnvelope } from '@/lib/api/v1/registry' import { withApiV1 } from '@/lib/api/v1/with-api-v1' import { v1ErrorResponse, v1ErrorResponseFromCode } from '@/lib/api/v1/errors' @@ -86,7 +86,7 @@ registerEndpoint({ idempotent: true, reversible: false, dryRunSupported: false, - response: { success: JournalEntryDetail }, + response: { success: dataEnvelope(JournalEntryDetail) }, }) export const GET = withApiV1<{ params: Promise<{ companyId: string; id: string }> }>( diff --git a/app/api/v1/companies/[companyId]/journal-entries/batch-create/route.ts b/app/api/v1/companies/[companyId]/journal-entries/batch-create/route.ts index 89460edd..28e70802 100644 --- a/app/api/v1/companies/[companyId]/journal-entries/batch-create/route.ts +++ b/app/api/v1/companies/[companyId]/journal-entries/batch-create/route.ts @@ -17,7 +17,7 @@ import { z } from 'zod' import type { SupabaseClient } from '@supabase/supabase-js' import { ok } from '@/lib/api/v1/response' import { dryRunPreview } from '@/lib/api/v1/dry-run' -import { registerEndpoint } from '@/lib/api/v1/registry' +import { registerEndpoint, dataEnvelope } from '@/lib/api/v1/registry' import { withApiV1 } from '@/lib/api/v1/with-api-v1' import { v1ErrorResponseFromCode } from '@/lib/api/v1/errors' import { ownsFiscalPeriod } from '@/lib/api/v1/owns-fiscal-period' @@ -89,7 +89,7 @@ registerEndpoint({ reversible: true, dryRunSupported: true, request: { body: BulkRequest }, - response: { success: BulkResponse }, + response: { success: dataEnvelope(BulkResponse) }, }) interface ResultItem { diff --git a/app/api/v1/companies/[companyId]/journal-entries/route.ts b/app/api/v1/companies/[companyId]/journal-entries/route.ts index 8c6c0987..f04e67f3 100644 --- a/app/api/v1/companies/[companyId]/journal-entries/route.ts +++ b/app/api/v1/companies/[companyId]/journal-entries/route.ts @@ -21,7 +21,7 @@ import { encodeDefaultCursor, parsePaginationParams, } from '@/lib/api/v1/pagination' -import { registerEndpoint, listEnvelope } from '@/lib/api/v1/registry' +import { registerEndpoint, listEnvelope, dataEnvelope } from '@/lib/api/v1/registry' import { withApiV1 } from '@/lib/api/v1/with-api-v1' import { v1ErrorResponse, v1ErrorResponseFromCode } from '@/lib/api/v1/errors' import { checkPeriodLock } from '@/lib/api/v1/check-period-lock' @@ -246,7 +246,7 @@ registerEndpoint({ reversible: true, dryRunSupported: true, request: { body: CreateJournalEntrySchema }, - response: { success: JournalEntryDetail }, + response: { success: dataEnvelope(JournalEntryDetail) }, }) export const POST = withApiV1<{ params: Promise<{ companyId: string }> }>( diff --git a/app/api/v1/companies/[companyId]/reconciliation/bank/run/route.ts b/app/api/v1/companies/[companyId]/reconciliation/bank/run/route.ts index ee41b3e1..43aa5d86 100644 --- a/app/api/v1/companies/[companyId]/reconciliation/bank/run/route.ts +++ b/app/api/v1/companies/[companyId]/reconciliation/bank/run/route.ts @@ -12,7 +12,7 @@ import { z } from 'zod' import { ok } from '@/lib/api/v1/response' import { dryRunPreview } from '@/lib/api/v1/dry-run' -import { registerEndpoint } from '@/lib/api/v1/registry' +import { registerEndpoint, dataEnvelope } from '@/lib/api/v1/registry' import { withApiV1 } from '@/lib/api/v1/with-api-v1' import { v1ErrorResponse, v1ErrorResponseFromCode } from '@/lib/api/v1/errors' import { runReconciliation } from '@/lib/reconciliation/bank-reconciliation' @@ -88,7 +88,7 @@ registerEndpoint({ reversible: false, dryRunSupported: true, request: { body: RunRequest }, - response: { success: RunResponse }, + response: { success: dataEnvelope(RunResponse) }, }) export const POST = withApiV1<{ params: Promise<{ companyId: string }> }>( diff --git a/app/api/v1/companies/[companyId]/reconciliation/bank/status/route.ts b/app/api/v1/companies/[companyId]/reconciliation/bank/status/route.ts index 0a1a9aaf..dcee40e4 100644 --- a/app/api/v1/companies/[companyId]/reconciliation/bank/status/route.ts +++ b/app/api/v1/companies/[companyId]/reconciliation/bank/status/route.ts @@ -7,7 +7,7 @@ */ import { z } from 'zod' import { ok } from '@/lib/api/v1/response' -import { registerEndpoint } from '@/lib/api/v1/registry' +import { registerEndpoint, dataEnvelope } from '@/lib/api/v1/registry' import { withApiV1 } from '@/lib/api/v1/with-api-v1' import { v1ErrorResponse, v1ErrorResponseFromCode } from '@/lib/api/v1/errors' import { getReconciliationStatus } from '@/lib/reconciliation/bank-reconciliation' @@ -56,7 +56,7 @@ registerEndpoint({ idempotent: true, reversible: false, dryRunSupported: false, - response: { success: StatusResponse }, + response: { success: dataEnvelope(StatusResponse) }, }) export const GET = withApiV1<{ params: Promise<{ companyId: string }> }>( diff --git a/app/api/v1/companies/[companyId]/reports/ar-ledger/route.ts b/app/api/v1/companies/[companyId]/reports/ar-ledger/route.ts index c8983698..54cdf08c 100644 --- a/app/api/v1/companies/[companyId]/reports/ar-ledger/route.ts +++ b/app/api/v1/companies/[companyId]/reports/ar-ledger/route.ts @@ -7,7 +7,7 @@ import { z } from 'zod' import { ok } from '@/lib/api/v1/response' -import { registerEndpoint } from '@/lib/api/v1/registry' +import { registerEndpoint, dataEnvelope } from '@/lib/api/v1/registry' import { withApiV1 } from '@/lib/api/v1/with-api-v1' import { v1ErrorResponseFromCode } from '@/lib/api/v1/errors' import { safeGenerate } from '@/lib/api/v1/report-period' @@ -39,7 +39,7 @@ registerEndpoint({ idempotent: true, reversible: false, dryRunSupported: false, - response: { success: z.unknown() }, + response: { success: dataEnvelope(z.unknown()) }, }) export const GET = withApiV1<{ params: Promise<{ companyId: string }> }>( diff --git a/app/api/v1/companies/[companyId]/reports/avgifter-basis/route.ts b/app/api/v1/companies/[companyId]/reports/avgifter-basis/route.ts index ba8fcf49..1812e6f7 100644 --- a/app/api/v1/companies/[companyId]/reports/avgifter-basis/route.ts +++ b/app/api/v1/companies/[companyId]/reports/avgifter-basis/route.ts @@ -7,7 +7,7 @@ import { z } from 'zod' import { ok } from '@/lib/api/v1/response' -import { registerEndpoint } from '@/lib/api/v1/registry' +import { registerEndpoint, dataEnvelope } from '@/lib/api/v1/registry' import { withApiV1 } from '@/lib/api/v1/with-api-v1' import { v1ErrorResponseFromCode } from '@/lib/api/v1/errors' import { safeGenerate } from '@/lib/api/v1/report-period' @@ -39,7 +39,7 @@ registerEndpoint({ idempotent: true, reversible: false, dryRunSupported: false, - response: { success: z.unknown() }, + response: { success: dataEnvelope(z.unknown()) }, }) export const GET = withApiV1<{ params: Promise<{ companyId: string }> }>( diff --git a/app/api/v1/companies/[companyId]/reports/balance-sheet/route.ts b/app/api/v1/companies/[companyId]/reports/balance-sheet/route.ts index aaa4e833..14cc1aa7 100644 --- a/app/api/v1/companies/[companyId]/reports/balance-sheet/route.ts +++ b/app/api/v1/companies/[companyId]/reports/balance-sheet/route.ts @@ -8,7 +8,7 @@ import { z } from 'zod' import { ok } from '@/lib/api/v1/response' -import { registerEndpoint } from '@/lib/api/v1/registry' +import { registerEndpoint, dataEnvelope } from '@/lib/api/v1/registry' import { withApiV1 } from '@/lib/api/v1/with-api-v1' import { loadPeriodFromQuery, safeGenerate } from '@/lib/api/v1/report-period' import { generateBalanceSheet } from '@/lib/reports/balance-sheet' @@ -49,7 +49,7 @@ registerEndpoint({ idempotent: true, reversible: false, dryRunSupported: false, - response: { success: BalanceSheetResponse }, + response: { success: dataEnvelope(BalanceSheetResponse) }, }) export const GET = withApiV1<{ params: Promise<{ companyId: string }> }>( diff --git a/app/api/v1/companies/[companyId]/reports/continuity-check/route.ts b/app/api/v1/companies/[companyId]/reports/continuity-check/route.ts index ee2c21e1..650b97cc 100644 --- a/app/api/v1/companies/[companyId]/reports/continuity-check/route.ts +++ b/app/api/v1/companies/[companyId]/reports/continuity-check/route.ts @@ -11,7 +11,7 @@ import { z } from 'zod' import { ok } from '@/lib/api/v1/response' -import { registerEndpoint } from '@/lib/api/v1/registry' +import { registerEndpoint, dataEnvelope } from '@/lib/api/v1/registry' import { withApiV1 } from '@/lib/api/v1/with-api-v1' import { loadPeriodFromQuery, safeGenerate } from '@/lib/api/v1/report-period' import { validateBalanceContinuity } from '@/lib/reports/continuity-check' @@ -42,7 +42,7 @@ registerEndpoint({ idempotent: true, reversible: false, dryRunSupported: false, - response: { success: z.unknown() }, + response: { success: dataEnvelope(z.unknown()) }, }) export const GET = withApiV1<{ params: Promise<{ companyId: string }> }>( diff --git a/app/api/v1/companies/[companyId]/reports/general-ledger/route.ts b/app/api/v1/companies/[companyId]/reports/general-ledger/route.ts index 6bf4e271..13d38e49 100644 --- a/app/api/v1/companies/[companyId]/reports/general-ledger/route.ts +++ b/app/api/v1/companies/[companyId]/reports/general-ledger/route.ts @@ -8,7 +8,7 @@ import { z } from 'zod' import { ok } from '@/lib/api/v1/response' -import { registerEndpoint } from '@/lib/api/v1/registry' +import { registerEndpoint, dataEnvelope } from '@/lib/api/v1/registry' import { withApiV1 } from '@/lib/api/v1/with-api-v1' import { loadPeriodFromQuery, safeGenerate } from '@/lib/api/v1/report-period' import { v1ErrorResponseFromCode } from '@/lib/api/v1/errors' @@ -43,7 +43,7 @@ registerEndpoint({ idempotent: true, reversible: false, dryRunSupported: false, - response: { success: GeneralLedgerResponse }, + response: { success: dataEnvelope(GeneralLedgerResponse) }, }) export const GET = withApiV1<{ params: Promise<{ companyId: string }> }>( diff --git a/app/api/v1/companies/[companyId]/reports/income-statement/route.ts b/app/api/v1/companies/[companyId]/reports/income-statement/route.ts index 412c7312..0c577136 100644 --- a/app/api/v1/companies/[companyId]/reports/income-statement/route.ts +++ b/app/api/v1/companies/[companyId]/reports/income-statement/route.ts @@ -8,7 +8,7 @@ import { z } from 'zod' import { ok } from '@/lib/api/v1/response' -import { registerEndpoint } from '@/lib/api/v1/registry' +import { registerEndpoint, dataEnvelope } from '@/lib/api/v1/registry' import { withApiV1 } from '@/lib/api/v1/with-api-v1' import { loadPeriodFromQuery, safeGenerate } from '@/lib/api/v1/report-period' import { generateIncomeStatement } from '@/lib/reports/income-statement' @@ -41,7 +41,7 @@ registerEndpoint({ idempotent: true, reversible: false, dryRunSupported: false, - response: { success: IncomeStatementResponse }, + response: { success: dataEnvelope(IncomeStatementResponse) }, }) export const GET = withApiV1<{ params: Promise<{ companyId: string }> }>( diff --git a/app/api/v1/companies/[companyId]/reports/journal-register/route.ts b/app/api/v1/companies/[companyId]/reports/journal-register/route.ts index 73e2dc37..c1433e52 100644 --- a/app/api/v1/companies/[companyId]/reports/journal-register/route.ts +++ b/app/api/v1/companies/[companyId]/reports/journal-register/route.ts @@ -7,7 +7,7 @@ import { z } from 'zod' import { ok } from '@/lib/api/v1/response' -import { registerEndpoint } from '@/lib/api/v1/registry' +import { registerEndpoint, dataEnvelope } from '@/lib/api/v1/registry' import { withApiV1 } from '@/lib/api/v1/with-api-v1' import { loadPeriodFromQuery, safeGenerate } from '@/lib/api/v1/report-period' import { generateJournalRegister } from '@/lib/reports/journal-register' @@ -39,7 +39,7 @@ registerEndpoint({ idempotent: true, reversible: false, dryRunSupported: false, - response: { success: z.unknown() }, + response: { success: dataEnvelope(z.unknown()) }, }) export const GET = withApiV1<{ params: Promise<{ companyId: string }> }>( diff --git a/app/api/v1/companies/[companyId]/reports/monthly-breakdown/route.ts b/app/api/v1/companies/[companyId]/reports/monthly-breakdown/route.ts index 7041669b..12455c85 100644 --- a/app/api/v1/companies/[companyId]/reports/monthly-breakdown/route.ts +++ b/app/api/v1/companies/[companyId]/reports/monthly-breakdown/route.ts @@ -8,7 +8,7 @@ import { z } from 'zod' import { ok } from '@/lib/api/v1/response' -import { registerEndpoint } from '@/lib/api/v1/registry' +import { registerEndpoint, dataEnvelope } from '@/lib/api/v1/registry' import { withApiV1 } from '@/lib/api/v1/with-api-v1' import { loadPeriodFromQuery, safeGenerate } from '@/lib/api/v1/report-period' import { generateMonthlyBreakdown } from '@/lib/reports/monthly-breakdown' @@ -36,7 +36,7 @@ registerEndpoint({ idempotent: true, reversible: false, dryRunSupported: false, - response: { success: z.unknown() }, + response: { success: dataEnvelope(z.unknown()) }, }) export const GET = withApiV1<{ params: Promise<{ companyId: string }> }>( diff --git a/app/api/v1/companies/[companyId]/reports/salary-journal/route.ts b/app/api/v1/companies/[companyId]/reports/salary-journal/route.ts index d7145857..ed6c1c82 100644 --- a/app/api/v1/companies/[companyId]/reports/salary-journal/route.ts +++ b/app/api/v1/companies/[companyId]/reports/salary-journal/route.ts @@ -7,7 +7,7 @@ import { z } from 'zod' import { ok } from '@/lib/api/v1/response' -import { registerEndpoint } from '@/lib/api/v1/registry' +import { registerEndpoint, dataEnvelope } from '@/lib/api/v1/registry' import { withApiV1 } from '@/lib/api/v1/with-api-v1' import { v1ErrorResponseFromCode } from '@/lib/api/v1/errors' import { safeGenerate } from '@/lib/api/v1/report-period' @@ -41,7 +41,7 @@ registerEndpoint({ idempotent: true, reversible: false, dryRunSupported: false, - response: { success: z.unknown() }, + response: { success: dataEnvelope(z.unknown()) }, }) export const GET = withApiV1<{ params: Promise<{ companyId: string }> }>( diff --git a/app/api/v1/companies/[companyId]/reports/supplier-ledger/route.ts b/app/api/v1/companies/[companyId]/reports/supplier-ledger/route.ts index 308a1045..2755a8c9 100644 --- a/app/api/v1/companies/[companyId]/reports/supplier-ledger/route.ts +++ b/app/api/v1/companies/[companyId]/reports/supplier-ledger/route.ts @@ -7,7 +7,7 @@ import { z } from 'zod' import { ok } from '@/lib/api/v1/response' -import { registerEndpoint } from '@/lib/api/v1/registry' +import { registerEndpoint, dataEnvelope } from '@/lib/api/v1/registry' import { withApiV1 } from '@/lib/api/v1/with-api-v1' import { v1ErrorResponseFromCode } from '@/lib/api/v1/errors' import { safeGenerate } from '@/lib/api/v1/report-period' @@ -39,7 +39,7 @@ registerEndpoint({ idempotent: true, reversible: false, dryRunSupported: false, - response: { success: z.unknown() }, + response: { success: dataEnvelope(z.unknown()) }, }) export const GET = withApiV1<{ params: Promise<{ companyId: string }> }>( diff --git a/app/api/v1/companies/[companyId]/reports/trial-balance/route.ts b/app/api/v1/companies/[companyId]/reports/trial-balance/route.ts index 80cdb281..7530c73c 100644 --- a/app/api/v1/companies/[companyId]/reports/trial-balance/route.ts +++ b/app/api/v1/companies/[companyId]/reports/trial-balance/route.ts @@ -9,7 +9,7 @@ import { z } from 'zod' import { ok } from '@/lib/api/v1/response' -import { registerEndpoint } from '@/lib/api/v1/registry' +import { registerEndpoint, dataEnvelope } from '@/lib/api/v1/registry' import { withApiV1 } from '@/lib/api/v1/with-api-v1' import { loadPeriodFromQuery, safeGenerate } from '@/lib/api/v1/report-period' import { generateTrialBalance } from '@/lib/reports/trial-balance' @@ -64,7 +64,7 @@ registerEndpoint({ idempotent: true, reversible: false, dryRunSupported: false, - response: { success: TrialBalanceResponse }, + response: { success: dataEnvelope(TrialBalanceResponse) }, }) export const GET = withApiV1<{ params: Promise<{ companyId: string }> }>( diff --git a/app/api/v1/companies/[companyId]/reports/vacation-liability/route.ts b/app/api/v1/companies/[companyId]/reports/vacation-liability/route.ts index 0ef05329..414eeaab 100644 --- a/app/api/v1/companies/[companyId]/reports/vacation-liability/route.ts +++ b/app/api/v1/companies/[companyId]/reports/vacation-liability/route.ts @@ -7,7 +7,7 @@ import { z } from 'zod' import { ok } from '@/lib/api/v1/response' -import { registerEndpoint } from '@/lib/api/v1/registry' +import { registerEndpoint, dataEnvelope } from '@/lib/api/v1/registry' import { withApiV1 } from '@/lib/api/v1/with-api-v1' import { v1ErrorResponseFromCode } from '@/lib/api/v1/errors' import { safeGenerate } from '@/lib/api/v1/report-period' @@ -39,7 +39,7 @@ registerEndpoint({ idempotent: true, reversible: false, dryRunSupported: false, - response: { success: z.unknown() }, + response: { success: dataEnvelope(z.unknown()) }, }) export const GET = withApiV1<{ params: Promise<{ companyId: string }> }>( diff --git a/app/api/v1/companies/[companyId]/reports/vat-declaration/route.ts b/app/api/v1/companies/[companyId]/reports/vat-declaration/route.ts index 4abbff23..89588a02 100644 --- a/app/api/v1/companies/[companyId]/reports/vat-declaration/route.ts +++ b/app/api/v1/companies/[companyId]/reports/vat-declaration/route.ts @@ -8,7 +8,7 @@ import { z } from 'zod' import { ok } from '@/lib/api/v1/response' -import { registerEndpoint } from '@/lib/api/v1/registry' +import { registerEndpoint, dataEnvelope } from '@/lib/api/v1/registry' import { withApiV1 } from '@/lib/api/v1/with-api-v1' import { v1ErrorResponseFromCode } from '@/lib/api/v1/errors' import { safeGenerate } from '@/lib/api/v1/report-period' @@ -72,7 +72,7 @@ registerEndpoint({ idempotent: true, reversible: false, dryRunSupported: false, - response: { success: z.unknown() }, + response: { success: dataEnvelope(z.unknown()) }, }) export const GET = withApiV1<{ params: Promise<{ companyId: string }> }>( diff --git a/app/api/v1/companies/[companyId]/salary-runs/[id]/approve/route.ts b/app/api/v1/companies/[companyId]/salary-runs/[id]/approve/route.ts index 11c7ed0c..ea7c3173 100644 --- a/app/api/v1/companies/[companyId]/salary-runs/[id]/approve/route.ts +++ b/app/api/v1/companies/[companyId]/salary-runs/[id]/approve/route.ts @@ -18,7 +18,7 @@ import { z } from 'zod' import { ok } from '@/lib/api/v1/response' import { dryRunPreview } from '@/lib/api/v1/dry-run' -import { registerEndpoint } from '@/lib/api/v1/registry' +import { registerEndpoint, dataEnvelope } from '@/lib/api/v1/registry' import { withApiV1 } from '@/lib/api/v1/with-api-v1' import { v1ErrorResponse, v1ErrorResponseFromCode } from '@/lib/api/v1/errors' import { eventBus } from '@/lib/events' @@ -68,7 +68,7 @@ registerEndpoint({ idempotent: true, reversible: false, dryRunSupported: true, - response: { success: SalaryRunApproved }, + response: { success: dataEnvelope(SalaryRunApproved) }, }) export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string }> }>( diff --git a/app/api/v1/companies/[companyId]/salary-runs/[id]/book/route.ts b/app/api/v1/companies/[companyId]/salary-runs/[id]/book/route.ts index e3530870..fc17e2d7 100644 --- a/app/api/v1/companies/[companyId]/salary-runs/[id]/book/route.ts +++ b/app/api/v1/companies/[companyId]/salary-runs/[id]/book/route.ts @@ -29,7 +29,7 @@ import { z } from 'zod' import { ok } from '@/lib/api/v1/response' import { dryRunPreview } from '@/lib/api/v1/dry-run' -import { registerEndpoint } from '@/lib/api/v1/registry' +import { registerEndpoint, dataEnvelope } from '@/lib/api/v1/registry' import { withApiV1 } from '@/lib/api/v1/with-api-v1' import { v1ErrorResponse, v1ErrorResponseFromCode } from '@/lib/api/v1/errors' import { checkPeriodLock } from '@/lib/api/v1/check-period-lock' @@ -99,7 +99,7 @@ registerEndpoint({ idempotent: true, reversible: false, dryRunSupported: true, - response: { success: SalaryRunBooked }, + response: { success: dataEnvelope(SalaryRunBooked) }, }) export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string }> }>( diff --git a/app/api/v1/companies/[companyId]/salary-runs/[id]/calculate/route.ts b/app/api/v1/companies/[companyId]/salary-runs/[id]/calculate/route.ts index ad1deac1..fce1107a 100644 --- a/app/api/v1/companies/[companyId]/salary-runs/[id]/calculate/route.ts +++ b/app/api/v1/companies/[companyId]/salary-runs/[id]/calculate/route.ts @@ -25,7 +25,7 @@ import { z } from 'zod' import { ok } from '@/lib/api/v1/response' import { dryRunPreview } from '@/lib/api/v1/dry-run' -import { registerEndpoint } from '@/lib/api/v1/registry' +import { registerEndpoint, dataEnvelope } from '@/lib/api/v1/registry' import { withApiV1 } from '@/lib/api/v1/with-api-v1' import { v1ErrorResponse, v1ErrorResponseFromCode } from '@/lib/api/v1/errors' import { runSalaryCalculation } from '@/lib/salary/run-calculation' @@ -85,7 +85,7 @@ registerEndpoint({ idempotent: true, reversible: false, dryRunSupported: true, - response: { success: SalaryRunCalculated }, + response: { success: dataEnvelope(SalaryRunCalculated) }, }) export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string }> }>( diff --git a/app/api/v1/companies/[companyId]/salary-runs/[id]/generate-agi/route.ts b/app/api/v1/companies/[companyId]/salary-runs/[id]/generate-agi/route.ts index 3cc27ddb..26dc815b 100644 --- a/app/api/v1/companies/[companyId]/salary-runs/[id]/generate-agi/route.ts +++ b/app/api/v1/companies/[companyId]/salary-runs/[id]/generate-agi/route.ts @@ -29,7 +29,7 @@ import { z } from 'zod' import { ok } from '@/lib/api/v1/response' -import { registerEndpoint } from '@/lib/api/v1/registry' +import { registerEndpoint, dataEnvelope } from '@/lib/api/v1/registry' import { withApiV1 } from '@/lib/api/v1/with-api-v1' import { v1ErrorResponseFromCode } from '@/lib/api/v1/errors' import { generateAgiDeclaration } from '@/lib/salary/agi/generate-declaration' @@ -99,7 +99,7 @@ registerEndpoint({ idempotent: true, reversible: false, dryRunSupported: false, - response: { success: AgiGenerated }, + response: { success: dataEnvelope(AgiGenerated) }, }) export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string }> }>( diff --git a/app/api/v1/companies/[companyId]/salary-runs/[id]/mark-paid/route.ts b/app/api/v1/companies/[companyId]/salary-runs/[id]/mark-paid/route.ts index 04a31b7d..b4ffc90c 100644 --- a/app/api/v1/companies/[companyId]/salary-runs/[id]/mark-paid/route.ts +++ b/app/api/v1/companies/[companyId]/salary-runs/[id]/mark-paid/route.ts @@ -13,7 +13,7 @@ import { z } from 'zod' import { ok } from '@/lib/api/v1/response' import { dryRunPreview } from '@/lib/api/v1/dry-run' -import { registerEndpoint } from '@/lib/api/v1/registry' +import { registerEndpoint, dataEnvelope } from '@/lib/api/v1/registry' import { withApiV1 } from '@/lib/api/v1/with-api-v1' import { v1ErrorResponse, v1ErrorResponseFromCode } from '@/lib/api/v1/errors' @@ -51,7 +51,7 @@ registerEndpoint({ idempotent: true, reversible: false, dryRunSupported: true, - response: { success: SalaryRunPaid }, + response: { success: dataEnvelope(SalaryRunPaid) }, }) export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string }> }>( diff --git a/app/api/v1/companies/[companyId]/salary-runs/[id]/route.ts b/app/api/v1/companies/[companyId]/salary-runs/[id]/route.ts index 00c3de97..ed3afbdd 100644 --- a/app/api/v1/companies/[companyId]/salary-runs/[id]/route.ts +++ b/app/api/v1/companies/[companyId]/salary-runs/[id]/route.ts @@ -14,7 +14,7 @@ import { z } from 'zod' import { ok, noContent } from '@/lib/api/v1/response' import { dryRunPreview } from '@/lib/api/v1/dry-run' -import { registerEndpoint } from '@/lib/api/v1/registry' +import { registerEndpoint, dataEnvelope, NoBodyResponse } from '@/lib/api/v1/registry' import { withApiV1 } from '@/lib/api/v1/with-api-v1' import { v1ErrorResponse, v1ErrorResponseFromCode } from '@/lib/api/v1/errors' @@ -92,7 +92,7 @@ registerEndpoint({ idempotent: true, reversible: false, dryRunSupported: false, - response: { success: SalaryRunDetail }, + response: { success: dataEnvelope(SalaryRunDetail) }, }) export const GET = withApiV1<{ params: Promise<{ companyId: string; id: string }> }>( @@ -160,7 +160,7 @@ registerEndpoint({ reversible: false, dryRunSupported: true, request: { body: UpdateSalaryRunSchema }, - response: { success: SalaryRunDetail }, + response: { success: dataEnvelope(SalaryRunDetail) }, }) export const PATCH = withApiV1<{ params: Promise<{ companyId: string; id: string }> }>( @@ -306,7 +306,7 @@ registerEndpoint({ idempotent: true, reversible: false, dryRunSupported: true, - response: { success: z.object({}) }, + response: { success: NoBodyResponse }, }) export const DELETE = withApiV1<{ params: Promise<{ companyId: string; id: string }> }>( diff --git a/app/api/v1/companies/[companyId]/salary-runs/route.ts b/app/api/v1/companies/[companyId]/salary-runs/route.ts index 25427b04..5b337a49 100644 --- a/app/api/v1/companies/[companyId]/salary-runs/route.ts +++ b/app/api/v1/companies/[companyId]/salary-runs/route.ts @@ -20,7 +20,7 @@ import { encodeDefaultCursor, parsePaginationParams, } from '@/lib/api/v1/pagination' -import { registerEndpoint, listEnvelope } from '@/lib/api/v1/registry' +import { registerEndpoint, listEnvelope, dataEnvelope } from '@/lib/api/v1/registry' import { withApiV1 } from '@/lib/api/v1/with-api-v1' import { v1ErrorResponse, v1ErrorResponseFromCode } from '@/lib/api/v1/errors' import { CreateSalaryRunSchema } from '@/lib/api/schemas' @@ -229,7 +229,7 @@ registerEndpoint({ reversible: true, dryRunSupported: true, request: { body: CreateSalaryRunSchema }, - response: { success: SalaryRunCreated }, + response: { success: dataEnvelope(SalaryRunCreated) }, }) export const POST = withApiV1<{ params: Promise<{ companyId: string }> }>( diff --git a/app/api/v1/companies/[companyId]/supplier-invoices/[id]/approve/route.ts b/app/api/v1/companies/[companyId]/supplier-invoices/[id]/approve/route.ts index 6aa07902..34808ab6 100644 --- a/app/api/v1/companies/[companyId]/supplier-invoices/[id]/approve/route.ts +++ b/app/api/v1/companies/[companyId]/supplier-invoices/[id]/approve/route.ts @@ -14,7 +14,7 @@ import { z } from 'zod' import { ok } from '@/lib/api/v1/response' import { dryRunPreview } from '@/lib/api/v1/dry-run' -import { registerEndpoint } from '@/lib/api/v1/registry' +import { registerEndpoint, dataEnvelope } from '@/lib/api/v1/registry' import { withApiV1 } from '@/lib/api/v1/with-api-v1' import { v1ErrorResponse, v1ErrorResponseFromCode } from '@/lib/api/v1/errors' import { eventBus } from '@/lib/events' @@ -56,7 +56,7 @@ registerEndpoint({ idempotent: true, reversible: false, dryRunSupported: true, - response: { success: SupplierInvoiceApproved }, + response: { success: dataEnvelope(SupplierInvoiceApproved) }, }) export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string }> }>( diff --git a/app/api/v1/companies/[companyId]/supplier-invoices/[id]/credit/route.ts b/app/api/v1/companies/[companyId]/supplier-invoices/[id]/credit/route.ts index fbc3d328..99444a4a 100644 --- a/app/api/v1/companies/[companyId]/supplier-invoices/[id]/credit/route.ts +++ b/app/api/v1/companies/[companyId]/supplier-invoices/[id]/credit/route.ts @@ -20,7 +20,7 @@ import { z } from 'zod' import { ok } from '@/lib/api/v1/response' import { dryRunPreview } from '@/lib/api/v1/dry-run' -import { registerEndpoint } from '@/lib/api/v1/registry' +import { registerEndpoint, dataEnvelope } from '@/lib/api/v1/registry' import { withApiV1 } from '@/lib/api/v1/with-api-v1' import { v1ErrorResponse, v1ErrorResponseFromCode } from '@/lib/api/v1/errors' import { checkPeriodLock } from '@/lib/api/v1/check-period-lock' @@ -102,7 +102,7 @@ registerEndpoint({ idempotent: true, reversible: false, dryRunSupported: true, - response: { success: SupplierInvoiceCredited }, + response: { success: dataEnvelope(SupplierInvoiceCredited) }, }) export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string }> }>( diff --git a/app/api/v1/companies/[companyId]/supplier-invoices/[id]/mark-paid/route.ts b/app/api/v1/companies/[companyId]/supplier-invoices/[id]/mark-paid/route.ts index 6deee803..2ab4865f 100644 --- a/app/api/v1/companies/[companyId]/supplier-invoices/[id]/mark-paid/route.ts +++ b/app/api/v1/companies/[companyId]/supplier-invoices/[id]/mark-paid/route.ts @@ -17,7 +17,7 @@ import { z } from 'zod' import { ok } from '@/lib/api/v1/response' import { dryRunPreview } from '@/lib/api/v1/dry-run' -import { registerEndpoint } from '@/lib/api/v1/registry' +import { registerEndpoint, dataEnvelope } from '@/lib/api/v1/registry' import { withApiV1 } from '@/lib/api/v1/with-api-v1' import { v1ErrorResponse, v1ErrorResponseFromCode } from '@/lib/api/v1/errors' import { checkPeriodLock } from '@/lib/api/v1/check-period-lock' @@ -85,7 +85,7 @@ registerEndpoint({ reversible: false, dryRunSupported: true, request: { body: MarkSupplierInvoicePaidSchema }, - response: { success: SupplierInvoicePaidResponse }, + response: { success: dataEnvelope(SupplierInvoicePaidResponse) }, }) export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string }> }>( diff --git a/app/api/v1/companies/[companyId]/supplier-invoices/[id]/route.ts b/app/api/v1/companies/[companyId]/supplier-invoices/[id]/route.ts index 04be8e51..a92cbb4e 100644 --- a/app/api/v1/companies/[companyId]/supplier-invoices/[id]/route.ts +++ b/app/api/v1/companies/[companyId]/supplier-invoices/[id]/route.ts @@ -16,7 +16,7 @@ import { z } from 'zod' import { ok } from '@/lib/api/v1/response' import { dryRunPreview } from '@/lib/api/v1/dry-run' import { parseExpand } from '@/lib/api/v1/expand' -import { registerEndpoint } from '@/lib/api/v1/registry' +import { registerEndpoint, dataEnvelope } from '@/lib/api/v1/registry' import { withApiV1 } from '@/lib/api/v1/with-api-v1' import { v1ErrorResponse, v1ErrorResponseFromCode } from '@/lib/api/v1/errors' import { UpdateSupplierInvoiceSchema } from '@/lib/api/schemas' @@ -110,7 +110,7 @@ registerEndpoint({ idempotent: true, reversible: false, dryRunSupported: false, - response: { success: SupplierInvoiceDetail }, + response: { success: dataEnvelope(SupplierInvoiceDetail) }, }) export const GET = withApiV1<{ params: Promise<{ companyId: string; id: string }> }>( @@ -197,7 +197,7 @@ registerEndpoint({ reversible: true, dryRunSupported: true, request: { body: V1PatchSupplierInvoiceSchema }, - response: { success: SupplierInvoiceDetail }, + response: { success: dataEnvelope(SupplierInvoiceDetail) }, }) export const PATCH = withApiV1<{ params: Promise<{ companyId: string; id: string }> }>( diff --git a/app/api/v1/companies/[companyId]/supplier-invoices/route.ts b/app/api/v1/companies/[companyId]/supplier-invoices/route.ts index cbf6a0ac..a9f12884 100644 --- a/app/api/v1/companies/[companyId]/supplier-invoices/route.ts +++ b/app/api/v1/companies/[companyId]/supplier-invoices/route.ts @@ -29,7 +29,7 @@ import { encodeDefaultCursor, parsePaginationParams, } from '@/lib/api/v1/pagination' -import { registerEndpoint, listEnvelope } from '@/lib/api/v1/registry' +import { registerEndpoint, listEnvelope, dataEnvelope } from '@/lib/api/v1/registry' import { withApiV1 } from '@/lib/api/v1/with-api-v1' import { v1ErrorResponse, v1ErrorResponseFromCode } from '@/lib/api/v1/errors' import { checkPeriodLock } from '@/lib/api/v1/check-period-lock' @@ -329,7 +329,7 @@ registerEndpoint({ reversible: true, dryRunSupported: true, request: { body: CreateSupplierInvoiceSchema }, - response: { success: SupplierInvoiceCreated }, + response: { success: dataEnvelope(SupplierInvoiceCreated) }, }) interface ComputedItem { diff --git a/app/api/v1/companies/[companyId]/suppliers/[id]/route.ts b/app/api/v1/companies/[companyId]/suppliers/[id]/route.ts index 83abfb68..3206db7b 100644 --- a/app/api/v1/companies/[companyId]/suppliers/[id]/route.ts +++ b/app/api/v1/companies/[companyId]/suppliers/[id]/route.ts @@ -16,7 +16,7 @@ import { z } from 'zod' import { noContent, ok } from '@/lib/api/v1/response' import { dryRunPreview } from '@/lib/api/v1/dry-run' import { parseExpand } from '@/lib/api/v1/expand' -import { registerEndpoint } from '@/lib/api/v1/registry' +import { registerEndpoint, dataEnvelope, NoBodyResponse } from '@/lib/api/v1/registry' import { withApiV1 } from '@/lib/api/v1/with-api-v1' import { v1ErrorResponse, v1ErrorResponseFromCode } from '@/lib/api/v1/errors' import { UpdateSupplierSchema } from '@/lib/api/schemas' @@ -112,7 +112,7 @@ registerEndpoint({ idempotent: true, reversible: false, dryRunSupported: false, - response: { success: SupplierDetail }, + response: { success: dataEnvelope(SupplierDetail) }, }) export const GET = withApiV1<{ params: Promise<{ companyId: string; id: string }> }>( @@ -242,7 +242,7 @@ registerEndpoint({ reversible: true, dryRunSupported: true, request: { body: UpdateSupplierSchema }, - response: { success: SupplierDetail }, + response: { success: dataEnvelope(SupplierDetail) }, }) export const PATCH = withApiV1<{ params: Promise<{ companyId: string; id: string }> }>( @@ -452,7 +452,7 @@ registerEndpoint({ idempotent: true, reversible: true, dryRunSupported: true, - response: { success: z.object({}) }, + response: { success: NoBodyResponse }, }) export const DELETE = withApiV1<{ params: Promise<{ companyId: string; id: string }> }>( diff --git a/app/api/v1/companies/[companyId]/suppliers/bulk-create/route.ts b/app/api/v1/companies/[companyId]/suppliers/bulk-create/route.ts index 7dde060a..79781871 100644 --- a/app/api/v1/companies/[companyId]/suppliers/bulk-create/route.ts +++ b/app/api/v1/companies/[companyId]/suppliers/bulk-create/route.ts @@ -17,7 +17,7 @@ import { z } from 'zod' import type { SupabaseClient } from '@supabase/supabase-js' import { ok } from '@/lib/api/v1/response' import { dryRunPreview } from '@/lib/api/v1/dry-run' -import { registerEndpoint } from '@/lib/api/v1/registry' +import { registerEndpoint, dataEnvelope } from '@/lib/api/v1/registry' import { withApiV1 } from '@/lib/api/v1/with-api-v1' import { v1ErrorResponseFromCode } from '@/lib/api/v1/errors' import { CreateSupplierSchema } from '@/lib/api/schemas' @@ -96,7 +96,7 @@ registerEndpoint({ reversible: true, dryRunSupported: true, request: { body: BulkCreateRequest }, - response: { success: BulkCreateResponse }, + response: { success: dataEnvelope(BulkCreateResponse) }, }) interface ResultItem { diff --git a/app/api/v1/companies/[companyId]/suppliers/route.ts b/app/api/v1/companies/[companyId]/suppliers/route.ts index 94dea686..f51df953 100644 --- a/app/api/v1/companies/[companyId]/suppliers/route.ts +++ b/app/api/v1/companies/[companyId]/suppliers/route.ts @@ -21,7 +21,7 @@ import { encodeDefaultCursor, parsePaginationParams, } from '@/lib/api/v1/pagination' -import { registerEndpoint, listEnvelope } from '@/lib/api/v1/registry' +import { registerEndpoint, listEnvelope, dataEnvelope } from '@/lib/api/v1/registry' import { withApiV1 } from '@/lib/api/v1/with-api-v1' import { v1ErrorResponse, v1ErrorResponseFromCode } from '@/lib/api/v1/errors' import { CreateSupplierSchema } from '@/lib/api/schemas' @@ -307,7 +307,7 @@ registerEndpoint({ reversible: true, dryRunSupported: true, request: { body: CreateSupplierSchema }, - response: { success: SupplierCreated }, + response: { success: dataEnvelope(SupplierCreated) }, }) export const POST = withApiV1<{ params: Promise<{ companyId: string }> }>( diff --git a/app/api/v1/companies/[companyId]/transactions/[id]/categorize/route.ts b/app/api/v1/companies/[companyId]/transactions/[id]/categorize/route.ts index b29f6e0c..72851aac 100644 --- a/app/api/v1/companies/[companyId]/transactions/[id]/categorize/route.ts +++ b/app/api/v1/companies/[companyId]/transactions/[id]/categorize/route.ts @@ -16,7 +16,7 @@ import { z } from 'zod' import { ok } from '@/lib/api/v1/response' import { dryRunPreview } from '@/lib/api/v1/dry-run' -import { registerEndpoint } from '@/lib/api/v1/registry' +import { registerEndpoint, dataEnvelope } from '@/lib/api/v1/registry' import { withApiV1 } from '@/lib/api/v1/with-api-v1' import { v1ErrorResponse, v1ErrorResponseFromCode } from '@/lib/api/v1/errors' import { checkPeriodLock } from '@/lib/api/v1/check-period-lock' @@ -89,7 +89,7 @@ registerEndpoint({ reversible: true, dryRunSupported: true, request: { body: CategorizeTransactionSchema }, - response: { success: CategorizeResponse }, + response: { success: dataEnvelope(CategorizeResponse) }, }) export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string }> }>( diff --git a/app/api/v1/companies/[companyId]/transactions/[id]/match-invoice/route.ts b/app/api/v1/companies/[companyId]/transactions/[id]/match-invoice/route.ts index f3706226..e3e4a490 100644 --- a/app/api/v1/companies/[companyId]/transactions/[id]/match-invoice/route.ts +++ b/app/api/v1/companies/[companyId]/transactions/[id]/match-invoice/route.ts @@ -18,7 +18,7 @@ */ import { z } from 'zod' import { ok } from '@/lib/api/v1/response' -import { registerEndpoint } from '@/lib/api/v1/registry' +import { registerEndpoint, dataEnvelope } from '@/lib/api/v1/registry' import { withApiV1 } from '@/lib/api/v1/with-api-v1' import { v1ErrorResponse, v1ErrorResponseFromCode } from '@/lib/api/v1/errors' import { MatchInvoiceSchema } from '@/lib/api/schemas' @@ -87,7 +87,7 @@ registerEndpoint({ reversible: false, dryRunSupported: false, request: { body: MatchInvoiceSchema }, - response: { success: MatchInvoiceResponse }, + response: { success: dataEnvelope(MatchInvoiceResponse) }, }) export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string }> }>( diff --git a/app/api/v1/companies/[companyId]/transactions/[id]/match-supplier-invoice/route.ts b/app/api/v1/companies/[companyId]/transactions/[id]/match-supplier-invoice/route.ts index 339fd3ba..01247c47 100644 --- a/app/api/v1/companies/[companyId]/transactions/[id]/match-supplier-invoice/route.ts +++ b/app/api/v1/companies/[companyId]/transactions/[id]/match-supplier-invoice/route.ts @@ -7,7 +7,7 @@ */ import { z } from 'zod' import { ok } from '@/lib/api/v1/response' -import { registerEndpoint } from '@/lib/api/v1/registry' +import { registerEndpoint, dataEnvelope } from '@/lib/api/v1/registry' import { withApiV1 } from '@/lib/api/v1/with-api-v1' import { v1ErrorResponse, v1ErrorResponseFromCode } from '@/lib/api/v1/errors' import { MatchSupplierInvoiceSchema } from '@/lib/api/schemas' @@ -67,7 +67,7 @@ registerEndpoint({ reversible: false, dryRunSupported: false, request: { body: MatchSupplierInvoiceSchema }, - response: { success: MatchSIResponse }, + response: { success: dataEnvelope(MatchSIResponse) }, }) export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string }> }>( diff --git a/app/api/v1/companies/[companyId]/transactions/[id]/route.ts b/app/api/v1/companies/[companyId]/transactions/[id]/route.ts index dece2cd9..6e7c8514 100644 --- a/app/api/v1/companies/[companyId]/transactions/[id]/route.ts +++ b/app/api/v1/companies/[companyId]/transactions/[id]/route.ts @@ -6,7 +6,7 @@ */ import { z } from 'zod' import { ok } from '@/lib/api/v1/response' -import { registerEndpoint } from '@/lib/api/v1/registry' +import { registerEndpoint, dataEnvelope } from '@/lib/api/v1/registry' import { withApiV1 } from '@/lib/api/v1/with-api-v1' import { v1ErrorResponse, v1ErrorResponseFromCode } from '@/lib/api/v1/errors' @@ -75,7 +75,7 @@ registerEndpoint({ idempotent: true, reversible: false, dryRunSupported: false, - response: { success: TransactionDetail }, + response: { success: dataEnvelope(TransactionDetail) }, }) export const GET = withApiV1<{ params: Promise<{ companyId: string; id: string }> }>( diff --git a/app/api/v1/companies/[companyId]/transactions/[id]/uncategorize/route.ts b/app/api/v1/companies/[companyId]/transactions/[id]/uncategorize/route.ts index 2908e2cd..d9787b96 100644 --- a/app/api/v1/companies/[companyId]/transactions/[id]/uncategorize/route.ts +++ b/app/api/v1/companies/[companyId]/transactions/[id]/uncategorize/route.ts @@ -12,7 +12,7 @@ import { z } from 'zod' import { ok } from '@/lib/api/v1/response' import { dryRunPreview } from '@/lib/api/v1/dry-run' -import { registerEndpoint } from '@/lib/api/v1/registry' +import { registerEndpoint, dataEnvelope } from '@/lib/api/v1/registry' import { withApiV1 } from '@/lib/api/v1/with-api-v1' import { v1ErrorResponse, v1ErrorResponseFromCode } from '@/lib/api/v1/errors' import { reverseEntry } from '@/lib/bookkeeping/engine' @@ -51,7 +51,7 @@ registerEndpoint({ idempotent: true, reversible: false, // The reversal itself cannot be reversed via this endpoint. dryRunSupported: true, - response: { success: UncategorizeResponse }, + response: { success: dataEnvelope(UncategorizeResponse) }, }) export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string }> }>( diff --git a/app/api/v1/companies/[companyId]/transactions/batch-categorize/route.ts b/app/api/v1/companies/[companyId]/transactions/batch-categorize/route.ts index e199d18f..dd36d46c 100644 --- a/app/api/v1/companies/[companyId]/transactions/batch-categorize/route.ts +++ b/app/api/v1/companies/[companyId]/transactions/batch-categorize/route.ts @@ -12,7 +12,7 @@ import { z } from 'zod' import { ok } from '@/lib/api/v1/response' import { dryRunPreview } from '@/lib/api/v1/dry-run' -import { registerEndpoint } from '@/lib/api/v1/registry' +import { registerEndpoint, dataEnvelope } from '@/lib/api/v1/registry' import { withApiV1 } from '@/lib/api/v1/with-api-v1' import { v1ErrorResponseFromCode } from '@/lib/api/v1/errors' import { checkPeriodLock } from '@/lib/api/v1/check-period-lock' @@ -102,7 +102,7 @@ registerEndpoint({ reversible: true, dryRunSupported: true, request: { body: BatchRequest }, - response: { success: BatchResponse }, + response: { success: dataEnvelope(BatchResponse) }, }) interface Item { diff --git a/app/api/v1/companies/[companyId]/transactions/ingest/route.ts b/app/api/v1/companies/[companyId]/transactions/ingest/route.ts index 2ac383ca..0007997d 100644 --- a/app/api/v1/companies/[companyId]/transactions/ingest/route.ts +++ b/app/api/v1/companies/[companyId]/transactions/ingest/route.ts @@ -18,7 +18,7 @@ import { z } from 'zod' import { ok } from '@/lib/api/v1/response' import { dryRunPreview } from '@/lib/api/v1/dry-run' -import { registerEndpoint } from '@/lib/api/v1/registry' +import { registerEndpoint, dataEnvelope } from '@/lib/api/v1/registry' import { withApiV1 } from '@/lib/api/v1/with-api-v1' import { v1ErrorResponse, v1ErrorResponseFromCode } from '@/lib/api/v1/errors' import { ingestTransactions } from '@/lib/transactions/ingest' @@ -99,7 +99,7 @@ registerEndpoint({ reversible: false, dryRunSupported: true, request: { body: IngestRequest }, - response: { success: IngestResponse }, + response: { success: dataEnvelope(IngestResponse) }, }) export const POST = withApiV1<{ params: Promise<{ companyId: string }> }>( diff --git a/app/api/v1/companies/[companyId]/voucher-gap-explanations/route.ts b/app/api/v1/companies/[companyId]/voucher-gap-explanations/route.ts index 03e67c99..0666d80c 100644 --- a/app/api/v1/companies/[companyId]/voucher-gap-explanations/route.ts +++ b/app/api/v1/companies/[companyId]/voucher-gap-explanations/route.ts @@ -19,7 +19,7 @@ import { z } from 'zod' import { created } from '@/lib/api/v1/response' import { dryRunPreview } from '@/lib/api/v1/dry-run' -import { registerEndpoint } from '@/lib/api/v1/registry' +import { registerEndpoint, dataEnvelope } from '@/lib/api/v1/registry' import { withApiV1 } from '@/lib/api/v1/with-api-v1' import { v1ErrorResponse, v1ErrorResponseFromCode } from '@/lib/api/v1/errors' @@ -83,7 +83,7 @@ registerEndpoint({ reversible: false, dryRunSupported: true, request: { body: CreateVoucherGapExplanation }, - response: { success: VoucherGapExplanationCreated }, + response: { success: dataEnvelope(VoucherGapExplanationCreated) }, }) export const POST = withApiV1<{ params: Promise<{ companyId: string }> }>( diff --git a/app/api/v1/companies/[companyId]/webhooks/[id]/rotate-secret/route.ts b/app/api/v1/companies/[companyId]/webhooks/[id]/rotate-secret/route.ts index 93af6f30..70e73f33 100644 --- a/app/api/v1/companies/[companyId]/webhooks/[id]/rotate-secret/route.ts +++ b/app/api/v1/companies/[companyId]/webhooks/[id]/rotate-secret/route.ts @@ -25,7 +25,7 @@ import { z } from 'zod' import { ok } from '@/lib/api/v1/response' -import { registerEndpoint } from '@/lib/api/v1/registry' +import { registerEndpoint, dataEnvelope } from '@/lib/api/v1/registry' import { withApiV1 } from '@/lib/api/v1/with-api-v1' import { v1ErrorResponse, v1ErrorResponseFromCode } from '@/lib/api/v1/errors' import { generateWebhookSecret } from '@/lib/webhooks/signing' @@ -66,7 +66,7 @@ registerEndpoint({ idempotent: false, reversible: false, dryRunSupported: false, - response: { success: RotateSecretResponse }, + response: { success: dataEnvelope(RotateSecretResponse) }, }) export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string }> }>( diff --git a/app/api/v1/companies/[companyId]/webhooks/[id]/route.ts b/app/api/v1/companies/[companyId]/webhooks/[id]/route.ts index d25d61c1..91e3a62c 100644 --- a/app/api/v1/companies/[companyId]/webhooks/[id]/route.ts +++ b/app/api/v1/companies/[companyId]/webhooks/[id]/route.ts @@ -19,7 +19,7 @@ import { z } from 'zod' import { ok, noContent } from '@/lib/api/v1/response' import { dryRunPreview } from '@/lib/api/v1/dry-run' -import { registerEndpoint } from '@/lib/api/v1/registry' +import { registerEndpoint, dataEnvelope, NoBodyResponse } from '@/lib/api/v1/registry' import { withApiV1 } from '@/lib/api/v1/with-api-v1' import { v1ErrorResponse, v1ErrorResponseFromCode } from '@/lib/api/v1/errors' import { validateWebhookUrl } from '@/lib/webhooks/url-guard' @@ -91,7 +91,7 @@ registerEndpoint({ idempotent: true, reversible: false, dryRunSupported: false, - response: { success: WebhookDetail }, + response: { success: dataEnvelope(WebhookDetail) }, }) export const GET = withApiV1<{ params: Promise<{ companyId: string; id: string }> }>( @@ -153,7 +153,7 @@ registerEndpoint({ reversible: true, dryRunSupported: true, request: { body: PatchWebhookSchema }, - response: { success: WebhookDetail }, + response: { success: dataEnvelope(WebhookDetail) }, }) export const PATCH = withApiV1<{ params: Promise<{ companyId: string; id: string }> }>( @@ -299,7 +299,7 @@ registerEndpoint({ idempotent: true, reversible: false, dryRunSupported: false, - response: { success: z.object({ deleted: z.boolean() }) }, + response: { success: NoBodyResponse }, }) export const DELETE = withApiV1<{ params: Promise<{ companyId: string; id: string }> }>( diff --git a/app/api/v1/companies/[companyId]/webhooks/[id]/test/route.ts b/app/api/v1/companies/[companyId]/webhooks/[id]/test/route.ts index e71f2e9e..29e2fe3a 100644 --- a/app/api/v1/companies/[companyId]/webhooks/[id]/test/route.ts +++ b/app/api/v1/companies/[companyId]/webhooks/[id]/test/route.ts @@ -10,7 +10,7 @@ import { z } from 'zod' import { ok } from '@/lib/api/v1/response' -import { registerEndpoint } from '@/lib/api/v1/registry' +import { registerEndpoint, dataEnvelope } from '@/lib/api/v1/registry' import { withApiV1 } from '@/lib/api/v1/with-api-v1' import { v1ErrorResponse, v1ErrorResponseFromCode } from '@/lib/api/v1/errors' @@ -49,10 +49,10 @@ registerEndpoint({ reversible: false, dryRunSupported: false, response: { - success: z.object({ + success: dataEnvelope(z.object({ webhook_delivery_id: z.string().uuid(), status: z.literal('pending'), - }), + })), }, }) diff --git a/app/api/v1/companies/[companyId]/webhooks/route.ts b/app/api/v1/companies/[companyId]/webhooks/route.ts index 4a3c2129..87acdbfb 100644 --- a/app/api/v1/companies/[companyId]/webhooks/route.ts +++ b/app/api/v1/companies/[companyId]/webhooks/route.ts @@ -209,7 +209,7 @@ registerEndpoint({ reversible: true, dryRunSupported: true, request: { body: CreateWebhookSchema }, - response: { success: WebhookCreated }, + response: { success: dataEnvelope(WebhookCreated) }, }) export const POST = withApiV1<{ params: Promise<{ companyId: string }> }>( diff --git a/app/api/v1/health/route.ts b/app/api/v1/health/route.ts index 034687f5..b3bb3e50 100644 --- a/app/api/v1/health/route.ts +++ b/app/api/v1/health/route.ts @@ -7,7 +7,7 @@ import { z } from 'zod' import { ok } from '@/lib/api/v1/response' -import { registerEndpoint } from '@/lib/api/v1/registry' +import { registerEndpoint, dataEnvelope } from '@/lib/api/v1/registry' import { API_V1_VERSION } from '@/lib/api/v1/version' import { withApiV1 } from '@/lib/api/v1/with-api-v1' @@ -45,7 +45,7 @@ registerEndpoint({ idempotent: true, reversible: false, dryRunSupported: false, - response: { success: HealthResponse }, + response: { success: dataEnvelope(HealthResponse) }, }) export const GET = withApiV1('health.check', async (_request, ctx) => { diff --git a/app/api/v1/operations/[id]/route.ts b/app/api/v1/operations/[id]/route.ts index 5bbadc4c..9d5ad717 100644 --- a/app/api/v1/operations/[id]/route.ts +++ b/app/api/v1/operations/[id]/route.ts @@ -16,7 +16,7 @@ import { z } from 'zod' import { ok } from '@/lib/api/v1/response' -import { registerEndpoint } from '@/lib/api/v1/registry' +import { registerEndpoint, dataEnvelope } from '@/lib/api/v1/registry' import { withApiV1 } from '@/lib/api/v1/with-api-v1' import { v1ErrorResponseFromCode } from '@/lib/api/v1/errors' import { getOperation } from '@/lib/api/v1/operations' @@ -76,7 +76,7 @@ registerEndpoint({ idempotent: true, reversible: false, dryRunSupported: false, - response: { success: OperationDetail }, + response: { success: dataEnvelope(OperationDetail) }, }) export const GET = withApiV1<{ params: Promise<{ id: string }> }>( diff --git a/app/api/v1/webhook-deliveries/[id]/retry/route.ts b/app/api/v1/webhook-deliveries/[id]/retry/route.ts index b3b07e42..61f554b1 100644 --- a/app/api/v1/webhook-deliveries/[id]/retry/route.ts +++ b/app/api/v1/webhook-deliveries/[id]/retry/route.ts @@ -20,7 +20,7 @@ import { z } from 'zod' import { ok } from '@/lib/api/v1/response' -import { registerEndpoint } from '@/lib/api/v1/registry' +import { registerEndpoint, dataEnvelope } from '@/lib/api/v1/registry' import { withApiV1 } from '@/lib/api/v1/with-api-v1' import { v1ErrorResponse, v1ErrorResponseFromCode } from '@/lib/api/v1/errors' import { minimisePayload } from '@/lib/webhooks/handler' @@ -59,10 +59,10 @@ registerEndpoint({ reversible: false, dryRunSupported: false, response: { - success: z.object({ + success: dataEnvelope(z.object({ webhook_delivery_id: z.string().uuid(), status: z.literal('pending'), - }), + })), }, }) diff --git a/lib/api/v1/__tests__/response-envelope-contract.test.ts b/lib/api/v1/__tests__/response-envelope-contract.test.ts new file mode 100644 index 00000000..2eec4ae0 --- /dev/null +++ b/lib/api/v1/__tests__/response-envelope-contract.test.ts @@ -0,0 +1,131 @@ +/** + * Response-envelope contract test. + * + * Every v1 handler returns the canonical `{ data, meta }` envelope — `ok()` and + * `created()` wrap a single object, `paginated()` wraps an array, both stamping + * the shared `meta` block (see `lib/api/v1/response.ts`). The OpenAPI generator, + * however, derives each endpoint's documented body purely from its registered + * `response.success` Zod schema, and that schema is NOT validated at runtime — + * so nothing stops a route from declaring a shape the handler never sends. + * + * That is exactly what issue #794 found: every list endpoint declared a bare + * `{ : [...] }` object that no handler emits. #802 fixed the list + * endpoints (via `listEnvelope`/`dataEnvelope`); the same drift was latent on + * the single-resource and write endpoints, which declared the bare resource + * schema instead of `{ data, meta }`. + * + * This test is the regression guard the issue asked for. It asserts EVERY + * JSON-returning endpoint declares the `{ data, meta }` envelope with the shared + * `ResponseMetaSchema` — so a new endpoint that forgets to wrap its schema + * (list OR single) fails CI here instead of shipping a lying spec. Binary + * downloads (`response.contentType`) and 204 No Content endpoints + * (`NoBodyResponse`) carry no JSON body and are the only exemptions. + */ + +import { describe, expect, it } from 'vitest' +import { z } from 'zod' +import { listEndpoints, ResponseMetaSchema, NoBodyResponse, listEnvelope, dataEnvelope } from '../registry' +// Side-effect import — every route file's registerEndpoint() runs at module +// load time and populates the shared ENDPOINTS map. +import '../load-routes' + +/** Binary downloads (PDF, SIE text) declare a non-JSON contentType. */ +function isBinary(success: { contentType?: string }): boolean { + return !!success.contentType && success.contentType !== 'application/json' +} + +describe('v1 response envelope contract', () => { + const endpoints = listEndpoints() + + it('every JSON endpoint declares the { data, meta } envelope with the shared meta schema', () => { + // Accumulate every violation so a failing run names ALL offending endpoints + // at once (a fresh route that forgets to wrap, plus any that drift later), + // instead of failing one-at-a-time across many edit cycles. + const violations: string[] = [] + + for (const ep of endpoints) { + const ctx = `${ep.method} ${ep.path} (${ep.operation})` + + // Exemptions: binary bodies and 204-no-content have no JSON envelope. + if (isBinary(ep.response)) continue + if (ep.response.success === NoBodyResponse) continue + + const success = ep.response.success + if (!(success instanceof z.ZodObject)) { + violations.push(`${ctx}: response.success is not a { data, meta } object — wrap it with listEnvelope()/dataEnvelope() (or use NoBodyResponse for 204 / response.contentType for binary).`) + continue + } + + const shape = (success as z.ZodObject).shape + const keys = Object.keys(shape).sort() + if (keys.length !== 2 || keys[0] !== 'data' || keys[1] !== 'meta') { + violations.push(`${ctx}: top-level keys must be [data, meta] — found [${keys.join(', ')}]. The handler returns { data, meta }; declare it with listEnvelope()/dataEnvelope().`) + continue + } + + // Reference equality: both envelope helpers wire in this exact schema, so + // a hand-rolled `{ data, meta: z.object({...}) }` that drifts from the + // real meta block is rejected too. + if (shape.meta !== ResponseMetaSchema) { + violations.push(`${ctx}: meta is not the shared ResponseMetaSchema (use listEnvelope()/dataEnvelope(), don't hand-roll the envelope).`) + } + } + + expect( + violations, + `\n${violations.length} v1 endpoint(s) declare a response.success that doesn't match the { data, meta } envelope the handler actually returns:\n\n${violations.map((v) => ` • ${v}`).join('\n')}\n`, + ).toEqual([]) + }) + + it('list endpoints expose data as an array (the paginated() envelope)', () => { + // Detect list endpoints structurally: their `data` is a Zod array. This is + // the half of the contract that maps onto paginated() specifically — guards + // against a list endpoint drifting from `{ data: [...] }` back to a bare + // `{ : [...] }` (which would drop the array out of `data` entirely). + const arrayDataEndpoints = endpoints.filter((ep) => { + const s = ep.response.success + return s instanceof z.ZodObject && (s as z.ZodObject).shape.data instanceof z.ZodArray + }) + + // The 10 cursor-paginated list endpoints (companies, customers, suppliers, + // invoices, supplier-invoices, journal-entries, transactions, employees, + // salary-runs, webhook deliveries). accounts/fiscal-periods/webhooks nest + // their array under a named key inside `data`, so they use dataEnvelope and + // are intentionally NOT counted here. A drop below this floor means a + // paginated endpoint silently lost its `data: [...]` shape. + expect( + arrayDataEndpoints.length, + `expected the known paginated list endpoints to keep data: z.array(...); found only ${arrayDataEndpoints.length}`, + ).toBeGreaterThanOrEqual(10) + + for (const ep of arrayDataEndpoints) { + const shape = (ep.response.success as z.ZodObject).shape + expect( + shape.meta === ResponseMetaSchema, + `${ep.method} ${ep.path}: list envelope meta must be the shared ResponseMetaSchema`, + ).toBe(true) + } + }) + + it('listEnvelope() and dataEnvelope() produce the canonical { data, meta } shape', () => { + const list = listEnvelope(z.object({ id: z.string() })) + expect(list instanceof z.ZodObject).toBe(true) + expect(Object.keys(list.shape).sort()).toEqual(['data', 'meta']) + expect(list.shape.data instanceof z.ZodArray).toBe(true) + expect(list.shape.meta === ResponseMetaSchema).toBe(true) + + const data = dataEnvelope(z.object({ id: z.string() })) + expect(data instanceof z.ZodObject).toBe(true) + expect(Object.keys(data.shape).sort()).toEqual(['data', 'meta']) + expect(data.shape.data instanceof z.ZodObject).toBe(true) + expect(data.shape.meta === ResponseMetaSchema).toBe(true) + }) + + it('the shared meta schema carries request_id + api_version', () => { + // The envelope helpers are only correct if meta itself is well-formed. + expect(ResponseMetaSchema instanceof z.ZodObject).toBe(true) + const metaKeys = Object.keys(ResponseMetaSchema.shape) + expect(metaKeys).toContain('request_id') + expect(metaKeys).toContain('api_version') + }) +}) diff --git a/lib/api/v1/registry.ts b/lib/api/v1/registry.ts index 374002e4..ad6e2872 100644 --- a/lib/api/v1/registry.ts +++ b/lib/api/v1/registry.ts @@ -21,15 +21,31 @@ import type { ZodTypeAny } from 'zod' import type { ApiKeyScope } from '@/lib/auth/api-keys' import { API_V1_VERSION } from './version' +/** + * The audit block surfaced inline on write responses (see `AuditBlock` in + * `lib/api/v1/response.ts`) so an agent gets the voucher number / audit-trail + * URL without a second round-trip. Every field is optional. + */ +const ResponseAuditSchema = z.object({ + voucher_number: z.string().optional(), + voucher_url: z.string().optional(), + audit_trail_url: z.string().optional(), + immutable_at: z.string().optional(), +}) + /** * The `meta` block echoed in every v1 response envelope (see * `lib/api/v1/response.ts`). List endpoints additionally populate - * `next_cursor`; it is absent on the final page. + * `next_cursor`; it is absent on the final page. Writes may surface an + * `audit` block, and soft-degraded `?expand=` responses a `partial_expansions` + * list — both optional, so reads and lists omit them. */ export const ResponseMetaSchema = z.object({ request_id: z.string(), api_version: z.string(), next_cursor: z.string().nullable().optional(), + audit: ResponseAuditSchema.optional(), + partial_expansions: z.array(z.string()).optional(), }) /** @@ -66,6 +82,18 @@ export function dataEnvelope(data: T) { }) } +/** + * Sentinel `response.success` for endpoints that return 204 No Content with an + * empty body — e.g. DELETE handlers calling `noContent()`. The OpenAPI + * generator emits a bare `204` response (no schema) for these instead of a + * `200 { data, meta }`, and the envelope contract test exempts them. + * + * Identified by REFERENCE equality, so every 204 route MUST import this exact + * constant rather than declaring its own `z.object({})` — that is what lets the + * generator and the contract test recognise the "no body" intent. + */ +export const NoBodyResponse = z.object({}) + export type HttpMethod = 'GET' | 'POST' | 'PATCH' | 'PUT' | 'DELETE' export type ActionRisk = 'low' | 'medium' | 'high' @@ -327,6 +355,13 @@ export function generateOpenApiSpec(serverUrl: string): OpenApiSpec { ? { [def.response.contentType]: { schema: { type: 'string', format: 'binary' } } } : { 'application/json': { schema: zodToJsonSchema(def.response.success) } } + // 204 No Content endpoints (DELETEs returning noContent()) carry no body — + // emit a bare 204 instead of a 200 { data, meta } so the spec stops + // advertising a response shape these handlers never send. + const successResponse = def.response.success === NoBodyResponse + ? { '204': { description: 'No Content' } } + : { '200': { description: 'Success', content: successContent } } + const operationDef: Record = { operationId: def.operation, summary: def.summary, @@ -343,10 +378,7 @@ export function generateOpenApiSpec(serverUrl: string): OpenApiSpec { 'x-dry-run-supported': def.dryRunSupported, ...(def.scope ? { 'x-required-scope': def.scope } : {}), responses: { - '200': { - description: 'Success', - content: successContent, - }, + ...successResponse, '400': { description: 'Validation error', $ref: '#/components/responses/Error' }, '401': { description: 'Unauthorized', $ref: '#/components/responses/Error' }, '403': { description: 'Insufficient scope', $ref: '#/components/responses/Error' }, diff --git a/lib/reports/__tests__/ar-ledger.test.ts b/lib/reports/__tests__/ar-ledger.test.ts index 2426e34f..654b94f9 100644 --- a/lib/reports/__tests__/ar-ledger.test.ts +++ b/lib/reports/__tests__/ar-ledger.test.ts @@ -9,7 +9,7 @@ let results: Array<{ data?: unknown; error?: unknown }> function makeBuilder() { const b: Record = {} - for (const m of ['select', 'eq', 'in', 'range']) { + for (const m of ['select', 'eq', 'in', 'order', 'range']) { b[m] = vi.fn().mockReturnValue(b) } b.single = vi.fn().mockImplementation(async () => results[resultIdx++] ?? { data: null, error: null }) diff --git a/lib/reports/__tests__/continuity-check.test.ts b/lib/reports/__tests__/continuity-check.test.ts index 6a9a1661..dbcd4d88 100644 --- a/lib/reports/__tests__/continuity-check.test.ts +++ b/lib/reports/__tests__/continuity-check.test.ts @@ -9,7 +9,7 @@ let mockResults: Record function makeBuilder(tableName: string) { const b: Record = {} - for (const m of ['select', 'eq', 'in', 'lt', 'neq', 'range', 'update']) { + for (const m of ['select', 'eq', 'in', 'lt', 'neq', 'order', 'range', 'update']) { b[m] = vi.fn().mockReturnValue(b) } const consume = (): MockResult => { diff --git a/lib/reports/__tests__/general-ledger.test.ts b/lib/reports/__tests__/general-ledger.test.ts index f789391c..48f3dcb8 100644 --- a/lib/reports/__tests__/general-ledger.test.ts +++ b/lib/reports/__tests__/general-ledger.test.ts @@ -124,6 +124,54 @@ describe('generateGeneralLedger', () => { expect(acc1930.closing_balance).toBe(1250) }) + it('does not double a balance when an unstable page boundary re-serves a line (#790/#791)', async () => { + // Reproduces the doubling bug's mechanism: a paginated query whose order + // was not stable can return the same journal_entry_line on two pages. + // Page 1 must be a FULL page (PAGE_SIZE rows) so fetchAllRows fetches a + // second page; page 2 re-serves the 5010 line. dedupeBy(line id) must + // collapse it so the single 4000 posting totals 4000, not 8000. + const PAGE_SIZE = 1000 + const filler = Array.from({ length: PAGE_SIZE - 1 }, (_, i) => ({ + id: `f${i}`, + account_number: '1930', + debit_amount: 0, + credit_amount: 0, + journal_entries: { entry_date: '2024-01-02', voucher_number: 1, voucher_series: 'A', description: 'filler', source_type: 'manual' }, + })) + const rentLine = { + id: 'rent-line-1', + account_number: '5010', + debit_amount: 4000, + credit_amount: 0, + journal_entries: { entry_date: '2024-01-15', voucher_number: 2, voucher_series: 'A', description: 'Lokalhyra', source_type: 'manual' }, + } + + mockResults = { + fiscal_periods: [ + { data: { period_start: '2024-01-01', period_end: '2024-12-31', opening_balance_entry_id: null }, error: null }, + ], + journal_entry_lines: [ + { data: [...filler, rentLine], error: null }, // page 1 — full → triggers page 2 + { data: [rentLine], error: null }, // page 2 — duplicate of the 5010 line + ], + chart_of_accounts: [ + { + data: [ + { account_number: '1930', account_name: 'Företagskonto' }, + { account_number: '5010', account_name: 'Lokalhyra' }, + ], + error: null, + }, + ], + } + + const report = await generateGeneralLedger(supabase, 'company-1', 'period-1') + + const acc5010 = report.accounts.find((a) => a.account_number === '5010')! + expect(acc5010.total_debit).toBe(4000) // not 8000 + expect(acc5010.lines).toHaveLength(1) // verifikat listed once, not twice + }) + it('computes opening balance from prior period entries', async () => { mockResults = { fiscal_periods: [ diff --git a/lib/reports/__tests__/journal-register.test.ts b/lib/reports/__tests__/journal-register.test.ts index 6ee2e480..91ce0403 100644 --- a/lib/reports/__tests__/journal-register.test.ts +++ b/lib/reports/__tests__/journal-register.test.ts @@ -67,6 +67,54 @@ describe('generateJournalRegister', () => { expect(report.period).toEqual({ start: '2024-01-01', end: '2024-12-31' }) }) + it('does not double an entry when an unstable page boundary re-serves a line (#790/#793)', async () => { + // Page 1 is a FULL page so fetchAllRows fetches page 2, which re-serves + // the 5010 line of voucher 2. dedupeBy(line id) must collapse it so the + // grundbok lists the voucher once with a 4000 (not 8000) total. + const PAGE_SIZE = 1000 + const filler = Array.from({ length: PAGE_SIZE - 1 }, (_, i) => ({ + id: `f${i}`, + account_number: '1930', + debit_amount: 0, + credit_amount: 0, + journal_entry_id: 'e0', + journal_entries: { id: 'e0', entry_date: '2024-01-02', voucher_number: 1, voucher_series: 'A', description: 'filler', source_type: 'manual', status: 'posted' }, + })) + const rentLine = { + id: 'rent-line-1', + account_number: '5010', + debit_amount: 4000, + credit_amount: 0, + journal_entry_id: 'e1', + journal_entries: { id: 'e1', entry_date: '2024-01-15', voucher_number: 2, voucher_series: 'A', description: 'Lokalhyra', source_type: 'manual', status: 'posted' }, + } + + mockResults = { + fiscal_periods: [ + { data: { period_start: '2024-01-01', period_end: '2024-12-31' }, error: null }, + ], + journal_entry_lines: [ + { data: [...filler, rentLine], error: null }, // page 1 — full → triggers page 2 + { data: [rentLine], error: null }, // page 2 — duplicate of the 5010 line + ], + chart_of_accounts: [ + { + data: [ + { account_number: '1930', account_name: 'Företagskonto' }, + { account_number: '5010', account_name: 'Lokalhyra' }, + ], + error: null, + }, + ], + } + + const report = await generateJournalRegister(supabase, 'company-1', 'period-1') + + const voucher2 = report.entries.find((e) => e.voucher_number === 2)! + expect(voucher2.total_debit).toBe(4000) // not 8000 + expect(voucher2.lines).toHaveLength(1) // listed once, not twice + }) + it('produces entries in registration order with correct totals', async () => { mockResults = { fiscal_periods: [ diff --git a/lib/reports/__tests__/monthly-breakdown.test.ts b/lib/reports/__tests__/monthly-breakdown.test.ts index 83e54c91..3f25246b 100644 --- a/lib/reports/__tests__/monthly-breakdown.test.ts +++ b/lib/reports/__tests__/monthly-breakdown.test.ts @@ -5,6 +5,20 @@ const { supabase, mockResult } = createMockSupabase() import { generateMonthlyBreakdown } from '../monthly-breakdown' +// Minimal chainable query mock: every filter/order method returns the same +// object; .single()/.range() resolve to the queued result. Tolerant of +// query-shape changes such as an added .order() (see fetch-all.ts ordering +// invariant) so the tests don't hardcode the exact method chain. +function chain(result: unknown) { + const c: Record = {} + for (const m of ['select', 'eq', 'in', 'gte', 'lte', 'lt', 'neq', 'order']) { + c[m] = () => c + } + c.single = () => Promise.resolve(result) + c.range = () => Promise.resolve(result) + return c +} + beforeEach(() => { vi.clearAllMocks() }) @@ -33,38 +47,9 @@ describe('generateMonthlyBreakdown', () => { let callCount = 0 supabase.from.mockImplementation(() => { callCount++ - if (callCount === 1) { - // fiscal_periods query - return { - select: () => ({ - eq: () => ({ - eq: () => ({ - single: () => - Promise.resolve({ - data: { period_start: '2024-01-01', period_end: '2024-12-31' }, - error: null, - }), - }), - }), - }), - } - } - // journal_entry_lines query - return { - select: () => ({ - eq: () => ({ - eq: () => ({ - eq: () => ({ - range: () => - Promise.resolve({ - data: [], - error: null, - }), - }), - }), - }), - }), - } + return callCount === 1 + ? chain({ data: { period_start: '2024-01-01', period_end: '2024-12-31' }, error: null }) + : chain({ data: [], error: null }) }) const result = await generateMonthlyBreakdown(supabase as never, 'company-1', 'period-1') @@ -79,61 +64,37 @@ describe('generateMonthlyBreakdown', () => { let callCount = 0 supabase.from.mockImplementation(() => { callCount++ - if (callCount === 1) { - return { - select: () => ({ - eq: () => ({ - eq: () => ({ - single: () => - Promise.resolve({ - data: { period_start: '2024-01-01', period_end: '2024-03-31' }, - error: null, - }), - }), - }), - }), - } - } - return { - select: () => ({ - eq: () => ({ - eq: () => ({ - eq: () => ({ - range: () => - Promise.resolve({ - data: [ - { - account_number: '3001', - debit_amount: 0, - credit_amount: 10000, - journal_entry: { entry_date: '2024-01-15', status: 'posted', user_id: 'user-1', fiscal_period_id: 'period-1' }, - }, - { - account_number: '5010', - debit_amount: 3000, - credit_amount: 0, - journal_entry: { entry_date: '2024-01-20', status: 'posted', user_id: 'user-1', fiscal_period_id: 'period-1' }, - }, - { - account_number: '3001', - debit_amount: 0, - credit_amount: 5000, - journal_entry: { entry_date: '2024-02-10', status: 'posted', user_id: 'user-1', fiscal_period_id: 'period-1' }, - }, - { - account_number: '6200', - debit_amount: 1500, - credit_amount: 0, - journal_entry: { entry_date: '2024-02-15', status: 'posted', user_id: 'user-1', fiscal_period_id: 'period-1' }, - }, - ], - error: null, - }), - }), - }), - }), - }), - } + return callCount === 1 + ? chain({ data: { period_start: '2024-01-01', period_end: '2024-03-31' }, error: null }) + : chain({ + data: [ + { + account_number: '3001', + debit_amount: 0, + credit_amount: 10000, + journal_entry: { entry_date: '2024-01-15', status: 'posted', user_id: 'user-1', fiscal_period_id: 'period-1' }, + }, + { + account_number: '5010', + debit_amount: 3000, + credit_amount: 0, + journal_entry: { entry_date: '2024-01-20', status: 'posted', user_id: 'user-1', fiscal_period_id: 'period-1' }, + }, + { + account_number: '3001', + debit_amount: 0, + credit_amount: 5000, + journal_entry: { entry_date: '2024-02-10', status: 'posted', user_id: 'user-1', fiscal_period_id: 'period-1' }, + }, + { + account_number: '6200', + debit_amount: 1500, + credit_amount: 0, + journal_entry: { entry_date: '2024-02-15', status: 'posted', user_id: 'user-1', fiscal_period_id: 'period-1' }, + }, + ], + error: null, + }) }) const result = await generateMonthlyBreakdown(supabase as never, 'company-1', 'period-1') @@ -160,61 +121,37 @@ describe('generateMonthlyBreakdown', () => { let callCount = 0 supabase.from.mockImplementation(() => { callCount++ - if (callCount === 1) { - return { - select: () => ({ - eq: () => ({ - eq: () => ({ - single: () => - Promise.resolve({ - data: { period_start: '2024-01-01', period_end: '2024-01-31' }, - error: null, - }), - }), - }), - }), - } - } - return { - select: () => ({ - eq: () => ({ - eq: () => ({ - eq: () => ({ - range: () => - Promise.resolve({ - data: [ - { - account_number: '1930', - debit_amount: 10000, - credit_amount: 0, - journal_entry: { entry_date: '2024-01-15', status: 'posted', user_id: 'user-1', fiscal_period_id: 'period-1' }, - }, - { - account_number: '2611', - debit_amount: 0, - credit_amount: 2500, - journal_entry: { entry_date: '2024-01-15', status: 'posted', user_id: 'user-1', fiscal_period_id: 'period-1' }, - }, - { - account_number: '8400', - debit_amount: 500, - credit_amount: 0, - journal_entry: { entry_date: '2024-01-20', status: 'posted', user_id: 'user-1', fiscal_period_id: 'period-1' }, - }, - { - account_number: '8300', - debit_amount: 0, - credit_amount: 200, - journal_entry: { entry_date: '2024-01-25', status: 'posted', user_id: 'user-1', fiscal_period_id: 'period-1' }, - }, - ], - error: null, - }), - }), - }), - }), - }), - } + return callCount === 1 + ? chain({ data: { period_start: '2024-01-01', period_end: '2024-01-31' }, error: null }) + : chain({ + data: [ + { + account_number: '1930', + debit_amount: 10000, + credit_amount: 0, + journal_entry: { entry_date: '2024-01-15', status: 'posted', user_id: 'user-1', fiscal_period_id: 'period-1' }, + }, + { + account_number: '2611', + debit_amount: 0, + credit_amount: 2500, + journal_entry: { entry_date: '2024-01-15', status: 'posted', user_id: 'user-1', fiscal_period_id: 'period-1' }, + }, + { + account_number: '8400', + debit_amount: 500, + credit_amount: 0, + journal_entry: { entry_date: '2024-01-20', status: 'posted', user_id: 'user-1', fiscal_period_id: 'period-1' }, + }, + { + account_number: '8300', + debit_amount: 0, + credit_amount: 200, + journal_entry: { entry_date: '2024-01-25', status: 'posted', user_id: 'user-1', fiscal_period_id: 'period-1' }, + }, + ], + error: null, + }) }) const result = await generateMonthlyBreakdown(supabase as never, 'company-1', 'period-1') diff --git a/lib/reports/__tests__/periodisk-sammanstallning.test.ts b/lib/reports/__tests__/periodisk-sammanstallning.test.ts index 9f0afd6a..714e90b9 100644 --- a/lib/reports/__tests__/periodisk-sammanstallning.test.ts +++ b/lib/reports/__tests__/periodisk-sammanstallning.test.ts @@ -9,7 +9,7 @@ let results: Array<{ data?: unknown; error?: unknown }> function makeBuilder() { const b: Record = {} - for (const m of ['select', 'eq', 'in', 'gte', 'lte', 'lt', 'or', 'not', 'range']) { + for (const m of ['select', 'eq', 'in', 'gte', 'lte', 'lt', 'or', 'not', 'order', 'range']) { b[m] = vi.fn().mockReturnValue(b) } b.single = vi.fn().mockImplementation(async () => results[resultIdx++] ?? { data: null, error: null }) diff --git a/lib/reports/__tests__/supplier-ledger.test.ts b/lib/reports/__tests__/supplier-ledger.test.ts index a969610c..f314e259 100644 --- a/lib/reports/__tests__/supplier-ledger.test.ts +++ b/lib/reports/__tests__/supplier-ledger.test.ts @@ -9,7 +9,7 @@ let results: Array<{ data?: unknown; error?: unknown }> function makeBuilder() { const b: Record = {} - for (const m of ['select', 'eq', 'in', 'range']) { + for (const m of ['select', 'eq', 'in', 'order', 'range']) { b[m] = vi.fn().mockReturnValue(b) } b.single = vi.fn().mockImplementation(async () => results[resultIdx++] ?? { data: null, error: null }) diff --git a/lib/reports/__tests__/trial-balance.test.ts b/lib/reports/__tests__/trial-balance.test.ts index 81d7aa77..206629b6 100644 --- a/lib/reports/__tests__/trial-balance.test.ts +++ b/lib/reports/__tests__/trial-balance.test.ts @@ -11,7 +11,7 @@ let mockResults: Record function makeBuilder(tableName: string) { const b: Record = {} - for (const m of ['select', 'eq', 'in', 'lt', 'lte', 'gte', 'neq', 'range']) { + for (const m of ['select', 'eq', 'in', 'lt', 'lte', 'gte', 'neq', 'order', 'range']) { b[m] = vi.fn().mockReturnValue(b) } const consume = (): MockResult => { diff --git a/lib/reports/__tests__/vat-declaration.test.ts b/lib/reports/__tests__/vat-declaration.test.ts index cf614b36..a337896d 100644 --- a/lib/reports/__tests__/vat-declaration.test.ts +++ b/lib/reports/__tests__/vat-declaration.test.ts @@ -9,7 +9,7 @@ let results: Array<{ data?: unknown; error?: unknown }> function makeBuilder() { const b: Record = {} - for (const m of ['select', 'eq', 'in', 'gte', 'lte', 'lt', 'or', 'not', 'range']) { + for (const m of ['select', 'eq', 'in', 'gte', 'lte', 'lt', 'or', 'not', 'order', 'range']) { b[m] = vi.fn().mockReturnValue(b) } b.single = vi.fn().mockImplementation(async () => results[resultIdx++] ?? { data: null, error: null }) diff --git a/lib/reports/ar-ledger.ts b/lib/reports/ar-ledger.ts index 45c2d882..85052a88 100644 --- a/lib/reports/ar-ledger.ts +++ b/lib/reports/ar-ledger.ts @@ -69,6 +69,8 @@ export async function generateARLedger( .select('*, customer:customers(id, name)') .eq('company_id', companyId) .in('status', ['sent', 'overdue', 'credited']) + // Stable total order for correct paging (see fetch-all.ts). + .order('id', { ascending: true }) .range(from, to) ) } catch { diff --git a/lib/reports/full-archive-export.ts b/lib/reports/full-archive-export.ts index eba58f31..d4dc0ac8 100644 --- a/lib/reports/full-archive-export.ts +++ b/lib/reports/full-archive-export.ts @@ -209,6 +209,8 @@ export async function estimateArchiveSize( .eq('company_id', companyId) .eq('fiscal_period_id', periodId) .in('status', ['posted', 'reversed']) + // Stable total order for correct paging (see fetch-all.ts). + .order('id', { ascending: true }) .range(from, to) ) const ids = periodEntryIds.map((e) => e.id) @@ -345,6 +347,8 @@ async function writeDocuments( ) .eq('company_id', companyId) .not('journal_entry_id', 'is', null) + // Stable total order for correct paging (see fetch-all.ts). + .order('id', { ascending: true }) .range(from, to) ) @@ -730,6 +734,9 @@ async function buildEntryToPeriodMap( query = query.in('fiscal_period_id', periodIds) } + // Stable total order for correct paging (see fetch-all.ts). + query = query.order('id', { ascending: true }) + const entries = await fetchAllRows<{ id: string; fiscal_period_id: string }>(({ from, to }) => query.range(from, to) ) diff --git a/lib/reports/general-ledger.ts b/lib/reports/general-ledger.ts index 90a8efa2..1d724d42 100644 --- a/lib/reports/general-ledger.ts +++ b/lib/reports/general-ledger.ts @@ -83,6 +83,7 @@ export async function generateGeneralLedger( // Supabase types !inner joins as arrays; for many-to-one (line → entry) // it returns a single object at runtime. Cast via `as any` on the query. const rawLines = await fetchAllRows<{ + id: string account_number: string debit_amount: number credit_amount: number @@ -97,7 +98,7 @@ export async function generateGeneralLedger( }>(({ from, to }) => { let query = supabase .from('journal_entry_lines') - .select('account_number, debit_amount, credit_amount, journal_entry_id, journal_entries!inner(entry_date, voucher_number, voucher_series, description, source_type, company_id, fiscal_period_id, status)') + .select('id, account_number, debit_amount, credit_amount, journal_entry_id, journal_entries!inner(entry_date, voucher_number, voucher_series, description, source_type, company_id, fiscal_period_id, status)') .eq('journal_entries.company_id', companyId) .eq('journal_entries.fiscal_period_id', periodId) .in('journal_entries.status', ['posted', 'reversed']) @@ -106,9 +107,13 @@ export async function generateGeneralLedger( query = query.neq('journal_entry_id', obEntryId) } + // Stable total order on the line PK — paging is only correct with a + // deterministic order, else rows duplicate/skip across pages and balances + // double or accounts vanish (see fetch-all.ts ordering invariant). The + // report re-sorts lines per account below, so this order is invisible. // eslint-disable-next-line @typescript-eslint/no-explicit-any - return query.range(from, to) as any - }) + return query.order('id', { ascending: true }).range(from, to) as any + }, { dedupeBy: (r) => r.id }) if (rawLines.length === 0 && openingBalances.size === 0) { return { accounts: [], period: { start: period.period_start, end: period.period_end } } @@ -120,6 +125,7 @@ export async function generateGeneralLedger( .from('chart_of_accounts') .select('account_number, account_name') .eq('company_id', companyId) + .order('account_number', { ascending: true }) .range(from, to) ) diff --git a/lib/reports/ink2/ink2-engine.ts b/lib/reports/ink2/ink2-engine.ts index a09fd6ba..47dd90aa 100644 --- a/lib/reports/ink2/ink2-engine.ts +++ b/lib/reports/ink2/ink2-engine.ts @@ -745,6 +745,7 @@ export async function generateINK2Declaration( .from('chart_of_accounts') .select('account_number, account_name') .eq('company_id', companyId) + .order('account_number', { ascending: true }) .range(from, to) ) diff --git a/lib/reports/journal-register.ts b/lib/reports/journal-register.ts index a7b1ff5c..378e33b1 100644 --- a/lib/reports/journal-register.ts +++ b/lib/reports/journal-register.ts @@ -62,6 +62,7 @@ export async function generateJournalRegister( // Supabase types !inner joins as arrays; for many-to-one (line → entry) // it returns a single object at runtime. Cast via `as any` on the query. const rawLines = await fetchAllRows<{ + id: string account_number: string debit_amount: number credit_amount: number @@ -75,16 +76,18 @@ export async function generateJournalRegister( source_type: string status: string } - }>(({ from, to }) => - supabase + }>(({ from, to }) => { + return supabase .from('journal_entry_lines') - .select('account_number, debit_amount, credit_amount, journal_entry_id, journal_entries!inner(id, entry_date, voucher_number, voucher_series, description, source_type, status, company_id, fiscal_period_id)') + .select('id, account_number, debit_amount, credit_amount, journal_entry_id, journal_entries!inner(id, entry_date, voucher_number, voucher_series, description, source_type, status, company_id, fiscal_period_id)') .eq('journal_entries.company_id', companyId) .eq('journal_entries.fiscal_period_id', periodId) .in('journal_entries.status', ['posted', 'reversed']) + // Stable total order on the line PK — without it, rows duplicate/skip + // across pages and entries appear twice or go missing (see fetch-all.ts). // eslint-disable-next-line @typescript-eslint/no-explicit-any - .range(from, to) as any - ) + .order('id', { ascending: true }).range(from, to) as any + }, { dedupeBy: (r) => r.id }) if (rawLines.length === 0) { return { entries: [], total_entries: 0, total_debit: 0, total_credit: 0, period: { start: period.period_start, end: period.period_end } } @@ -96,6 +99,7 @@ export async function generateJournalRegister( .from('chart_of_accounts') .select('account_number, account_name') .eq('company_id', companyId) + .order('account_number', { ascending: true }) .range(from, to) ) diff --git a/lib/reports/monthly-breakdown.ts b/lib/reports/monthly-breakdown.ts index c61a2259..89a0b292 100644 --- a/lib/reports/monthly-breakdown.ts +++ b/lib/reports/monthly-breakdown.ts @@ -63,6 +63,8 @@ export async function generateMonthlyBreakdown( .eq('journal_entries.fiscal_period_id', fiscalPeriodId) .eq('journal_entries.company_id', companyId) .eq('journal_entries.status', 'posted') + // Stable total order for correct paging (see fetch-all.ts). + .order('id', { ascending: true }) .range(from, to) ) } catch { diff --git a/lib/reports/ne-bilaga/ne-engine.ts b/lib/reports/ne-bilaga/ne-engine.ts index 404f09fe..eb3ef39a 100644 --- a/lib/reports/ne-bilaga/ne-engine.ts +++ b/lib/reports/ne-bilaga/ne-engine.ts @@ -213,6 +213,7 @@ export async function generateNEDeclaration( .from('chart_of_accounts') .select('account_number, account_name') .eq('company_id', companyId) + .order('account_number', { ascending: true }) .range(from, to) ) diff --git a/lib/reports/opening-balances.ts b/lib/reports/opening-balances.ts index 7e552ba4..c65f7125 100644 --- a/lib/reports/opening-balances.ts +++ b/lib/reports/opening-balances.ts @@ -41,16 +41,20 @@ export async function getOpeningBalances( // for consistency (avoids silent truncation) and joins journal_entries // to enforce company_id ownership (defense in depth alongside RLS). const obLines = await fetchAllRows<{ + id: string account_number: string debit_amount: number credit_amount: number }>(({ from, to }) => supabase .from('journal_entry_lines') - .select('account_number, debit_amount, credit_amount, journal_entries!inner(company_id)') + .select('id, account_number, debit_amount, credit_amount, journal_entries!inner(company_id)') .eq('journal_entry_id', obEntryId) .eq('journal_entries.company_id', companyId) - .range(from, to) + // Stable total order for correct paging (see fetch-all.ts). + .order('id', { ascending: true }) + .range(from, to), + { dedupeBy: (r) => r.id } ) for (const line of obLines) { diff --git a/lib/reports/periodisk-sammanstallning.ts b/lib/reports/periodisk-sammanstallning.ts index 6b1a3d8d..860738f9 100644 --- a/lib/reports/periodisk-sammanstallning.ts +++ b/lib/reports/periodisk-sammanstallning.ts @@ -200,6 +200,8 @@ export async function generatePeriodiskSammanstallning( .in('journal_entries.source_type', ['invoice_created', 'credit_note']) .gte('journal_entries.entry_date', start) .lte('journal_entries.entry_date', end) + // Stable total order for correct paging (see fetch-all.ts). + .order('id', { ascending: true }) .range(from, to) as unknown as PromiseLike<{ data: RawLine[] | null; error: { message: string } | null }>, ) @@ -229,6 +231,8 @@ export async function generatePeriodiskSammanstallning( ) `) .in('id', invoiceIds) + // Stable total order for correct paging (see fetch-all.ts). + .order('id', { ascending: true }) .range(from, to) as unknown as PromiseLike<{ data: RawInvoice[] | null; error: { message: string } | null }>, ) for (const inv of invoices) invoiceMap.set(inv.id, inv) diff --git a/lib/reports/rc-basis-gaps.ts b/lib/reports/rc-basis-gaps.ts index 7af6514e..1a0cc712 100644 --- a/lib/reports/rc-basis-gaps.ts +++ b/lib/reports/rc-basis-gaps.ts @@ -90,6 +90,7 @@ function pickEntry(row: RcLineRow): EntryFields | null { } interface SiblingLineRow { + id: string journal_entry_id: string account_number: string debit_amount: number @@ -122,6 +123,8 @@ export async function findRcBasisGaps( .eq('journal_entries.status', 'posted') .gte('journal_entries.entry_date', start) .lte('journal_entries.entry_date', end) + // Stable total order for correct paging (see fetch-all.ts). + .order('id', { ascending: true }) .range(from, to), )) as RcLineRow[] @@ -132,9 +135,12 @@ export async function findRcBasisGaps( const siblingLines = await fetchAllRows(({ from, to }) => supabase .from('journal_entry_lines') - .select('journal_entry_id, account_number, debit_amount, credit_amount') + .select('id, journal_entry_id, account_number, debit_amount, credit_amount') .in('journal_entry_id', entryIds) + // Stable total order for correct paging (see fetch-all.ts). + .order('id', { ascending: true }) .range(from, to), + { dedupeBy: (r) => r.id }, ) const basisByEntry = new Map() diff --git a/lib/reports/salary-journal.ts b/lib/reports/salary-journal.ts index 9660a8a7..dfa005cc 100644 --- a/lib/reports/salary-journal.ts +++ b/lib/reports/salary-journal.ts @@ -73,6 +73,9 @@ export async function generateSalaryJournal( .eq('salary_runs.period_year', year) .eq('salary_runs.status', 'booked') .order('created_at') + // id tiebreaker — created_at is not unique, so it alone is not a stable + // total order for paging (see fetch-all.ts). + .order('id', { ascending: true }) .range(from, to) ) diff --git a/lib/reports/supplier-ledger.ts b/lib/reports/supplier-ledger.ts index 72c6b21f..4a67d943 100644 --- a/lib/reports/supplier-ledger.ts +++ b/lib/reports/supplier-ledger.ts @@ -47,6 +47,8 @@ export async function generateSupplierLedger( .select('*, supplier:suppliers(id, name)') .eq('company_id', companyId) .in('status', ['registered', 'approved', 'partially_paid', 'overdue']) + // Stable total order for correct paging (see fetch-all.ts). + .order('id', { ascending: true }) .range(from, to) ) } catch { diff --git a/lib/reports/trial-balance.ts b/lib/reports/trial-balance.ts index a56ddbfb..f4e45a35 100644 --- a/lib/reports/trial-balance.ts +++ b/lib/reports/trial-balance.ts @@ -60,13 +60,14 @@ export async function generateTrialBalance( options.fromDate > period.period_start ) { const priorLines = await fetchAllRows<{ + id: string account_number: string debit_amount: number credit_amount: number }>(({ from, to }) => { let query = supabase .from('journal_entry_lines') - .select('account_number, debit_amount, credit_amount, journal_entries!inner(company_id, fiscal_period_id, status, source_type, entry_date)') + .select('id, account_number, debit_amount, credit_amount, journal_entries!inner(company_id, fiscal_period_id, status, source_type, entry_date)') .eq('journal_entries.company_id', companyId) .eq('journal_entries.fiscal_period_id', fiscalPeriodId) .in('journal_entries.status', ['posted', 'reversed']) @@ -81,8 +82,9 @@ export async function generateTrialBalance( query = query.neq('journal_entries.source_type', 'year_end') } - return query.range(from, to) - }) + // Stable total order on the line PK for correct paging (see fetch-all.ts). + return query.order('id', { ascending: true }).range(from, to) + }, { dedupeBy: (r) => r.id }) for (const line of priorLines) { const existing = openingBalances.get(line.account_number) || { debit: 0, credit: 0 } @@ -100,13 +102,14 @@ export async function generateTrialBalance( // be missed from both IB and period. The window is sub-second and the // consequence is a single stale report — acceptable. const lines = await fetchAllRows<{ + id: string account_number: string debit_amount: number credit_amount: number }>(({ from, to }) => { let query = supabase .from('journal_entry_lines') - .select('account_number, debit_amount, credit_amount, journal_entries!inner(company_id, fiscal_period_id, status, source_type, entry_date)') + .select('id, account_number, debit_amount, credit_amount, journal_entries!inner(company_id, fiscal_period_id, status, source_type, entry_date)') .eq('journal_entries.company_id', companyId) .eq('journal_entries.fiscal_period_id', fiscalPeriodId) .in('journal_entries.status', ['posted', 'reversed']) @@ -132,8 +135,9 @@ export async function generateTrialBalance( query = query.neq('journal_entries.source_type', 'year_end') } - return query.range(from, to) - }) + // Stable total order on the line PK for correct paging (see fetch-all.ts). + return query.order('id', { ascending: true }).range(from, to) + }, { dedupeBy: (r) => r.id }) if (lines.length === 0 && openingBalances.size === 0) { return { rows: [], totalDebit: 0, totalCredit: 0, isBalanced: true } @@ -149,6 +153,7 @@ export async function generateTrialBalance( .from('chart_of_accounts') .select('account_number, account_name, account_class') .eq('company_id', companyId) + .order('account_number', { ascending: true }) .range(from, to) ) diff --git a/lib/reports/vacation-liability.ts b/lib/reports/vacation-liability.ts index 56a7a2fb..080d6feb 100644 --- a/lib/reports/vacation-liability.ts +++ b/lib/reports/vacation-liability.ts @@ -61,6 +61,9 @@ export async function generateVacationLiability( .eq('is_active', true) .not('vacation_rule', 'in', '(none,semesterersattning)') .order('last_name') + // id tiebreaker — last_name is not unique, so it alone is not a stable + // total order for paging (see fetch-all.ts). + .order('id', { ascending: true }) .range(from, to) ) @@ -80,6 +83,8 @@ export async function generateVacationLiability( .eq('company_id', companyId) .eq('salary_runs.period_year', year) .eq('salary_runs.status', 'booked') + // Stable total order for correct paging (see fetch-all.ts). + .order('id', { ascending: true }) .range(from, to) ) diff --git a/lib/reports/vat-declaration.ts b/lib/reports/vat-declaration.ts index 2c10f67d..70ea4bca 100644 --- a/lib/reports/vat-declaration.ts +++ b/lib/reports/vat-declaration.ts @@ -283,6 +283,8 @@ export async function calculateVatDeclaration( .in('journal_entries.status', ['posted', 'reversed']) .gte('journal_entries.entry_date', start) .lte('journal_entries.entry_date', end) + // Stable total order for correct paging (see fetch-all.ts). + .order('id', { ascending: true }) .range(from, to) ) diff --git a/lib/supabase/__tests__/fetch-all.test.ts b/lib/supabase/__tests__/fetch-all.test.ts new file mode 100644 index 00000000..e5d49dac --- /dev/null +++ b/lib/supabase/__tests__/fetch-all.test.ts @@ -0,0 +1,88 @@ +import { describe, it, expect } from 'vitest' +import { fetchAllRows } from '../fetch-all' + +const PAGE_SIZE = 1000 + +type Row = { id: string; value?: number } + +/** + * Build a queryFn that serves predefined pages keyed by the `from` offset. + * Mirrors how `fetchAllRows` drives PostgREST `.range(from, to)`. + */ +function pagedQuery(pages: Record) { + return ({ from }: { from: number; to: number }) => + Promise.resolve({ data: pages[from] ?? [], error: null }) +} + +function makeRows(start: number, count: number): Row[] { + return Array.from({ length: count }, (_, i) => ({ id: String(start + i), value: 1 })) +} + +describe('fetchAllRows', () => { + it('returns a single page as-is and stops (page < PAGE_SIZE)', async () => { + const rows = makeRows(0, 3) + const out = await fetchAllRows(pagedQuery({ 0: rows })) + expect(out).toHaveLength(3) + expect(out.map((r) => r.id)).toEqual(['0', '1', '2']) + }) + + it('paginates across multiple pages and concatenates in order', async () => { + const page1 = makeRows(0, PAGE_SIZE) // full page → fetch continues + const page2 = makeRows(PAGE_SIZE, 5) // partial page → stop + const out = await fetchAllRows(pagedQuery({ 0: page1, [PAGE_SIZE]: page2 })) + expect(out).toHaveLength(PAGE_SIZE + 5) + expect(out[0].id).toBe('0') + expect(out[out.length - 1].id).toBe(String(PAGE_SIZE + 4)) + }) + + it('throws when the query returns an error', async () => { + await expect( + fetchAllRows(() => Promise.resolve({ data: null, error: { message: 'boom' } })), + ).rejects.toThrow('boom') + }) + + it('returns [] when the first page is empty', async () => { + const out = await fetchAllRows(pagedQuery({ 0: [] })) + expect(out).toEqual([]) + }) + + // ── The regression-critical behaviour: an unstable cross-page order ── + // (a query missing a stable .order()) can return the same row on two + // pages. This is the mechanism behind the doubled-balance bugs (#790/#791). + + it('dedupeBy drops a row duplicated across page boundaries (keeps first)', async () => { + const page1 = makeRows(0, PAGE_SIZE) // ids 0..999 + // Unstable order: page 2 re-serves id "999" (already on page 1) plus a new id. + const page2: Row[] = [ + { id: '999', value: 1 }, + { id: '1000', value: 1 }, + ] + const out = await fetchAllRows( + pagedQuery({ 0: page1, [PAGE_SIZE]: page2 }), + { dedupeBy: (r) => r.id }, + ) + // 1001 unique ids (0..1000), the duplicate "999" removed → no doubling. + expect(out).toHaveLength(PAGE_SIZE + 1) + const ids = out.map((r) => r.id) + expect(ids.filter((id) => id === '999')).toHaveLength(1) + expect(new Set(ids).size).toBe(out.length) + }) + + it('without dedupeBy, cross-page duplicates pass through (unsafe default)', async () => { + const page1 = makeRows(0, PAGE_SIZE) + const page2: Row[] = [{ id: '999', value: 1 }] + const out = await fetchAllRows(pagedQuery({ 0: page1, [PAGE_SIZE]: page2 })) + expect(out).toHaveLength(PAGE_SIZE + 1) + expect(out.map((r) => r.id).filter((id) => id === '999')).toHaveLength(2) + }) + + it('dedupeBy is a no-op for a single page (no cross-page duplicates possible)', async () => { + const rows: Row[] = [ + { id: 'a' }, + { id: 'b' }, + { id: 'a' }, // an intra-page repeat is left untouched — single page is trusted + ] + const out = await fetchAllRows(pagedQuery({ 0: rows }), { dedupeBy: (r) => r.id }) + expect(out).toHaveLength(3) + }) +}) diff --git a/lib/supabase/fetch-all.ts b/lib/supabase/fetch-all.ts index f1cb7c96..4352b74f 100644 --- a/lib/supabase/fetch-all.ts +++ b/lib/supabase/fetch-all.ts @@ -1,11 +1,41 @@ +import { createLogger } from '@/lib/logger' + +const log = createLogger('fetch-all') + const PAGE_SIZE = 1000 +export interface FetchAllRowsOptions { + /** + * Stable de-duplication key. When supplied AND more than one page was + * fetched, rows are de-duplicated by this key after all pages are collected, + * and a warn is logged if any duplicates were dropped. + * + * This is a safety net, NOT the fix: PostgREST `.range()` paging is only + * correct when the underlying query has a stable TOTAL order (see the + * ordering invariant below). If a duplicate is ever observed here it means a + * caller's query is missing that `.order()` — the warn surfaces the + * regression in logs instead of letting it silently double financial totals. + * Note this only catches *duplicates*; *skipped* rows can only be prevented + * by ordering on a unique column at the call site. + */ + dedupeBy?: (row: T) => string | number +} + /** * Fetches all rows from a Supabase query by paginating through results. * Overcomes PostgREST's default 1000-row limit. * + * **Ordering invariant:** any query that can return more than `PAGE_SIZE` rows + * MUST `.order()` on a unique column (e.g. the table's `id` PK). Postgres + * returns rows in an undefined order that can differ between the two `.range()` + * requests, so without a stable total order, rows on a page boundary are + * silently DUPLICATED and/or SKIPPED across pages. For aggregating reports + * (general ledger, trial balance, grundbok) that means doubled or missing + * balances. Order is purely for paging stability — callers that need a + * different display order should re-sort after fetching. + * * The callback receives `{ from, to }` range values — append `.range(from, to)` - * to your query builder: + * to your query builder, AFTER a stable `.order()`: * * ```ts * const accounts = await fetchAllRows(({ from, to }) => @@ -13,27 +43,62 @@ const PAGE_SIZE = 1000 * .from('chart_of_accounts') * .select('account_number, account_name') * .eq('company_id', companyId) + * .order('account_number', { ascending: true }) // stable total order * .range(from, to) * ) * ``` + * + * Pass `{ dedupeBy }` as defense-in-depth for queries where a missing/regressed + * order would corrupt money: + * + * ```ts + * const lines = await fetchAllRows( + * ({ from, to }) => q.order('id').range(from, to), + * { dedupeBy: (r) => r.id }, + * ) + * ``` */ export async function fetchAllRows( queryFn: (range: { from: number; to: number }) => PromiseLike<{ data: T[] | null error: { message: string } | null - }> + }>, + options?: FetchAllRowsOptions ): Promise { const allRows: T[] = [] let from = 0 + let pages = 0 while (true) { const { data, error } = await queryFn({ from, to: from + PAGE_SIZE - 1 }) if (error) throw new Error(error.message) if (!data || data.length === 0) break allRows.push(...data) + pages += 1 if (data.length < PAGE_SIZE) break from += PAGE_SIZE } + // Duplicates are only possible across page boundaries, so single-page results + // never need the dedup pass. + if (options?.dedupeBy && pages > 1) { + const seen = new Set() + const deduped: T[] = [] + for (const row of allRows) { + const key = options.dedupeBy(row) + if (seen.has(key)) continue + seen.add(key) + deduped.push(row) + } + const dropped = allRows.length - deduped.length + if (dropped > 0) { + log.warn( + 'fetchAllRows dropped duplicate rows across pages — a paginated query is missing a stable .order() on a unique column', + { dropped, total: allRows.length, pages } + ) + return deduped + } + } + return allRows }