feat(rot-rut): overview page, beslutsfil import, avslag reclaim, MCP list + settle (#2397)
* feat(rot-rut): overview page, beslutsfil import, avslag reclaim, MCP list + settle Follow-up to #2239/#2360 for firms whose every invoice carries ROT/RUT. - /invoices/rot-rut: tiles (at Skatteverket on 1513, awaiting beslut, refused to book, ready to request) and one row per begaran with mark uploaded, cancel, download and "Bokfor nekat belopp"; the Fakturor button links here, ?rot-rut=1 still opens the file dialog. - Beslutsfil import from the UI through the existing import route. - Reclaim of the share Skatteverket refused: one voucher debit 1510 / credit 1513 per invoice (source_type rot_rut_reclaim), CAS-attached to the begaran and guarded by a partial unique index; the invoice reopens for the refused share via invoices.deduction_reclaimed_total, with the customer-share formula and its SQL twin gaining the same term. The payment dialog and bank match then settle the reopened remaining as a plain 1510 clearing; a booked kontantmetod invoice is proposed accrual- shaped so revenue is never recognised twice. Unknown per-invoice split of a partial beslut is refused, never allocated. - MCP: gnubok_list_rot_rut_payout_requests (search-only read) and gnubok_settle_rot_rut_payout (staged write, op settle_rot_rut_payout) sharing one pre-flight + settle with the dashboard match route. - Migrations 20260907140000 (reclaim state, source_type, INSERT guard), 20260907140100/140101 (pending_operations op type). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01D9wvsGnvu5tHGqYnJnjnaB * chore(rot-rut): renumber migrations after merging main Main already carries 20260907143000 and 20260907150000, so the three rot-rut migrations move to 20260907160000/160100/160101 to keep the applied order monotonic (see memory: migration-version-collisions). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01D9wvsGnvu5tHGqYnJnjnaB * fix(rot-rut): close the reclaim gaps found by skeptics, CI and review Skeptic refutations (#2397): - payment-sync recomputes remaining with deduction_reclaimed_total, so a storno of a payment on a reopened invoice no longer strands the refused share (R1). - Reclaim refused while an invoice sits in a later live begäran (ROT_RUT_RECLAIM_INVOICE_REREQUESTED); the overview and the MCP list hide the action for the same case (C2). - A reclaimed invoice is blocked from a new begäran (DEDUCTION_RECLAIMED) until the reclaim voucher is reversed (R2/C3). - Storno of the reclaim voucher syncs the invoices and the begäran back (rot-rut-reclaim-reversal.ts, hooked into reverseEntry) (R3). - A paid invoice with NULL paid_amount counts its customer share as paid (C4). Crediting an invoice with a reclaimed share is refused on the dashboard, v1 and MCP paths (R4). CI and review: - Build: custom-coded MCP errors via Object.assign, not codedError. - pg-real: column default for default_voucher_series_per_source_type re-stated with rot_rut_reclaim (20260907160200); the default test now re-applies the latest default migration. - Checks: accounted-api skill regenerated (journal-entries source types). - CodeRabbit/Superagent: per-item refused shares must reconcile with the request-level beslut; per-invoice reopen through the idempotent RPC apply_rot_rut_reclaim_invoice (20260907160300) with a resume path; update-stage settle failures keep the voucher id (failed_partial); Stockholm calendar date for the booking; existing-voucher tab uses the same proposal method; MCP stage checks bank_line junction rows. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01D9wvsGnvu5tHGqYnJnjnaB * fix(rot-rut): carry the voucher id through the match outcome type; date the reclaim on the beslut - The shared match outcome now declares journalEntryId on update-stage errors, matching the settle service (Core Build TS2339 on 2d6cece1a). - The reclaim voucher is dated on the Swedish calendar day of Skatteverkets beslut (decided_at), today only when no decision date is recorded, and the confirm dialog states the date (Swedish accounting review: BFL 5 kap 6-7 §, datum for affarshandelsen). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01D9wvsGnvu5tHGqYnJnjnaB * fix(rot-rut): reclaim RPCs validate the share and derive the invoice state; idempotent revert; v1 credit guard reads the column - apply_rot_rut_reclaim_invoice (20260907160400 replaces the 160300 signature) takes only the refused share, validates it against the locked item, request and invoice, and derives remaining_amount and status from the INSERT-guard formula (review: caller-supplied accounting values, CWE-862). revert_rot_rut_reclaim_invoice mirrors it for a reversed reclaim voucher; the request link is cleared only after every leg. - v1 credit route projection includes deduction_reclaimed_total so the reclaim guard actually fires there. - Overview keeps "Bokfor nekat belopp" available while legs are pending (resume after a partial failure). - Match and settle routes attach journal_entry_id on update-stage errors. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01D9wvsGnvu5tHGqYnJnjnaB --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
5d735097a1
commit
fdcb7d937e
@@ -429,7 +429,19 @@ describe('tools/list payload size guard', () => {
|
||||
// search-only: versions exist only once a report is rendered for
|
||||
// signing or filing, which is the same switched-off iXBRL path as its
|
||||
// sibling filing_status tool. Ceiling unchanged.
|
||||
expect(approxTokens).toBeLessThan(60_000)
|
||||
// * 60K to 60.5K, 2026-09-07, ROT/RUT payout flow (#2239 follow-up):
|
||||
// gnubok_settle_rot_rut_payout is a WRITE (books Skatteverkets
|
||||
// utbetalning against its begäran from the bank row) so it must stay
|
||||
// in the default catalog; its READ sibling
|
||||
// gnubok_list_rot_rut_payout_requests shipped search-only from day
|
||||
// one. Measured 60 428 on the accounted projection after trimming
|
||||
// the write's description to one sentence per fact and dropping its
|
||||
// property descriptions (two ids are the whole contract). Same
|
||||
// deliberate skip of the read-demotion rule as set_run_salary and
|
||||
// ignore_transaction: picking a read to demote needs prod usage data,
|
||||
// not a guess inside a ROT/RUT PR; do that demotion as its own change
|
||||
// and ratchet this ceiling back down.
|
||||
expect(approxTokens).toBeLessThan(60_500)
|
||||
})
|
||||
|
||||
/**
|
||||
|
||||
@@ -57,6 +57,7 @@ import { resolveSettlementAccount } from '@/lib/bookkeeping/settlement-account'
|
||||
import { buildTransactionEntryLines, createTransactionJournalEntry } from '@/lib/bookkeeping/transaction-entries'
|
||||
import { upsertCounterpartyTemplate, findCounterpartyTemplatesBatch, formatCounterpartyName } from '@/lib/bookkeeping/counterparty-templates'
|
||||
import { formatVoucherLabel, hasLiveJournalEntryLink } from '@/lib/transactions/link-journal-entry'
|
||||
import { hasBankLineJunctionRow } from '@/lib/transactions/is-booked'
|
||||
import { setTransactionIgnored } from '@/lib/transactions/ignore'
|
||||
import { canApproveSupplierInvoice } from '@/lib/supplier-invoices/lifecycle'
|
||||
import { eventBus } from '@/lib/events/bus'
|
||||
@@ -233,6 +234,13 @@ import { findMatchingInvoices } from '@/lib/invoices/invoice-matching'
|
||||
import { sanitizeDeliveryRecipientStatuses } from '@/lib/invoices/delivery-recipient-statuses'
|
||||
import { listRotRutCandidates, createRotRutPayoutRequest } from '@/lib/invoices/rot-rut-service'
|
||||
import { importRotRutBeslutFile } from '@/lib/invoices/rot-rut-beslut-import'
|
||||
import { computeRefusedShares } from '@/lib/invoices/rot-rut-reclaim'
|
||||
import {
|
||||
expectedRotRutPayoutAmount,
|
||||
isMatchableRotRutPayoutRequest,
|
||||
OPEN_ROT_RUT_PAYOUT_STATUSES,
|
||||
type RotRutPayoutRequestCandidate,
|
||||
} from '@/lib/invoices/rot-rut-payout-matching'
|
||||
import {
|
||||
CreateInvoiceFromSalesOrderSchema,
|
||||
CreateSalesOrderSchema,
|
||||
@@ -17222,6 +17230,297 @@ export const tools: McpTool[] = [
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
name: 'gnubok_list_rot_rut_payout_requests',
|
||||
keywords: ['rotavdrag', 'rutavdrag', 'begäran om utbetalning', 'skatteverket', '1513', 'husavdrag'],
|
||||
title: 'List Rot/Rut Payout Requests',
|
||||
description:
|
||||
'List ROT/RUT begäran om utbetalning: status, requested and decided amounts, expected payout, settlement and reclaim vouchers, refused share per invoice. Use to see what Skatteverket owes on 1513 and which begäran a bank row settles (gnubok_settle_rot_rut_payout).',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
deduction_type: { type: 'string', enum: ['rot', 'rut'] },
|
||||
status: {
|
||||
type: 'string',
|
||||
enum: ['generated', 'submitted', 'paid', 'partially_paid', 'rejected', 'cancelled'],
|
||||
},
|
||||
open_only: {
|
||||
type: 'boolean',
|
||||
description: 'Only begäran without a settlement voucher (what a Skatteverket bank row can still settle)',
|
||||
},
|
||||
limit: { type: 'number', description: 'Max results (default 50, max 100)' },
|
||||
offset: { type: 'integer', minimum: 0, description: 'Number of results to skip (default 0)' },
|
||||
},
|
||||
},
|
||||
outputSchema: paginatedSchema('payout_requests', {
|
||||
type: 'object',
|
||||
properties: {
|
||||
request_id: { type: 'string' },
|
||||
name: { type: 'string', description: 'NamnPaBegaran as shown in Skatteverkets e-tjänst' },
|
||||
deduction_type: { type: 'string', enum: ['rot', 'rut'] },
|
||||
status: { type: 'string' },
|
||||
requested_total: { type: 'number' },
|
||||
decided_total: { type: ['number', 'null'], description: 'Godkänt belopp per Skatteverkets beslut; null until recorded' },
|
||||
decided_at: { type: ['string', 'null'] },
|
||||
expected_payout: { type: 'number', description: 'decided_total when recorded, else requested_total: what the bank row must equal' },
|
||||
settlement_journal_entry_id: { type: ['string', 'null'], description: 'Voucher that booked Skatteverkets utbetalning (debit 19xx / credit 1513)' },
|
||||
refused_total: { type: 'number', description: 'Share Skatteverket refused (requested minus decided), 0 until a beslut is recorded' },
|
||||
reclaim_journal_entry_id: { type: ['string', 'null'], description: 'Voucher that moved the refused share back onto the customers (debit 1510 / credit 1513); null while still to book' },
|
||||
refused_needs_reclaim: { type: 'boolean', description: 'True when a refused share exists, no reclaim voucher is booked yet, and no invoice of the begäran is re-requested in a later live begäran (book it in the dashboard, Fakturor > ROT/RUT)' },
|
||||
skv_referensnummer: { type: ['string', 'null'] },
|
||||
created_at: { type: 'string' },
|
||||
submitted_at: { type: ['string', 'null'] },
|
||||
items: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
item_id: { type: 'string' },
|
||||
invoice_id: { type: 'string' },
|
||||
invoice_number: { type: ['string', 'null'] },
|
||||
requested_amount: { type: 'number' },
|
||||
decided_amount: { type: ['number', 'null'] },
|
||||
reclaimed_amount: { type: ['number', 'null'] },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
annotations: ANNOTATIONS_READ_ONLY,
|
||||
// Search-only: a READ reachable through gnubok_call_tool, and ROT/RUT
|
||||
// begäran are a niche flow (a few companies, a few rows a month), so it
|
||||
// does not earn a slot in the default tools/list payload budget.
|
||||
catalogVisibility: 'search',
|
||||
async execute(args, companyId, _userId, supabase) {
|
||||
const limit = Math.min(Math.max(1, Number(args.limit) || 50), 100)
|
||||
const offset = Math.max(0, Math.floor(Number(args.offset) || 0))
|
||||
const deductionType = args.deduction_type as string | undefined
|
||||
const status = args.status as string | undefined
|
||||
const openOnly = args.open_only === true
|
||||
|
||||
let query = supabase
|
||||
.from('rot_rut_payout_requests')
|
||||
.select(
|
||||
'id, name, deduction_type, status, requested_total, decided_total, decided_at, settlement_journal_entry_id, reclaim_journal_entry_id, skv_referensnummer, created_at, submitted_at, items:rot_rut_payout_request_items(id, invoice_id, requested_amount, decided_amount, reclaimed_amount, invoice:invoices(invoice_number))',
|
||||
{ count: 'exact' },
|
||||
)
|
||||
.eq('company_id', companyId)
|
||||
if (deductionType === 'rot' || deductionType === 'rut') query = query.eq('deduction_type', deductionType)
|
||||
if (status) query = query.eq('status', status)
|
||||
if (openOnly) {
|
||||
query = query.in('status', [...OPEN_ROT_RUT_PAYOUT_STATUSES]).is('settlement_journal_entry_id', null)
|
||||
}
|
||||
|
||||
const { data, error, count } = await query
|
||||
.order('created_at', { ascending: false })
|
||||
.order('id', { ascending: false })
|
||||
.range(offset, offset + limit)
|
||||
if (error) throw dbError(error)
|
||||
|
||||
type Row = {
|
||||
id: string
|
||||
name: string
|
||||
deduction_type: 'rot' | 'rut'
|
||||
status: string
|
||||
requested_total: number | string
|
||||
decided_total: number | string | null
|
||||
decided_at: string | null
|
||||
settlement_journal_entry_id: string | null
|
||||
reclaim_journal_entry_id: string | null
|
||||
skv_referensnummer: string | null
|
||||
created_at: string
|
||||
submitted_at: string | null
|
||||
items: Array<{
|
||||
id: string
|
||||
invoice_id: string
|
||||
requested_amount: number | string
|
||||
decided_amount: number | string | null
|
||||
reclaimed_amount: number | string | null
|
||||
invoice: { invoice_number: string | null } | null
|
||||
}> | null
|
||||
}
|
||||
const rows = ((data ?? []) as unknown as Row[])
|
||||
const pageRows = rows.slice(0, limit)
|
||||
// An invoice re-requested in a later live begäran keeps its refused
|
||||
// share at Skatteverket: the reclaim service refuses it, so the list
|
||||
// must not advertise it either (one query for the whole page).
|
||||
const rerequestedByRequest = new Map<string, Set<string>>()
|
||||
const pageInvoiceIds = [...new Set(pageRows.flatMap((row) => (row.items ?? []).map((item) => item.invoice_id)))]
|
||||
if (pageInvoiceIds.length > 0) {
|
||||
const { data: siblings, error: siblingsError } = await supabase
|
||||
.from('rot_rut_payout_request_items')
|
||||
.select('invoice_id, request_id, request:rot_rut_payout_requests!inner(id, status, company_id)')
|
||||
.eq('request.company_id', companyId)
|
||||
.in('invoice_id', pageInvoiceIds)
|
||||
.not('request.status', 'in', '("cancelled","rejected")')
|
||||
if (siblingsError) throw dbError(siblingsError)
|
||||
for (const row of pageRows) {
|
||||
const own = new Set((row.items ?? []).map((item) => item.invoice_id))
|
||||
const hits = new Set<string>()
|
||||
for (const sibling of (siblings ?? []) as Array<{ invoice_id: string; request_id: string }>) {
|
||||
if (sibling.request_id !== row.id && own.has(sibling.invoice_id)) hits.add(sibling.invoice_id)
|
||||
}
|
||||
rerequestedByRequest.set(row.id, hits)
|
||||
}
|
||||
}
|
||||
const payoutRequests = pageRows.map((row) => {
|
||||
const items = row.items ?? []
|
||||
const refused = computeRefusedShares(row, items)
|
||||
const refusedTotal = refused.ok ? refused.total : 0
|
||||
const rerequested = rerequestedByRequest.get(row.id)?.size ?? 0
|
||||
return {
|
||||
request_id: row.id,
|
||||
name: row.name,
|
||||
deduction_type: row.deduction_type,
|
||||
status: row.status,
|
||||
requested_total: Number(row.requested_total),
|
||||
decided_total: row.decided_total == null ? null : Number(row.decided_total),
|
||||
decided_at: row.decided_at,
|
||||
expected_payout: expectedRotRutPayoutAmount(row),
|
||||
settlement_journal_entry_id: row.settlement_journal_entry_id,
|
||||
refused_total: refusedTotal,
|
||||
reclaim_journal_entry_id: row.reclaim_journal_entry_id,
|
||||
refused_needs_reclaim: refusedTotal > 0 && !row.reclaim_journal_entry_id && rerequested === 0,
|
||||
skv_referensnummer: row.skv_referensnummer,
|
||||
created_at: row.created_at,
|
||||
submitted_at: row.submitted_at,
|
||||
items: items.map((item) => ({
|
||||
item_id: item.id,
|
||||
invoice_id: item.invoice_id,
|
||||
invoice_number: item.invoice?.invoice_number ?? null,
|
||||
requested_amount: Number(item.requested_amount),
|
||||
decided_amount: item.decided_amount == null ? null : Number(item.decided_amount),
|
||||
reclaimed_amount: item.reclaimed_amount == null ? null : Number(item.reclaimed_amount),
|
||||
})),
|
||||
}
|
||||
})
|
||||
|
||||
const hasMore = count == null ? rows.length > limit : offset + payoutRequests.length < count
|
||||
const total = count ?? offset + payoutRequests.length + (hasMore ? 1 : 0)
|
||||
return {
|
||||
payout_requests: payoutRequests,
|
||||
count: payoutRequests.length,
|
||||
total_count: total,
|
||||
has_more: hasMore,
|
||||
...(hasMore ? { next_offset: offset + payoutRequests.length } : {}),
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
name: 'gnubok_settle_rot_rut_payout',
|
||||
keywords: ['rotavdrag', 'rutavdrag', 'utbetalning skatteverket', '1513', 'matcha utbetalning'],
|
||||
title: 'Settle Rot/Rut Payout from Bank Row',
|
||||
description:
|
||||
'Match 1 income bank tx from Skatteverket to the ROT/RUT begäran it pays (several when one transfer covers several: their payouts must sum to the row). Books debit 19xx / credit 1513, links the row. List begäran via gnubok_list_rot_rut_payout_requests first. Stages.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
transaction_id: { type: 'string' },
|
||||
request_ids: { type: 'array', items: { type: 'string' }, minItems: 1, maxItems: 10 },
|
||||
},
|
||||
required: ['transaction_id', 'request_ids'],
|
||||
},
|
||||
outputSchema: STAGED_OPERATION_SCHEMA,
|
||||
annotations: ANNOTATIONS_STAGED_WRITE,
|
||||
async execute(args, companyId, userId, supabase, actor) {
|
||||
const transactionId = args.transaction_id as string
|
||||
const rawIds = Array.isArray(args.request_ids) ? (args.request_ids as unknown[]) : []
|
||||
const requestIds = [...new Set(rawIds.filter((id): id is string => typeof id === 'string' && id.length > 0))]
|
||||
if (!transactionId || requestIds.length === 0) {
|
||||
throw codedError('VALIDATION_ERROR', 'transaction_id and request_ids are required')
|
||||
}
|
||||
if (requestIds.length > 10) {
|
||||
throw codedError('VALIDATION_ERROR', 'request_ids: at most 10 begäran per transfer')
|
||||
}
|
||||
|
||||
const { data: transaction, error: txError } = await supabase
|
||||
.from('transactions')
|
||||
.select('id, description, merchant_name, amount, currency, date, journal_entry_id, transaction_voucher_links(journal_entry_id, role)')
|
||||
.eq('id', transactionId)
|
||||
.eq('company_id', companyId)
|
||||
.single()
|
||||
if (txError || !transaction) throw registryError('TX_CATEGORIZE_TX_NOT_FOUND')
|
||||
if (!(transaction.amount > 0)) throw registryError('ROT_RUT_MATCH_NOT_INCOME')
|
||||
if ((transaction.currency || 'SEK').toUpperCase() !== 'SEK') throw registryError('ROT_RUT_MATCH_CURRENCY')
|
||||
// Same predicate as the commit writer: a live pointer OR a bank_line
|
||||
// junction row (samlingsverifikat) means the row is already booked.
|
||||
if (
|
||||
hasBankLineJunctionRow(
|
||||
(transaction as { transaction_voucher_links?: Array<{ journal_entry_id: string; role?: string | null }> | null })
|
||||
.transaction_voucher_links,
|
||||
) ||
|
||||
(await hasLiveJournalEntryLink(supabase, companyId, transaction.journal_entry_id))
|
||||
) {
|
||||
throw registryError('ROT_RUT_MATCH_TX_ALREADY_LINKED')
|
||||
}
|
||||
|
||||
const { data: requestRows, error: reqError } = await supabase
|
||||
.from('rot_rut_payout_requests')
|
||||
.select('id, name, deduction_type, status, requested_total, decided_total, settlement_journal_entry_id')
|
||||
.eq('company_id', companyId)
|
||||
.in('id', requestIds)
|
||||
if (reqError) throw dbError(reqError)
|
||||
const requests = (requestRows ?? []) as Array<RotRutPayoutRequestCandidate & { name: string }>
|
||||
const missing = requestIds.filter((id) => !requests.some((r) => r.id === id))
|
||||
if (missing.length > 0) throw registryError('ROT_RUT_REQUEST_NOT_FOUND')
|
||||
const blocked = requests.find((r) => !isMatchableRotRutPayoutRequest(r))
|
||||
if (blocked) {
|
||||
throw Object.assign(
|
||||
new Error(
|
||||
`Begäran "${blocked.name}" är ${blocked.settlement_journal_entry_id ? 'redan bokförd som utbetald' : blocked.status} och kan inte matchas.`,
|
||||
),
|
||||
{ code: 'ROT_RUT_SETTLE_INVALID_STATE' },
|
||||
)
|
||||
}
|
||||
|
||||
// Same rule the commit enforces (settleRotRutPayoutRequestSet): the
|
||||
// bundle must equal the decided sums to the öre. Refuse at stage so the
|
||||
// approver never sees an op that can only fail.
|
||||
const expectedTotal = roundOre(requests.reduce((sum, r) => sum + expectedRotRutPayoutAmount(r), 0))
|
||||
const txAmount = roundOre(transaction.amount)
|
||||
if (Math.abs(txAmount - expectedTotal) > 0.005) {
|
||||
throw Object.assign(
|
||||
new Error(
|
||||
`Transaktionen är ${txAmount} kr men ${requestIds.length === 1 ? 'begäran väntar' : 'de valda begäran väntar tillsammans'} ${expectedTotal} kr. Skatteverket betalar exakt beslutade belopp: välj de begäran som summerar till transaktionen, eller registrera beslutet först.`,
|
||||
),
|
||||
{ code: requestIds.length === 1 ? 'ROT_RUT_SETTLE_AMOUNT_EXCEEDS' : 'ROT_RUT_SETTLE_SET_AMOUNT' },
|
||||
)
|
||||
}
|
||||
|
||||
const txDesc = transaction.merchant_name || transaction.description || transactionId
|
||||
// Booking order: largest first, as the matcher offers them.
|
||||
const ordered = [...requests].sort((a, b) => expectedRotRutPayoutAmount(b) - expectedRotRutPayoutAmount(a))
|
||||
|
||||
return stagePendingOperation(supabase, companyId, userId, 'settle_rot_rut_payout',
|
||||
`ROT/RUT-utbetalning: ${txDesc} → ${ordered.map((r) => r.name).join(', ')}`,
|
||||
{ transaction_id: transactionId, request_ids: ordered.map((r) => r.id) },
|
||||
{
|
||||
transaction_description: txDesc,
|
||||
transaction_amount: transaction.amount,
|
||||
transaction_currency: transaction.currency,
|
||||
transaction_date: transaction.date,
|
||||
expected_total: expectedTotal,
|
||||
requests: ordered.map((r) => ({
|
||||
request_id: r.id,
|
||||
name: r.name,
|
||||
deduction_type: r.deduction_type,
|
||||
status: r.status,
|
||||
expected_payout: expectedRotRutPayoutAmount(r),
|
||||
})),
|
||||
},
|
||||
actor,
|
||||
{
|
||||
description: 'After approval the transfer is booked debit 19xx / credit 1513 (one leg per begäran), every begäran is marked paid and the row is linked. Verify with gnubok_list_rot_rut_payout_requests.',
|
||||
tool: 'gnubok_list_rot_rut_payout_requests',
|
||||
},
|
||||
{ dateForPeriodCheck: transaction.date },
|
||||
)
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
name: 'gnubok_import_rot_rut_beslut',
|
||||
keywords: ['rotavdrag', 'rutavdrag', 'beslutsfil', 'skatteverket'],
|
||||
|
||||
Reference in New Issue
Block a user