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:
+126
-13
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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: [] },
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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;
|
||||
$$;
|
||||
@@ -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({
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user