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:
Jakob Wennberg
2026-09-08 21:25:01 +02:00
committed by GitHub
co-authored by Jakob Wennberg Claude Fable 5.1
parent a7dcaac6ad
commit 20925f6c65
10 changed files with 353 additions and 9 deletions
+1
View File
@@ -1675,6 +1675,7 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
[2026-09-08] #2425 ROT/RUT: the picker's begäran rows keep single-click handoff and gain a checkbox per row for a bundled pick, instead of a new multi-select mode or an ambiguity-tolerant matcher. Why: the server (request_ids, settleRotRutPayoutRequestSet) and the confirm dialog already take N begäran since #2360; only the manual fallback was 1:1, and a firm whose jobs share a price hits the matcher's ambiguity refusal often. Guessing between two same-amount begäran would clear the wrong 1513 fordran, so the choice stays with the user.
[2026-09-08] Issue #2426 ROT/RUT begäran state in the invoice list: normal states (Att begära, Skapad, Uppladdad, Beviljad) render as muted text and only Delvis beviljad and Avslagen get a chip (design convention 5: a fönsterputsfirma where every invoice is ROT/RUT would otherwise carry the same chip on every row); the filter is a third ContextPicker (?rotrut=) gated on rot_rut_enabled or an invoiced deduction, not extra entries in the status view, so a ROT/RUT state can be combined with any status view and companies without ROT/RUT never see it. One predicate (lib/invoices/rot-rut-list-status.ts) feeds the column, the filter and its counts; the state is read from a rot_rut_payout_request_items embed on the existing list query, no new API and no migration.
[2026-09-08] Receipt purchase date: fixed the extraction prompt's description of invoice.invoiceDate (schema comment plus an explicit "invoiceDate on receipts" rule) instead of adding a receipt-specific purchaseDate field. Why: prod (last 30 days) showed receipts losing the date on 46% of items (75% via WhatsApp) while supplier invoices lost it on 0.4%, and purchaseTime was filled on nearly every affected receipt: the date was described as a bare ISO field under the invoice block beside a purchaseTime rule marked "receipts only", and the model read that as "invoice-only". A new field would need a schema change, a UI column and every consumer (WhatsApp M4 ack date, the Bokför entry-date default that falls back to today, the worklist) taught a second date; the prompt fix stops the class with no data-shape change. Existing undated items are not re-extracted by this; a backfill is a separate call.
[2026-09-08] #2187 (a) Att göra row "Betala in till Skatteverket" shows the shortfall (next charge minus the last synced saldo, the full charge only when no snapshot exists) and appears only when something has to be paid in, instead of a deadline-style row for every upcoming charge: a company whose saldo covers the dragning has nothing to do, and the /skattekonto page's Nästa dragning math already defines the number, now computed once in lib/worklist for Hem and /api/worklist/counts. Part (b), a betalfil for the skattekonto payment, stays a follow-up: the existing payment-file route (app/api/skatteverket/tax-payments/[period]/payment-file) is AGI-scoped (amount from agi_declarations, reachable only from a salary run) and needs an amount source before it can pay a general dragning.
[2026-09-08] Issue #2224 offert -> kundorder: the quote source was added to the existing proforma -> order service (lib/sales-orders/convert-to-sales-order.ts) and to gnubok_convert_invoice as a target parameter staged under the existing convert_invoice operation type, instead of a second service, a separate MCP tool and a new pending-operation type. Why: one conversion path and one guard pair (a quote with a live order cannot become an invoice, a quote with a live invoice cannot become an order) keep the proforma and quote flows identical, and reusing the operation type keeps the pending-operations type list untouched. The quote flips to accepted like quote -> invoice does; cancelling it like the proforma path would hide the customer's accepted agreement. Skeptic refutation: for an already-accepted quote that accepted -> accepted compare-and-set cannot detect a concurrent conversion, so the "never twice" guard moved into the database (migration 20260908165000: one live kundorder per source, and two triggers that lock the quote row and refuse a live order beside a live converted invoice and vice versa); the service pre-checks stay for the friendly error. Second refutation: order -> invoice booked a foreign-currency invoice 1:1 when Riksbanken had no rate (pre-existing, but the quote now depends on that path and the fail-closed quote -> invoice route is refused while an order lives), so createInvoiceFromSalesOrder now fails closed with SALES_ORDER_INVOICE_FX_RATE_UNAVAILABLE. Locking the quote decision (open/declined) while a live order exists was left out: the DB trigger only knows about converted invoices and changing it is a migration; a declined quote with a live order is harmless since the order carries the sale.
[2026-09-08] Issue #2224 follow-up from the correctness skeptic: the quote decision (open/declined) is now locked in the database while a live kundorder exists (migration 20260908165100 extends invoices_quote_decision_guard), reversing the earlier call to leave it open; a declined offert behind a confirmed, invoiced order was a contradictory agreement trail and the dashboard hid the re-accept button, so the quote was stuck. The three source and decision guards run as SECURITY DEFINER: a SELECT FOR UPDATE under RLS admits only the caller's active company, so a multi-company member writing for another company through raw PostgREST got no row, no lock and no guard. Both landed as a second migration rather than an edit of 20260908165000, which was already applied to staging under that version.
[2026-09-08] Draft invoice PDF marks a draft with one diagonal, faint word (UTKAST / DRAFT) across every page instead of a banner in the top margin (#2437): a banner reads as UI chrome on a document, a watermark reads as a stamp and leaves the preview pixel-identical to the final print. The long legal sentence (saknar löpnummer, ML 17 kap 24 §) is dropped on purpose: the word alone says the document is not a valid invoice, and the download dialog (#2399) already explains why before the file exists. Rotation and opacity sit on a padded wrapper View so the word turns about its own centre. Skeptic refutation accepted: the first cut (#6b7280 at 0.14, about 92% brightness) would drop out of a monochrome print or greyscale scan, and a numbered draft otherwise prints title, number and OCR like a real faktura; now #4b5563 at 0.3 (about 79% brightness), with a test pinning the composited grey between 70% and 85%. A 1-bit scan can still threshold the word away; a second explicit line on numbered drafts was left out because the request was the word alone, and that residual is Emil's call. Second refutation accepted: the overlay is emitted as the LAST child of the Page, because react-pdf paints in document order and `fixed` does not hoist, so an overlay emitted first was painted under the opaque payment and customer boxes and the word vanished on the page that carries totals and OCR; a test now inflates the PDF content streams and asserts the glyph run comes after the last rectangle fill on every page. BETALD and MAKULERAD banners are left as they are.
+16 -1
View File
@@ -6,6 +6,7 @@ import { HemNotices } from '@/components/dashboard/HemNotices'
import {
getWorklistCounts,
listExpensePayoutsDue,
listSkattekontoPaymentDue,
listSuggestedMatches,
SUGGESTED_MATCH_SCAN_CAP,
} from '@/lib/worklist'
@@ -199,16 +200,29 @@ export async function HemPanesSection({
// Same pattern for people owed for utlägg: Hem renders one row per person
// and the worklist count is the list's length.
const expensePayoutsPromise = listExpensePayoutsDue(supabase, companyId)
const [worklist, suggestedMatches, expensePayouts, resumeItems, bankConnectionsRes, postedEntries] =
// And for the next uncovered skattekonto charge: Hem renders the Betala
// row with amount, bankgiro and OCR; the worklist count is 1 or 0 from it.
const skattekontoPaymentPromise = listSkattekontoPaymentDue(supabase, companyId)
const [
worklist,
suggestedMatches,
expensePayouts,
skattekontoPayment,
resumeItems,
bankConnectionsRes,
postedEntries,
] =
await Promise.all([
// Pending-work counts come from lib/worklist: the same source as the
// sidebar badges, so the numbers can never diverge.
getWorklistCounts(supabase, companyId, {
suggestedMatches: suggestedMatchesPromise,
expensePayoutsDue: expensePayoutsPromise,
skattekontoPaymentDue: skattekontoPaymentPromise,
}),
suggestedMatchesPromise,
expensePayoutsPromise,
skattekontoPaymentPromise,
// In-progress work for the Fortsätt pane: pure draft-state derivation.
listResumeItems(supabase, companyId, now),
supabase.from('bank_connections').select('id, status, consent_expires, bank_name, last_sie_sweep').eq('company_id', companyId).eq('status', 'active'),
@@ -242,6 +256,7 @@ export async function HemPanesSection({
worklist={worklist}
suggestedMatches={suggestedMatches.slice(0, 5)}
expensePayouts={expensePayouts}
skattekontoPayment={skattekontoPayment}
expiringBankConnections={expiringBankConnections}
emptyLedger={emptyLedger}
hasActiveBankConnection={hasActiveBankConnection}
+35 -2
View File
@@ -30,7 +30,12 @@ import {
ShieldCheck,
Stamp,
} from 'lucide-react'
import type { ExpensePayoutDue, SuggestedMatch, WorklistCounts } from '@/lib/worklist/types'
import type {
ExpensePayoutDue,
SkattekontoPaymentDue,
SuggestedMatch,
WorklistCounts,
} from '@/lib/worklist/types'
/**
* AttGoraSection: the dashboard's unified worklist ("Att göra").
@@ -57,6 +62,8 @@ interface AttGoraSectionProps {
suggestedMatches: SuggestedMatch[]
/** People owed for registered, unpaid utlägg: one Betala row each. */
expensePayouts?: ExpensePayoutDue[]
/** The next skattekonto charge the balance does not cover: one Betala row. */
skattekontoPayment?: SkattekontoPaymentDue | null
expiringBankConnections?: ExpiringBankConnection[]
/**
* True while the setup checklist is open and the company has zero posted
@@ -119,6 +126,7 @@ export default function AttGoraSection({
worklist,
suggestedMatches,
expensePayouts = [],
skattekontoPayment = null,
expiringBankConnections = [],
emptyLedger = false,
hasActiveBankConnection = true,
@@ -218,7 +226,7 @@ export default function AttGoraSection({
counts.book_skattekonto > 0 ||
showInboxDocuments ||
matches.length > 0
const betalaRows = expensePayouts.length > 0
const betalaRows = expensePayouts.length > 0 || skattekontoPayment !== null
const granskaRows =
counts.supplier_invoice_approval > 0 ||
counts.verifikat_missing_document > 0 ||
@@ -396,6 +404,31 @@ export default function AttGoraSection({
<div>
<BandHeader>{t('band_betala')}</BandHeader>
<div>
{skattekontoPayment && (
<WorklistRow
href="/skattekonto"
icon={Landmark}
label={t('row_skattekonto_payment')}
detail={
skattekontoPayment.ocr
? t('row_skattekonto_payment_detail', {
bankgiro: skattekontoPayment.bankgiro,
ocr: skattekontoPayment.ocr,
date: formatDate(skattekontoPayment.due),
})
: t('row_skattekonto_payment_detail_no_ocr', {
bankgiro: skattekontoPayment.bankgiro,
date: formatDate(skattekontoPayment.due),
})
}
count={1}
badge={
<span className="text-xs tabular-nums text-muted-foreground">
{formatCurrency(skattekontoPayment.amount)}
</span>
}
/>
)}
{expensePayouts.map((p) => (
<WorklistRow
key={p.key}
+18 -3
View File
@@ -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)
})
})
+113
View File
@@ -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)
})
})
+15 -2
View File
@@ -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
View File
@@ -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
+39
View File
@@ -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>
/**
+3
View File
@@ -6859,6 +6859,9 @@
"row_expense_payout": "Pay out expenses to {name}",
"row_expense_payout_detail_one": "1 receipt · {date}",
"row_expense_payout_detail_other": "{count} receipts · oldest {date}",
"row_skattekonto_payment": "Pay in to Skatteverket",
"row_skattekonto_payment_detail": "Bankgiro {bankgiro} · OCR {ocr} · by {date}",
"row_skattekonto_payment_detail_no_ocr": "Bankgiro {bankgiro} · by {date}",
"skv_promo_title": "Connect Skatteverket",
"skv_promo_description": "See your tax account and file VAT and employer declarations directly from here. Connect with BankID in a couple of minutes.",
"skv_promo_cta": "Connect",
+3
View File
@@ -6859,6 +6859,9 @@
"row_expense_payout": "Betala ut utlägg till {name}",
"row_expense_payout_detail_one": "1 kvitto · {date}",
"row_expense_payout_detail_other": "{count} kvitton · äldsta {date}",
"row_skattekonto_payment": "Betala in till Skatteverket",
"row_skattekonto_payment_detail": "Bankgiro {bankgiro} · OCR {ocr} · senast {date}",
"row_skattekonto_payment_detail_no_ocr": "Bankgiro {bankgiro} · senast {date}",
"skv_promo_title": "Koppla Skatteverket",
"skv_promo_description": "Se skattekontot och lämna moms- och arbetsgivardeklarationer direkt härifrån. Anslut med BankID på ett par minuter.",
"skv_promo_cta": "Anslut",