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>
This commit is contained in:
Mattsson
2026-08-25 14:23:56 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent cd46d936f3
commit 1f9578ca76
20 changed files with 1231 additions and 22 deletions
+12 -3
View File
@@ -54,6 +54,14 @@ export const POST = withRouteContext<{ params: Promise<{ id: string }> }>(
details: { invoice_id: order.invoice_id },
})
}
// Marked as booked outside the integration: booking it here would post
// the same business event twice. The mark is user-reversible.
if (order.manually_booked_at) {
return errorResponseFromCode('WEBSHOP_ORDER_MANUALLY_BOOKED', log, {
requestId,
details: { manually_booked_at: order.manually_booked_at },
})
}
// Refunds of an invoiced order belong in the credit-note flow.
if (order.row_type === 'refund' && order.parent_order_id) {
const { data: parent } = await supabase
@@ -189,9 +197,9 @@ export const POST = withRouteContext<{ params: Promise<{ id: string }> }>(
}
}
// The claim guards BOTH links: a concurrent create-invoice between our
// read and this update must lose too (mutual exclusivity, not just
// no-double-booking).
// The claim guards BOTH links plus the manual mark: a concurrent
// create-invoice or mark-booked between our read and this update must
// lose too (mutual exclusivity, not just no-double-booking).
const { data: claimed, error: claimError } = await supabase
.from('webshop_orders')
.update({ journal_entry_id: draft.id })
@@ -199,6 +207,7 @@ export const POST = withRouteContext<{ params: Promise<{ id: string }> }>(
.eq('company_id', companyId)
.is('journal_entry_id', null)
.is('invoice_id', null)
.is('manually_booked_at', null)
.select('id')
if (claimError || !claimed || claimed.length === 0) {
await cancelDraft()
@@ -71,6 +71,14 @@ export const POST = withRouteContext<{ params: Promise<{ id: string }> }>(
details: { journal_entry_id: order.journal_entry_id },
})
}
// Marked as booked outside the integration: an invoice for the same sale
// would double-count the revenue. The mark is user-reversible.
if (order.manually_booked_at) {
return errorResponseFromCode('WEBSHOP_ORDER_MANUALLY_BOOKED', log, {
requestId,
details: { manually_booked_at: order.manually_booked_at },
})
}
// Refund rows never convert (kreditfaktura is created from the invoice).
if (order.row_type === 'refund') {
return errorResponseFromCode('WEBSHOP_ORDER_REFUND_NOT_CONVERTIBLE', log, { requestId })
@@ -296,6 +304,7 @@ export const POST = withRouteContext<{ params: Promise<{ id: string }> }>(
.eq('company_id', companyId)
.is('invoice_id', null)
.is('journal_entry_id', null)
.is('manually_booked_at', null)
.select('id')
if (linkError || !linked || linked.length === 0) {
await supabase.from('invoice_items').delete().eq('invoice_id', invoice.id)
@@ -0,0 +1,190 @@
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 },
)
@@ -77,6 +77,7 @@ function makeOrderRow(overrides: Record<string, unknown> = {}) {
journal_entry_id: null,
invoice_id: null,
legacy_transaction_id: null,
manually_booked_at: null,
...overrides,
}
}
@@ -168,6 +169,31 @@ describe('POST /api/webshop-orders/[id]/book', () => {
expect(body.error.code).toBe('WEBSHOP_ORDER_ALREADY_INVOICED')
})
it('returns 409 when marked as booked outside the integration', async () => {
enqueue({ data: makeOrderRow({ manually_booked_at: '2026-08-01T00:00:00Z' }) })
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(
await postBook(),
)
expect(status).toBe(409)
expect(body.error.code).toBe('WEBSHOP_ORDER_MANUALLY_BOOKED')
expect(mockCreateDraftEntry).not.toHaveBeenCalled()
})
it('excludes manually marked rows in the atomic claim', async () => {
enqueue({ data: makeOrderRow() }) // fetch
enqueue({ data: [{ id: 'order-1' }] }) // claim
const { status } = await parseJsonResponse(await postBook())
expect(status).toBe(200)
const isFilters = findCalls('webshop_orders', 'is')
expect(isFilters).toEqual(
expect.arrayContaining([
['journal_entry_id', null],
['invoice_id', null],
['manually_booked_at', null],
]),
)
})
it('returns 409 for unpaid orders', async () => {
enqueue({ data: makeOrderRow({ is_paid: false, paid_date: null }) })
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(
@@ -64,6 +64,7 @@ function makeOrderRow(overrides: Record<string, unknown> = {}) {
journal_entry_id: null,
invoice_id: null,
legacy_transaction_id: null,
manually_booked_at: null,
store_label: 'Butiken',
store_scope: 'butik.example.se',
...overrides,
@@ -152,6 +153,15 @@ describe('POST /api/webshop-orders/[id]/create-invoice', () => {
expect(body.error.code).toBe('WEBSHOP_ORDER_ALREADY_BOOKED')
})
it('returns 409 when marked as booked outside the integration', async () => {
enqueue({ data: makeOrderRow({ manually_booked_at: '2026-08-01T00:00:00Z' }) })
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(
await postCreate(),
)
expect(status).toBe(409)
expect(body.error.code).toBe('WEBSHOP_ORDER_MANUALLY_BOOKED')
})
it('returns 422 when the order carries no customer data and none is chosen', async () => {
enqueue({
data: makeOrderRow({
@@ -7,7 +7,8 @@ import {
} from '@/tests/helpers'
import { eventBus } from '@/lib/events'
const { supabase: mockSupabase, enqueue, reset, findCall } = createQueuedMockSupabase()
const { supabase: mockSupabase, enqueue, reset, findCall, findCalls } =
createQueuedMockSupabase()
const requireAuthMock = vi.fn()
vi.mock('@/lib/auth/require-auth', () => ({
@@ -81,6 +82,37 @@ describe('GET /api/webshop-orders', () => {
expect(body.data).toHaveLength(2)
expect(body.stores.map((s) => s.store_scope)).toEqual(['a.se', 'b.se'])
})
it('unbooked filter excludes manually marked rows (#1879)', async () => {
enqueue({ data: [], count: 0 })
enqueue({ data: [] })
const response = await listOrders(
createMockRequest('/api/webshop-orders?booked=unbooked'),
)
expect(response.status).toBe(200)
const isFilters = findCalls('webshop_orders', 'is')
expect(isFilters).toEqual(
expect.arrayContaining([
['journal_entry_id', null],
['manually_booked_at', null],
]),
)
})
it('booked filter includes manually marked rows (#1879)', async () => {
enqueue({ data: [], count: 0 })
enqueue({ data: [] })
const response = await listOrders(
createMockRequest('/api/webshop-orders?booked=booked'),
)
expect(response.status).toBe(200)
const orFilters = findCalls('webshop_orders', 'or')
expect(orFilters).toEqual(
expect.arrayContaining([
['journal_entry_id.not.is.null,manually_booked_at.not.is.null'],
]),
)
})
})
describe('GET|PUT /api/webshop-orders/settings', () => {
@@ -0,0 +1,292 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { NextResponse } from 'next/server'
import {
createMockRequest,
parseJsonResponse,
createMockRouteParams,
createQueuedMockSupabase,
} from '@/tests/helpers'
import { eventBus } from '@/lib/events'
const { supabase: mockSupabase, enqueue, reset, findCall, findCalls } =
createQueuedMockSupabase()
const requireAuthMock = vi.fn()
vi.mock('@/lib/auth/require-auth', () => ({
requireAuth: (...args: unknown[]) => requireAuthMock(...args),
}))
vi.mock('@/lib/init', () => ({
ensureInitialized: vi.fn(),
}))
vi.mock('@/lib/company/context', () => ({
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
}))
const requireWriteMock = vi.fn()
vi.mock('@/lib/auth/require-write', () => ({
requireWritePermission: (...args: unknown[]) => requireWriteMock(...args),
}))
import { POST, DELETE } from '../[id]/mark-booked/route'
const ENTRY_UUID = '550e8400-e29b-41d4-a716-446655440001'
function makeOrderRow(overrides: Record<string, unknown> = {}) {
return {
id: 'order-1',
journal_entry_id: null,
invoice_id: null,
manually_booked_at: null,
legacy_transaction_id: null,
...overrides,
}
}
function postMark(body: unknown = {}, id = 'order-1') {
const request = createMockRequest(`/api/webshop-orders/${id}/mark-booked`, {
method: 'POST',
body,
})
return POST(request, createMockRouteParams({ id }))
}
function deleteMark(id = 'order-1') {
const request = createMockRequest(`/api/webshop-orders/${id}/mark-booked`, {
method: 'DELETE',
})
return DELETE(request, createMockRouteParams({ id }))
}
describe('POST /api/webshop-orders/[id]/mark-booked', () => {
const mockUser = { id: 'user-1', email: 'test@test.se' }
beforeEach(() => {
vi.clearAllMocks()
reset()
eventBus.clear()
requireAuthMock.mockResolvedValue({ user: mockUser, supabase: mockSupabase })
requireWriteMock.mockResolvedValue({ ok: true })
})
it('returns 401 when not authenticated', async () => {
requireAuthMock.mockResolvedValue({
user: null,
supabase: mockSupabase,
error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }),
})
const { status } = await parseJsonResponse(await postMark())
expect(status).toBe(401)
})
it('returns 403 when the caller is a viewer (requireWrite)', async () => {
requireWriteMock.mockResolvedValue({
ok: false,
response: NextResponse.json({ error: 'Forbidden' }, { status: 403 }),
})
const { status } = await parseJsonResponse(await postMark())
expect(status).toBe(403)
})
it('returns 400 on an invalid journal_entry_id', async () => {
const { status } = await parseJsonResponse(
await postMark({ journal_entry_id: 'not-a-uuid' }),
)
expect(status).toBe(400)
})
it('returns 404 when the order does not exist for the company', async () => {
enqueue({ data: null, error: { message: 'not found' } })
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(
await postMark(),
)
expect(status).toBe(404)
expect(body.error.code).toBe('WEBSHOP_ORDER_NOT_FOUND')
})
it('returns 409 when the order is booked through the integration', async () => {
enqueue({ data: makeOrderRow({ journal_entry_id: 'je-1' }) })
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(
await postMark(),
)
expect(status).toBe(409)
expect(body.error.code).toBe('WEBSHOP_ORDER_ALREADY_BOOKED')
})
it('returns 409 when the order is invoiced', async () => {
enqueue({ data: makeOrderRow({ invoice_id: 'inv-1' }) })
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(
await postMark(),
)
expect(status).toBe(409)
expect(body.error.code).toBe('WEBSHOP_ORDER_ALREADY_INVOICED')
})
it('is idempotent for a bare re-mark of an already-marked row', async () => {
enqueue({ data: makeOrderRow({ manually_booked_at: '2026-08-01T00:00:00Z' }) })
const { status, body } = await parseJsonResponse<{ already_marked: boolean }>(
await postMark(),
)
expect(status).toBe(200)
expect(body.already_marked).toBe(true)
expect(findCall('webshop_orders', 'update')).toBeUndefined()
})
it('updates the verifikat link when re-marking with a journal_entry_id', async () => {
enqueue({ data: makeOrderRow({ manually_booked_at: '2026-08-01T00:00:00Z' }) })
enqueue({ data: { id: ENTRY_UUID, status: 'posted' } }) // entry lookup
enqueue({ data: null }) // link update
const { status, body } = await parseJsonResponse<{
already_marked: boolean
link_updated: boolean
}>(await postMark({ journal_entry_id: ENTRY_UUID }))
expect(status).toBe(200)
expect(body.already_marked).toBe(true)
expect(body.link_updated).toBe(true)
const update = findCall('webshop_orders', 'update')
expect(update![0]).toEqual({ manually_booked_journal_entry_id: ENTRY_UUID })
})
it('refuses to mark while the legacy feed transaction is still OPEN (double-booking gate)', async () => {
enqueue({ data: makeOrderRow({ legacy_transaction_id: 'txn-1' }) })
enqueue({ data: { id: 'txn-1', journal_entry_id: null, is_ignored: false } })
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(
await postMark(),
)
expect(status).toBe(409)
expect(body.error.code).toBe('WEBSHOP_ORDER_LEGACY_TRANSACTION_OPEN')
expect(findCall('webshop_orders', 'update')).toBeUndefined()
})
it('marks when the legacy feed transaction was ignored', async () => {
enqueue({ data: makeOrderRow({ legacy_transaction_id: 'txn-1' }) })
enqueue({ data: { id: 'txn-1', journal_entry_id: null, is_ignored: true } })
enqueue({ data: [{ id: 'order-1' }] }) // claim
const { status } = await parseJsonResponse(await postMark())
expect(status).toBe(200)
})
it('marks when the legacy feed transaction is already booked (no open twin remains)', async () => {
enqueue({ data: makeOrderRow({ legacy_transaction_id: 'txn-1' }) })
enqueue({ data: { id: 'txn-1', journal_entry_id: 'je-77', is_ignored: false } })
enqueue({ data: [{ id: 'order-1' }] }) // claim
const { status } = await parseJsonResponse(await postMark())
expect(status).toBe(200)
})
it('marks the row with who/when via a conditional claim', async () => {
enqueue({ data: makeOrderRow() }) // fetch
enqueue({ data: [{ id: 'order-1' }] }) // claim
const { status, body } = await parseJsonResponse<{ success: boolean }>(await postMark())
expect(status).toBe(200)
expect(body.success).toBe(true)
const update = findCall('webshop_orders', 'update')
expect(update).toBeDefined()
const payload = update![0] as Record<string, unknown>
expect(typeof payload.manually_booked_at).toBe('string')
expect(payload.manually_booked_by).toBe('user-1')
expect(payload.manually_booked_journal_entry_id).toBeNull()
// The claim must exclude rows already booked, invoiced or marked.
const isFilters = findCalls('webshop_orders', 'is')
expect(isFilters).toEqual(
expect.arrayContaining([
['journal_entry_id', null],
['invoice_id', null],
['manually_booked_at', null],
]),
)
})
it('links a posted verifikat when journal_entry_id is provided', async () => {
enqueue({ data: makeOrderRow() }) // fetch order
enqueue({ data: { id: ENTRY_UUID, status: 'posted' } }) // entry lookup
enqueue({ data: [{ id: 'order-1' }] }) // claim
const { status } = await parseJsonResponse(
await postMark({ journal_entry_id: ENTRY_UUID }),
)
expect(status).toBe(200)
const update = findCall('webshop_orders', 'update')
expect((update![0] as Record<string, unknown>).manually_booked_journal_entry_id).toBe(
ENTRY_UUID,
)
})
it('returns 404 when the linked verifikat does not exist in the company', async () => {
enqueue({ data: makeOrderRow() })
enqueue({ data: null }) // entry lookup
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(
await postMark({ journal_entry_id: ENTRY_UUID }),
)
expect(status).toBe(404)
expect(body.error.code).toBe('WEBSHOP_ORDER_MARK_ENTRY_NOT_FOUND')
})
it('refuses linking a non-posted verifikat', async () => {
enqueue({ data: makeOrderRow() })
enqueue({ data: { id: ENTRY_UUID, status: 'draft' } })
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(
await postMark({ journal_entry_id: ENTRY_UUID }),
)
expect(status).toBe(409)
expect(body.error.code).toBe('WEBSHOP_ORDER_MARK_ENTRY_NOT_POSTED')
expect(findCall('webshop_orders', 'update')).toBeUndefined()
})
it('returns 409 when the claim matches zero rows (raced)', async () => {
enqueue({ data: makeOrderRow() }) // fetch (sees open row)
enqueue({ data: [] }) // claim matched zero rows
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(
await postMark(),
)
expect(status).toBe(409)
expect(body.error.code).toBe('WEBSHOP_ORDER_ALREADY_BOOKED')
})
})
describe('DELETE /api/webshop-orders/[id]/mark-booked', () => {
const mockUser = { id: 'user-1', email: 'test@test.se' }
beforeEach(() => {
vi.clearAllMocks()
reset()
eventBus.clear()
requireAuthMock.mockResolvedValue({ user: mockUser, supabase: mockSupabase })
requireWriteMock.mockResolvedValue({ ok: true })
})
it('returns 401 when not authenticated', async () => {
requireAuthMock.mockResolvedValue({
user: null,
supabase: mockSupabase,
error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }),
})
const { status } = await parseJsonResponse(await deleteMark())
expect(status).toBe(401)
})
it('returns 404 when the order does not exist for the company', async () => {
enqueue({ data: [] }) // update matched zero rows
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(
await deleteMark(),
)
expect(status).toBe(404)
expect(body.error.code).toBe('WEBSHOP_ORDER_NOT_FOUND')
})
it('clears the mark fields', async () => {
enqueue({ data: [{ id: 'order-1' }] })
const { status, body } = await parseJsonResponse<{ success: boolean }>(await deleteMark())
expect(status).toBe(200)
expect(body.success).toBe(true)
const update = findCall('webshop_orders', 'update')
expect(update![0]).toEqual({
manually_booked_at: null,
manually_booked_by: null,
manually_booked_journal_entry_id: null,
})
})
})
+9 -2
View File
@@ -36,8 +36,15 @@ export const GET = withRouteContext(
if (status) query = query.eq('status', status)
if (row_type) query = query.eq('row_type', row_type)
if (paid) query = query.eq('is_paid', paid === 'paid')
if (booked === 'booked') query = query.not('journal_entry_id', 'is', null)
if (booked === 'unbooked') query = query.is('journal_entry_id', null)
// "Booked" counts every closed exit: booked via the integration OR
// marked as manually booked outside it; "unbooked" is the open set the
// Att bokfora tab shows, so a manual mark removes the row from it.
if (booked === 'booked') {
query = query.or('journal_entry_id.not.is.null,manually_booked_at.not.is.null')
}
if (booked === 'unbooked') {
query = query.is('journal_entry_id', null).is('manually_booked_at', null)
}
const { data, error, count } = await query
if (error) {