diff --git a/app/(dashboard)/orders/page.tsx b/app/(dashboard)/orders/page.tsx index efb4be64..383ae081 100644 --- a/app/(dashboard)/orders/page.tsx +++ b/app/(dashboard)/orders/page.tsx @@ -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([]) const [stores, setStores] = useState([]) const [settings, setSettings] = useState([]) @@ -66,6 +80,7 @@ export default function OrdersPage() { const [page, setPage] = useState(0) const [bookingOrder, setBookingOrder] = useState(null) const [invoicingOrder, setInvoicingOrder] = useState(null) + const [markingOrder, setMarkingOrder] = useState(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 && ( + { + if (!open) setMarkingOrder(null) + }} + order={markingOrder} + onMarked={() => { + setMarkingOrder(null) + void load() + }} + /> + )} ) } @@ -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> }) { 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 ( @@ -370,15 +439,44 @@ function OrderRow({ side pushed the table past the panel width and the overflow clip swallowed single-button cells. */} - {bookable ? ( - - ) : invoiceable ? ( - - ) : null} +
+ {bookable ? ( + + ) : invoiceable ? ( + + ) : null} + {(markable || unmarkable) && ( + + + + + + {markable && ( + + {t('action_mark_booked')} + + )} + {unmarkable && ( + + {t('action_unmark_booked')} + + )} + + + )} +
) @@ -402,6 +500,21 @@ function OrderStatus({ if (order.journal_entry_id) { return {t('status_booked')} } + // 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 ? ( + + {t('status_marked_booked')} + + ) : ( + {t('status_marked_booked')} + ) + } if (order.invoice_id) { return ( }>( 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() diff --git a/app/api/webshop-orders/[id]/create-invoice/route.ts b/app/api/webshop-orders/[id]/create-invoice/route.ts index b1552465..89e22d8b 100644 --- a/app/api/webshop-orders/[id]/create-invoice/route.ts +++ b/app/api/webshop-orders/[id]/create-invoice/route.ts @@ -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) diff --git a/app/api/webshop-orders/[id]/mark-booked/route.ts b/app/api/webshop-orders/[id]/mark-booked/route.ts new file mode 100644 index 00000000..fb91943c --- /dev/null +++ b/app/api/webshop-orders/[id]/mark-booked/route.ts @@ -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 }, +) diff --git a/app/api/webshop-orders/__tests__/book.test.ts b/app/api/webshop-orders/__tests__/book.test.ts index f3389ee9..7cf6265c 100644 --- a/app/api/webshop-orders/__tests__/book.test.ts +++ b/app/api/webshop-orders/__tests__/book.test.ts @@ -77,6 +77,7 @@ function makeOrderRow(overrides: Record = {}) { 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 } }>( diff --git a/app/api/webshop-orders/__tests__/create-invoice.test.ts b/app/api/webshop-orders/__tests__/create-invoice.test.ts index 36b828d5..26aad1cb 100644 --- a/app/api/webshop-orders/__tests__/create-invoice.test.ts +++ b/app/api/webshop-orders/__tests__/create-invoice.test.ts @@ -64,6 +64,7 @@ function makeOrderRow(overrides: Record = {}) { 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({ diff --git a/app/api/webshop-orders/__tests__/list-and-settings.test.ts b/app/api/webshop-orders/__tests__/list-and-settings.test.ts index 4a366aff..f97d8509 100644 --- a/app/api/webshop-orders/__tests__/list-and-settings.test.ts +++ b/app/api/webshop-orders/__tests__/list-and-settings.test.ts @@ -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', () => { diff --git a/app/api/webshop-orders/__tests__/mark-booked.test.ts b/app/api/webshop-orders/__tests__/mark-booked.test.ts new file mode 100644 index 00000000..6b1c77cb --- /dev/null +++ b/app/api/webshop-orders/__tests__/mark-booked.test.ts @@ -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 = {}) { + 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 + 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).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, + }) + }) +}) diff --git a/app/api/webshop-orders/route.ts b/app/api/webshop-orders/route.ts index 65151011..f5262b8c 100644 --- a/app/api/webshop-orders/route.ts +++ b/app/api/webshop-orders/route.ts @@ -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) { diff --git a/components/orders/MarkOrderBookedDialog.tsx b/components/orders/MarkOrderBookedDialog.tsx new file mode 100644 index 00000000..87c9c67b --- /dev/null +++ b/components/orders/MarkOrderBookedDialog.tsx @@ -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([]) + 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 ( + + + + {/* data-ph-mask: the order number is user data */} + + {isRefund + ? t('mark_refund_title', { number: order.order_number }) + : t('mark_title', { number: order.order_number })} + + + {formatDate(order.paid_date ?? order.order_date)} + {' · '} + {formatCurrency(order.total, order.currency)} + + + +

{t('mark_description')}

+ +
+

{t('mark_link_label')}

+ setSearch(e.target.value)} + placeholder={t('mark_link_search_placeholder')} + aria-label={t('mark_link_search_placeholder')} + /> + {loading ? ( +
+ + {t('mark_link_loading')} +
+ ) : candidates.length === 0 ? ( +

+ {t('mark_link_empty')} +

+ ) : ( +
+ {candidates.map((entry) => { + const active = selected === entry.id + const gross = candidateGross(entry) + return ( + + ) + })} +
+ )} +

+ {candidates.length >= CANDIDATE_LIMIT + ? t('mark_link_capped', { count: CANDIDATE_LIMIT }) + : t('mark_link_optional_hint')} +

+
+ + + + + +
+
+ ) +} diff --git a/lib/api/schemas.ts b/lib/api/schemas.ts index 87e7c5a1..e5023741 100644 --- a/lib/api/schemas.ts +++ b/lib/api/schemas.ts @@ -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(), +}) + /** {"": {mode:'book', account:'1930'} | {mode:'invoice'}} */ export const WebshopStoreSettingsUpdateSchema = z.object({ platform: WebshopPlatformSchema, diff --git a/lib/errors/structured-errors.ts b/lib/errors/structured-errors.ts index c40dcbce..9bd7edcc 100644 --- a/lib/errors/structured-errors.ts +++ b/lib/errors/structured-errors.ts @@ -3880,6 +3880,25 @@ const WEBSHOP_ORDERS: Record = { 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 = { diff --git a/lib/webshop-orders/__tests__/ingest.test.ts b/lib/webshop-orders/__tests__/ingest.test.ts index d0adac01..24159859 100644 --- a/lib/webshop-orders/__tests__/ingest.test.ts +++ b/lib/webshop-orders/__tests__/ingest.test.ts @@ -56,6 +56,7 @@ function existingRow(overrides: Record = {}) { 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 + 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: [] }, diff --git a/lib/webshop-orders/ingest.ts b/lib/webshop-orders/ingest.ts index 499bf445..5e89e542 100644 --- a/lib/webshop-orders/ingest.ts +++ b/lib/webshop-orders/ingest.ts @@ -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(items: T[], size: number): T[][] { return out } -function isFrozen(row: Pick): 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, +): 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) diff --git a/messages/en.json b/messages/en.json index feb32dea..4bc5e717 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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.", diff --git a/messages/sv.json b/messages/sv.json index 64243460..e8702b35 100644 --- a/messages/sv.json +++ b/messages/sv.json @@ -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.", diff --git a/supabase/migrations/20260825124500_webshop_orders_manual_booking.sql b/supabase/migrations/20260825124500_webshop_orders_manual_booking.sql new file mode 100644 index 00000000..edf43257 --- /dev/null +++ b/supabase/migrations/20260825124500_webshop_orders_manual_booking.sql @@ -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'; diff --git a/supabase/migrations/20260825130000_webshop_orders_manual_mark_freeze.sql b/supabase/migrations/20260825130000_webshop_orders_manual_mark_freeze.sql new file mode 100644 index 00000000..bb76010c --- /dev/null +++ b/supabase/migrations/20260825130000_webshop_orders_manual_mark_freeze.sql @@ -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; +$$; diff --git a/tests/pg/webshop-orders.pg.test.ts b/tests/pg/webshop-orders.pg.test.ts index e1cac0a8..7daa359a 100644 --- a/tests/pg/webshop-orders.pg.test.ts +++ b/tests/pg/webshop-orders.pg.test.ts @@ -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({ diff --git a/types/index.ts b/types/index.ts index 2b676409..5b2605a0 100644 --- a/types/index.ts +++ b/types/index.ts @@ -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 }