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>
This commit is contained in:
Jakob Wennberg
2026-08-19 19:51:02 +02:00
committed by GitHub
parent 3a1b842e4a
commit bb5fafe87b
11 changed files with 431 additions and 11 deletions
+1
View File
@@ -1043,6 +1043,7 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
[2026-08-17] Supplier standardkonto empty-string fix lives in the API schemas, split by verb: '' normalizes to undefined on create (key dropped, column NULL) but to null on update, because update routes pass validated fields straight into .update() where undefined means "leave unchanged"; without the null mapping a cleared standardkonto/e-post would silently never clear. Client keeps sending '' as-is (the old email-strip hack removed), since stripping client-side would break exactly that clear path. The field itself became an AccountCombobox filtered to cost classes 4-7 (matches the agent-path expenseAccountField rule); other 4-digit numbers stay typeable, and the API still enforces format only. Standardkonto stays optional: it only prefills supplier-invoice lines, and the ledger-context suggestion covers the empty case, so requiring it (what the bug accidentally did) is wrong for the target user.
[2026-08-17] Replay-masking skeptic round (PR #1639): explicit data-ph tags now resolve BEFORE the th chrome fallback in replayMaskText (a single closest over tags-plus-th let a th nested in a masked container win on DOM proximity, CodeRabbit); seven missed text-leak sites got call-site masks (delete-invoice number, credit-page number, IB voucher ref, TIC orgnr since TIC serves it unnormalized so the separator scrub cannot be relied on, articles search term, dimension segment labels, activate-account buttons); the attribute channel (placeholders prefilled with effective values, title tooltips) is handled with rrweb's blockClass: user-data placeholders carry ph-no-capture, which removes the element from the recording while app UX keeps the founder-approved prefill-override pattern intact. Chose ph-no-capture over stripping the placeholders because the prefilled effective value IS the UX.
[2026-08-17] Skattekontoutdrag file import (Sebastian's request) writes into skattekonto_transactions, not into transactions as a pseudo-bank with 1630 unlocked in BankFileConfirmStep: rows inherit the skattekonto_rules 1630 booking engine, matching, drift and both UIs for free, while the literal ask would bypass the rules and double against the SKV inbox for connected companies. Dedup pairs file hash-keys with API id-keys by CONTENT in both directions (import-time skip/promote against existing rows, sync-time takeover that rewrites an imported row's key in place so journal links survive connecting the API later). Import is free for everyone per the requireSkvCapability doctrine (manual paths never blocked); only sync/saldo stay capability-gated. The parse route hard-rejects files that fail detectSkattekontoFile and statements whose opening+sum!=closing, and warns on orgnr mismatch against company_settings: wrong-company imports are a known support-incident class.
[2026-08-18] Webshop order booking defaults to BAS 1686 (Fordringar för kontokort och kuponger), not 1680: 1680 is the generic "Andra kortfristiga fordringar" parent, while 1686 is the account BAS defines for a claim on a payment provider (bas.se moved this receivable off 1580 onto 1686 for exactly that reason), and the Stripe extension already settles against 1686, so a store running both surfaces now shares one clearing account. Separately, booking a webshop order no longer dies on AccountsNotInChartError: seed_chart_of_accounts() seeds a minimal chart that omits 3004, 3740 and any clearing account, all of which an ordinary order reaches (0%-rate line, öre residual, unmapped payment method), so the book route now ensures the closed WEBSHOP_PREFILL_ACCOUNTS set exists first. Deliberately narrow and non-fatal: only accounts our own prefill emits are ever auto-created (a user-typed account still errors), a deactivated row is reactivated rather than duplicated, and any failure is swallowed so the engine's typed error still wins. Inserts go one literal-payload row at a time because the no-phantom-columns guard cannot resolve a .map()-built array, and verifying the 13 columns beats saving at most eight first-use round trips.
[2026-08-17] articles.housework_type keeps two vocabularies (Skatteverket arbetstypskod, or bare ROT/RUT) instead of migrating legacy ROT/RUT rows: a kind-only row cannot be upgraded to a code without knowing the work, so the article form preserves the legacy choice as an explicit option and the invoice prefill treats it as kind-only; everything else normalizes to null and is rejected at the API.
[2026-08-17] ROT/RUT claim completeness (arbetstyp + arbetstimmar) is enforced at invoice creation (validateInvoice + CreateInvoiceItemSchema + editor), not only at begäran-file time: the file blocker fired when the invoice was already numbered/booked/paid with no repair path short of a credit note; schablontjänster (TRANSPORT/TVATT) stay hours-exempt. Yearly ceiling accumulation is per CUSTOMER in the editor (personnummer is ciphertext client-side) and warning-only; server warnings are still dropped on success, so the editor computes its own via the shared deductionCapWarnings helper.
[2026-08-17] invoices.remaining_amount gets a BEFORE INSERT trigger deriving total - paid_amount - deduction_total when a fresh unpaid real invoice arrives with NULL/0, instead of only fixing the writers: four writers had drifted (proforma conversion x2, MCP create_invoice, sandbox seed) and 337 open invoices on prod sat at 0, so the column's DEFAULT 0 must never be able to mean 'settled' again; UPDATE is left to the settlement code, which legitimately writes 0 on full payment. Writers fixed too (defense in depth).
+14
View File
@@ -8,6 +8,7 @@ import { BookWebshopOrderSchema } from '@/lib/api/schemas'
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 { roundOre } from '@/lib/money'
import type { Currency, WebshopOrder } from '@/types'
@@ -140,6 +141,19 @@ export const POST = withRouteContext<{ params: Promise<{ id: string }> }>(
// 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.
// 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,
user.id,
lines.map((l) => l.account_number),
log,
)
let draft
try {
draft = await createDraftEntry(supabase, companyId, user.id, {
@@ -43,6 +43,13 @@ vi.mock('@/lib/currency/riksbanken', () => ({
fetchExchangeRate: (...args: unknown[]) => mockFetchExchangeRate(...args),
}))
// Behaviour lives in lib/webshop-orders/__tests__/ensure-accounts.test.ts; here
// we only assert that the route hands it the accounts it is about to book.
const mockEnsureAccounts = vi.fn().mockResolvedValue(undefined)
vi.mock('@/lib/webshop-orders/ensure-accounts', () => ({
ensureWebshopPrefillAccounts: (...args: unknown[]) => mockEnsureAccounts(...args),
}))
import { POST } from '../[id]/book/route'
const PERIOD_UUID = '550e8400-e29b-41d4-a716-446655440000'
@@ -200,6 +207,26 @@ describe('POST /api/webshop-orders/[id]/book', () => {
expect(mockCommitEntry).toHaveBeenCalled()
})
it('ensures the prefill accounts exist in the chart before drafting', async () => {
// Regression: seed_chart_of_accounts() does not seed 1686/3740/3004, so a
// fresh company used to hit AccountsNotInChartError on its first Bokför.
enqueue({ data: makeOrderRow() })
enqueue({ data: [{ id: 'order-1' }] }) // claim
const { status } = await parseJsonResponse(await postBook())
expect(status).toBe(200)
expect(mockEnsureAccounts).toHaveBeenCalledWith(
expect.anything(),
'company-1',
'user-1',
validBody.lines.map((l) => l.account_number),
expect.anything(),
)
// Order matters: the chart must be repaired before the engine reads it.
expect(mockEnsureAccounts.mock.invocationCallOrder[0]).toBeLessThan(
mockCreateDraftEntry.mock.invocationCallOrder[0],
)
})
it('returns 422 when a non-SEK order has no rate and the retry fails', async () => {
enqueue({ data: makeOrderRow({ currency: 'EUR', total_sek: null, exchange_rate: null }) })
mockFetchExchangeRate.mockResolvedValue(null)
@@ -12,6 +12,7 @@ import {
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 {
@@ -117,7 +118,9 @@ export function PaymentMethodMappingForm({
...prev,
[method]: {
mode: 'book',
account: current?.mode === 'book' ? current.account : '1680',
// 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,
},
}
})
+11 -5
View File
@@ -5,11 +5,17 @@ import { woocommerceApiRoutes } from './api-routes'
* WooCommerce extension
*
* Connects a company's WooCommerce store via the wc-auth key handshake (or
* manual key entry) and imports the store's paid orders and refunds into the
* transactions inbox as a bank-style feed on the 1680 cash account. Feed-only
* (same doctrine as the Stripe feed, decision 2026-08-06): nothing is
* auto-booked, and payment-gateway fees/payouts are out of scope: core wc/v3
* does not expose them.
* manual key entry) and imports the store's orders and refunds as rows in the
* Orders workspace (webshop_orders). Feed-only (same doctrine as the Stripe
* feed, decision 2026-08-06): nothing is auto-booked. The user books a row
* from the Orders page, prefilled against the per-store payment-method
* mapping and otherwise BAS 1686 (Fordringar för kontokort och kuponger).
*
* Gateway fees and payouts are still out of scope, but not because they are
* unreachable: core wc/v3 does not expose them, yet a WooPayments store also
* serves /wc/v3/payments/deposits and /payments/reports/transactions (fees,
* net, deposit_id) under the same consumer key, given a key whose user has
* manage_woocommerce. Booking those is a separate settlement-ledger feature.
*
* Required environment variables:
* - WOOCOMMERCE_CREDENTIALS_ENCRYPTION_KEY (at-rest key for consumer key/secret)
@@ -5,6 +5,7 @@ import {
resolveBookingWarnings,
resolvePaymentAccount,
DEFAULT_PAYMENT_ACCOUNT,
WEBSHOP_PREFILL_ACCOUNTS,
} from '../booking-lines'
import type { CreateJournalEntryLineInput, WebshopStoreSettings } from '@/types'
@@ -65,13 +66,46 @@ describe('resolvePaymentAccount', () => {
expect(result.invoiceMode).toBe(true)
})
it('falls back to 1680 when unmapped or without settings', () => {
it('falls back to the 1686 clearing account when unmapped or without settings', () => {
expect(resolvePaymentAccount(makeOrder({ payment_method: 'stripe' }), settings).account).toBe(
DEFAULT_PAYMENT_ACCOUNT,
)
expect(resolvePaymentAccount(makeOrder(), null).account).toBe(DEFAULT_PAYMENT_ACCOUNT)
expect(resolvePaymentAccount(makeOrder({ payment_method: null }), settings).mapped).toBe(false)
})
it('defaults to BAS 1686, the card/PSP receivable, not the 1680 parent', () => {
expect(DEFAULT_PAYMENT_ACCOUNT).toBe('1686')
})
})
describe('WEBSHOP_PREFILL_ACCOUNTS', () => {
it('covers every account the builder can emit', () => {
// Guards the ensure-accounts contract: an account the prefill emits but
// this set omits would resurface as AccountsNotInChartError in booking.
const emitted = new Set<string>()
for (const rate of [25, 12, 6, 0]) {
for (const lineSet of [
buildOrderBookingLines({
order: makeOrder({
total: 100.01,
total_tax: rate === 0 ? 0 : 20,
vat_breakdown: [{ rate, net: 80, tax: rate === 0 ? 0 : 20 }],
}),
settings: null,
}),
]) {
for (const line of lineSet) emitted.add(line.account_number)
}
}
for (const account of emitted) {
expect(WEBSHOP_PREFILL_ACCOUNTS).toContain(account)
}
// The residual account is only reachable through rounding drift; assert
// it explicitly so the set never silently loses it.
expect(WEBSHOP_PREFILL_ACCOUNTS).toContain('3740')
expect(WEBSHOP_PREFILL_ACCOUNTS).toContain('3004')
})
})
describe('fallbackVatBreakdown', () => {
@@ -0,0 +1,177 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { ensureWebshopPrefillAccounts } from '../ensure-accounts'
import type { SupabaseClient } from '@supabase/supabase-js'
import type { Logger } from '@/lib/logger'
/**
* Minimal chart_of_accounts double: records what the helper selected,
* updated and upserted so each assertion can check the exact call shape.
*/
function makeSupabase(options: {
existing?: Array<{ id: string; account_number: string; is_active: boolean }>
selectError?: { message: string }
upsertError?: { message: string }
updateError?: { message: string }
} = {}) {
const calls = {
selectedIn: [] as string[][],
upserted: [] as Array<Record<string, unknown>>,
upsertOptions: [] as unknown[],
reactivatedIds: [] as string[][],
}
const from = vi.fn((table: string) => {
expect(table).toBe('chart_of_accounts')
return {
select: () => ({
eq: () => ({
in: (_col: string, values: string[]) => {
calls.selectedIn.push(values)
return Promise.resolve({
data: options.selectError ? null : (options.existing ?? []),
error: options.selectError ?? null,
})
},
}),
}),
update: (_patch: Record<string, unknown>) => ({
in: (_col: string, ids: string[]) => {
calls.reactivatedIds.push(ids)
return {
eq: () => Promise.resolve({ error: options.updateError ?? null }),
}
},
}),
upsert: (row: Record<string, unknown>, opts: unknown) => {
calls.upserted.push(row)
calls.upsertOptions.push(opts)
return Promise.resolve({ error: options.upsertError ?? null })
},
}
})
return { supabase: { from } as unknown as SupabaseClient, calls, from }
}
const log = {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
child: vi.fn(() => log),
} as unknown as Logger
beforeEach(() => {
vi.clearAllMocks()
})
describe('ensureWebshopPrefillAccounts', () => {
it('creates the prefill accounts that a seeded chart is missing', async () => {
const { supabase, calls } = makeSupabase({ existing: [] })
await ensureWebshopPrefillAccounts(supabase, 'company-1', 'user-1', ['1686', '3740'], log)
// One literal-payload insert per account, so the phantom-column guard can
// actually verify the columns.
expect(calls.upserted).toHaveLength(2)
expect(calls.upserted.map((r) => r.account_number).sort()).toEqual(['1686', '3740'])
// BAS metadata comes from the reference, never invented.
const clearing = calls.upserted.find((r) => r.account_number === '1686')!
expect(clearing.account_name).toBe('Fordringar för kontokort och kuponger')
expect(clearing.account_class).toBe(1)
expect(clearing.account_type).toBe('asset')
expect(clearing.normal_balance).toBe('debit')
expect(clearing.company_id).toBe('company-1')
expect(clearing.user_id).toBe('user-1')
expect(clearing.is_active).toBe(true)
// Concurrent bookings must not 23505 each other.
expect(calls.upsertOptions[0]).toMatchObject({
onConflict: 'company_id,account_number',
ignoreDuplicates: true,
})
})
it('never creates an account outside the closed prefill set', async () => {
const { supabase, calls } = makeSupabase({ existing: [] })
// 1930 and 4010 are legitimate accounts a user may have picked, and 9999
// is a typo. None of them are ours to create.
await ensureWebshopPrefillAccounts(
supabase,
'company-1',
'user-1',
['1930', '4010', '9999'],
log,
)
expect(calls.selectedIn).toHaveLength(0)
expect(calls.upserted).toHaveLength(0)
})
it('does nothing when every prefill account is already active', async () => {
const { supabase, calls } = makeSupabase({
existing: [
{ id: 'a', account_number: '1686', is_active: true },
{ id: 'b', account_number: '3001', is_active: true },
],
})
await ensureWebshopPrefillAccounts(supabase, 'company-1', 'user-1', ['1686', '3001'], log)
expect(calls.upserted).toHaveLength(0)
expect(calls.reactivatedIds).toHaveLength(0)
})
it('reactivates a deactivated account instead of inserting a duplicate', async () => {
// The engine treats is_active = false exactly like missing, so a user who
// once hid 3740 would otherwise be stuck with an unbookable order.
const { supabase, calls } = makeSupabase({
existing: [{ id: 'row-3740', account_number: '3740', is_active: false }],
})
await ensureWebshopPrefillAccounts(supabase, 'company-1', 'user-1', ['3740'], log)
expect(calls.reactivatedIds).toEqual([['row-3740']])
expect(calls.upserted).toHaveLength(0)
})
it('deduplicates repeated account numbers from the submitted lines', async () => {
const { supabase, calls } = makeSupabase({ existing: [] })
await ensureWebshopPrefillAccounts(
supabase,
'company-1',
'user-1',
['3001', '3001', '2611'],
log,
)
expect(calls.selectedIn[0].sort()).toEqual(['2611', '3001'])
})
it('is a no-op for an empty line set', async () => {
const { supabase, from } = makeSupabase()
await ensureWebshopPrefillAccounts(supabase, 'company-1', 'user-1', [], log)
expect(from).not.toHaveBeenCalled()
})
it('swallows a lookup failure so booking still reaches the engine', async () => {
const { supabase, calls } = makeSupabase({ selectError: { message: 'rls denied' } })
await expect(
ensureWebshopPrefillAccounts(supabase, 'company-1', 'user-1', ['1686'], log),
).resolves.toBeUndefined()
expect(calls.upserted).toHaveLength(0)
expect(log.warn).toHaveBeenCalled()
})
it('swallows an insert failure so booking still reaches the engine', async () => {
const { supabase } = makeSupabase({ existing: [], upsertError: { message: 'boom' } })
await expect(
ensureWebshopPrefillAccounts(supabase, 'company-1', 'user-1', ['1686'], log),
).resolves.toBeUndefined()
expect(log.warn).toHaveBeenCalled()
})
})
+33 -2
View File
@@ -25,8 +25,20 @@ import type {
* this while total_sek is null (booking is blocked until FX resolves).
*/
/** Fallback counter-account when no mapping exists: the old feed's ledger. */
export const DEFAULT_PAYMENT_ACCOUNT = '1680'
/**
* Fallback counter-account when no mapping exists.
*
* BAS 2026 1686 "Fordringar för kontokort och kuponger": money the payment
* provider is holding but has not paid out yet. This is the same ledger the
* Stripe extension settles against, so a store that runs both surfaces keeps
* one clearing account. 1680 "Andra kortfristiga fordringar" was used before
* and is the generic parent bucket, not the card/PSP receivable BAS defines
* for this; bas.se moved this receivable off 1580 onto 1686 precisely because
* it is a claim on the payment provider, not on the customer.
*/
export const DEFAULT_PAYMENT_ACCOUNT = '1686'
/** BAS 2026 name for DEFAULT_PAYMENT_ACCOUNT; used when adding it to a chart. */
export const DEFAULT_PAYMENT_ACCOUNT_NAME = 'Fordringar för kontokort och kuponger'
/** Revenue account per Swedish VAT rate (BAS 2026). */
const REVENUE_ACCOUNT_BY_RATE: Record<number, string> = {
@@ -46,6 +58,25 @@ const VAT_ACCOUNT_BY_RATE: Record<number, string> = {
/** Öresavrundning. */
const ROUNDING_ACCOUNT = '3740'
/**
* Every account this prefill can emit, as a closed set.
*
* seed_chart_of_accounts() seeds a minimal chart: 3001/3002/3003 and
* 2611/2621/2631 are in it, but 3004, 3740 and the clearing account are not.
* The engine throws AccountsNotInChartError for an account that is missing or
* inactive, so an untouched company hit that error the moment an order had a
* rounding residual, a 0%-rate line, or no payment-method mapping. Callers
* pass this set to ensureWebshopPrefillAccounts() so the accounts our own
* prefill needs are added to the chart on first use, and only ever these:
* an account the user typed themselves is never auto-created.
*/
export const WEBSHOP_PREFILL_ACCOUNTS: readonly string[] = [
DEFAULT_PAYMENT_ACCOUNT,
...Object.values(REVENUE_ACCOUNT_BY_RATE),
...Object.values(VAT_ACCOUNT_BY_RATE),
ROUNDING_ACCOUNT,
]
/**
* Resolve the prefilled payment counter-account for an order from the
* per-store mapping. Returns the account plus whether the store marked this
+127
View File
@@ -0,0 +1,127 @@
import { getBASReference } from '@/lib/bookkeeping/bas-reference'
import { WEBSHOP_PREFILL_ACCOUNTS } from './booking-lines'
import type { SupabaseClient } from '@supabase/supabase-js'
import type { Logger } from '@/lib/logger'
/**
* Add the BAS accounts our own webshop prefill needs to the company's chart,
* so booking an order does not fail on AccountsNotInChartError.
*
* Why this exists: seed_chart_of_accounts() seeds a deliberately small chart.
* 3001/3002/3003 and 2611/2621/2631 are in it; 3004 (momsfri försäljning),
* 3740 (öresavrundning) and 1686 (the clearing account) are not. Every one of
* those is reachable from a perfectly ordinary order: a 0%-rate line, an öre
* residual, or simply no payment-method mapping yet. Before this, the user's
* first click on Bokför returned "kontot saknas i kontoplanen" with no way
* forward except hand-adding accounts they had no reason to know about.
*
* Deliberately narrow: only account numbers in WEBSHOP_PREFILL_ACCOUNTS are
* ever created, and only when a submitted line actually uses one. An account
* the user typed or picked themselves is never auto-created, so a typo still
* surfaces as a real error instead of quietly growing the chart.
*
* Reactivates a soft-deleted (is_active = false) row rather than inserting a
* duplicate: the engine treats inactive exactly like missing, and the unique
* (company_id, account_number) index would reject the insert anyway.
*
* Non-fatal by design. If this cannot write, booking proceeds and the engine
* raises its normal typed error; we never block a verifikat on a chart tidy-up.
*/
export async function ensureWebshopPrefillAccounts(
supabase: SupabaseClient,
companyId: string,
userId: string,
accountNumbers: string[],
log?: Logger,
): Promise<void> {
const wanted = [...new Set(accountNumbers)].filter((n) =>
WEBSHOP_PREFILL_ACCOUNTS.includes(n),
)
if (wanted.length === 0) return
try {
const { data: existing, error } = await supabase
.from('chart_of_accounts')
.select('id, account_number, is_active')
.eq('company_id', companyId)
.in('account_number', wanted)
if (error) {
log?.warn('webshop chart lookup failed; booking continues', { error: error.message })
return
}
const present = new Map<string, { id: string; is_active: boolean }>()
for (const row of existing ?? []) {
present.set(row.account_number as string, {
id: row.id as string,
is_active: row.is_active as boolean,
})
}
const toReactivate = wanted
.map((n) => present.get(n))
.filter((row): row is { id: string; is_active: boolean } => !!row && !row.is_active)
.map((row) => row.id)
if (toReactivate.length > 0) {
const { error: reactivateError } = await supabase
.from('chart_of_accounts')
.update({ is_active: true })
.in('id', toReactivate)
.eq('company_id', companyId)
if (reactivateError) {
log?.warn('webshop chart reactivate failed; booking continues', {
error: reactivateError.message,
})
}
}
const missing = wanted.filter((n) => !present.has(n))
if (missing.length === 0) return
// Inserted one row at a time with a literal payload on purpose: the
// no-phantom-columns guard can only verify columns it can resolve
// statically, and a .map()-built array reads as an opaque expression. At
// most eight accounts, only on first use, so the extra round trips are
// cheaper than an unverifiable insert.
for (const accountNumber of missing) {
// Every WEBSHOP_PREFILL_ACCOUNTS member is a real BAS 2026 account, so a
// miss here means the reference data drifted: skip rather than invent
// metadata for an account we cannot describe.
const bas = getBASReference(accountNumber)
if (!bas) {
log?.warn('no BAS reference for webshop prefill account', { accountNumber })
continue
}
// Concurrent bookings of two orders race here; ignoreDuplicates makes
// the loser a no-op instead of a 23505 that would fail a fine entry.
const { error: insertError } = await supabase.from('chart_of_accounts').upsert(
{
user_id: userId,
company_id: companyId,
account_number: accountNumber,
account_name: bas.account_name,
account_class: bas.account_class,
account_group: bas.account_group,
account_type: bas.account_type,
normal_balance: bas.normal_balance,
sru_code: bas.sru_code ?? null,
k2_excluded: bas.k2_excluded ?? false,
plan_type: 'full_bas',
is_active: true,
is_system_account: false,
},
{ onConflict: 'company_id,account_number', ignoreDuplicates: true },
)
if (insertError) {
log?.warn('webshop chart insert failed; booking continues', {
accountNumber,
error: insertError.message,
})
}
}
} catch (err) {
log?.warn('webshop chart ensure threw; booking continues', {
error: err instanceof Error ? err.message : String(err),
})
}
}
+1 -1
View File
@@ -6009,7 +6009,7 @@
"mapping_saved": "Account mapping saved",
"mapping_save_failed": "Could not save the account mapping",
"mapping_mode_aria": "Handling for {method}",
"mapping_mode_unmapped": "Default (1680)",
"mapping_mode_unmapped": "Default (1686)",
"mapping_mode_book": "Book to account",
"mapping_mode_invoice": "Invoiced",
"mapping_account_aria": "Account for {method}",
+1 -1
View File
@@ -6009,7 +6009,7 @@
"mapping_saved": "Kontomappning sparad",
"mapping_save_failed": "Kunde inte spara kontomappningen",
"mapping_mode_aria": "Hantering för {method}",
"mapping_mode_unmapped": "Standard (1680)",
"mapping_mode_unmapped": "Standard (1686)",
"mapping_mode_book": "Bokför mot konto",
"mapping_mode_invoice": "Faktureras",
"mapping_account_aria": "Konto för {method}",