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:
co-authored by
Claude Sonnet 4.6
parent
7cd1a7388f
commit
5b4cefe8ab
@@ -4,7 +4,12 @@
|
|||||||
* Link an already-uploaded document to a journal entry (and optionally a
|
* Link an already-uploaded document to a journal entry (and optionally a
|
||||||
* specific line). Wraps lib/core/documents/document-service.linkToJournalEntry.
|
* 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
|
* 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).
|
* needed (no unlink endpoint in v1 yet to keep the WORM contract tight).
|
||||||
@@ -27,6 +32,7 @@ const Body = z
|
|||||||
.object({
|
.object({
|
||||||
journal_entry_id: z.string().uuid(),
|
journal_entry_id: z.string().uuid(),
|
||||||
journal_entry_line_id: z.string().uuid().optional(),
|
journal_entry_line_id: z.string().uuid().optional(),
|
||||||
|
inbox_item_id: z.string().uuid().optional(),
|
||||||
})
|
})
|
||||||
.strict()
|
.strict()
|
||||||
|
|
||||||
@@ -43,15 +49,16 @@ registerEndpoint({
|
|||||||
path: '/api/v1/companies/:companyId/documents/:id/link',
|
path: '/api/v1/companies/:companyId/documents/:id/link',
|
||||||
summary: 'Link a document to a journal entry.',
|
summary: 'Link a document to a journal entry.',
|
||||||
description:
|
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:
|
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:
|
doNotUseFor:
|
||||||
'Unlinking — no v1 unlink endpoint. The dashboard exposes a manual override; v1 keeps the WORM contract by refusing to revert posted-JE links.',
|
'Unlinking — no v1 unlink endpoint. The dashboard exposes a manual override; v1 keeps the WORM contract by refusing to revert posted-JE links.',
|
||||||
pitfalls: [
|
pitfalls: [
|
||||||
'Idempotency-Key is mandatory.',
|
'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).',
|
'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.',
|
'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: {
|
example: {
|
||||||
request: { journal_entry_id: 'a8f1…' },
|
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_id,
|
||||||
body.journal_entry_line_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(
|
return ok(
|
||||||
{
|
{
|
||||||
id: updated.id,
|
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
|
// 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`] = `
|
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/fiscal-periods/:id/year-end",
|
||||||
"POST /api/v1/companies/:companyId/imports/bank",
|
"POST /api/v1/companies/:companyId/imports/bank",
|
||||||
"POST /api/v1/companies/:companyId/imports/sie",
|
"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",
|
||||||
"POST /api/v1/companies/:companyId/invoices/:id/credit",
|
"POST /api/v1/companies/:companyId/invoices/:id/credit",
|
||||||
"POST /api/v1/companies/:companyId/invoices/:id/mark-paid",
|
"POST /api/v1/companies/:companyId/invoices/:id/mark-paid",
|
||||||
|
|||||||
@@ -129,4 +129,7 @@ import '@/app/api/v1/webhook-deliveries/[id]/retry/route'
|
|||||||
// Phase 6 PR-3 — webhook secret rotation.
|
// Phase 6 PR-3 — webhook secret rotation.
|
||||||
import '@/app/api/v1/companies/[companyId]/webhooks/[id]/rotate-secret/route'
|
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 {}
|
export {}
|
||||||
|
|||||||
Reference in New Issue
Block a user