feat(worklist): the next Skatteverket payment with bankgiro, OCR and due date under Att göra on Hem (#2435)
Part (a) of #2187. A twelfth worklist category, skattekonto_payment_due: the earliest upcoming skattekonto charge whose sum exceeds the last synced saldo, computed once in lib/worklist (server-side twin of the /skattekonto page's Nästa dragning math) and rendered as one Betala row on Hem with the shortfall, bankgiro 5050-1055, the OCR reference and the due date. The row appears only when money has to move: a saldo that covers the charge yields nothing, and no upcoming charge yields nothing. Ignored rows take part, since Skatteverket draws them regardless of our flag. Without a balance snapshot the full charge is the amount. Without an org number the row keeps its bankgiro and date and drops the OCR. No table, route or migration: /api/worklist/counts picks the category up through getWorklistCounts, and Hem passes the computed row into the same options wave as the expense payouts. Part (b), a betalfil for the skattekonto payment, stays a follow-up: the existing payment-file route is AGI-scoped. Claude-Session: https://claude.ai/code/session_0179bdetHyofL6ATfQxB5wP5 Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Jakob Wennberg
Claude Fable 5.1
parent
a7dcaac6ad
commit
20925f6c65
@@ -13,6 +13,7 @@ vi.mock('../categories', () => ({
|
||||
countPendingOperations: vi.fn().mockResolvedValue(2),
|
||||
countReconciliationDue: vi.fn().mockResolvedValue(1),
|
||||
countExpensePayoutsDue: vi.fn().mockResolvedValue(2),
|
||||
countSkattekontoPaymentDue: vi.fn().mockResolvedValue(1),
|
||||
}))
|
||||
|
||||
import { getWorklistCounts } from '../aggregate'
|
||||
@@ -38,6 +39,7 @@ describe('getWorklistCounts', () => {
|
||||
pending_operations: 2,
|
||||
reconciliation_due: 1,
|
||||
expense_payout: 2,
|
||||
skattekonto_payment_due: 1,
|
||||
})
|
||||
})
|
||||
|
||||
@@ -61,10 +63,23 @@ describe('getWorklistCounts', () => {
|
||||
expect(countExpensePayoutsDue).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('takes the skattekonto payment count from a caller-supplied value instead of rescanning', async () => {
|
||||
const { countSkattekontoPaymentDue } = await import('../categories')
|
||||
const due = { due: '2026-09-12', amount: 1000 } as never
|
||||
const withDue = await getWorklistCounts(supabase, 'company-1', {
|
||||
skattekontoPaymentDue: Promise.resolve(due),
|
||||
})
|
||||
expect(withDue.counts.skattekonto_payment_due).toBe(1)
|
||||
// null is a value ("nothing to pay in"), not an absent option.
|
||||
const without = await getWorklistCounts(supabase, 'company-1', { skattekontoPaymentDue: null })
|
||||
expect(without.counts.skattekonto_payment_due).toBe(0)
|
||||
expect(countSkattekontoPaymentDue).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('excludes suggested_match from the total (subset of book_transaction)', async () => {
|
||||
const { total } = await getWorklistCounts(supabase, 'company-1')
|
||||
// 4 + 7 + 6 + 1 + 3 + 5 + 1 + 2 + 1 + 2 (people owed for utlägg), without
|
||||
// the 2 suggested matches.
|
||||
expect(total).toBe(32)
|
||||
// 4 + 7 + 6 + 1 + 3 + 5 + 1 + 2 + 1 + 2 (people owed for utlägg) + 1
|
||||
// (skattekonto payment), without the 2 suggested matches.
|
||||
expect(total).toBe(33)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -14,6 +14,8 @@ import {
|
||||
countVerifikatMissingDocument,
|
||||
listExpensePayoutsDue,
|
||||
listExpensePayoutSuggestions,
|
||||
listSkattekontoPaymentDue,
|
||||
countSkattekontoPaymentDue,
|
||||
listRotRutPayoutSetSuggestions,
|
||||
listSuggestedMatches,
|
||||
} from '../categories'
|
||||
@@ -699,3 +701,114 @@ describe('listExpensePayoutSuggestions', () => {
|
||||
expect(mockSupabase.from).not.toHaveBeenCalledWith('transactions')
|
||||
})
|
||||
})
|
||||
|
||||
describe('listSkattekontoPaymentDue', () => {
|
||||
const TODAY = '2026-09-08'
|
||||
const company = { org_number: '559547-0021', entity_type: 'aktiebolag' }
|
||||
// Queue order follows the function's `from` calls: upcoming rows and the
|
||||
// balance snapshot (one parallel wave), then the company, then the OCR
|
||||
// resolver's own snapshot read.
|
||||
const snapshot = (saldoSkatteverket: number) => ({
|
||||
data: { value: { saldo: { saldoSkatteverket }, fetchedAt: 1757300000000 } },
|
||||
})
|
||||
|
||||
it('returns null when Skatteverket has no upcoming charge', async () => {
|
||||
enqueue({ data: [] })
|
||||
enqueue({ data: null })
|
||||
await expect(listSkattekontoPaymentDue(supabase, COMPANY, TODAY)).resolves.toBeNull()
|
||||
expect(mockSupabase.from).toHaveBeenCalledWith('skattekonto_transactions')
|
||||
expect(findCalls('skattekonto_transactions', 'eq')).toContainEqual(['status', 'upcoming'])
|
||||
// Ignored rows are not filtered out: Skatteverket draws them regardless.
|
||||
expect(findCalls('skattekonto_transactions', 'eq')).not.toContainEqual(['is_ignored', false])
|
||||
})
|
||||
|
||||
it('returns null when the synced saldo covers the next charge', async () => {
|
||||
enqueue({
|
||||
data: [
|
||||
{ transaktionsdatum: '2026-09-12', forfallodatum: '2026-09-12', belopp_skatteverket: -12000 },
|
||||
{ transaktionsdatum: '2026-09-12', forfallodatum: '2026-09-12', belopp_skatteverket: -3000 },
|
||||
],
|
||||
})
|
||||
enqueue(snapshot(15000))
|
||||
await expect(listSkattekontoPaymentDue(supabase, COMPANY, TODAY)).resolves.toBeNull()
|
||||
// No company or OCR read once nothing has to be paid in.
|
||||
expect(mockSupabase.from).not.toHaveBeenCalledWith('companies')
|
||||
})
|
||||
|
||||
it('reports the shortfall on the earliest due date with bankgiro and OCR', async () => {
|
||||
enqueue({
|
||||
data: [
|
||||
// A later charge: not part of the next payment.
|
||||
{ transaktionsdatum: '2026-10-12', forfallodatum: '2026-10-12', belopp_skatteverket: -9000 },
|
||||
{ transaktionsdatum: '2026-09-12', forfallodatum: '2026-09-12', belopp_skatteverket: '-12000.50' },
|
||||
{ transaktionsdatum: '2026-09-12', forfallodatum: '2026-09-12', belopp_skatteverket: -3000 },
|
||||
// Already drawn: a due date before today belongs to book_skattekonto.
|
||||
{ transaktionsdatum: '2026-09-01', forfallodatum: '2026-09-01', belopp_skatteverket: -500 },
|
||||
],
|
||||
})
|
||||
enqueue(snapshot(4000.25))
|
||||
enqueue({ data: company })
|
||||
// OCR resolver: no reported OCR in the snapshot, so it is derived.
|
||||
enqueue({ data: null })
|
||||
await expect(listSkattekontoPaymentDue(supabase, COMPANY, TODAY)).resolves.toEqual({
|
||||
due: '2026-09-12',
|
||||
charge: 15000.5,
|
||||
balance: 4000.25,
|
||||
amount: 11000.25,
|
||||
count: 2,
|
||||
ocr: '1655954700217',
|
||||
bankgiro: '5050-1055',
|
||||
})
|
||||
})
|
||||
|
||||
it('shows the full charge when no balance snapshot exists', async () => {
|
||||
enqueue({
|
||||
data: [{ transaktionsdatum: '2026-09-12', forfallodatum: null, belopp_skatteverket: -2500 }],
|
||||
})
|
||||
enqueue({ data: null })
|
||||
enqueue({ data: company })
|
||||
enqueue({ data: null })
|
||||
const due = await listSkattekontoPaymentDue(supabase, COMPANY, TODAY)
|
||||
expect(due).toMatchObject({ due: '2026-09-12', charge: 2500, balance: null, amount: 2500, count: 1 })
|
||||
})
|
||||
|
||||
it('adds a negative saldo (a debt) to the amount to pay in', async () => {
|
||||
enqueue({
|
||||
data: [{ transaktionsdatum: '2026-09-12', forfallodatum: '2026-09-12', belopp_skatteverket: -1000 }],
|
||||
})
|
||||
enqueue(snapshot(-250))
|
||||
enqueue({ data: company })
|
||||
enqueue({ data: null })
|
||||
const due = await listSkattekontoPaymentDue(supabase, COMPANY, TODAY)
|
||||
expect(due).toMatchObject({ charge: 1000, balance: -250, amount: 1250 })
|
||||
})
|
||||
|
||||
it('keeps the row without an OCR when the company has no org number', async () => {
|
||||
enqueue({
|
||||
data: [{ transaktionsdatum: '2026-09-12', forfallodatum: '2026-09-12', belopp_skatteverket: -1000 }],
|
||||
})
|
||||
enqueue({ data: null })
|
||||
enqueue({ data: { org_number: null, entity_type: 'aktiebolag' } })
|
||||
const due = await listSkattekontoPaymentDue(supabase, COMPANY, TODAY)
|
||||
expect(due).toMatchObject({ amount: 1000, ocr: null, bankgiro: '5050-1055' })
|
||||
})
|
||||
|
||||
it('soft-fails to null on query error', async () => {
|
||||
enqueue({ error: { message: 'boom' } })
|
||||
enqueue({ data: null })
|
||||
await expect(listSkattekontoPaymentDue(supabase, COMPANY, TODAY)).resolves.toBeNull()
|
||||
})
|
||||
|
||||
it('counts 1 when a payment is due and 0 otherwise', async () => {
|
||||
enqueue({
|
||||
data: [{ transaktionsdatum: '2026-09-12', forfallodatum: '2026-09-12', belopp_skatteverket: -1000 }],
|
||||
})
|
||||
enqueue({ data: null })
|
||||
enqueue({ data: company })
|
||||
enqueue({ data: null })
|
||||
await expect(countSkattekontoPaymentDue(supabase, COMPANY)).resolves.toBe(1)
|
||||
enqueue({ data: [] })
|
||||
enqueue({ data: null })
|
||||
await expect(countSkattekontoPaymentDue(supabase, COMPANY)).resolves.toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
import type { ExpensePayoutDue, SuggestedMatch } from './types'
|
||||
import type { ExpensePayoutDue, SkattekontoPaymentDue, SuggestedMatch } from './types'
|
||||
import type { WorklistCounts } from './types'
|
||||
import {
|
||||
countDeadlinesNeedingAction,
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
countOverdueInvoices,
|
||||
countPendingOperations,
|
||||
countReconciliationDue,
|
||||
countSkattekontoPaymentDue,
|
||||
countSuggestedMatches,
|
||||
countSupplierInvoicesAwaitingApproval,
|
||||
countUnbookedSkattekontoRows,
|
||||
@@ -39,6 +40,12 @@ export interface GetWorklistCountsOptions {
|
||||
* a second scan of expense_claims.
|
||||
*/
|
||||
expensePayoutsDue?: ExpensePayoutDue[] | Promise<ExpensePayoutDue[]>
|
||||
/**
|
||||
* The next uncovered skattekonto charge the caller is already fetching
|
||||
* (Hem renders it as one Betala row): the count is 1 or 0 from that value
|
||||
* instead of a second scan. Pass null for "nothing to pay in".
|
||||
*/
|
||||
skattekontoPaymentDue?: SkattekontoPaymentDue | null | Promise<SkattekontoPaymentDue | null>
|
||||
}
|
||||
|
||||
export async function getWorklistCounts(
|
||||
@@ -58,6 +65,7 @@ export async function getWorklistCounts(
|
||||
pendingOperations,
|
||||
reconciliationDue,
|
||||
expensePayout,
|
||||
skattekontoPaymentDue,
|
||||
] = await Promise.all([
|
||||
countUnbookedTransactions(supabase, companyId),
|
||||
countUnbookedSkattekontoRows(supabase, companyId),
|
||||
@@ -74,6 +82,9 @@ export async function getWorklistCounts(
|
||||
options.expensePayoutsDue
|
||||
? Promise.resolve(options.expensePayoutsDue).then((p) => p.length)
|
||||
: countExpensePayoutsDue(supabase, companyId),
|
||||
options.skattekontoPaymentDue !== undefined
|
||||
? Promise.resolve(options.skattekontoPaymentDue).then((p) => (p ? 1 : 0))
|
||||
: countSkattekontoPaymentDue(supabase, companyId),
|
||||
])
|
||||
|
||||
return {
|
||||
@@ -89,6 +100,7 @@ export async function getWorklistCounts(
|
||||
pending_operations: pendingOperations,
|
||||
reconciliation_due: reconciliationDue,
|
||||
expense_payout: expensePayout,
|
||||
skattekonto_payment_due: skattekontoPaymentDue,
|
||||
},
|
||||
total:
|
||||
bookTransaction +
|
||||
@@ -100,6 +112,7 @@ export async function getWorklistCounts(
|
||||
deadlineAction +
|
||||
pendingOperations +
|
||||
reconciliationDue +
|
||||
expensePayout,
|
||||
expensePayout +
|
||||
skattekontoPaymentDue,
|
||||
}
|
||||
}
|
||||
|
||||
+110
-1
@@ -27,7 +27,9 @@ import {
|
||||
MATCHABLE_INVOICE_STATUSES,
|
||||
MATCHABLE_SUPPLIER_INVOICE_STATUSES,
|
||||
} from '@/lib/invoices/matchable-statuses'
|
||||
import type { ExpensePayoutDue, SuggestedMatch } from './types'
|
||||
import { todayIsoStockholm } from '@/lib/dates/iso'
|
||||
import { resolveSkattekontoOcr, SKATTEKONTO_BANKGIRO } from '@/lib/skatteverket/skattekonto-ocr'
|
||||
import type { ExpensePayoutDue, SkattekontoPaymentDue, SuggestedMatch } from './types'
|
||||
|
||||
// Canonical home is lib/worklist/types.ts (dependency-free, client-safe);
|
||||
// re-exported here so existing server-side imports keep working.
|
||||
@@ -723,6 +725,113 @@ export async function countExpensePayoutsDue(
|
||||
return (await listExpensePayoutsDue(supabase, companyId)).length
|
||||
}
|
||||
|
||||
/**
|
||||
* Where the skatteverket extension caches the saldo it last fetched. Core
|
||||
* reads the row directly (never imports `@/extensions/*`); the same row the
|
||||
* reconciliation engine and the OCR resolver read.
|
||||
*/
|
||||
const SKATTEVERKET_EXTENSION_ID = 'skatteverket'
|
||||
const BALANCE_SNAPSHOT_KEY = 'skattekonto_balance_snapshot'
|
||||
|
||||
/**
|
||||
* The next skattekonto charge the balance does not cover, or null.
|
||||
*
|
||||
* Server-side twin of the /skattekonto page's "Nästa dragning" math: the
|
||||
* earliest due date on or after today among Skatteverket's upcoming rows
|
||||
* (ignored rows included, Skatteverket draws them regardless of our flag),
|
||||
* the sum drawn that day, and the shortfall against the last synced saldo.
|
||||
* Nothing to pay in (no upcoming charge, or a saldo that covers it) is null,
|
||||
* so the Att göra row only appears when money actually has to move.
|
||||
*
|
||||
* Soft-fails to null with a logged error, like every other category.
|
||||
*/
|
||||
export async function listSkattekontoPaymentDue(
|
||||
supabase: SupabaseClient,
|
||||
companyId: string,
|
||||
today: string = todayIsoStockholm(),
|
||||
): Promise<SkattekontoPaymentDue | null> {
|
||||
type UpcomingRow = {
|
||||
transaktionsdatum: string
|
||||
forfallodatum: string | null
|
||||
belopp_skatteverket: number | string
|
||||
}
|
||||
const [rowsRes, snapshotRes] = await Promise.all([
|
||||
supabase
|
||||
.from('skattekonto_transactions')
|
||||
.select('transaktionsdatum, forfallodatum, belopp_skatteverket')
|
||||
.eq('company_id', companyId)
|
||||
.eq('status', 'upcoming'),
|
||||
supabase
|
||||
.from('extension_data')
|
||||
.select('value')
|
||||
.eq('company_id', companyId)
|
||||
.eq('extension_id', SKATTEVERKET_EXTENSION_ID)
|
||||
.eq('key', BALANCE_SNAPSHOT_KEY)
|
||||
.maybeSingle(),
|
||||
])
|
||||
if (rowsRes.error) {
|
||||
logAndZero('skattekonto_payment_due', companyId, rowsRes.error)
|
||||
return null
|
||||
}
|
||||
|
||||
const dueOf = (r: UpcomingRow) => r.forfallodatum ?? r.transaktionsdatum
|
||||
const upcoming = ((rowsRes.data ?? []) as UpcomingRow[]).filter((r) => dueOf(r) >= today)
|
||||
const due = upcoming.map(dueOf).sort()[0]
|
||||
if (!due) return null
|
||||
const rows = upcoming.filter((r) => dueOf(r) === due)
|
||||
const charge = roundOre(Math.abs(rows.reduce((sum, r) => sum + Number(r.belopp_skatteverket), 0)))
|
||||
if (charge <= 0) return null
|
||||
|
||||
// A failed snapshot read is "balance unknown", never "balance zero": the
|
||||
// full charge is then the honest amount to show.
|
||||
const snapshotValue = snapshotRes.error ? null : (snapshotRes.data?.value as
|
||||
| { saldo?: { saldoSkatteverket?: unknown } }
|
||||
| null
|
||||
| undefined)
|
||||
const rawBalance = Number(snapshotValue?.saldo?.saldoSkatteverket)
|
||||
const balance = snapshotValue && Number.isFinite(rawBalance) ? roundOre(rawBalance) : null
|
||||
if (balance !== null && balance >= charge) return null
|
||||
const amount = balance === null ? charge : roundOre(charge - balance)
|
||||
|
||||
const { data: company, error: companyError } = await supabase
|
||||
.from('companies')
|
||||
.select('org_number, entity_type')
|
||||
.eq('id', companyId)
|
||||
.maybeSingle()
|
||||
if (companyError) logAndZero('skattekonto_payment_due', companyId, companyError)
|
||||
// Same collapse as the tax-payment file route: companies.entity_type is
|
||||
// NOT NULL with a CHECK, and only enskild firma builds its redovisare from
|
||||
// a personnummer. Without an org number there is no reference to derive;
|
||||
// the row still shows, the user reads the OCR off Skatteverket instead.
|
||||
let ocr: string | null = null
|
||||
const orgNumber = (company as { org_number?: string | null; entity_type?: string | null } | null)?.org_number
|
||||
if (orgNumber) {
|
||||
try {
|
||||
ocr = await resolveSkattekontoOcr(
|
||||
supabase,
|
||||
companyId,
|
||||
orgNumber,
|
||||
company?.entity_type === 'enskild_firma' ? 'enskild_firma' : 'aktiebolag',
|
||||
)
|
||||
} catch (err) {
|
||||
log.warn('worklist skattekonto_payment_due: OCR could not be derived', {
|
||||
companyId,
|
||||
reason: (err as { message?: string })?.message,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return { due, charge, balance, amount, count: rows.length, ocr, bankgiro: SKATTEKONTO_BANKGIRO }
|
||||
}
|
||||
|
||||
/** 1 when a skattekonto charge needs a payment in (see listSkattekontoPaymentDue), else 0. */
|
||||
export async function countSkattekontoPaymentDue(
|
||||
supabase: SupabaseClient,
|
||||
companyId: string,
|
||||
): Promise<number> {
|
||||
return (await listSkattekontoPaymentDue(supabase, companyId)) ? 1 : 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Unbooked SEK outflows whose amount equals one person's outstanding utlägg
|
||||
* to the öre. Read-time pairing over the open claims: the candidate pool is
|
||||
|
||||
@@ -116,6 +116,23 @@ export const WORKLIST_CATEGORIES = [
|
||||
* Counts PEOPLE, not receipts: the action is one transfer per person.
|
||||
*/
|
||||
'expense_payout',
|
||||
/**
|
||||
* The next skattekonto charge the balance does not cover ("Betala in till
|
||||
* Skatteverket"), one item at most.
|
||||
* Pending: skattekonto_transactions with status = 'upcoming' (Skatteverket's
|
||||
* "kommande": a charge it will draw on forfallodatum), grouped on
|
||||
* the earliest due date on or after today, whose sum exceeds the
|
||||
* last synced saldo (extension_data skattekonto_balance_snapshot).
|
||||
* Ignored rows take part: Skatteverket draws regardless of our
|
||||
* flag. With no snapshot the whole charge is the amount to pay in.
|
||||
* Done: the saldo covers the charge (the payment reached Skatteverket
|
||||
* and the next sync or file import shows it), or the due date
|
||||
* passes and the charge becomes a booked event, which is then
|
||||
* book_skattekonto's work, not this row's.
|
||||
* Never counts with book_skattekonto: that one is a verifikat to write for
|
||||
* a settled event; this one is money that has to move before a date.
|
||||
*/
|
||||
'skattekonto_payment_due',
|
||||
] as const
|
||||
|
||||
export type WorklistCategory = (typeof WORKLIST_CATEGORIES)[number]
|
||||
@@ -140,6 +157,28 @@ export interface ExpensePayoutDue {
|
||||
oldest_expense_date: string
|
||||
}
|
||||
|
||||
/**
|
||||
* The next charge Skatteverket will draw that the skattekonto balance does
|
||||
* not cover: the Att göra row "Betala in till Skatteverket". Same math as the
|
||||
* /skattekonto page's Nästa dragning line and its payment dialog.
|
||||
*/
|
||||
export interface SkattekontoPaymentDue {
|
||||
/** ISO due date of the charge (forfallodatum, else transaktionsdatum). */
|
||||
due: string
|
||||
/** Everything Skatteverket draws on `due`, as a positive amount. */
|
||||
charge: number
|
||||
/** Last synced saldo; null when no balance snapshot exists. */
|
||||
balance: number | null
|
||||
/** What has to be paid in: charge minus balance (never below 0), or the full charge when the balance is unknown. */
|
||||
amount: number
|
||||
/** Number of skattekonto rows drawn on `due`. */
|
||||
count: number
|
||||
/** OCR reference for the payment; null when the company's org number cannot produce one. */
|
||||
ocr: string | null
|
||||
/** Bankgiro for every skattekonto payment (5050-1055). */
|
||||
bankgiro: string
|
||||
}
|
||||
|
||||
export interface WorklistCounts {
|
||||
counts: Record<WorklistCategory, number>
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user