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
parent cd46d936f3
commit 1f9578ca76
20 changed files with 1231 additions and 22 deletions
+126 -13
View File
@@ -3,16 +3,24 @@
import { useCallback, useEffect, useState } from 'react'
import dynamic from 'next/dynamic'
import Link from 'next/link'
import { useTranslations } from 'next-intl'
import { ShoppingCart } from 'lucide-react'
import { useLocale, useTranslations } from 'next-intl'
import { MoreHorizontal, ShoppingCart } from 'lucide-react'
import { PageHeader } from '@/components/ui/page-header'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { EmptyState } from '@/components/ui/empty-state'
import { Skeleton } from '@/components/ui/skeleton'
import { useToast } from '@/components/ui/use-toast'
import { ContextPicker } from '@/components/common/ContextPicker'
import { TH_CLASS, TD_CLASS } from '@/components/ui/dry-table'
import { cn, formatCurrency, formatDate } from '@/lib/utils'
import { getErrorMessage, type ErrorLocale } from '@/lib/errors/get-error-message'
import { useCanWrite } from '@/lib/hooks/use-can-write'
import type { WebshopOrder, WebshopStoreSettings } from '@/types'
@@ -23,6 +31,10 @@ const CreateInvoiceFromOrderDialog = dynamic(
() => import('@/components/orders/CreateInvoiceFromOrderDialog'),
{ ssr: false },
)
const MarkOrderBookedDialog = dynamic(
() => import('@/components/orders/MarkOrderBookedDialog'),
{ ssr: false },
)
interface StoreFacet {
platform: string
@@ -52,7 +64,9 @@ function tabQuery(tab: StatusTab): string {
export default function OrdersPage() {
const t = useTranslations('webshop_orders')
const errorLocale = useLocale() as ErrorLocale
const { canWrite } = useCanWrite()
const { toast } = useToast()
const [rows, setRows] = useState<WebshopOrder[]>([])
const [stores, setStores] = useState<StoreFacet[]>([])
const [settings, setSettings] = useState<WebshopStoreSettings[]>([])
@@ -66,6 +80,7 @@ export default function OrdersPage() {
const [page, setPage] = useState(0)
const [bookingOrder, setBookingOrder] = useState<WebshopOrder | null>(null)
const [invoicingOrder, setInvoicingOrder] = useState<WebshopOrder | null>(null)
const [markingOrder, setMarkingOrder] = useState<WebshopOrder | null>(null)
const load = useCallback(async () => {
setLoading(true)
@@ -121,6 +136,35 @@ export default function OrdersPage() {
[settings],
)
// Undo a manual "booked outside the integration" mark: no accounting
// objects were created, so this simply returns the row to the to-book list.
const unmarkOrder = useCallback(
async (order: WebshopOrder) => {
try {
const res = await fetch(`/api/webshop-orders/${order.id}/mark-booked`, {
method: 'DELETE',
})
const json = await res.json()
if (!res.ok || json.error) {
toast({
title: t('unmark_failed'),
description: getErrorMessage(json, {
context: 'transaction',
statusCode: res.status,
locale: errorLocale,
}),
variant: 'destructive',
})
return
}
void load()
} catch {
toast({ title: t('unmark_failed'), variant: 'destructive' })
}
},
[load, t, toast, errorLocale],
)
const tabs: Array<{ key: StatusTab; label: string }> = [
{ key: 'all', label: t('tab_all') },
{ key: 'unpaid', label: t('tab_unpaid') },
@@ -218,6 +262,8 @@ export default function OrdersPage() {
canWrite={canWrite}
onBook={() => setBookingOrder(order)}
onInvoice={() => setInvoicingOrder(order)}
onMarkBooked={() => setMarkingOrder(order)}
onUnmark={() => void unmarkOrder(order)}
t={t}
/>
))}
@@ -290,6 +336,19 @@ export default function OrdersPage() {
}}
/>
)}
{markingOrder && (
<MarkOrderBookedDialog
open={!!markingOrder}
onOpenChange={(open) => {
if (!open) setMarkingOrder(null)
}}
order={markingOrder}
onMarked={() => {
setMarkingOrder(null)
void load()
}}
/>
)}
</div>
)
}
@@ -300,6 +359,8 @@ function OrderRow({
canWrite,
onBook,
onInvoice,
onMarkBooked,
onUnmark,
t,
}: {
order: WebshopOrder
@@ -307,17 +368,25 @@ function OrderRow({
canWrite: boolean
onBook: () => void
onInvoice: () => void
onMarkBooked: () => void
onUnmark: () => void
t: ReturnType<typeof useTranslations<'webshop_orders'>>
}) {
const isRefund = order.row_type === 'refund'
const booked = order.journal_entry_id !== null
const invoiced = order.invoice_id !== null
const manuallyMarked = order.manually_booked_at !== null
// Cross-marked rows (legacy_transaction_id) keep their action buttons: the
// server guard decides (it allows booking once the feed row is booked-
// elsewhere-no, ignored-yes) and its 409 message explains what to do.
// Hiding the button would be a dead-end soft guard.
const bookable = canWrite && !booked && !invoiced && (isRefund || order.is_paid)
const invoiceable = canWrite && !isRefund && !booked && !invoiced
const bookable =
canWrite && !booked && !invoiced && !manuallyMarked && (isRefund || order.is_paid)
const invoiceable = canWrite && !isRefund && !booked && !invoiced && !manuallyMarked
// Secondary actions live in the overflow menu so the cell keeps one text
// button (the two-button layout used to overflow the panel width).
const markable = canWrite && !booked && !invoiced && !manuallyMarked
const unmarkable = canWrite && manuallyMarked
return (
<tr className="group transition-colors duration-150 hover:bg-secondary/35">
@@ -370,15 +439,44 @@ function OrderRow({
side pushed the table past the panel width and the overflow clip
swallowed single-button cells. */}
<td className={cn(TD_CLASS, 'whitespace-nowrap text-right')}>
{bookable ? (
<Button variant="outline" size="sm" onClick={onBook}>
{t('action_book')}
</Button>
) : invoiceable ? (
<Button variant="outline" size="sm" onClick={onInvoice}>
{t('action_create_invoice')}
</Button>
) : null}
<div className="flex items-center justify-end gap-1">
{bookable ? (
<Button variant="outline" size="sm" onClick={onBook}>
{t('action_book')}
</Button>
) : invoiceable ? (
<Button variant="outline" size="sm" onClick={onInvoice}>
{t('action_create_invoice')}
</Button>
) : null}
{(markable || unmarkable) && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
className="h-8 w-8 shrink-0"
aria-label={t('row_menu_aria', { number: order.order_number })}
>
<MoreHorizontal className="h-4 w-4 text-muted-foreground" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{markable && (
<DropdownMenuItem onSelect={onMarkBooked}>
{t('action_mark_booked')}
</DropdownMenuItem>
)}
{unmarkable && (
<DropdownMenuItem onSelect={onUnmark}>
{t('action_unmark_booked')}
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
</td>
</tr>
)
@@ -402,6 +500,21 @@ function OrderStatus({
if (order.journal_entry_id) {
return <span className="text-xs text-muted-foreground">{t('status_booked')}</span>
}
// Marked as handled outside the integration: a normal done state, so muted
// text, not a chip (convention 5). Links to the referenced verifikat when
// the user picked one.
if (order.manually_booked_at) {
return order.manually_booked_journal_entry_id ? (
<Link
href={`/bookkeeping/${order.manually_booked_journal_entry_id}`}
className="text-xs text-muted-foreground underline decoration-border underline-offset-4 hover:text-foreground"
>
{t('status_marked_booked')}
</Link>
) : (
<span className="text-xs text-muted-foreground">{t('status_marked_booked')}</span>
)
}
if (order.invoice_id) {
return (
<Link
+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) {
+261
View File
@@ -0,0 +1,261 @@
'use client'
import { useCallback, useEffect, useState } from 'react'
import { useLocale, useTranslations } from 'next-intl'
import { Loader2 } from 'lucide-react'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { useToast } from '@/components/ui/use-toast'
import { cn, formatCurrency, formatDate } from '@/lib/utils'
import { roundOre } from '@/lib/money'
import { formatVoucher } from '@/lib/bookkeeping/voucher-series-resolver'
import { getErrorMessage, type ErrorLocale } from '@/lib/errors/get-error-message'
import type { WebshopOrder } from '@/types'
interface MarkOrderBookedDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
order: WebshopOrder
onMarked: () => void
}
interface EntryCandidate {
id: string
entry_date: string
description: string | null
voucher_series?: string | null
voucher_number?: number | null
/** The list API returns full rows with nested lines; the gross is their debit sum. */
lines?: Array<{ debit_amount: number | string | null }>
}
/** Gross amount of a candidate = sum of its debit legs (total_amount is a DB
* computed column and not part of the select the list route returns). */
function candidateGross(entry: EntryCandidate): number | null {
if (!entry.lines || entry.lines.length === 0) return null
const sum = entry.lines.reduce((acc, l) => acc + (Number(l.debit_amount) || 0), 0)
return roundOre(sum)
}
// ±45 days around the order date: wide enough for a manual booking done in
// the same period, narrow enough to keep the candidate list short. Typing a
// search drops the window (search over all posted entries instead).
const WINDOW_DAYS = 45
const CANDIDATE_LIMIT = 30
function shiftDate(isoDate: string, deltaDays: number): string {
const d = new Date(isoDate)
if (Number.isNaN(d.getTime())) return isoDate
d.setDate(d.getDate() + deltaDays)
return d.toISOString().slice(0, 10)
}
/**
* Marks one order/refund row as already booked/handled outside the
* integration (issue #1879): no verifikat is created, the row just leaves
* the to-book list. Optionally links the existing posted verifikat that
* covers the order, picked from a searchable candidate list.
*/
export default function MarkOrderBookedDialog({
open,
onOpenChange,
order,
onMarked,
}: MarkOrderBookedDialogProps) {
const t = useTranslations('webshop_orders')
const errorLocale = useLocale() as ErrorLocale
const { toast } = useToast()
const [candidates, setCandidates] = useState<EntryCandidate[]>([])
const [loading, setLoading] = useState(false)
const [search, setSearch] = useState('')
const [selected, setSelected] = useState('')
const [submitting, setSubmitting] = useState(false)
const loadCandidates = useCallback(
async (query: string, signal: { cancelled: boolean }) => {
setLoading(true)
try {
const params = new URLSearchParams()
params.set('status', 'posted')
params.set('exclude_draft', 'true')
params.set('limit', String(CANDIDATE_LIMIT))
// Newest first: the manual booking is usually recent relative to the
// window; default voucher order would surface the year's first
// vouchers and hide the relevant ones behind the cap.
params.set('sort_by', 'date_desc')
if (query) {
params.set('search', query)
} else {
const anchor = order.paid_date ?? order.order_date
params.set('date_from', shiftDate(anchor, -WINDOW_DAYS))
params.set('date_to', shiftDate(anchor, WINDOW_DAYS))
}
const res = await fetch(`/api/bookkeeping/journal-entries?${params}`)
if (!res.ok) throw new Error(`list failed: ${res.status}`)
const json = (await res.json()) as { data: EntryCandidate[] }
if (!signal.cancelled) setCandidates(json.data ?? [])
} catch {
if (!signal.cancelled) setCandidates([])
} finally {
if (!signal.cancelled) setLoading(false)
}
},
[order.paid_date, order.order_date],
)
// (Re)load when the dialog opens or the search changes (debounced).
useEffect(() => {
if (!open) return
const signal = { cancelled: false }
const timer = setTimeout(() => void loadCandidates(search.trim(), signal), 250)
return () => {
signal.cancelled = true
clearTimeout(timer)
}
}, [open, search, loadCandidates])
// Reset transient state when the dialog closes.
useEffect(() => {
if (open) return
setCandidates([])
setSearch('')
setSelected('')
}, [open])
async function handleConfirm() {
setSubmitting(true)
try {
const res = await fetch(`/api/webshop-orders/${order.id}/mark-booked`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(selected ? { journal_entry_id: selected } : {}),
})
const json = await res.json()
if (!res.ok || json.error) {
toast({
title: t('mark_failed'),
description: getErrorMessage(json, {
context: 'transaction',
statusCode: res.status,
locale: errorLocale,
}),
variant: 'destructive',
})
return
}
onMarked()
} catch {
toast({ title: t('mark_failed'), variant: 'destructive' })
} finally {
setSubmitting(false)
}
}
const isRefund = order.row_type === 'refund'
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
{/* data-ph-mask: the order number is user data */}
<DialogTitle data-ph-mask="">
{isRefund
? t('mark_refund_title', { number: order.order_number })
: t('mark_title', { number: order.order_number })}
</DialogTitle>
<DialogDescription>
{formatDate(order.paid_date ?? order.order_date)}
{' · '}
{formatCurrency(order.total, order.currency)}
</DialogDescription>
</DialogHeader>
<p className="text-sm text-muted-foreground">{t('mark_description')}</p>
<div className="space-y-2">
<p className="text-xs font-medium text-muted-foreground">{t('mark_link_label')}</p>
<Input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder={t('mark_link_search_placeholder')}
aria-label={t('mark_link_search_placeholder')}
/>
{loading ? (
<div className="flex items-center justify-center gap-2 rounded-lg border border-border py-6 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
{t('mark_link_loading')}
</div>
) : candidates.length === 0 ? (
<p className="rounded-lg border border-border px-3 py-4 text-center text-sm text-muted-foreground">
{t('mark_link_empty')}
</p>
) : (
<div
role="radiogroup"
aria-label={t('mark_link_label')}
className="max-h-56 space-y-1 overflow-y-auto rounded-lg border border-border p-1"
>
{candidates.map((entry) => {
const active = selected === entry.id
const gross = candidateGross(entry)
return (
<button
key={entry.id}
type="button"
role="radio"
aria-checked={active}
onClick={() => setSelected(active ? '' : entry.id)}
className={cn(
'flex w-full items-center gap-3 rounded-sm px-2 py-2 text-left text-[13px] transition-colors duration-150',
active ? 'bg-secondary text-foreground' : 'hover:bg-secondary/60',
)}
>
<span className="w-12 shrink-0 font-medium tabular-nums">
{formatVoucher(entry)}
</span>
<span className="shrink-0 tabular-nums text-muted-foreground">
{formatDate(entry.entry_date)}
</span>
<span className="min-w-0 flex-1 truncate">{entry.description}</span>
<span className="shrink-0 text-right tabular-nums">
{gross != null ? formatCurrency(gross) : ''}
</span>
</button>
)
})}
</div>
)}
<p className="text-xs text-muted-foreground">
{candidates.length >= CANDIDATE_LIMIT
? t('mark_link_capped', { count: CANDIDATE_LIMIT })
: t('mark_link_optional_hint')}
</p>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={submitting}>
{t('cancel')}
</Button>
<Button onClick={handleConfirm} disabled={submitting}>
{submitting ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
{t('mark_submitting')}
</>
) : (
t('mark_confirm')
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
+8
View File
@@ -1630,6 +1630,14 @@ export const CreateInvoiceFromWebshopOrderSchema = z.object({
customer_id: uuid.optional(),
})
/**
* Mark a webshop order as booked/handled outside the integration, with an
* optional reference to the existing (posted) verifikat that covers it.
*/
export const MarkWebshopOrderBookedSchema = z.object({
journal_entry_id: uuid.optional(),
})
/** {"<payment_method>": {mode:'book', account:'1930'} | {mode:'invoice'}} */
export const WebshopStoreSettingsUpdateSchema = z.object({
platform: WebshopPlatformSchema,
+19
View File
@@ -3880,6 +3880,25 @@ const WEBSHOP_ORDERS: Record<string, StructuredErrorEntry> = {
message_en:
'The order has no customer data. Choose an existing customer to invoice.',
},
WEBSHOP_ORDER_MANUALLY_BOOKED: {
httpStatus: 409,
message_sv:
'Ordern är markerad som bokförd utanför integrationen. Ångra markeringen först om du vill bokföra eller fakturera den härifrån.',
message_en:
'The order is marked as booked outside the integration. Undo the mark first if you want to book or invoice it from here.',
},
WEBSHOP_ORDER_MARK_ENTRY_NOT_FOUND: {
httpStatus: 404,
message_sv: 'Verifikatet som ordern skulle kopplas till hittades inte.',
message_en: 'The journal entry to link the order to was not found.',
},
WEBSHOP_ORDER_MARK_ENTRY_NOT_POSTED: {
httpStatus: 409,
message_sv:
'Verifikatet är inte bokfört. Ordern kan bara kopplas till ett bokfört verifikat.',
message_en:
'The journal entry is not posted. The order can only be linked to a posted entry.',
},
}
const NODE_SYSTEM: Record<string, StructuredErrorEntry> = {
@@ -56,6 +56,7 @@ function existingRow(overrides: Record<string, unknown> = {}) {
external_id: 'woo_butik.example.se_order_1001',
journal_entry_id: null,
invoice_id: null,
manually_booked_at: null,
legacy_transaction_id: null,
remote_changed_after_freeze: false,
total: 500,
@@ -253,6 +254,24 @@ describe('upsertWebshopOrders', () => {
expect(update).not.toHaveProperty('paid_date')
})
it('flags a manually marked row whose financials drifted instead of updating them (#1879)', async () => {
mock.enqueueMany([
{ data: [existingRow({ manually_booked_at: '2026-08-10T00:00:00Z' })] },
{ data: [] },
{ data: null }, // safe-field update
])
const result = await upsertWebshopOrders(supabase(), COMPANY, USER, [
makeUpsert({ total: 600, status: 'completed' }),
])
expect(result.frozenFlagged).toBe(1)
const update = mock.findCall('webshop_orders', 'update')![0] as Record<string, unknown>
expect(update.remote_changed_after_freeze).toBe(true)
expect(update).not.toHaveProperty('total')
expect(update).not.toHaveProperty('line_items')
})
it('leaves total_sek null when the exchange rate cannot resolve', async () => {
mock.enqueueMany([
{ data: [] },
+18 -3
View File
@@ -53,6 +53,7 @@ type ExistingRow = Pick<
| 'external_id'
| 'journal_entry_id'
| 'invoice_id'
| 'manually_booked_at'
| 'legacy_transaction_id'
| 'remote_changed_after_freeze'
| 'total'
@@ -86,8 +87,22 @@ function chunk<T>(items: T[], size: number): T[][] {
return out
}
function isFrozen(row: Pick<ExistingRow, 'journal_entry_id' | 'invoice_id'>): boolean {
return row.journal_entry_id !== null || row.invoice_id !== null
/**
* Rows whose financials must not be silently refreshed. Booked/invoiced rows
* are frozen by the DB trigger; manually marked rows (#1879) are treated the
* same APPLICATION-side: the user asserted "this row is covered by verifikat
* X", so a remote financial delta must surface as remote_changed_after_freeze
* (the same badge booked rows get) instead of mutating the row under that
* assertion and hiding the incremental business event forever.
*/
function isFrozen(
row: Pick<ExistingRow, 'journal_entry_id' | 'invoice_id' | 'manually_booked_at'>,
): boolean {
return (
row.journal_entry_id !== null ||
row.invoice_id !== null ||
row.manually_booked_at !== null
)
}
/**
@@ -231,7 +246,7 @@ export async function upsertWebshopOrders(
const { data: existingData, error: existingError } = await supabase
.from('webshop_orders')
.select(
'id, external_id, journal_entry_id, invoice_id, legacy_transaction_id, remote_changed_after_freeze, total, total_tax, total_sek, exchange_rate, currency, order_date, paid_date, is_paid, payment_method, payment_method_title, gateway_reference, order_number, status, refunded_total, store_label, connection_id, customer_name, customer_company, customer_email, customer_orgnr, customer_country, vat_breakdown, line_items',
'id, external_id, journal_entry_id, invoice_id, manually_booked_at, legacy_transaction_id, remote_changed_after_freeze, total, total_tax, total_sek, exchange_rate, currency, order_date, paid_date, is_paid, payment_method, payment_method_title, gateway_reference, order_number, status, refunded_total, store_label, connection_id, customer_name, customer_company, customer_email, customer_orgnr, customer_country, vat_breakdown, line_items',
)
.eq('company_id', companyId)
.in('external_id', lookupIds)
+17
View File
@@ -6217,8 +6217,25 @@
"status_in_transactions": "In transactions",
"status_unpaid": "Unpaid",
"status_to_book": "Not booked",
"status_marked_booked": "Booked manually",
"action_book": "Book",
"action_create_invoice": "Create invoice",
"action_mark_booked": "Mark as booked",
"action_unmark_booked": "Undo mark",
"row_menu_aria": "More actions for order {number}",
"mark_title": "Mark order {number} as booked",
"mark_refund_title": "Mark refund of order {number} as booked",
"mark_description": "The order is checked off as already booked outside the integration. No verifikat is created and the order leaves the to-book list. The mark can be undone from the row menu.",
"mark_link_label": "Link to an existing verifikat (optional)",
"mark_link_search_placeholder": "Search voucher text",
"mark_link_loading": "Searching entries",
"mark_link_empty": "No posted entries found near the order date. Search to look wider.",
"mark_link_optional_hint": "Click an entry to link it; click again to clear the choice.",
"mark_link_capped": "Showing the {count} most recent. Search to find more.",
"mark_confirm": "Mark as booked",
"mark_submitting": "Marking",
"mark_failed": "Could not mark the order",
"unmark_failed": "Could not undo the mark",
"book_title": "Book order {number}",
"book_refund_title": "Book refund of order {number}",
"fx_unresolved": "The exchange rate for the order currency could not be fetched yet. Try again shortly.",
+17
View File
@@ -6217,8 +6217,25 @@
"status_in_transactions": "Finns i transaktioner",
"status_unpaid": "Obetald",
"status_to_book": "Ej bokförd",
"status_marked_booked": "Bokförd manuellt",
"action_book": "Bokför",
"action_create_invoice": "Skapa faktura",
"action_mark_booked": "Markera som bokförd",
"action_unmark_booked": "Ångra markering",
"row_menu_aria": "Fler åtgärder för order {number}",
"mark_title": "Markera order {number} som bokförd",
"mark_refund_title": "Markera återbetalning av order {number} som bokförd",
"mark_description": "Ordern prickas av som redan bokförd utanför integrationen. Inget verifikat skapas och ordern försvinner från Att bokföra. Markeringen kan ångras via radmenyn.",
"mark_link_label": "Koppla till befintligt verifikat (valfritt)",
"mark_link_search_placeholder": "Sök verifikationstext",
"mark_link_loading": "Söker verifikationer",
"mark_link_empty": "Inga bokförda verifikationer hittades nära orderdatumet. Sök för att leta bredare.",
"mark_link_optional_hint": "Klicka på ett verifikat för att koppla det; klicka igen för att ta bort valet.",
"mark_link_capped": "Visar de {count} senaste. Sök för att hitta fler.",
"mark_confirm": "Markera som bokförd",
"mark_submitting": "Markerar",
"mark_failed": "Kunde inte markera ordern",
"unmark_failed": "Kunde inte ångra markeringen",
"book_title": "Bokför order {number}",
"book_refund_title": "Bokför återbetalning av order {number}",
"fx_unresolved": "Växelkursen för orderns valuta har inte kunnat hämtas ännu. Försök igen om en stund.",
@@ -0,0 +1,34 @@
-- Manual booking mark for webshop orders (issue #1879).
--
-- Orders booked by hand BEFORE the integration was connected sit in the
-- "Att bokfora" list forever: the only exits are the book and create-invoice
-- routes. These columns add a third, non-accounting exit: the user marks the
-- row as already handled outside the integration, optionally pointing at the
-- existing verifikat.
--
-- Deliberately separate from journal_entry_id: that column is the claim the
-- booking route takes atomically and the freeze trigger treats it as "this
-- row PRODUCED that entry" (financial fields freeze, link becomes immutable
-- once posted). A manual mark produced nothing; it is a user assertion with
-- an optional soft reference, so it stays reversible (unmark) and does not
-- freeze the row. The book/create-invoice routes refuse marked rows
-- application-side, mirroring the legacy_transaction_id double-booking lock.
--
-- No RLS change: the existing member UPDATE policy already covers the mark/
-- unmark writes. No audit trigger (consistent with the table: accounting-
-- relevant events are audited on journal_entries/invoices; the mark keeps
-- who/when on the row itself via manually_booked_by/_at).
alter table public.webshop_orders
add column manually_booked_at timestamptz,
add column manually_booked_by uuid references auth.users(id) on delete set null,
add column manually_booked_journal_entry_id uuid references public.journal_entries(id) on delete set null;
comment on column public.webshop_orders.manually_booked_at is
'When the user marked this row as already booked/handled outside the integration; null = not marked. Marked rows leave the to-book list and the book/create-invoice routes refuse them.';
comment on column public.webshop_orders.manually_booked_by is
'User who marked the row as manually booked.';
comment on column public.webshop_orders.manually_booked_journal_entry_id is
'Optional user-chosen reference to the existing verifikat that covers this order. Informational link only: the entry was created outside the order flow, so this never freezes the row.';
NOTIFY pgrst, 'reload schema';
@@ -0,0 +1,72 @@
-- Freeze v3: financial fields are also frozen while a row is MANUALLY marked
-- as booked outside the integration (issue #1879, review finding on PR #1895).
--
-- v2 (20260812124858) froze financials once journal_entry_id/invoice_id was
-- set. The manual mark (manually_booked_at) got the same protection only in
-- application code (lib/webshop-orders/ingest.ts isFrozen): any other write
-- path (browser-client PATCH through the member UPDATE policy, a future
-- endpoint, an ad-hoc script) could still silently mutate total/line_items
-- under the user's "this row is covered by verifikat X" assertion. Now the
-- trigger holds the same line: while marked, the financial fields are
-- immutable at the DB level; sync's safe-field updates (status, refund
-- summary, labels, remote_changed_after_freeze) still pass, and clearing the
-- mark itself stays allowed (the manual columns are not in the protected
-- list), which is exactly the unmark route's escape hatch: unmark first,
-- then the row is fully mutable again.
--
-- The link-column protections from v2 are unchanged. CREATE OR REPLACE keeps
-- the trigger binding intact.
create or replace function public.enforce_webshop_order_financial_freeze()
returns trigger
language plpgsql
as $$
declare
v_entry_status text;
begin
-- Link-column protection runs FIRST: it applies even when the row was
-- frozen by the other link.
if old.invoice_id is not null
and new.invoice_id is distinct from old.invoice_id
then
raise exception 'webshop_orders row % is linked to an invoice; the link is immutable', old.id
using errcode = 'P0001';
end if;
if old.journal_entry_id is not null
and new.journal_entry_id is distinct from old.journal_entry_id
then
select status into v_entry_status
from public.journal_entries
where id = old.journal_entry_id;
if v_entry_status is null or v_entry_status = 'posted' then
raise exception 'webshop_orders row % is booked; the journal link is immutable (use storno)', old.id
using errcode = 'P0001';
end if;
end if;
if old.journal_entry_id is not null
or old.invoice_id is not null
or old.manually_booked_at is not null
then
if new.total is distinct from old.total
or new.total_tax is distinct from old.total_tax
or new.total_sek is distinct from old.total_sek
or new.exchange_rate is distinct from old.exchange_rate
or new.currency is distinct from old.currency
or new.vat_breakdown is distinct from old.vat_breakdown
or new.line_items is distinct from old.line_items
or new.order_date is distinct from old.order_date
or new.paid_date is distinct from old.paid_date
or new.is_paid is distinct from old.is_paid
or new.payment_method is distinct from old.payment_method
or new.external_id is distinct from old.external_id
or new.platform_order_id is distinct from old.platform_order_id
then
raise exception 'webshop_orders row % is booked/invoiced/marked as booked; financial fields are frozen (unmark or use storno)', old.id
using errcode = 'P0001';
end if;
end if;
return new;
end;
$$;
+54
View File
@@ -191,6 +191,60 @@ describe('webshop_orders financial freeze', () => {
expect(ok.rowCount).toBe(1)
})
it('freezes financial fields while manually marked as booked; unmark restores mutability (#1879, freeze v3)', async () => {
const { userId, companyId } = await seedCompany()
const rowId = await insertOrderRow({ companyId, userId })
// Mark as booked outside the integration (what the mark-booked route does).
const marked = await getPool().query(
`UPDATE public.webshop_orders
SET manually_booked_at = now(), manually_booked_by = $2
WHERE id = $1`,
[rowId, userId],
)
expect(marked.rowCount).toBe(1)
// Financial fields are frozen at the DB level while marked.
await expect(
getPool().query(
`UPDATE public.webshop_orders SET total = 600.00 WHERE id = $1`,
[rowId],
),
).rejects.toThrow(/financial fields are frozen/i)
await expect(
getPool().query(
`UPDATE public.webshop_orders SET line_items = '[{"name":"x"}]'::jsonb WHERE id = $1`,
[rowId],
),
).rejects.toThrow(/financial fields are frozen/i)
// Safe sync fields still pass (drift flagging keeps working).
const safe = await getPool().query(
`UPDATE public.webshop_orders
SET status = 'completed', remote_changed_after_freeze = true
WHERE id = $1`,
[rowId],
)
expect(safe.rowCount).toBe(1)
// Unmark (the DELETE route) is the escape hatch...
const unmark = await getPool().query(
`UPDATE public.webshop_orders
SET manually_booked_at = NULL, manually_booked_by = NULL,
manually_booked_journal_entry_id = NULL
WHERE id = $1`,
[rowId],
)
expect(unmark.rowCount).toBe(1)
// ...after which the row is fully mutable again.
const thawed = await getPool().query(
`UPDATE public.webshop_orders SET total = 600.00, total_sek = 600.00 WHERE id = $1`,
[rowId],
)
expect(thawed.rowCount).toBe(1)
})
it('rejects clearing the journal link once the entry is posted', async () => {
const { userId, companyId, fiscalPeriodId } = await seedCompany()
const postedId = await insertDraftJournalEntry({
+5
View File
@@ -4218,6 +4218,11 @@ export interface WebshopOrder {
legacy_transaction_id: string | null
/** Financial delta arrived from the store after booking froze this row. */
remote_changed_after_freeze: boolean
/** User marked the row as booked/handled outside the integration. */
manually_booked_at: string | null
manually_booked_by: string | null
/** Optional informational reference to the existing verifikat. */
manually_booked_journal_entry_id: string | null
created_at: string
updated_at: string
}