feat(woo): select multiple orders and book them with one template sweep (#1900)
* feat(woo): select multiple orders and book them with one template sweep Adds bulk booking to the orders page (issue #1880): hover-reveal checkbox column, a bulkbar with select-all/clear, and a confirm dialog that books every selected order with the standard order template (per-store payment- method mapping, optionally one override account for the whole selection). Server side, POST /api/webshop-orders/bulk-book books each order as its OWN verifikat through the exact same flow as the single-order endpoint: the guards, FX retry and race-free draft -> claim -> commit sequence are extracted to lib/webshop-orders/book-order.ts and shared by both routes, so nothing added to the single path can miss the bulk path. Partial failure is reported per order and never aborts the batch. Fixes #1880 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(woo): replace mangled NUL byte in bulk dialog grouping key with a pipe The account-group key template literal picked up a raw 0x00 byte during generation (known escape-mangling hazard), making git treat the file as binary. Same grouping semantics, plain '|' separator. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(woo): bulk sweep only books derived lines, never guessed ones (skeptic findings) The sweep has no reviewing user, so everything the single dialog relies on a human to catch is now refused per order or aborted: - empty vat_breakdown: the ratio-inferred fallback split (a 25%+6% mixed sale classified as 12%, refunds reversing zero moms via 3004) is only allowed as the single dialog's editable prefill; bulk refuses with WEBSHOP_ORDER_VAT_BREAKDOWN_MISSING - invoice-mode payment methods: booking would foreclose Skapa faktura and post a wrong clearing leg; refused with WEBSHOP_ORDER_INVOICE_MODE_METHOD (the account override does not bypass the merchant's configured flow) - 3740 residual above ore scale (gift-card gaps booked as 'oresavrundning'): refused with WEBSHOP_ORDER_RESIDUAL_TOO_LARGE - settings-fetch failure now aborts the sweep instead of silently rebooking every order to 1686 against the confirmed dialog - maxDuration 300 so a platform kill cannot strand an order between claim and commit - per-order guard details (e.g. journal_entry_id) survive into the failure envelope The dialog mirrors the skip rules up front (named order numbers, not an anonymous count) so the confirmation describes exactly what will book. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(woo): refuse non-Swedish VAT-rate buckets in the bulk sweep A foreign OSS bucket (e.g. German 19%) passes the non-empty breakdown gate with zero residual, but the rate-to-account maps would fall back to the 25% accounts and book foreign VAT as Swedish utgaende moms 2611 (skeptic finding). The sweep now refuses such orders per order with WEBSHOP_ORDER_UNSUPPORTED_VAT_RATE (details.rates names the offending rates); the dialog mirrors the rule and names the skipped orders. Only the single dialog may show that prefill, as an editable guess. 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
5fc0be9ed7
commit
c634430677
@@ -1625,6 +1625,20 @@ export const BookWebshopOrderSchema = z.object({
|
||||
notes: z.string().max(2000).optional(),
|
||||
})
|
||||
|
||||
/**
|
||||
* Bulk booking of webshop orders: each order books as its OWN verifikat
|
||||
* through the same server-side flow as the single-order endpoint (never one
|
||||
* combined journal write). Max 50 = one orders-page of selection.
|
||||
*/
|
||||
export const BulkBookWebshopOrdersSchema = z.object({
|
||||
order_ids: z.array(uuid).min(1).max(50),
|
||||
/**
|
||||
* Optional override: prefill every order's payment leg against this
|
||||
* account instead of the per-store payment-method mapping.
|
||||
*/
|
||||
payment_account: accountNumber.optional(),
|
||||
})
|
||||
|
||||
export const CreateInvoiceFromWebshopOrderSchema = z.object({
|
||||
/** Omitted: match by email/orgnr within the company, else create. */
|
||||
customer_id: uuid.optional(),
|
||||
|
||||
@@ -3900,6 +3900,34 @@ const WEBSHOP_ORDERS: Record<string, StructuredErrorEntry> = {
|
||||
message_en:
|
||||
'The order was invoiced through a customer invoice. Handle the refund with a credit note instead of booking the refund row directly.',
|
||||
},
|
||||
WEBSHOP_ORDER_VAT_BREAKDOWN_MISSING: {
|
||||
httpStatus: 422,
|
||||
message_sv:
|
||||
'Ordern saknar momsuppdelning från butiken, så konteringen kan inte härledas säkert. Bokför ordern enskilt och granska raderna.',
|
||||
message_en:
|
||||
'The order has no VAT breakdown from the store, so the posting cannot be derived reliably. Book the order individually and review the lines.',
|
||||
},
|
||||
WEBSHOP_ORDER_INVOICE_MODE_METHOD: {
|
||||
httpStatus: 409,
|
||||
message_sv:
|
||||
'Betalsättet är markerat som fakturaflöde i butiksinställningarna. Skapa faktura från ordern i stället, eller bokför den enskilt.',
|
||||
message_en:
|
||||
'The payment method is marked as invoice flow in the store settings. Create an invoice from the order instead, or book it individually.',
|
||||
},
|
||||
WEBSHOP_ORDER_UNSUPPORTED_VAT_RATE: {
|
||||
httpStatus: 422,
|
||||
message_sv:
|
||||
'Ordern har en momssats som inte är en svensk sats (25/12/6/0 %), till exempel utländsk OSS-moms. Bokför ordern enskilt och granska raderna.',
|
||||
message_en:
|
||||
'The order has a VAT rate that is not a Swedish rate (25/12/6/0 %), for example foreign OSS VAT. Book the order individually and review the lines.',
|
||||
},
|
||||
WEBSHOP_ORDER_RESIDUAL_TOO_LARGE: {
|
||||
httpStatus: 422,
|
||||
message_sv:
|
||||
'Orderns belopp stämmer inte med momsuppdelningen (differensen är större än öresavrundning). Bokför ordern enskilt och granska raderna.',
|
||||
message_en:
|
||||
'The order total does not match its VAT breakdown (the difference is larger than öre rounding). Book the order individually and review the lines.',
|
||||
},
|
||||
WEBSHOP_ORDER_CREATE_INVOICE_CUSTOMER_FAILED: {
|
||||
httpStatus: 500,
|
||||
message_sv: 'Kunden kunde inte skapas från orderns uppgifter.',
|
||||
|
||||
@@ -2,6 +2,7 @@ import { describe, it, expect } from 'vitest'
|
||||
import {
|
||||
buildOrderBookingLines,
|
||||
fallbackVatBreakdown,
|
||||
orderBookingDescription,
|
||||
resolveBookingWarnings,
|
||||
resolvePaymentAccount,
|
||||
DEFAULT_PAYMENT_ACCOUNT,
|
||||
@@ -311,3 +312,46 @@ describe('resolveBookingWarnings', () => {
|
||||
).toEqual(['foreign_vat'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('orderBookingDescription', () => {
|
||||
it('labels an order with its payment method', () => {
|
||||
expect(
|
||||
orderBookingDescription({
|
||||
row_type: 'order',
|
||||
order_number: '1001',
|
||||
payment_method: 'swish',
|
||||
payment_method_title: 'Swish',
|
||||
}),
|
||||
).toBe('Order 1001 (Swish)')
|
||||
})
|
||||
|
||||
it('falls back to the raw method key, then to no method', () => {
|
||||
expect(
|
||||
orderBookingDescription({
|
||||
row_type: 'order',
|
||||
order_number: '1001',
|
||||
payment_method: 'swish',
|
||||
payment_method_title: null,
|
||||
}),
|
||||
).toBe('Order 1001 (swish)')
|
||||
expect(
|
||||
orderBookingDescription({
|
||||
row_type: 'order',
|
||||
order_number: '1001',
|
||||
payment_method: null,
|
||||
payment_method_title: null,
|
||||
}),
|
||||
).toBe('Order 1001')
|
||||
})
|
||||
|
||||
it('labels refunds without the method', () => {
|
||||
expect(
|
||||
orderBookingDescription({
|
||||
row_type: 'refund',
|
||||
order_number: '1001',
|
||||
payment_method: 'swish',
|
||||
payment_method_title: 'Swish',
|
||||
}),
|
||||
).toBe('Återbetalning order 1001')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,323 @@
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
import type { Logger } from '@/lib/logger'
|
||||
import { createDraftEntry, commitEntry } from '@/lib/bookkeeping/engine'
|
||||
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 {
|
||||
CreateJournalEntryLineInput,
|
||||
Currency,
|
||||
JournalEntry,
|
||||
WebshopOrder,
|
||||
} from '@/types'
|
||||
|
||||
/**
|
||||
* The server-side booking flow for one webshop order/refund row, extracted
|
||||
* from POST /api/webshop-orders/[id]/book so the bulk endpoint runs the exact
|
||||
* same code per order. Three composable steps, called in this order by both
|
||||
* routes:
|
||||
*
|
||||
* 1. assertOrderBookable(): state guards (already booked/invoiced, unpaid,
|
||||
* refund-of-invoiced-parent, legacy transactions-feed overlap).
|
||||
* 2. resolveOrderFx(): booking-time retry for a missing SEK conversion on
|
||||
* non-SEK rows.
|
||||
* 3. bookOrderThroughEngine(): chart repair for our own prefill accounts,
|
||||
* then the race-free draft -> atomic claim -> commit sequence through
|
||||
* lib/bookkeeping/engine (source_type 'webshop_order').
|
||||
*
|
||||
* Anything added to the single-order path (e.g. underlag anchoring) belongs
|
||||
* in these functions, never inline in one route, so single and bulk booking
|
||||
* can not drift apart.
|
||||
*/
|
||||
|
||||
/** Guard failure: a structured-error code plus optional envelope details. */
|
||||
export interface OrderBookableFailure {
|
||||
code:
|
||||
| 'WEBSHOP_ORDER_ALREADY_BOOKED'
|
||||
| 'WEBSHOP_ORDER_ALREADY_INVOICED'
|
||||
| 'WEBSHOP_ORDER_MANUALLY_BOOKED'
|
||||
| 'WEBSHOP_ORDER_REFUND_PARENT_INVOICED'
|
||||
| 'WEBSHOP_ORDER_NOT_PAID'
|
||||
| 'WEBSHOP_ORDER_LEGACY_TRANSACTION_BOOKED'
|
||||
| 'WEBSHOP_ORDER_LEGACY_TRANSACTION_OPEN'
|
||||
details?: Record<string, unknown>
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-check the order's state server-side (the client list can be stale).
|
||||
* Returns null when the row may be booked, otherwise the structured-error
|
||||
* code the route should answer with.
|
||||
*/
|
||||
export async function assertOrderBookable(
|
||||
supabase: SupabaseClient,
|
||||
companyId: string,
|
||||
order: WebshopOrder,
|
||||
): Promise<OrderBookableFailure | null> {
|
||||
if (order.journal_entry_id) {
|
||||
return {
|
||||
code: 'WEBSHOP_ORDER_ALREADY_BOOKED',
|
||||
details: { journal_entry_id: order.journal_entry_id },
|
||||
}
|
||||
}
|
||||
if (order.invoice_id) {
|
||||
return {
|
||||
code: 'WEBSHOP_ORDER_ALREADY_INVOICED',
|
||||
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 {
|
||||
code: 'WEBSHOP_ORDER_MANUALLY_BOOKED',
|
||||
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
|
||||
.from('webshop_orders')
|
||||
.select('invoice_id')
|
||||
.eq('id', order.parent_order_id)
|
||||
.eq('company_id', companyId)
|
||||
.maybeSingle()
|
||||
if (parent?.invoice_id) {
|
||||
return {
|
||||
code: 'WEBSHOP_ORDER_REFUND_PARENT_INVOICED',
|
||||
details: { invoice_id: parent.invoice_id },
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!order.is_paid && order.row_type === 'order') {
|
||||
return { code: 'WEBSHOP_ORDER_NOT_PAID' }
|
||||
}
|
||||
|
||||
// Double-booking lock against the legacy transactions feed: the same
|
||||
// money event may already sit in the inbox (imported before the Orders
|
||||
// switch-over). A booked feed row means this order IS booked via the
|
||||
// feed; an open one must be booked or IGNORED there first, and an
|
||||
// ignored row (is_ignored) unlocks order-side booking, exactly as the
|
||||
// error message instructs.
|
||||
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) {
|
||||
if (legacyTxn.journal_entry_id) {
|
||||
return {
|
||||
code: 'WEBSHOP_ORDER_LEGACY_TRANSACTION_BOOKED',
|
||||
details: {
|
||||
transaction_id: legacyTxn.id,
|
||||
journal_entry_id: legacyTxn.journal_entry_id,
|
||||
},
|
||||
}
|
||||
}
|
||||
if (!legacyTxn.is_ignored) {
|
||||
return {
|
||||
code: 'WEBSHOP_ORDER_LEGACY_TRANSACTION_OPEN',
|
||||
details: { transaction_id: legacyTxn.id },
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Non-SEK rows book in SEK; retry the rate once at booking time before
|
||||
* refusing (a sync-time Riksbanken hiccup should not strand the order).
|
||||
* Returns the order with total_sek/exchange_rate resolved, or null when the
|
||||
* rate still cannot be fetched (the route answers WEBSHOP_ORDER_FX_UNRESOLVED).
|
||||
*/
|
||||
export async function resolveOrderFx(
|
||||
supabase: SupabaseClient,
|
||||
companyId: string,
|
||||
order: WebshopOrder,
|
||||
log: Logger,
|
||||
): Promise<WebshopOrder | null> {
|
||||
if (order.currency.toUpperCase() === 'SEK' || order.total_sek !== null) {
|
||||
return order
|
||||
}
|
||||
try {
|
||||
const rate = await fetchExchangeRate(
|
||||
order.currency.toUpperCase() as Currency,
|
||||
new Date(`${order.paid_date ?? order.order_date}T00:00:00Z`),
|
||||
supabase,
|
||||
)
|
||||
if (rate?.rate) {
|
||||
const totalSek = roundOre(order.total * rate.rate)
|
||||
const { error: fxError } = await supabase
|
||||
.from('webshop_orders')
|
||||
.update({ total_sek: totalSek, exchange_rate: rate.rate })
|
||||
.eq('id', order.id)
|
||||
.eq('company_id', companyId)
|
||||
if (!fxError) {
|
||||
return { ...order, total_sek: totalSek, exchange_rate: rate.rate }
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
log.warn('booking-time FX retry failed', err as Error)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export interface BookOrderEngineInput {
|
||||
fiscal_period_id: string
|
||||
entry_date: string
|
||||
description: string
|
||||
lines: CreateJournalEntryLineInput[]
|
||||
voucher_series?: string
|
||||
notes?: string
|
||||
}
|
||||
|
||||
export type BookOrderEngineOutcome =
|
||||
/** Committed; journalEntry is commitEntry's post-commit fetch (may be null). */
|
||||
| {
|
||||
ok: true
|
||||
journalEntry: JournalEntry | null
|
||||
journalEntryId: string
|
||||
/** Orderunderlag PDF archived on the verifikat (#1881); never fatal. */
|
||||
underlagArchived: boolean
|
||||
}
|
||||
/** Another request booked/invoiced the row between our read and the claim. */
|
||||
| { ok: false; kind: 'claimed_elsewhere' }
|
||||
/** The conditional claim update itself errored (DB failure). */
|
||||
| { ok: false; kind: 'claim_error'; error: unknown }
|
||||
/** createDraftEntry/commitEntry threw; usually a typed bookkeeping error. */
|
||||
| { ok: false; kind: 'engine_error'; stage: 'draft' | 'commit'; error: unknown }
|
||||
|
||||
/**
|
||||
* Race-free booking: draft -> atomic claim -> commit. The read-then-book
|
||||
* pattern let two concurrent requests each post an immutable verifikat
|
||||
* for the same order (skeptic finding). Instead the order row is claimed
|
||||
* with a conditional update BEFORE anything gets a voucher number: the
|
||||
* loser's claim matches zero rows and its draft (no voucher yet, so no
|
||||
* series gap) is cancelled.
|
||||
*/
|
||||
export async function bookOrderThroughEngine(
|
||||
supabase: SupabaseClient,
|
||||
companyId: string,
|
||||
userId: string,
|
||||
/**
|
||||
* The order row, with FX already resolved (resolveOrderFx): the underlag
|
||||
* archived after commit renders the SEK conversion facts from it.
|
||||
*/
|
||||
order: WebshopOrder,
|
||||
input: BookOrderEngineInput,
|
||||
log: Logger,
|
||||
): Promise<BookOrderEngineOutcome> {
|
||||
const orderId = order.id
|
||||
// The prefill can legitimately reach 3004, 3740 and the 1686 clearing
|
||||
// account, none of which seed_chart_of_accounts() seeds. Without this the
|
||||
// first Bokför on a fresh company died on AccountsNotInChartError for an
|
||||
// account the user never chose. Only our own closed prefill set is added,
|
||||
// and failures here are swallowed so the engine's typed error still wins.
|
||||
await ensureWebshopPrefillAccounts(
|
||||
supabase,
|
||||
companyId,
|
||||
userId,
|
||||
input.lines.map((l) => l.account_number),
|
||||
log,
|
||||
)
|
||||
|
||||
let draft: JournalEntry
|
||||
try {
|
||||
draft = await createDraftEntry(supabase, companyId, userId, {
|
||||
fiscal_period_id: input.fiscal_period_id,
|
||||
entry_date: input.entry_date,
|
||||
description: input.description,
|
||||
source_type: 'webshop_order',
|
||||
source_id: orderId,
|
||||
voucher_series: input.voucher_series,
|
||||
notes: input.notes,
|
||||
lines: input.lines,
|
||||
})
|
||||
} catch (err) {
|
||||
return { ok: false, kind: 'engine_error', stage: 'draft', error: err }
|
||||
}
|
||||
|
||||
const cancelDraft = async () => {
|
||||
const { error: cancelError } = await supabase
|
||||
.from('journal_entries')
|
||||
.update({ status: 'cancelled' })
|
||||
.eq('id', draft.id)
|
||||
.eq('status', 'draft')
|
||||
if (cancelError) {
|
||||
log.error('draft cleanup failed after claim/commit failure', cancelError, {
|
||||
entryId: draft.id,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 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 })
|
||||
.eq('id', orderId)
|
||||
.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()
|
||||
if (claimError) {
|
||||
log.error('webshop order claim failed', claimError, { orderId })
|
||||
return { ok: false, kind: 'claim_error', error: claimError }
|
||||
}
|
||||
// Zero rows matched: someone else booked it between our read and claim.
|
||||
return { ok: false, kind: 'claimed_elsewhere' }
|
||||
}
|
||||
|
||||
let journalEntry: JournalEntry | null
|
||||
try {
|
||||
journalEntry = await commitEntry(supabase, companyId, userId, draft.id)
|
||||
} catch (err) {
|
||||
// Unlink so the row does not point at a cancelled draft, then cancel.
|
||||
// Order matters: the financial-freeze trigger keys on journal_entry_id
|
||||
// being set, but journal_entry_id itself is not in its protected list,
|
||||
// so the unlink passes.
|
||||
await supabase
|
||||
.from('webshop_orders')
|
||||
.update({ journal_entry_id: null })
|
||||
.eq('id', orderId)
|
||||
.eq('company_id', companyId)
|
||||
.eq('journal_entry_id', draft.id)
|
||||
await cancelDraft()
|
||||
return { ok: false, kind: 'engine_error', stage: 'commit', error: err }
|
||||
}
|
||||
|
||||
// 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. Living here, not in a
|
||||
// route, so the single-order and bulk paths can never diverge on it.
|
||||
const underlag = await archiveWebshopOrderUnderlag({
|
||||
supabase,
|
||||
companyId,
|
||||
userId,
|
||||
order,
|
||||
journalEntryId: journalEntry?.id ?? draft.id,
|
||||
log,
|
||||
})
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
journalEntry,
|
||||
// commitEntry's post-commit fetch can theoretically return no row;
|
||||
// the entry still exists under draft.id.
|
||||
journalEntryId: journalEntry?.id ?? draft.id,
|
||||
underlagArchived: underlag.ok,
|
||||
}
|
||||
}
|
||||
@@ -55,8 +55,10 @@ const VAT_ACCOUNT_BY_RATE: Record<number, string> = {
|
||||
6: '2631',
|
||||
}
|
||||
|
||||
/** Öresavrundning. */
|
||||
const ROUNDING_ACCOUNT = '3740'
|
||||
/** Öresavrundning. Exported so the bulk route can find and bound the
|
||||
* residual line it emits (a residual above öre scale means the order's
|
||||
* totals do not match its VAT breakdown and needs per-order review). */
|
||||
export const ROUNDING_ACCOUNT = '3740'
|
||||
|
||||
/**
|
||||
* Every account this prefill can emit, as a closed set.
|
||||
@@ -118,6 +120,21 @@ export function fallbackVatBreakdown(
|
||||
return [{ rate: 25, net, tax }]
|
||||
}
|
||||
|
||||
/**
|
||||
* VAT-bucket rates the account maps above can express (Swedish rates). A
|
||||
* bucket with any other rate (e.g. a German 19% OSS bucket stored raw by the
|
||||
* sync) would fall back to the 25% accounts: acceptable only as the single
|
||||
* dialog's editable prefill, never in an unreviewed sweep. Returns the
|
||||
* distinct offending rates, empty when every bucket is representable.
|
||||
*/
|
||||
export function unsupportedVatRates(breakdown: WebshopVatBreakdownLine[]): number[] {
|
||||
const bad = new Set<number>()
|
||||
for (const bucket of breakdown) {
|
||||
if (REVENUE_ACCOUNT_BY_RATE[bucket.rate] === undefined) bad.add(bucket.rate)
|
||||
}
|
||||
return Array.from(bad).sort((a, b) => a - b)
|
||||
}
|
||||
|
||||
export type BookingWarning = 'zero_rate_foreign' | 'foreign_vat'
|
||||
|
||||
/**
|
||||
@@ -153,6 +170,26 @@ export function resolveBookingWarnings(
|
||||
return warnings
|
||||
}
|
||||
|
||||
/**
|
||||
* The default verifikat/line description for an order or refund row. Kept as
|
||||
* a single helper so the dialog prefill, the single-order route and the bulk
|
||||
* route all label the booking identically.
|
||||
*/
|
||||
export function orderBookingDescription(
|
||||
order: Pick<
|
||||
WebshopOrder,
|
||||
'row_type' | 'order_number' | 'payment_method' | 'payment_method_title'
|
||||
>,
|
||||
): string {
|
||||
if (order.row_type === 'refund') {
|
||||
return `Återbetalning order ${order.order_number}`
|
||||
}
|
||||
const methodLabel = order.payment_method_title || order.payment_method || ''
|
||||
return methodLabel
|
||||
? `Order ${order.order_number} (${methodLabel})`
|
||||
: `Order ${order.order_number}`
|
||||
}
|
||||
|
||||
export interface OrderBookingLinesInput {
|
||||
order: Pick<
|
||||
WebshopOrder,
|
||||
@@ -201,13 +238,7 @@ export function buildOrderBookingLines({
|
||||
|
||||
const toSek = (amount: number) => round(Math.abs(amount) * rate)
|
||||
|
||||
const methodLabel = order.payment_method_title || order.payment_method || ''
|
||||
const baseDescription = methodLabel
|
||||
? `Order ${order.order_number} (${methodLabel})`
|
||||
: `Order ${order.order_number}`
|
||||
const description = isRefund
|
||||
? `Återbetalning order ${order.order_number}`
|
||||
: baseDescription
|
||||
const description = orderBookingDescription(order)
|
||||
|
||||
const currencyMeta = (amountAbs: number): Partial<CreateJournalEntryLineInput> =>
|
||||
isSek
|
||||
|
||||
Reference in New Issue
Block a user