Four fixes from the exit mail Anders Orback (Center Node AB) sent hours after churning. His five points were mostly one job: reconciling skattekontot against banken before årsredovisningen. Skattekonto look-alike rows. Skatteverket splits a retroactive omprövningsbeslut across every month it re-charges and sends one transaction per month, sharing date, text and amount; only ranteberakningsdatum separates them, and we stored it but rendered it nowhere. A real company posted 15 such vouchers (67 785 kr across Feb 2025-Apr 2026) unable to tell them from duplicates of the automatic hämtning. Surface the field when it carries information: its month differs from the Datum column, or another row in the same band is otherwise indistinguishable. The list-to-voucher round trip. The verifikat list collapsed to a skeleton on every refetch and sprang back, moving rows under the pointer; only the first load shows a skeleton now. Filter state is React-only, so leaving the list loses it: add a hover-revealed open-in-new-tab affordance on the voucher list and the skattekonto page, where the link had been behind a hand-rolled opacity-0 that coarse pointers never trigger. Makulerad rendering. A stornoed verifikat now reads as struck out, per data cell rather than on the row, because text-decoration propagates and a child cannot opt out. Vouchers-per-account discoverability. /reports/huvudbok?account=1930 already existed; the palette matcher requires every token and the entry never contained the word "verifikat". Add ReportDescriptor.searchTerms plus a report-library search box. Also fixes a false "Saknar underlag" compliance chip that flashed before attachment counts resolved, and a keyboard-access regression where HOVER_REVEAL_CLASS carried focus-visible only, hiding controls inside a non-focusable wrapper from keyboard users. No migration. No write paths, storno paths or posted entries touched. Follow-ups filed: #1300 #1301 #1302 #1303 #1304 #1305 #1306 #1307 #1308. Open decision: #1305 (Omförd vs Makulerad). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
78 lines
3.1 KiB
TypeScript
78 lines
3.1 KiB
TypeScript
/**
|
|
* Disambiguation of look-alike skattekonto rows.
|
|
*
|
|
* When Skatteverket issues a retroactive omprövningsbeslut it does not send one
|
|
* transaction: it splits the decision across every month it re-charges and
|
|
* sends one transaction per month. Those rows share transaktionsdatum,
|
|
* transaktionstext and belopp, and carry their own transaktionsidentitet. The
|
|
* only human-readable field that separates them is `ranteberakningsdatum`.
|
|
*
|
|
* The skattekonto table shows Datum / Händelse / Belopp, so such a decision
|
|
* renders as N pixel-identical rows. That reads as duplicates from the
|
|
* automatic Skatteverket fetch, which is a well-known failure mode in this
|
|
* market and has cost us at least one customer who booked all of them and then
|
|
* could not tell whether he had to reverse fourteen of them.
|
|
*
|
|
* We surface `ranteberakningsdatum` only on rows where it actually carries
|
|
* information, so it stays an exception marker (design convention 5) rather
|
|
* than noise repeated on every row. A row qualifies when either:
|
|
* 1. its interest date falls in a different month than the date shown in the
|
|
* Datum column (the retroactive-decision signature), or
|
|
* 2. another row in the same band is otherwise indistinguishable from it.
|
|
*
|
|
* Rule 2 catches the look-alikes the month rule alone would miss, such as a
|
|
* decision split inside a single month. It cannot separate rows that share a
|
|
* ränteberäkningsdatum, or that both lack one, since that is the only field
|
|
* left to distinguish them; in practice Skatteverket gives each month of a
|
|
* decision its own date, which is exactly the case this exists for.
|
|
*/
|
|
|
|
export interface InterestPeriodRow {
|
|
id: string
|
|
/** The date actually rendered in the Datum column for this row. */
|
|
displayDate: string
|
|
transaktionstext: string
|
|
belopp: number
|
|
ranteberakningsdatum: string | null | undefined
|
|
}
|
|
|
|
/** `yyyy-MM` from an ISO date or timestamp; '' when the input is unusable. */
|
|
function monthOf(value: string): string {
|
|
if (typeof value !== 'string' || value.length < 7) return ''
|
|
return value.slice(0, 7)
|
|
}
|
|
|
|
/**
|
|
* Identity as the table renders it. Two rows with the same key are
|
|
* indistinguishable to the reader.
|
|
*/
|
|
function renderedIdentity(row: InterestPeriodRow): string {
|
|
return `${row.displayDate}|${row.transaktionstext}|${row.belopp}`
|
|
}
|
|
|
|
/**
|
|
* Ids of the rows that should display their ränteberäkningsdatum.
|
|
*
|
|
* Pass one band (upcoming / overdue / booked) at a time: rows are only
|
|
* confusable with the rows they are rendered next to.
|
|
*/
|
|
export function rowsNeedingInterestDate(rows: InterestPeriodRow[]): Set<string> {
|
|
const seen = new Map<string, number>()
|
|
for (const row of rows) {
|
|
const key = renderedIdentity(row)
|
|
seen.set(key, (seen.get(key) ?? 0) + 1)
|
|
}
|
|
|
|
const result = new Set<string>()
|
|
for (const row of rows) {
|
|
if (!row.ranteberakningsdatum) continue
|
|
|
|
const spansAnotherMonth =
|
|
monthOf(row.ranteberakningsdatum) !== monthOf(row.displayDate)
|
|
const hasTwin = (seen.get(renderedIdentity(row)) ?? 0) > 1
|
|
|
|
if (spansAnotherMonth || hasTwin) result.add(row.id)
|
|
}
|
|
return result
|
|
}
|