feat(api): v1 endpoints to stamp invoice inbox items as consumed (#767)

* 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 <noreply@anthropic.com>

Signed-off-by: Jonas Flodén <jonas@floden.nu>

* fix(api): wrap stamp response in dataEnvelope and register route in load-routes

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

Signed-off-by: Jonas Flodén <jonas@floden.nu>

---------

Signed-off-by: Jonas Flodén <jonas@floden.nu>
This commit is contained in:
Jonas Flodén
2026-06-29 23:08:01 +02:00
committed by GitHub
parent 7cd1a7388f
commit 5b4cefe8ab
4 changed files with 221 additions and 4 deletions
@@ -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,
@@ -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 },
)
@@ -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",
+3
View File
@@ -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 {}