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:
co-authored by
Claude Fable 5
parent
cd46d936f3
commit
1f9578ca76
@@ -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,
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user