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:
co-authored by
Claude Fable 5
parent
c6f2bebab9
commit
5fc0be9ed7
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user