Files
accounted/app/api/webshop-orders/[id]/mark-booked/route.ts
T
MattssonandClaude Fable 5 1f9578ca76 feat(woo): mark an order as already booked outside the integration (#1895)
* feat(woo): mark an order as already booked outside the integration

Orders booked by hand before the store was connected sat under Att
bokfora forever: the only exits were the book and create-invoice routes.

- Migration: manually_booked_at/_by + optional
  manually_booked_journal_entry_id on webshop_orders (informational link,
  no financial freeze; the mark produced no accounting objects).
- POST/DELETE /api/webshop-orders/[id]/mark-booked: mark with optional
  posted-verifikat reference (validated per company), conditional claim
  against concurrent booking/invoicing; unmark is a plain revert.
- book and create-invoice routes refuse marked rows (409
  WEBSHOP_ORDER_MANUALLY_BOOKED) and exclude them in their atomic claims.
- List route: booked/unbooked filters treat a manual mark as a closed
  exit, so marked rows leave the Att bokfora tab and join Bokforda.
- Orders page: row overflow menu with Markera som bokford / Angra
  markering, MarkOrderBookedDialog with a searchable candidate list of
  posted entries near the order date, muted status text linking to the
  referenced verifikat.

Fixes #1879

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(woo): close skeptic findings on the manual-booked mark

- mark-booked applies the same open-twin gate as book/create-invoice:
  an OPEN legacy feed transaction blocks the mark (409
  WEBSHOP_ORDER_LEGACY_TRANSACTION_OPEN); ignored or booked feed rows
  unlock it, so no open path to a duplicate remains.
- ingest treats manually marked rows as frozen for drift purposes:
  remote financial deltas set remote_changed_after_freeze (same badge as
  booked rows) instead of silently refreshing the row under the user's
  assertion.
- re-marking with a journal_entry_id updates the informational link
  instead of silently dropping it.
- dialog: candidate amount computed from the returned lines (the list
  API does not return total_amount), newest-first ordering, cap hint.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* chore(migrations): bump webshop manual-booking migration past freshly merged 20260825120000

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(woo): resolve PR review findings in one pass

- freeze v3 migration: financial fields are frozen at the DB level while
  a row is manually marked as booked (review finding: the mark's freeze
  lived only in ingest.ts, so any other write path could silently mutate
  a marked row); unmark stays the escape hatch. pg test added.
- pass the active locale to getErrorMessage in the orders page and
  MarkOrderBookedDialog (CodeRabbit: English users got Swedish errors).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 14:23:56 +02:00

191 lines
7.2 KiB
TypeScript

import { NextResponse } from 'next/server'
import { ensureInitialized } from '@/lib/init'
import { withRouteContext } from '@/lib/api/with-route-context'
import { validateBody } from '@/lib/api/validate'
import { MarkWebshopOrderBookedSchema } from '@/lib/api/schemas'
import { errorResponse, errorResponseFromCode } from '@/lib/errors/get-structured-error'
ensureInitialized()
/**
* POST /api/webshop-orders/[id]/mark-booked
*
* Mark one order/refund row as already booked/handled OUTSIDE the
* integration (typically booked by hand before the store was connected), so
* it leaves the "Att bokfora" list without creating a verifikat. An optional
* journal_entry_id records which existing posted verifikat covers the order;
* the link is informational (the entry was not produced by this row), so the
* financial freeze deliberately does not apply.
*
* Mutually exclusive with the real exits: refuses rows that are booked or
* invoiced through the integration, and the book/create-invoice routes
* refuse marked rows in return. The claim is a conditional update so a
* concurrent booking cannot interleave.
*/
export const POST = withRouteContext<{ params: Promise<{ id: string }> }>(
'webshop_order.mark_booked',
async (request, { supabase, user, companyId, log, requestId }, { params }) => {
const { id } = await params
const validation = await validateBody(request, MarkWebshopOrderBookedSchema)
if (!validation.success) return validation.response
const { journal_entry_id } = validation.data
const { data: order, error: fetchError } = await supabase
.from('webshop_orders')
.select('id, journal_entry_id, invoice_id, manually_booked_at, legacy_transaction_id')
.eq('id', id)
.eq('company_id', companyId)
.single()
if (fetchError || !order) {
return errorResponseFromCode('WEBSHOP_ORDER_NOT_FOUND', log, { requestId })
}
if (order.journal_entry_id) {
return errorResponseFromCode('WEBSHOP_ORDER_ALREADY_BOOKED', log, {
requestId,
details: { journal_entry_id: order.journal_entry_id },
})
}
if (order.invoice_id) {
return errorResponseFromCode('WEBSHOP_ORDER_ALREADY_INVOICED', log, {
requestId,
details: { invoice_id: order.invoice_id },
})
}
// Same open-twin gate as the book/create-invoice routes (skeptic
// finding): when the money event also sits as an OPEN row in the legacy
// transactions inbox, marking the order would hide the twin while it is
// still bookable there, so the sale could reach the ledger twice. The
// user must book or ignore the feed row first; an ignored or booked feed
// row unlocks the mark (no open path to a duplicate remains).
if (order.legacy_transaction_id) {
const { data: legacyTxn } = await supabase
.from('transactions')
.select('id, journal_entry_id, is_ignored')
.eq('id', order.legacy_transaction_id)
.eq('company_id', companyId)
.maybeSingle()
if (legacyTxn && !legacyTxn.journal_entry_id && !legacyTxn.is_ignored) {
return errorResponseFromCode('WEBSHOP_ORDER_LEGACY_TRANSACTION_OPEN', log, {
requestId,
details: { transaction_id: legacyTxn.id },
})
}
}
// The optional verifikat reference must be a real, posted entry in this
// company: linking a draft/cancelled entry would assert underlag that
// does not exist in the ledger.
if (journal_entry_id) {
const { data: entry } = await supabase
.from('journal_entries')
.select('id, status')
.eq('id', journal_entry_id)
.eq('company_id', companyId)
.maybeSingle()
if (!entry) {
return errorResponseFromCode('WEBSHOP_ORDER_MARK_ENTRY_NOT_FOUND', log, {
requestId,
details: { journal_entry_id },
})
}
if (entry.status !== 'posted') {
return errorResponseFromCode('WEBSHOP_ORDER_MARK_ENTRY_NOT_POSTED', log, {
requestId,
details: { journal_entry_id, status: entry.status },
})
}
}
if (order.manually_booked_at) {
// Idempotent for a bare re-mark (mirrors the transactions ignore
// route). A re-mark WITH a verifikat reference updates the link
// instead of silently dropping it (skeptic finding): the row is only
// marked, not booked, so refining the informational link is safe.
if (!journal_entry_id) {
return NextResponse.json({ success: true, already_marked: true })
}
const { error: linkError } = await supabase
.from('webshop_orders')
.update({ manually_booked_journal_entry_id: journal_entry_id })
.eq('id', id)
.eq('company_id', companyId)
.is('journal_entry_id', null)
.is('invoice_id', null)
if (linkError) {
log.error('failed to update manual booking link', linkError, { orderId: id })
return errorResponse(linkError, log, { requestId })
}
return NextResponse.json({ success: true, already_marked: true, link_updated: true })
}
// Conditional claim: a concurrent book/create-invoice between our read
// and this update must win cleanly (zero rows matched here).
const { data: marked, error: markError } = await supabase
.from('webshop_orders')
.update({
manually_booked_at: new Date().toISOString(),
manually_booked_by: user.id,
manually_booked_journal_entry_id: journal_entry_id ?? null,
})
.eq('id', id)
.eq('company_id', companyId)
.is('journal_entry_id', null)
.is('invoice_id', null)
.is('manually_booked_at', null)
.select('id')
if (markError) {
log.error('failed to mark webshop order as manually booked', markError, {
orderId: id,
})
return errorResponse(markError, log, { requestId })
}
if (!marked || marked.length === 0) {
// Raced: the row was booked, invoiced or marked concurrently.
return errorResponseFromCode('WEBSHOP_ORDER_ALREADY_BOOKED', log, { requestId })
}
return NextResponse.json({ success: true })
},
{ requireWrite: true },
)
/**
* DELETE /api/webshop-orders/[id]/mark-booked
*
* Undo a manual mark. Reversible by design (soft-guard doctrine): the mark
* created no accounting objects, so clearing it has no ledger side effects
* and the row simply returns to the to-book list.
*/
export const DELETE = withRouteContext<{ params: Promise<{ id: string }> }>(
'webshop_order.unmark_booked',
async (_request, { supabase, companyId, log, requestId }, { params }) => {
const { id } = await params
const { data: cleared, error: updateError } = await supabase
.from('webshop_orders')
.update({
manually_booked_at: null,
manually_booked_by: null,
manually_booked_journal_entry_id: null,
})
.eq('id', id)
.eq('company_id', companyId)
.select('id')
if (updateError) {
log.error('failed to unmark webshop order', updateError, { orderId: id })
return errorResponse(updateError, log, { requestId })
}
if (!cleared || cleared.length === 0) {
return errorResponseFromCode('WEBSHOP_ORDER_NOT_FOUND', log, { requestId })
}
return NextResponse.json({ success: true })
},
{ requireWrite: true },
)