feat(webshop): generate orderunderlag and attach it to the verifikat at booking (#1899)

* feat(webshop): generate orderunderlag PDF and attach it to the verifikat at booking

Booked webshop orders only carried the VAT split; the verifikat showed no
product lines, customer or payment method although the sync already stores
all of it in webshop_orders.line_items (#1881).

- lib/webshop-orders/order-underlag.tsx: pure model builder + react-pdf
  template (order lines, customer, payment method, per-rate VAT summary,
  SEK conversion facts) + archiveWebshopOrderUnderlag, which renders and
  archives the PDF on the committed verifikat through uploadDocument
  (upload_source system, extraction none), mirroring archiveIssuedInvoicePdf.
  Never throws: the booking is immutable by then.
- book route: archive after commitEntry; response gains underlag_archived.
  FX-retry now also syncs the in-memory row so the underlag shows the
  resolved SEK facts.
- webshop_order added to NEEDS_DOC_SOURCE_TYPES and (new migration
  20260825140000) to the verifikat_without_documents needs-doc list, so a
  failed attach or a historical booking surfaces on the saknar-underlag
  worklist. transactions_without_documents is deliberately unchanged.
- tests: underlag model/render/archive unit tests, book-route archive and
  failure-isolation cases, pg test extended (per-source-type probe now
  covers webshop_order; explicit flagged/silenced pair).

Fixes #1881

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* chore(migrations): move webshop needs-doc migration after main's 20260825150000

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(webshop): add manually_booked fields to the underlag order fixture

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(webshop): skeptic findings on the orderunderlag (#1881)

Two refutations from the skeptic pass on PR #1899, both fixed:

1. Correctness: sv-SE Intl emits U+2212 MINUS SIGN for negatives, which
   Helvetica/WinAnsi PDF fonts drop silently, so refund and discount
   amounts on the archived underlag rendered as POSITIVE. formatAmount now
   replaces U+2212 with an ASCII hyphen (same guard as formatPdfCurrency),
   is exported, and is pinned by a regression test.

2. Regression: NEEDS_DOC_SOURCE_TYPES had two hardcoded copies that missed
   webshop_order, so flagged rows rendered without the "Underlag saknas"
   chip, waiver toggle, or batch-exempt selection, and the weekly
   missing-underlag push cron disagreed with the badge. The constant now
   lives in dependency-free lib/worklist/types.ts (client-safe), is
   re-exported from categories.ts, and both JournalEntryList.tsx and
   push-notifications/notification-scheduler.ts consume it instead of
   their own copies.

Also: "Bokfört i SEK" reworded to "Motsvarande i SEK" (compliance skeptic
observation: the dialog's lines are user-editable, so the underlag must
state the order's conversion, not claim a booking fact).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Mattsson
2026-08-25 15:15:33 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent c6f2bebab9
commit 5fc0be9ed7
11 changed files with 1150 additions and 31 deletions
+22
View File
@@ -9,6 +9,7 @@ import { errorResponseFromCode } from '@/lib/errors/get-structured-error'
import { getErrorMessage } from '@/lib/errors/get-error-message'
import { fetchExchangeRate } from '@/lib/currency/riksbanken'
import { ensureWebshopPrefillAccounts } from '@/lib/webshop-orders/ensure-accounts'
import { archiveWebshopOrderUnderlag } from '@/lib/webshop-orders/order-underlag'
import { roundOre } from '@/lib/money'
import type { Currency, WebshopOrder } from '@/types'
@@ -131,6 +132,12 @@ export const POST = withRouteContext<{ params: Promise<{ id: string }> }>(
.eq('id', id)
.eq('company_id', companyId)
resolved = !fxError
if (resolved) {
// Keep the in-memory row in sync: the underlag renders the SEK
// conversion facts from it after commit.
order.total_sek = totalSek
order.exchange_rate = rate.rate
}
}
} catch (err) {
log.warn('booking-time FX retry failed', err as Error)
@@ -249,11 +256,26 @@ export const POST = withRouteContext<{ params: Promise<{ id: string }> }>(
// No extra event here: commitEntry() already emits
// journal_entry.committed from inside the engine.
// Archive the orderunderlag (lines, customer, payment method) on the
// committed verifikat (#1881). Never fatal: the booking is immutable at
// this point, and a verifikat left without underlag surfaces on the
// "saknar underlag" worklist (webshop_order is a needs-doc source type),
// where the user can attach a document by hand.
const underlag = await archiveWebshopOrderUnderlag({
supabase,
companyId,
userId: user.id,
order,
journalEntryId: journalEntry?.id ?? draft.id,
log,
})
return NextResponse.json({
data: journalEntry,
// commitEntry's post-commit fetch can theoretically return no row;
// the entry still exists under draft.id.
journal_entry_id: journalEntry?.id ?? draft.id,
underlag_archived: underlag.ok,
success: true,
})
},
@@ -50,6 +50,14 @@ vi.mock('@/lib/webshop-orders/ensure-accounts', () => ({
ensureWebshopPrefillAccounts: (...args: unknown[]) => mockEnsureAccounts(...args),
}))
// Underlag rendering/archiving behaviour lives in
// lib/webshop-orders/__tests__/order-underlag.test.ts; here we only assert
// when the route archives and that a failure never breaks the booking.
const mockArchiveUnderlag = vi.fn()
vi.mock('@/lib/webshop-orders/order-underlag', () => ({
archiveWebshopOrderUnderlag: (...args: unknown[]) => mockArchiveUnderlag(...args),
}))
import { POST } from '../[id]/book/route'
const PERIOD_UUID = '550e8400-e29b-41d4-a716-446655440000'
@@ -112,6 +120,7 @@ describe('POST /api/webshop-orders/[id]/book', () => {
requireWriteMock.mockResolvedValue({ ok: true })
mockCreateDraftEntry.mockResolvedValue(makeJournalEntry({ id: 'draft-1', status: 'draft' }))
mockCommitEntry.mockResolvedValue(makeJournalEntry({ id: 'je-1' }))
mockArchiveUnderlag.mockResolvedValue({ ok: true, documentId: 'doc-1' })
})
it('returns 401 when not authenticated', async () => {
@@ -289,6 +298,56 @@ describe('POST /api/webshop-orders/[id]/book', () => {
)
})
it('archives the orderunderlag on the committed verifikat (#1881)', async () => {
enqueue({ data: makeOrderRow() }) // fetch
enqueue({ data: [{ id: 'order-1' }] }) // claim
const { status, body } = await parseJsonResponse<{ underlag_archived: boolean }>(
await postBook(),
)
expect(status).toBe(200)
expect(body.underlag_archived).toBe(true)
expect(mockArchiveUnderlag).toHaveBeenCalledTimes(1)
expect(mockArchiveUnderlag).toHaveBeenCalledWith(
expect.objectContaining({
companyId: 'company-1',
userId: 'user-1',
journalEntryId: 'je-1',
order: expect.objectContaining({ id: 'order-1' }),
}),
)
// Only after the commit: an underlag must never anchor to a draft that
// could still be cancelled.
expect(mockCommitEntry.mock.invocationCallOrder[0]).toBeLessThan(
mockArchiveUnderlag.mock.invocationCallOrder[0],
)
})
it('a failed underlag archive never breaks the booking', async () => {
mockArchiveUnderlag.mockResolvedValueOnce({ ok: false, documentId: null })
enqueue({ data: makeOrderRow() }) // fetch
enqueue({ data: [{ id: 'order-1' }] }) // claim
const { status, body } = await parseJsonResponse<{
journal_entry_id: string
underlag_archived: boolean
success: boolean
}>(await postBook())
expect(status).toBe(200)
expect(body.success).toBe(true)
expect(body.journal_entry_id).toBe('je-1')
expect(body.underlag_archived).toBe(false)
})
it('does not archive an underlag when the commit fails', async () => {
mockCommitEntry.mockRejectedValueOnce(new Error('period locked'))
enqueue({ data: makeOrderRow() }) // fetch
enqueue({ data: [{ id: 'order-1' }] }) // claim
enqueue({ data: null }) // unlink
enqueue({ data: null }) // cancel draft
const { status } = await parseJsonResponse(await postBook())
expect(status).toBeGreaterThanOrEqual(400)
expect(mockArchiveUnderlag).not.toHaveBeenCalled()
})
it('returns 409 and cancels the draft when another request wins the claim', async () => {
enqueue({ data: makeOrderRow() }) // fetch (sees unbooked)
enqueue({ data: [] }) // claim matched ZERO rows: raced