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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user