fix(vat,documents): EU reverse-charge packs feed ruta 20/21; daily reanchor cron for floating supplier-invoice underlag (#2095)

Two user reports (Anders, 2026-08-25 + 2026-08-29):

1. The seeded standardmallar "Inkop EU-varor/-tjanster, omvand moms 25%"
   booked the cost on 4010/6540, which no momsdeklaration ruta reads, so the
   fiktiv moms filled ruta 30/48 while ruta 20/21 (inkopsvarde) stayed 0;
   Skatteverket rejects that (FK004, ML 13 kap). The packs now book directly
   on the basis accounts 4515/4535 (ACCOUNT_RUTA -> ruta 20/21); the
   transaction-picker path already skips its own basis emission for basis
   debit accounts, so no double counting. Regression test pins every
   reverse-charge pack to a 44xx/45xx business debit. Prod rows update via
   the existing pack sync cron (upsert on pack_slug).

2. A kontantmetod payment verifikat stayed "Underlag saknas" although the
   invoice PDF was attached and eligible on every static condition: the
   inline anchorSupplierInvoiceDocument silently did nothing (prod case
   2026-08-28, verified in audit_log: no document_attachments update between
   the payment booking and the user's manual re-upload). The helper now
   verifies the guarded update actually matched a row instead of claiming
   success on zero rows, logs its silent bail branches, and a new daily cron
   (/api/documents/reanchor/cron) re-runs the anchor for any floating
   retained document with a posted verifikat, replacing the pattern of
   one-off repair migrations (20260727180000, 20260824150000). The sweep
   names the FK in its embed and is idempotent; locked/closed periods are
   skipped as before.


Claude-Session: https://claude.ai/code/session_01Jj6Rg1ViyFRej55gbxLVgj

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Mattsson
2026-09-01 10:16:07 +02:00
committed by GitHub
parent 08dbfdc5c4
commit e113e9c099
12 changed files with 376 additions and 10 deletions
+2
View File
@@ -1420,3 +1420,5 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
[2026-08-31] SKV broker hardening (two skeptic refutations on PR #1757, same classes as the bank fixes): the token route's code exchange now requires a verified connector state (signature, key, svc 'skv') plus an existing pending row before spending Arcim's client secret, and a concurrently consumed state withholds the tokens with 409 (SKV has no revoke endpoint; the pair expires unused); refresh requires the presented token's hash to match an ACTIVE ledger row under the presenting key (it was an open refresh oracle for any leaked token); the metering redaction gains a 10+-digit rule because personnummer/orgnr/redovisare12 in SKV data-proxy paths slipped the EB-tuned thresholds and rested in cleartext; authorize-url adopts the countHeldConnections reservation re-count; all SKV base URLs are https-only (loopback excepted). SELF-HOSTING.md's connector section collapsed from three contradictory copies (accreted across the stack merges) to one.
[2026-08-31] lib/connect/instance/upstreams.ts reuses lib/entitlements/own-credentials.ts instead of duplicating the own-credentials checks: the seam was forward-ported into the entitlement partition during the stack's bottom-up merge (skeptic refutation on PR #1747), and two copies of "what counts as own credentials" would eventually disagree, splitting the gate from the routing. upstreams.ts re-exports the two functions for its instance-side callers.
[2026-08-31] UpgradeNote is self-host-aware (operator-skeptic refutation on PR #1758): on a self-host every UpgradeNote surface is by definition a connector capability (local capabilities are always on), so the component centrally swaps the hosted subscription copy + /settings/billing link for the connector-key note, mirroring CAPABILITY_BLOCKED_MESSAGE_SELF_HOSTED_SV; the SKV connect button tooltip branches the same way. Fixed centrally rather than per panel so the EB/SKV/AGI/reports surfaces can never drift. SOVEREIGN.md updated to the merged reality (infra live, keys not sold, wiring pending). Standing rule reaffirmed: no connector key is issued before the instance-side wiring (PR6b) lands, or a paying customer sees granted capabilities with clients that still call the upstreams directly and fail on missing env credentials.
[2026-09-01] EU reverse-charge packs book directly on 4515/4535 instead of adding a 45xx D / 4598 K basbelopp pair (Anders' literal suggestion): same ruta 20/21 outcome, standard BAS practice for a template that owns the cost account anyway, and a 3-business-line pack would return null from convertLibraryToBookingTemplate and silently vanish from the transaction picker. The 4598 motkonto pattern remains the right tool only where the user's own cost account must be preserved (engine-generated bookings, supplier invoices).
[2026-09-01] Floating supplier-invoice underlag gets a standing daily reanchor cron (/api/documents/reanchor/cron) instead of another one-off repair migration: prod case 2026-08-28 (kontantmetod payment verifikat, doc eligible on every static condition, inline anchor silently did nothing, no log line recorded why) is the second time a hand-written sweep (20260727180000, 20260824150000) was needed; the inline anchor is best-effort by design, so the retry belongs in infrastructure. anchorSupplierInvoiceDocument also stops claiming success on a zero-row guarded update and logs its silent bail branches.
@@ -0,0 +1,60 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
const verifyCronSecret = vi.fn<() => unknown>(() => null)
vi.mock('@/lib/auth/cron', () => ({
verifyCronSecret: () => verifyCronSecret(),
}))
vi.mock('@/lib/supabase/service-client', () => ({
createServiceRoleClient: vi.fn(() => ({ mocked: true })),
}))
const sweepFloatingSupplierInvoiceDocuments = vi.fn()
vi.mock('@/lib/core/documents/supplier-invoice-underlag', () => ({
sweepFloatingSupplierInvoiceDocuments: (...args: unknown[]) =>
sweepFloatingSupplierInvoiceDocuments(...args),
}))
import { GET } from '../route'
function cronRequest(): Request {
return new Request('http://localhost:3000/api/documents/reanchor/cron')
}
beforeEach(() => {
vi.clearAllMocks()
verifyCronSecret.mockReturnValue(null)
process.env.NEXT_PUBLIC_SUPABASE_URL = 'http://localhost:54321'
process.env.SUPABASE_SERVICE_ROLE_KEY = 'service-role-key'
})
describe('GET /api/documents/reanchor/cron', () => {
it('rejects a request without a valid cron secret', async () => {
verifyCronSecret.mockReturnValue({ error: 'unauthorized' })
const response = await GET(cronRequest())
expect(response.status).toBe(401)
expect(sweepFloatingSupplierInvoiceDocuments).not.toHaveBeenCalled()
})
it('runs the sweep under the service-role client and reports the summary', async () => {
sweepFloatingSupplierInvoiceDocuments.mockResolvedValue({ candidates: 3, anchored: 2 })
const response = await GET(cronRequest())
const json = await response.json()
expect(response.status).toBe(200)
expect(json).toEqual({ data: { candidates: 3, anchored: 2 } })
expect(sweepFloatingSupplierInvoiceDocuments).toHaveBeenCalledWith({ mocked: true })
})
it('fails closed when Supabase configuration is missing', async () => {
delete process.env.SUPABASE_SERVICE_ROLE_KEY
const response = await GET(cronRequest())
expect(response.status).toBeGreaterThanOrEqual(500)
expect(sweepFloatingSupplierInvoiceDocuments).not.toHaveBeenCalled()
})
})
+40
View File
@@ -0,0 +1,40 @@
import { NextResponse } from 'next/server'
import { withCronContext } from '@/lib/api/with-cron-context'
import { createServiceRoleClient } from '@/lib/supabase/service-client'
import { sweepFloatingSupplierInvoiceDocuments } from '@/lib/core/documents/supplier-invoice-underlag'
import { errorResponseFromCode } from '@/lib/errors/get-structured-error'
/**
* GET /api/documents/reanchor/cron: daily 03:30 UTC (schedule in vercel.json).
*
* Re-anchors supplier-invoice retained documents that are floating although
* the invoice has a posted verifikat. The inline anchoring in the payment
* routes is best-effort (the booking is already committed when it runs), so a
* transient failure there leaves the verifikat flagged "Underlag saknas" with
* the PDF plainly attached to the invoice, and until now only a hand-written
* repair migration ever retried. Prod case 2026-08-28: kontantmetod payment
* verifikat posted, document eligible, anchor silently did nothing.
*
* Idempotent: an anchored document is never moved, locked/closed periods are
* skipped, and a clean run touches nothing.
*/
export const maxDuration = 120
export const GET = withCronContext('cron.documents_reanchor', async (_request, ctx) => {
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL
const supabaseServiceKey = process.env.SUPABASE_SERVICE_ROLE_KEY
if (!supabaseUrl || !supabaseServiceKey) {
return errorResponseFromCode('INTERNAL_ERROR', ctx.log, {
requestId: ctx.requestId,
details: { reason: 'Missing Supabase configuration' },
})
}
const supabase = createServiceRoleClient(supabaseUrl, supabaseServiceKey)
const result = await sweepFloatingSupplierInvoiceDocuments(supabase)
ctx.log.info('reanchor sweep finished', { ...result })
return NextResponse.json({ data: result })
})
+1
View File
@@ -32,6 +32,7 @@
45 3 * * * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/extensions/woocommerce/orders/cron
15 3 * * * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/extensions/shopify/orders/cron
0 3 * * * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/documents/verify/cron
30 3 * * * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/documents/reanchor/cron
0 4 * * * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/sandbox/cleanup/cron
0 2 * * * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/events/cleanup/cron
0 * * * * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/extensions/cloud-backup/auto-sync/cron
+1
View File
@@ -32,6 +32,7 @@
45 3 * * * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/extensions/woocommerce/orders/cron
15 3 * * * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/extensions/shopify/orders/cron
0 3 * * * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/documents/verify/cron
30 3 * * * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/documents/reanchor/cron
0 4 * * * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/sandbox/cleanup/cron
0 2 * * * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/events/cleanup/cron
0 * * * * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/extensions/cloud-backup/auto-sync/cron
@@ -4,6 +4,7 @@ import { createQueuedMockSupabase } from '@/tests/helpers'
import {
anchorSupplierInvoiceDocument,
reanchorOrphanedSupplierInvoiceDocuments,
sweepFloatingSupplierInvoiceDocuments,
} from '../supplier-invoice-underlag'
/**
@@ -45,7 +46,7 @@ describe('anchorSupplierInvoiceDocument', () => {
{ id: 'je-pay', status: 'posted', fiscal_period: openPeriod },
],
},
{ data: null },
{ data: [{ id: 'doc-1' }] },
])
const anchored = await anchorSupplierInvoiceDocument(
@@ -77,7 +78,7 @@ describe('anchorSupplierInvoiceDocument', () => {
{ id: 'je-pay', status: 'posted', fiscal_period: openPeriod },
],
},
{ data: null },
{ data: [{ id: 'doc-1' }] },
])
expect(
@@ -108,7 +109,7 @@ describe('anchorSupplierInvoiceDocument', () => {
{ id: 'je-p2', status: 'posted', fiscal_period: openPeriod },
],
},
{ data: null },
{ data: [{ id: 'doc-1' }] },
])
expect(
@@ -227,6 +228,32 @@ describe('anchorSupplierInvoiceDocument', () => {
expect(supabase.from).toHaveBeenCalledTimes(1)
})
it('reports a zero-row update as null: a write that never happened is not an anchor', async () => {
// Prod case 2026-08-28 (faktura 118776): every static condition passed yet
// the document stayed floating. The guarded update can match nothing (a
// concurrent writer, or RLS filtering the row); claiming success then hides
// the miss from both callers and the reconcile cron.
const { supabase, enqueueMany } = createQueuedMockSupabase()
enqueueMany([
{
data: {
id: 'si-1',
document_id: 'doc-1',
registration_journal_entry_id: null,
payment_journal_entry_id: 'je-pay',
},
},
{ data: { id: 'doc-1', journal_entry_id: null, is_current_version: true } },
{ data: [] },
{ data: [{ id: 'je-pay', status: 'posted', fiscal_period: openPeriod }] },
{ data: [] }, // UPDATE matched no rows
])
await expect(
anchorSupplierInvoiceDocument(supabase as unknown as SupabaseClient, 'company-1', 'si-1'),
).resolves.toBeNull()
})
it('reports failure as null instead of throwing at the caller', async () => {
const { supabase, enqueueMany } = createQueuedMockSupabase()
enqueueMany([
@@ -250,6 +277,63 @@ describe('anchorSupplierInvoiceDocument', () => {
})
})
describe('sweepFloatingSupplierInvoiceDocuments', () => {
beforeEach(() => {
vi.clearAllMocks()
})
const openPeriod = { is_closed: false, locked_at: null }
it('anchors every floating retained document it finds, across companies', async () => {
const { supabase, enqueueMany } = createQueuedMockSupabase()
enqueueMany([
// Candidate scan: two invoices with floating current-version documents.
{
data: [
{ id: 'si-1', company_id: 'company-1', document: { id: 'doc-1', journal_entry_id: null, is_current_version: true } },
{ id: 'si-2', company_id: 'company-2', document: { id: 'doc-2', journal_entry_id: null, is_current_version: true } },
],
},
// anchorSupplierInvoiceDocument for si-1 (5 queries)
{ data: { id: 'si-1', document_id: 'doc-1', registration_journal_entry_id: null, payment_journal_entry_id: 'je-1' } },
{ data: { id: 'doc-1', journal_entry_id: null, is_current_version: true } },
{ data: [] },
{ data: [{ id: 'je-1', status: 'posted', fiscal_period: openPeriod }] },
{ data: [{ id: 'doc-1' }] },
// anchorSupplierInvoiceDocument for si-2: its only verifikat is locked.
{ data: { id: 'si-2', document_id: 'doc-2', registration_journal_entry_id: null, payment_journal_entry_id: 'je-2' } },
{ data: { id: 'doc-2', journal_entry_id: null, is_current_version: true } },
{ data: [] },
{ data: [{ id: 'je-2', status: 'posted', fiscal_period: { is_closed: true, locked_at: null } }] },
])
const result = await sweepFloatingSupplierInvoiceDocuments(
supabase as unknown as SupabaseClient,
)
expect(result).toEqual({ candidates: 2, anchored: 1 })
})
it('returns zeros and touches nothing when no document is floating', async () => {
const { supabase, enqueueMany } = createQueuedMockSupabase()
enqueueMany([{ data: [] }])
const result = await sweepFloatingSupplierInvoiceDocuments(
supabase as unknown as SupabaseClient,
)
expect(result).toEqual({ candidates: 0, anchored: 0 })
expect(supabase.from).toHaveBeenCalledTimes(1)
})
it('survives a failed candidate scan without throwing', async () => {
const { supabase, enqueueMany } = createQueuedMockSupabase()
enqueueMany([{ error: { message: 'relation walk failed' } }])
await expect(
sweepFloatingSupplierInvoiceDocuments(supabase as unknown as SupabaseClient),
).resolves.toEqual({ candidates: 0, anchored: 0 })
})
})
describe('reanchorOrphanedSupplierInvoiceDocuments', () => {
beforeEach(() => {
vi.clearAllMocks()
@@ -272,7 +356,7 @@ describe('reanchorOrphanedSupplierInvoiceDocuments', () => {
{
data: [{ id: 'je-pay', status: 'posted', fiscal_period: { is_closed: false, locked_at: null } }],
},
{ data: null },
{ data: [{ id: 'doc-1' }] },
])
expect(
@@ -78,9 +78,21 @@ export async function anchorSupplierInvoiceDocument(
payment_journal_entry_id: string | null
},
)
if (!entryId) return null
if (!entryId) {
// The invoice HAS a floating retained document but no verifikat that can
// take it. In the payment routes (which call this right after posting)
// that is an anomaly worth a log line: prod case 2026-08-28 (Anders,
// faktura 118776) stayed "Underlag saknas" through exactly this kind of
// silent bail, and nothing recorded which branch gave up.
log.warn('supplier invoice document is floating but no verifikat can anchor it', {
companyId,
supplierInvoiceId,
documentId: doc.id,
})
return null
}
const { error } = await supabase
const { data: updatedRows, error } = await supabase
.from('document_attachments')
.update({ journal_entry_id: entryId })
.eq('id', doc.id)
@@ -89,6 +101,7 @@ export async function anchorSupplierInvoiceDocument(
// read above. Never steal a document that already serves a verifikat.
.is('journal_entry_id', null)
.eq('is_current_version', true)
.select('id')
if (error) {
log.warn('failed to anchor supplier invoice document to verifikat', {
@@ -100,6 +113,19 @@ export async function anchorSupplierInvoiceDocument(
})
return null
}
// The guarded update can match zero rows (a concurrent writer got there
// first, or RLS filtered the row). That is NOT a successful anchor: report
// null so callers and the reconcile cron treat the document as still
// floating instead of trusting a write that never happened.
if (!updatedRows || updatedRows.length === 0) {
log.warn('anchor update matched no rows; document left floating', {
companyId,
supplierInvoiceId,
documentId: doc.id,
journalEntryId: entryId,
})
return null
}
return entryId
} catch (err) {
log.warn('anchorSupplierInvoiceDocument threw', {
@@ -193,6 +219,69 @@ async function pickAnchorEntry(
*
* Returns the number of documents re-anchored.
*/
export interface FloatingDocumentSweepResult {
/** Invoices whose floating retained document was examined. */
candidates: number
/** Documents actually anchored to a verifikat this run. */
anchored: number
}
/**
* Prod-wide self-heal for retained supplier-invoice documents that stayed
* floating although the invoice has a posted verifikat: the inline anchoring
* in the payment routes is best-effort by design (never throws, the booking is
* already committed), so a transient failure there strands the document until
* something retries. Historically that "something" was a hand-written repair
* migration (20260727180000, 20260824150000); this makes the retry a standing
* daily cron instead. Prod case 2026-08-28 (Anders, faktura 118776): payment
* verifikat posted, invoice document eligible on every static condition, yet
* the inline anchor did nothing and no log recorded why, so the verifikat
* showed "Underlag saknas" until the user re-uploaded the PDF by hand.
*
* Anchoring an already-shown-but-floating document is strictly an improvement
* (it puts the file behind the WORM deletion guard and satisfies BFL 5 kap
* 7 §), and anchorSupplierInvoiceDocument never moves an anchored document,
* so re-running this sweep is idempotent. Locked/closed periods are skipped by
* pickAnchorEntry, matching the repair migrations.
*
* Meant to run under the service-role client from a cron: RLS would otherwise
* scope the candidate scan to one user's companies.
*/
export async function sweepFloatingSupplierInvoiceDocuments(
supabase: SupabaseClient,
opts: { limit?: number } = {},
): Promise<FloatingDocumentSweepResult> {
const limit = Math.min(Math.max(opts.limit ?? 200, 1), 1000)
// FK named explicitly: with an ambiguous relationship PostgREST rejects the
// embed and the sweep would silently see zero candidates (the #2022 lesson).
const { data, error } = await supabase
.from('supplier_invoices')
.select(
'id, company_id, document:document_attachments!supplier_invoices_document_id_fkey!inner(id, journal_entry_id, is_current_version)',
)
.not('document_id', 'is', null)
.or('payment_journal_entry_id.not.is.null,registration_journal_entry_id.not.is.null')
.is('document.journal_entry_id', null)
.eq('document.is_current_version', true)
.limit(limit)
if (error) {
log.warn('floating-document sweep query failed', { reason: error.message })
return { candidates: 0, anchored: 0 }
}
const rows = (data ?? []) as unknown as Array<{ id: string; company_id: string }>
let anchored = 0
for (const row of rows) {
if (await anchorSupplierInvoiceDocument(supabase, row.company_id, row.id)) anchored++
}
if (rows.length > 0) {
log.info('floating-document sweep complete', { candidates: rows.length, anchored })
}
return { candidates: rows.length, anchored }
}
export async function reanchorOrphanedSupplierInvoiceDocuments(
supabase: SupabaseClient,
companyId: string,
@@ -63,6 +63,21 @@ const INTENTIONAL_DIVERGENCES: Record<string, Divergence> = {
'taxes paid by the firm are an eget uttag, so the template now debits 2013 Övriga egna ' +
'uttag; migration 20260810120000 retargets the seeded rows the same way.',
},
'inkop-eu-varor-omvand-moms-25': {
seededName: 'Inköp EU-varor, omvänd moms 25%',
reason:
'Seeded version booked the cost on 4010 Varuinköp, which no momsdeklaration ruta reads: ' +
'the fiktiv moms filled ruta 30/48 while ruta 20 (inköpsvärdet) stayed 0, which Skatteverket ' +
'rejects (felkod FK004, ML 13 kap kräver både underlag och moms). Now books on 4515 Inköp ' +
'varor från annat EU-land 25%, the ACCOUNT_RUTA basis account for ruta 20. User report ' +
'(Anders, 2026-08-25).',
},
'inkop-eu-tjanster-omvand-moms-25': {
seededName: 'Inköp EU-tjänster, omvänd moms 25%',
reason:
'Same ruta gap as inkop-eu-varor-omvand-moms-25: cost sat on 6540 IT-tjänster, so ruta 21 ' +
'stayed 0. Now books on 4535 Inköp tjänster från annat EU-land 25% (ruta 21).',
},
'representation-avdragsgill-25-moms': {
seededName: 'Representation (avdragsgill, 25% moms)',
reason:
@@ -0,0 +1,70 @@
import { describe, it, expect } from 'vitest'
import path from 'node:path'
import { loadPacks } from '@/lib/packs/load'
/**
* A reverse-charge pack must book its cost on a basbelopp account.
*
* Momsdeklaration rutor 20-24 are driven purely by BAS account number
* (ACCOUNT_RUTA in lib/reports/vat-declaration.ts): the fiktiv-moms pair
* (2614/2624/2634 + 2645/2647) fills ruta 30-32 and 48, but the purchase
* value itself only reaches ruta 20-24 when the expense sits on the 44xx/45xx
* basis series. A reverse-charge pack that books its cost elsewhere (the
* pre-fix 4010/6540) produces a declaration with VAT but no inköpsvärde,
* which Skatteverket rejects (felkod FK004; ML 13 kap requires both sides).
* User report: Anders, 2026-08-25.
*/
const ROOT = path.resolve(__dirname, '../../..')
// Fictitious output-VAT accounts that mark a purchase-side reverse-charge pack.
const FIKTIV_OUTPUT_VAT = new Set(['2614', '2624', '2634'])
// Same range isBasisAccount() in lib/bookkeeping/booking-templates.ts guards:
// 44xx/45xx, the ruta 20-24 inputs.
const BASIS_ACCOUNT_RE = /^4[45]\d{2}$/
describe('reverse-charge packs feed momsdeklaration ruta 20-24', () => {
const { packs, errors } = loadPacks(ROOT)
it('catalogue loads', () => {
expect(errors).toEqual([])
})
const reverseChargePacks = packs.filter((p) =>
p.pack.lines.some((l) => FIKTIV_OUTPUT_VAT.has(l.account)),
)
it('the catalogue actually contains reverse-charge purchase packs', () => {
// Guards the filter above: if the fiktiv accounts are ever renumbered,
// this suite must be updated rather than silently asserting nothing.
expect(reverseChargePacks.length).toBeGreaterThanOrEqual(2)
})
it.each([
['inkop-eu-varor-omvand-moms-25', '4515'],
['inkop-eu-tjanster-omvand-moms-25', '4535'],
])('%s books its cost on basis account %s', (slug, account) => {
const pack = packs.find((p) => p.pack.meta.slug === slug)
expect(pack).toBeDefined()
const business = pack!.pack.lines.filter((l) => l.type === 'business')
expect(business).toHaveLength(1)
expect(business[0].account).toBe(account)
expect(business[0].side).toBe('debit')
})
it('every reverse-charge purchase pack books its business debit on a 44xx/45xx basis account', () => {
for (const p of reverseChargePacks) {
const businessDebits = p.pack.lines.filter(
(l) => l.type === 'business' && l.side === 'debit',
)
for (const line of businessDebits) {
expect(
BASIS_ACCOUNT_RE.test(line.account),
`${p.pack.meta.slug}: business debit on ${line.account} never reaches ruta 20-24; ` +
`book reverse-charge cost on the 44xx/45xx basis series`,
).toBe(true)
}
}
})
})
+2 -2
View File
@@ -9,8 +9,8 @@ meta:
description: >-
Köp av tjänster från annat EU-land. Omvänd skattskyldighet — du redovisar både utgående och ingående moms.
lines:
- account: '6540'
label: 'IT-tjänster'
- account: '4535'
label: 'Inköp tjänster från annat EU-land 25%'
side: debit
type: business
ratio: 1.0
+2 -2
View File
@@ -9,8 +9,8 @@ meta:
description: >-
Köp av varor från annat EU-land. Omvänd skattskyldighet — du redovisar både utgående och ingående moms.
lines:
- account: '4010'
label: 'Varuinköp'
- account: '4515'
label: 'Inköp varor från annat EU-land 25%'
side: debit
type: business
ratio: 1.0
+4
View File
@@ -38,6 +38,10 @@
"path": "/api/documents/verify/cron",
"schedule": "0 3 * * *"
},
{
"path": "/api/documents/reanchor/cron",
"schedule": "30 3 * * *"
},
{
"path": "/api/sandbox/cleanup/cron",
"schedule": "0 4 * * *"