Files
accounted/app/(dashboard)/hem-sections.tsx
T
20925f6c65 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>
2026-09-08 21:25:01 +02:00

268 lines
10 KiB
TypeScript

import { after } from 'next/server'
import NewUserChecklist from '@/components/onboarding/NewUserChecklist'
import AttGoraSection from '@/components/dashboard/AttGoraSection'
import ResumePane from '@/components/dashboard/ResumePane'
import { HemNotices } from '@/components/dashboard/HemNotices'
import {
getWorklistCounts,
listExpensePayoutsDue,
listSkattekontoPaymentDue,
listSuggestedMatches,
SUGGESTED_MATCH_SCAN_CAP,
} from '@/lib/worklist'
import { listResumeItems } from '@/lib/worklist/resume'
import { getCompanyNotices } from '@/lib/notices'
import { expiringBankConnectionsFrom } from '@/lib/notices/categories'
import { vatDeadlineLine } from '@/lib/onboarding/checklist'
import type { InitialSetupState, MomsPeriod, OnboardingProgress } from '@/types'
import { getDashboardAuthContext } from './request-context'
/**
* Hem's streamed sections. Each is an async server component rendered behind
* its own <Suspense> in page.tsx, so the greeting shell paints as soon as the
* page's small first wave resolves and each block fills in when its own
* queries land. The Supabase client comes from the request-cached auth
* context, never from props (not serialisable).
*/
type BankConnectionRow = {
id: string
status: string
consent_expires: string | null
bank_name: string
last_sie_sweep: unknown
}
type SieSweepSummaryLite = {
auto_linked?: number
suggested?: number
unmatched?: number
errors?: number
ran_at?: string
}
export async function HemNoticesSection({
companyId,
userId,
now,
}: {
companyId: string
userId: string
now: Date
}) {
const { supabase } = await getDashboardAuthContext()
// Degraded-state notices (lib/notices): broken/expiring bank connections,
// Skatteverket reconnect, failing backups, the wrong-account hint (#1231),
// priority-ordered and dismissal-filtered. The stale-dismissal reap is
// hygiene and runs after the response (Next `after`), not on the read path.
const notices = await getCompanyNotices(supabase, companyId, {
userId,
now,
deferReap: (task) => after(task),
})
return <HemNotices notices={notices} />
}
export async function HemChecklistSection({
companyId,
userId,
now,
initialSetup,
hasMcpKey,
vatRegistered,
momsPeriod,
}: {
companyId: string
userId: string
now: Date
initialSetup: InitialSetupState
/** Live OAuth-minted MCP key exists for this user: see claudeStepDone(). */
hasMcpKey: boolean
vatRegistered: boolean
momsPeriod: MomsPeriod | null
}) {
const { supabase } = await getDashboardAuthContext()
const [
{ count: customerCount },
{ count: invoiceCount },
{ count: transactionCount },
{ data: bankConnections },
{ count: sieImportCount },
{ count: skatteverketTokenCount },
{ count: inboxItemCount },
{ data: nextVatDeadline },
{ data: latestFileSweep },
] = await Promise.all([
supabase.from('customers').select('*', { count: 'exact', head: true }).eq('company_id', companyId),
supabase.from('invoices').select('*', { count: 'exact', head: true }).eq('company_id', companyId),
supabase.from('transactions').select('*', { count: 'exact', head: true }).eq('company_id', companyId),
supabase.from('bank_connections').select('id, status, consent_expires, bank_name, last_sie_sweep').eq('company_id', companyId).eq('status', 'active'),
supabase.from('sie_imports').select('*', { count: 'exact', head: true }).eq('company_id', companyId).eq('status', 'completed'),
// Skatteverket connections are per (user, company): filtering on user_id
// alone made a connection on ANY of the user's companies hide the connect
// nudge on all of them.
supabase.from('skatteverket_tokens').select('*', { count: 'exact', head: true }).eq('user_id', userId).eq('company_id', companyId),
// Any item ever received in the document inbox (email/WhatsApp/upload)
// marks the receipts checklist step done: same "has ever done X" shape
// as the other flags above.
supabase.from('invoice_inbox_items').select('*', { count: 'exact', head: true }).eq('company_id', companyId),
// Next upcoming momsdeklaration for the checklist's Skatteverket step.
// Rows are system-generated per company settings; dismissed rows are
// excluded everywhere deadlines are listed, so here too.
supabase
.from('deadlines')
.select('due_date')
.eq('company_id', companyId)
.in('tax_deadline_type', ['moms_monthly', 'moms_quarterly', 'moms_yearly'])
.eq('is_completed', false)
.is('dismissed_at', null)
.gte('due_date', now.toISOString().slice(0, 10))
.order('due_date', { ascending: true })
.limit(1)
.maybeSingle(),
// Latest bank-file SIE sweep (PSD2 sync stamps bank_connections.last_sie_sweep
// instead). Feeds the checklist's bank step with "X matchade, Y att
// granska". Best-effort: absent rows just render no note.
supabase
.from('bank_file_imports')
.select('sie_sweep')
.eq('company_id', companyId)
.not('sie_sweep', 'is', null)
.order('created_at', { ascending: false })
.limit(1)
.maybeSingle(),
])
const connections = (bankConnections ?? []) as BankConnectionRow[]
const onboardingProgress: OnboardingProgress = {
hasCustomers: (customerCount || 0) > 0,
hasInvoices: (invoiceCount || 0) > 0,
hasBankConnected: connections.length > 0 || (transactionCount || 0) > 0,
hasSIEImport: (sieImportCount || 0) > 0,
hasSkatteverketConnected: (skatteverketTokenCount || 0) > 0,
hasInboxItems: (inboxItemCount || 0) > 0,
}
const vatLine = vatDeadlineLine({
vatRegistered,
momsPeriod,
nextVatDueDate: nextVatDeadline?.due_date ?? null,
})
const sweepCandidates: SieSweepSummaryLite[] = [
...connections
.map((c) => c.last_sie_sweep as SieSweepSummaryLite | null)
.filter((s): s is SieSweepSummaryLite => Boolean(s)),
...(latestFileSweep?.sie_sweep ? [latestFileSweep.sie_sweep as SieSweepSummaryLite] : []),
]
const sieSweep =
sweepCandidates.length > 0
? sweepCandidates.reduce((a, b) => ((a.ran_at ?? '') >= (b.ran_at ?? '') ? a : b))
: null
return (
<NewUserChecklist
initialState={initialSetup}
hasBookkeepingImported={onboardingProgress.hasSIEImport}
hasBankConnected={onboardingProgress.hasBankConnected}
hasSkatteverketConnected={onboardingProgress.hasSkatteverketConnected}
hasInboxItems={onboardingProgress.hasInboxItems}
hasMcpKey={hasMcpKey}
vatLine={vatLine}
sieSweep={
sieSweep
? {
auto_linked: sieSweep.auto_linked ?? 0,
suggested: sieSweep.suggested ?? 0,
unmatched: sieSweep.unmatched ?? 0,
errors: sieSweep.errors ?? 0,
}
: null
}
/>
)
}
export async function HemPanesSection({
companyId,
now,
setupOpen,
}: {
companyId: string
now: Date
setupOpen: boolean
}) {
const { supabase } = await getDashboardAuthContext()
// Fetched once at the scan cap: the Att göra pane shows the first five and
// the worklist count is the list's length (it used to scan the same rows
// twice). Everything else runs in the same wave.
const suggestedMatchesPromise = listSuggestedMatches(supabase, companyId, SUGGESTED_MATCH_SCAN_CAP)
// 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)
// 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'),
// Posted entries distinguish "brand-new empty ledger" from "all caught
// up" in the Att göra empty state (hits the partial posted/reversed index).
supabase.from('journal_entries').select('*', { count: 'exact', head: true }).eq('company_id', companyId).in('status', ['posted', 'reversed']),
])
// "Empty ledger" only matters while the setup checklist is still open; once
// it is completed or dismissed the ordinary all-clear copy applies. A failed
// count must NOT read as empty: that would tell a company with real
// bookkeeping that its ledger is blank, so errors degrade to the normal copy.
const emptyLedger = setupOpen && !postedEntries.error && (postedEntries.count || 0) === 0
const bankConnections = bankConnectionsRes.data
// Same degrade-on-error rule as emptyLedger: a failed fetch must NOT tell a
// connected company that no bank is connected, so errors read as connected.
const hasActiveBankConnection = !!bankConnectionsRes.error || (bankConnections ?? []).length > 0
// Same day-math as the bank_connection_expiring notice predicate
// (lib/notices/categories.ts): the Bevaka row and the notice can never
// disagree on the threshold.
const expiringBankConnections = expiringBankConnectionsFrom(
(bankConnections ?? []) as BankConnectionRow[],
now,
)
return (
<div className={resumeItems.length > 0 ? 'grid items-start gap-x-6 gap-y-8 md:grid-cols-2' : undefined}>
<AttGoraSection
worklist={worklist}
suggestedMatches={suggestedMatches.slice(0, 5)}
expensePayouts={expensePayouts}
skattekontoPayment={skattekontoPayment}
expiringBankConnections={expiringBankConnections}
emptyLedger={emptyLedger}
hasActiveBankConnection={hasActiveBankConnection}
/>
<ResumePane items={resumeItems} />
</div>
)
}