Files
accounted/components/orders/PaymentMethodMappingForm.tsx
Jakob Wennberg bb5fafe87b fix(orders): book webshop orders against 1686 and stop the missing-account dead end (#1697)
Booking an order from the Orders page could fail outright on a fresh
company. seed_chart_of_accounts() seeds a deliberately small chart:
3001/3002/3003 and 2611/2621/2631 are in it, but 3004, 3740 and the
clearing account are not. All three are reachable from an entirely
ordinary order (a 0%-rate line, an ore residual, or simply no
payment-method mapping yet), and the engine treats a missing or
inactive account as AccountsNotInChartError, so the user's first click
on Bokfor returned an error naming accounts they had no reason to know
about, with no way forward but to hand-add them.

The book route now ensures the closed set of accounts our own prefill
can emit exists before drafting. Deliberately narrow: only accounts in
WEBSHOP_PREFILL_ACCOUNTS are ever created, and only when a submitted
line uses one, so an account the user typed still surfaces as a real
error instead of quietly growing the chart. A deactivated row is
reactivated rather than duplicated, and every failure is swallowed so
the engine's typed error still wins over a chart tidy-up.

The unmapped default also moves from 1680 to 1686. 1680 is the generic
"Andra kortfristiga fordringar" parent; 1686 "Fordringar for kontokort
och kuponger" is what BAS defines for a claim on a payment provider,
which is what money sitting at Klarna or Stripe actually is. The Stripe
extension already settles against 1686, so a store running both
surfaces now shares one clearing account instead of splitting the same
receivable across two.

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-19 19:51:02 +02:00

220 lines
7.7 KiB
TypeScript

'use client'
import { useEffect, useMemo, useState } from 'react'
import { useTranslations } from 'next-intl'
import { Button } from '@/components/ui/button'
import { useToast } from '@/components/ui/use-toast'
import {
SettingsGroup,
SettingsRow,
SettingsRowEnd,
SettingsRowNote,
SettingsInput,
} from '@/components/settings/SettingsRows'
import { ACCOUNT_NUMBER_RE } from '@/lib/invariants/account-number'
import { DEFAULT_PAYMENT_ACCOUNT } from '@/lib/webshop-orders/booking-lines'
import type { WebshopPaymentMethodPolicy, WebshopPlatform, WebshopStoreSettings } from '@/types'
interface PaymentMethodMappingFormProps {
platform: WebshopPlatform
storeScope: string
/**
* Payment methods observed on this store's orders. Omitted: derived from a
* sample of the store's synced orders.
*/
methods?: Array<{ method: string; title: string | null }>
}
type DraftPolicy = { mode: 'book'; account: string } | { mode: 'invoice' }
/**
* Per-store payment-method -> account mapping (prefill only; never books).
* Fönster language: flat hairline rows, save appears only when dirty.
* "Faktureras" marks a method as invoice-flow: the booking dialog then nudges
* toward Skapa faktura for those orders.
*/
export function PaymentMethodMappingForm({
platform,
storeScope,
methods,
}: PaymentMethodMappingFormProps) {
const t = useTranslations('webshop_orders')
const { toast } = useToast()
const [saved, setSaved] = useState<Record<string, WebshopPaymentMethodPolicy>>({})
const [draft, setDraft] = useState<Record<string, DraftPolicy>>({})
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [derivedMethods, setDerivedMethods] = useState<
Array<{ method: string; title: string | null }>
>([])
useEffect(() => {
if (methods !== undefined) return
let cancelled = false
fetch(
`/api/webshop-orders?platform=${platform}&store_scope=${encodeURIComponent(storeScope)}&limit=200`,
)
.then((r) => (r.ok ? r.json() : { data: [] }))
.then(
(json: {
data: Array<{ payment_method: string | null; payment_method_title: string | null }>
}) => {
if (cancelled) return
const seen = new Map<string, string | null>()
for (const row of json.data ?? []) {
if (row.payment_method && !seen.has(row.payment_method)) {
seen.set(row.payment_method, row.payment_method_title)
}
}
setDerivedMethods(
Array.from(seen.entries()).map(([method, title]) => ({ method, title })),
)
},
)
.catch(() => undefined)
return () => {
cancelled = true
}
}, [methods, platform, storeScope])
useEffect(() => {
let cancelled = false
fetch(
`/api/webshop-orders/settings?platform=${platform}&store_scope=${encodeURIComponent(storeScope)}`,
)
.then((r) => (r.ok ? r.json() : { data: [] }))
.then((json: { data: WebshopStoreSettings[] }) => {
if (cancelled) return
const map = json.data[0]?.payment_method_account_map ?? {}
setSaved(map)
setDraft(map as Record<string, DraftPolicy>)
})
.catch(() => undefined)
.finally(() => {
if (!cancelled) setLoading(false)
})
return () => {
cancelled = true
}
}, [platform, storeScope])
// Methods seen on orders plus any mapped-but-no-longer-seen leftovers.
const rows = useMemo(() => {
const source = methods ?? derivedMethods
const known = new Map(source.map((m) => [m.method, m.title]))
for (const method of Object.keys(saved)) {
if (!known.has(method)) known.set(method, null)
}
return Array.from(known.entries()).map(([method, title]) => ({ method, title }))
}, [methods, derivedMethods, saved])
const dirty = useMemo(() => JSON.stringify(draft) !== JSON.stringify(saved), [draft, saved])
const setMode = (method: string, mode: 'book' | 'invoice') => {
setDraft((prev) => {
const current = prev[method]
if (mode === 'invoice') return { ...prev, [method]: { mode: 'invoice' } }
return {
...prev,
[method]: {
mode: 'book',
// Same constant the booking prefill falls back to: a hardcoded
// number here would silently drift from it (it just did).
account: current?.mode === 'book' ? current.account : DEFAULT_PAYMENT_ACCOUNT,
},
}
})
}
const setAccount = (method: string, account: string) => {
setDraft((prev) => ({ ...prev, [method]: { mode: 'book', account } }))
}
const invalid = Object.values(draft).some(
(p) => p.mode === 'book' && !ACCOUNT_NUMBER_RE.test(p.account),
)
async function save() {
setSaving(true)
try {
const res = await fetch('/api/webshop-orders/settings', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
platform,
store_scope: storeScope,
payment_method_account_map: draft,
}),
})
if (!res.ok) throw new Error(`save failed: ${res.status}`)
const json = (await res.json()) as { data: WebshopStoreSettings }
setSaved(json.data.payment_method_account_map)
setDraft(json.data.payment_method_account_map as Record<string, DraftPolicy>)
toast({ title: t('mapping_saved') })
} catch {
toast({ title: t('mapping_save_failed'), variant: 'destructive' })
} finally {
setSaving(false)
}
}
if (loading || rows.length === 0) return null
return (
<SettingsGroup label={t('mapping_title')}>
<p className="px-1 pb-1 text-xs leading-relaxed text-muted-foreground">
{t('mapping_intro')}
</p>
{rows.map(({ method, title }) => {
const policy = draft[method]
const mode = policy?.mode ?? 'book'
const account = policy?.mode === 'book' ? policy.account : ''
return (
<SettingsRow key={method} label={<span data-ph-mask="">{title || method}</span>}>
<SettingsRowEnd>
<select
value={policy ? mode : 'unmapped'}
onChange={(e) => {
if (e.target.value === 'unmapped') {
setDraft((prev) => {
const next = { ...prev }
delete next[method]
return next
})
} else {
setMode(method, e.target.value as 'book' | 'invoice')
}
}}
className="rounded-full border border-border bg-transparent px-3 py-[5px] text-[13px]"
aria-label={t('mapping_mode_aria', { method: title || method })}
>
<option value="unmapped">{t('mapping_mode_unmapped')}</option>
<option value="book">{t('mapping_mode_book')}</option>
<option value="invoice">{t('mapping_mode_invoice')}</option>
</select>
{policy?.mode === 'book' && (
<SettingsInput
value={account}
onChange={(e) => setAccount(method, e.target.value.trim())}
inputMode="numeric"
maxLength={4}
className="w-20 text-right tabular-nums"
aria-label={t('mapping_account_aria', { method: title || method })}
/>
)}
</SettingsRowEnd>
</SettingsRow>
)
})}
<div className="flex items-center justify-between px-1 pt-3">
<SettingsRowNote>{t('mapping_note')}</SettingsRowNote>
{dirty && (
<Button size="sm" onClick={save} disabled={saving || invalid}>
{saving ? t('mapping_saving') : t('mapping_save')}
</Button>
)}
</div>
</SettingsGroup>
)
}