From 5b4cefe8abd53ebfaa4dddb79a991cc1d502f5a0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonas=20Flod=C3=A9n?= Date: Mon, 29 Jun 2026 23:08:01 +0200 Subject: [PATCH] feat(api): v1 endpoints to stamp invoice inbox items as consumed (#767) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(api): v1 endpoints to stamp invoice inbox items as consumed Adds inbox_item_id support to POST /api/v1/companies/{companyId}/documents/{id}/link (best-effort stamp on the originating invoice_inbox_items row) and a new dedicated POST /api/v1/companies/{companyId}/inbox-items/{id}/stamp endpoint for stamping independently of the document link — both use documents:write scope and require Idempotency-Key. Co-Authored-By: Claude Sonnet 4.6 Signed-off-by: Jonas Flodén * fix(api): wrap stamp response in dataEnvelope and register route in load-routes Co-Authored-By: Claude Sonnet 4.6 Signed-off-by: Jonas Flodén --------- Signed-off-by: Jonas Flodén --- .../[companyId]/documents/[id]/link/route.ts | 29 ++- .../inbox-items/[id]/stamp/route.ts | 190 ++++++++++++++++++ .../__snapshots__/spec-snapshot.test.ts.snap | 3 +- lib/api/v1/load-routes.ts | 3 + 4 files changed, 221 insertions(+), 4 deletions(-) create mode 100644 app/api/v1/companies/[companyId]/inbox-items/[id]/stamp/route.ts 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 5ca71406..aba54cea 100644 --- a/app/api/v1/companies/[companyId]/documents/[id]/link/route.ts +++ b/app/api/v1/companies/[companyId]/documents/[id]/link/route.ts @@ -4,7 +4,12 @@ * Link an already-uploaded document to a journal entry (and optionally a * specific line). Wraps lib/core/documents/document-service.linkToJournalEntry. * - * Body: `{ journal_entry_id: UUID, journal_entry_line_id?: UUID }`. + * Body: `{ journal_entry_id: UUID, journal_entry_line_id?: UUID, inbox_item_id?: UUID }`. + * + * When `inbox_item_id` is supplied the matching invoice_inbox_items row is + * stamped with `created_journal_entry_id` so it drops out of the active inbox + * into the resolved state. The stamp is best-effort: a failure is logged but + * does not fail the request (the document link is the legally-relevant write). * * The link is REVERSIBLE — set journal_entry_id back via the dashboard if * needed (no unlink endpoint in v1 yet to keep the WORM contract tight). @@ -27,6 +32,7 @@ const Body = z .object({ journal_entry_id: z.string().uuid(), journal_entry_line_id: z.string().uuid().optional(), + inbox_item_id: z.string().uuid().optional(), }) .strict() @@ -43,15 +49,16 @@ registerEndpoint({ path: '/api/v1/companies/:companyId/documents/:id/link', summary: 'Link a document to a journal entry.', description: - 'Sets journal_entry_id (and optionally journal_entry_line_id) on an existing document. Use this after /documents upload when the link target was unknown at upload time, or to re-link a stray document. Once the target JE is posted, the document row is effectively immutable per BFL 7 kap retention.', + 'Sets journal_entry_id (and optionally journal_entry_line_id) on an existing document. Optionally stamps the originating invoice_inbox_items row as consumed via inbox_item_id. Use this after /documents upload when the link target was unknown at upload time, or to re-link a stray document. Once the target JE is posted, the document row is effectively immutable per BFL 7 kap retention.', useWhen: - 'A document was uploaded without a journal_entry_id (e.g. bulk import) and you now want to attach it to a posted verifikation.', + 'A document was uploaded without a journal_entry_id (e.g. bulk import) and you now want to attach it to a posted verifikation. Pass inbox_item_id when the document came from the invoice inbox so the item is marked resolved in one call.', doNotUseFor: 'Unlinking — no v1 unlink endpoint. The dashboard exposes a manual override; v1 keeps the WORM contract by refusing to revert posted-JE links.', pitfalls: [ 'Idempotency-Key is mandatory.', 'Both the document and the journal_entry_id must belong to the caller\'s company. NOT_FOUND on mismatch (enumeration hardening).', 'Re-linking an already-linked document overwrites the previous journal_entry_id — confirm the old target is what you intend to break.', + 'inbox_item_id stamping is best-effort: if the stamp fails the document link still succeeds. Use POST /api/v1/companies/:companyId/inbox-items/:id/stamp to stamp independently.', ], example: { request: { journal_entry_id: 'a8f1…' }, @@ -224,6 +231,22 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string body.journal_entry_id, body.journal_entry_line_id, ) + + if (body.inbox_item_id) { + const { error: stampErr } = await ctx.supabase + .from('invoice_inbox_items') + .update({ created_journal_entry_id: body.journal_entry_id }) + .eq('id', body.inbox_item_id) + .eq('company_id', ctx.companyId!) + .is('created_journal_entry_id', null) + if (stampErr) { + ctx.log.error('documents.link inbox stamp failed (best-effort)', stampErr as Error, { + inboxItemId: body.inbox_item_id, + journalEntryId: body.journal_entry_id, + }) + } + } + return ok( { id: updated.id, diff --git a/app/api/v1/companies/[companyId]/inbox-items/[id]/stamp/route.ts b/app/api/v1/companies/[companyId]/inbox-items/[id]/stamp/route.ts new file mode 100644 index 00000000..cf2b2aa2 --- /dev/null +++ b/app/api/v1/companies/[companyId]/inbox-items/[id]/stamp/route.ts @@ -0,0 +1,190 @@ +/** + * POST /api/v1/companies/{companyId}/inbox-items/{id}/stamp + * + * Mark an invoice inbox item as consumed by stamping it with the journal entry + * that was created for it. Sets `created_journal_entry_id` on the + * invoice_inbox_items row, which removes the item from the active inbox todo. + * + * Idempotent: calling with the same journal_entry_id when the item is already + * stamped returns 200. Calling with a different journal_entry_id when the item + * is already stamped returns CONFLICT. + * + * Use this when the document was linked via POST .../documents/{id}/link + * WITHOUT an inbox_item_id (e.g. the v1 link call was made before this + * endpoint existed). For new flows, pass inbox_item_id directly to the + * link endpoint to do both in one call. + */ + +import { z } from 'zod' +import { ok } from '@/lib/api/v1/response' +import { registerEndpoint, dataEnvelope } from '@/lib/api/v1/registry' +import { withApiV1 } from '@/lib/api/v1/with-api-v1' +import { v1ErrorResponseFromCode } from '@/lib/api/v1/errors' + +const Body = z + .object({ + journal_entry_id: z.string().uuid(), + }) + .strict() + +const InboxItemStampedResponse = z.object({ + id: z.string().uuid(), + created_journal_entry_id: z.string().uuid(), +}) + +registerEndpoint({ + operation: 'inbox-items.stamp', + method: 'POST', + path: '/api/v1/companies/:companyId/inbox-items/:id/stamp', + summary: 'Mark an inbox item as consumed by a journal entry.', + description: + 'Sets created_journal_entry_id on an invoice_inbox_items row so the item drops out of the active inbox todo list. Use when the document was linked to a JE via a separate call and you need to close the inbox item independently.', + useWhen: + 'An inbox document has already been attached to a verifikation (via documents link) but the inbox item itself was not stamped at link time — e.g. when using the v1 link endpoint without inbox_item_id.', + doNotUseFor: + 'Creating a new journal entry from an inbox item — use the invoice-inbox extension book-direct route for that.', + pitfalls: [ + 'Idempotency-Key is mandatory.', + 'The inbox item and journal_entry_id must both belong to the caller\'s company.', + 'Stamping with a different journal_entry_id than the one already set returns CONFLICT — the item is already resolved.', + ], + example: { + request: { journal_entry_id: 'dcccb3c5-b44a-4536-82fa-f0b9bb77f900' }, + response: { + data: { + id: '4d2fcdbb-13b3-4ff3-911f-a4cc82f1f6db', + created_journal_entry_id: 'dcccb3c5-b44a-4536-82fa-f0b9bb77f900', + }, + meta: { request_id: 'req_…', api_version: '2026-05-12' }, + }, + }, + scope: 'documents:write', + risk: 'low', + idempotent: true, + reversible: false, + dryRunSupported: false, + request: { body: Body }, + response: { success: dataEnvelope(InboxItemStampedResponse) }, +}) + +export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string }> }>( + 'inbox-items.stamp', + async (request, ctx, params) => { + const { id } = await params.params + const idParse = z.string().uuid().safeParse(id) + if (!idParse.success) { + return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, { + requestId: ctx.requestId, + details: { field: 'id', message: 'inbox item id must be a UUID.' }, + }) + } + const itemId = idParse.data + + let rawBody: unknown + try { + rawBody = await request.json() + } catch { + return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, { + requestId: ctx.requestId, + details: { field: 'body', message: 'Body is not valid JSON.' }, + }) + } + const parsed = Body.safeParse(rawBody) + if (!parsed.success) { + return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, { + requestId: ctx.requestId, + details: { issues: parsed.error.issues.map((i) => ({ field: i.path.join('.'), message: i.message })) }, + }) + } + const body = parsed.data + + const [itemRes, jeRes] = await Promise.all([ + ctx.supabase + .from('invoice_inbox_items') + .select('id, created_journal_entry_id') + .eq('id', itemId) + .eq('company_id', ctx.companyId!) + .maybeSingle(), + ctx.supabase + .from('journal_entries') + .select('id') + .eq('id', body.journal_entry_id) + .eq('company_id', ctx.companyId!) + .maybeSingle(), + ]) + + if (itemRes.error) { + ctx.log.error('inbox-items.stamp item pre-check DB error', itemRes.error as Error, { itemId }) + return v1ErrorResponseFromCode('INTERNAL_ERROR', ctx.log, { + requestId: ctx.requestId, + details: { step: 'item_ownership_check' }, + }) + } + if (jeRes.error) { + ctx.log.error('inbox-items.stamp JE pre-check DB error', jeRes.error as Error, { + journalEntryId: body.journal_entry_id, + }) + return v1ErrorResponseFromCode('INTERNAL_ERROR', ctx.log, { + requestId: ctx.requestId, + details: { step: 'je_ownership_check' }, + }) + } + + if (!itemRes.data) { + return v1ErrorResponseFromCode('NOT_FOUND', ctx.log, { + requestId: ctx.requestId, + details: { resource: 'inbox_item' }, + }) + } + if (!jeRes.data) { + return v1ErrorResponseFromCode('NOT_FOUND', ctx.log, { + requestId: ctx.requestId, + details: { resource: 'journal_entry', field: 'journal_entry_id' }, + }) + } + + const item = itemRes.data as { id: string; created_journal_entry_id: string | null } + + // Idempotent: already stamped to the same JE — return success. + if (item.created_journal_entry_id === body.journal_entry_id) { + return ok( + { id: item.id, created_journal_entry_id: item.created_journal_entry_id! }, + { requestId: ctx.requestId }, + ) + } + + // Conflict: already stamped to a different JE. + if (item.created_journal_entry_id && item.created_journal_entry_id !== body.journal_entry_id) { + return v1ErrorResponseFromCode('CONFLICT', ctx.log, { + requestId: ctx.requestId, + details: { + reason: 'inbox_item_already_stamped', + current_journal_entry_id: item.created_journal_entry_id, + }, + }) + } + + const { error: updateErr } = await ctx.supabase + .from('invoice_inbox_items') + .update({ created_journal_entry_id: body.journal_entry_id }) + .eq('id', itemId) + .eq('company_id', ctx.companyId!) + + if (updateErr) { + ctx.log.error('inbox-items.stamp update failed', updateErr as Error, { + itemId, + journalEntryId: body.journal_entry_id, + }) + return v1ErrorResponseFromCode('INTERNAL_ERROR', ctx.log, { + requestId: ctx.requestId, + details: { step: 'stamp_update' }, + }) + } + + return ok( + { id: itemId, created_journal_entry_id: body.journal_entry_id }, + { requestId: ctx.requestId }, + ) + }, + { requireIdempotencyKey: true }, +) diff --git a/lib/api/v1/__tests__/__snapshots__/spec-snapshot.test.ts.snap b/lib/api/v1/__tests__/__snapshots__/spec-snapshot.test.ts.snap index c86f6400..20d961d8 100644 --- a/lib/api/v1/__tests__/__snapshots__/spec-snapshot.test.ts.snap +++ b/lib/api/v1/__tests__/__snapshots__/spec-snapshot.test.ts.snap @@ -1,6 +1,6 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html -exports[`v1 spec snapshot > matches the recorded endpoint count > endpoint-count 1`] = `101`; +exports[`v1 spec snapshot > matches the recorded endpoint count > endpoint-count 1`] = `102`; exports[`v1 spec snapshot > matches the recorded endpoint key set > endpoint-keys 1`] = ` [ @@ -70,6 +70,7 @@ exports[`v1 spec snapshot > matches the recorded endpoint key set > endpoint-key "POST /api/v1/companies/:companyId/fiscal-periods/:id/year-end", "POST /api/v1/companies/:companyId/imports/bank", "POST /api/v1/companies/:companyId/imports/sie", + "POST /api/v1/companies/:companyId/inbox-items/:id/stamp", "POST /api/v1/companies/:companyId/invoices", "POST /api/v1/companies/:companyId/invoices/:id/credit", "POST /api/v1/companies/:companyId/invoices/:id/mark-paid", diff --git a/lib/api/v1/load-routes.ts b/lib/api/v1/load-routes.ts index 41a9cd60..84b3109d 100644 --- a/lib/api/v1/load-routes.ts +++ b/lib/api/v1/load-routes.ts @@ -129,4 +129,7 @@ import '@/app/api/v1/webhook-deliveries/[id]/retry/route' // Phase 6 PR-3 — webhook secret rotation. import '@/app/api/v1/companies/[companyId]/webhooks/[id]/rotate-secret/route' +// Inbox item stamp. +import '@/app/api/v1/companies/[companyId]/inbox-items/[id]/stamp/route' + export {}