Files
accounted/extensions/general/stripe/lib/transaction-sync.ts
T
f266c386f3 chore: repo-wide bloat sweep, remove dead code and fold duplicate helpers (#2150)
* chore: repo-wide bloat sweep, remove dead code and fold duplicate helpers

Remove 33 dead files, ~270 unreferenced exports/types, 13 dead i18n
namespaces and 4 unused dependencies; fold byte-identical helper copies
into one canonical home each (lib/utils chunk/sleep/utcDateStamp,
lib/dates/iso, lib/invariants/uuid, lib/xml/escape, lib/reports/sru/format,
lib/pdf/number-text, lib/browser/panel-request, lib/api/v1/body +
v1ValidationError rolled out to ~55 v1 routes, booking-template schemas).

No behaviour change: v1 bodies and status codes, MCP tool schemas, DB
writes and money math are untouched. Naive ore rounding was deliberately
not swapped for roundOre; see DECISIONS.md 2026-09-02 for the full list
of things left alone on purpose.

tsc, lint, 19588 unit tests and check:guards green; antipattern baseline
ratcheted (naive-ore-round 622 -> 620, hand-rolled-invariant 115 -> 113).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* test(transactions): import RawTransaction from @/types after the ingest re-export removal

CI's type ratchet (check:types, full tsconfig) caught the one test file
that still imported the type through lib/transactions/ingest.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-02 11:51:16 +02:00

587 lines
21 KiB
TypeScript

import { chunk as chunked } from '@/lib/utils'
import type Stripe from 'stripe'
import type { SupabaseClient } from '@supabase/supabase-js'
import { getStripe } from '@/lib/stripe/client'
import { ingestTransactions } from '@/lib/transactions/ingest'
import { ensureManualCashAccount } from '@/lib/cash-accounts/service'
import { syncMappedAccounts } from '@/lib/import/account-sync'
import { createLogger, type Logger } from '@/lib/logger'
import type { RawTransaction, TransactionMethod } from '@/types'
import { connectedAccountOptions, isRevokedConnectionError } from './connect'
import type { StripeConnection } from '../types'
const defaultLog = createLogger('stripe/transaction-sync')
/**
* Stripe balance-transaction sync: the connected Stripe balance treated as a
* bank feed.
*
* The Stripe balance becomes a cash account on ledger 1686 (Fordringar för
* kontokort), and the account's balance transactions land in the transactions
* inbox exactly like PSD2 bank rows: deduped on external_id, bound to the
* cash account so booking settles against 1686, and categorized/booked by the
* user through the normal flows. Nothing here auto-books.
*
* Row model (two-row gross+fee split): every balance transaction produces a
* main row for its gross amount, plus a fee row (negative) when Stripe
* deducted a fee. The feed then sums to Stripe's actual balance movements
* (+gross -fee per charge, -net per payout), which is what makes the cash
* account reconcile against the real Stripe balance.
*
* Double-booking protection: money the deterministic flows already booked is
* imported pre-linked to its journal entry instead of appearing bookable:
* - charge gross rows whose checkout session settled an invoice link to the
* settlement entry (stripe_payment_events.matched_booked),
* - payout rows AND the fee rows of the charges inside a booked payout link
* to the payout entry (stripe_payouts.booked): the payout booking carries
* the whole payout's fees in aggregate (see payouts.ts), so its 1686+6570
* movement equals the payout row plus those fee rows.
* Fee rows whose payout has not been booked yet stay unbooked and are claimed
* by processPayoutPaidEvent when the payout books (linkPayoutFeedRows below).
*
* Cursor: stripe_connections.last_balance_txn_synced_at (max `created`
* processed), re-polled with a 24h overlap. Safe because balance transactions
* are immutable and carry stable txn_... ids: a re-seen transaction collides
* on (company_id, external_id) and is skipped. First run backfills 90 days,
* floored at the day after the company lock date: rows behind the lock can
* never be booked and would only be permanent inbox noise.
*/
/** BAS ledger account for the Stripe balance cash account. */
export const STRIPE_LEDGER_ACCOUNT = '1686'
/** BAS 2026 name for 1686; used when creating the chart account. */
const STRIPE_LEDGER_ACCOUNT_NAME = 'Fordringar för kontokort och kuponger'
/** transactions.import_source for Stripe feed rows. */
export const STRIPE_IMPORT_SOURCE = 'stripe'
/** First-run backfill window (matches the Enable Banking convention). */
export const BACKFILL_DAYS = 90
/** Cursor re-poll overlap; external_id dedup makes duplicates no-ops. */
const CURSOR_OVERLAP_SECONDS = 24 * 60 * 60
/** Balance transactions per ingest chunk (each maps to at most 2 rows). */
const INGEST_CHUNK_SIZE = 200
/** Hard cap per run; the cursor resumes where a truncated run stopped. */
const MAX_TXNS_PER_RUN = 10_000
/**
* ⚠️ STORED-KEY FORMATS. These are persisted to transactions.external_id and
* dedup compares stored ids byte-for-byte, exactly like the Enable Banking
* scheme in lib/transactions/external-id.ts. Changing either template
* silently orphans every prior row and re-imports the whole feed on the next
* sync. Locked by the frozen-format test in transaction-sync.test.ts; any
* change MUST ship a coordinated backfill.
*/
export function stripeExternalId(stripeAccountId: string, balanceTxnId: string): string {
return `stripe_${stripeAccountId}_${balanceTxnId}`
}
/** Fee-split row id for a balance transaction (see stripeExternalId). */
export function stripeFeeExternalId(stripeAccountId: string, balanceTxnId: string): string {
return `stripe_${stripeAccountId}_${balanceTxnId}_fee`
}
export interface StripeTransactionSyncSummary {
/** Balance transactions listed from Stripe. */
fetched: number
/** New inbox rows inserted. */
imported: number
/** Rows skipped by external_id / content dedup. */
duplicates: number
/** Rows pre-linked to journal entries the deterministic flows already booked. */
linked: number
errors: number
/** Set when the caller's time budget ran out before all chunks processed. */
deadlineReached?: boolean
/** Set when the connection turned out to be revoked upstream. */
revoked?: boolean
}
const round = (n: number) => Math.round(n * 100) / 100
function isoDate(epochSeconds: number): string {
return new Date(epochSeconds * 1000).toISOString().split('T')[0]
}
/** Minimal shape shared by live Stripe objects and test fixtures. */
export type BalanceTxnLike = Pick<Stripe.BalanceTransaction, 'id' | 'type' | 'fee'> & {
amount: number
currency: string
created: number
description?: string | null
reporting_category?: string
source?: Stripe.BalanceTransaction['source']
}
function sourceId(source: BalanceTxnLike['source']): string | null {
if (!source) return null
return typeof source === 'string' ? source : source.id
}
/** Expanded charge behind a charge/payment balance transaction, if present. */
function chargeOf(txn: BalanceTxnLike): Stripe.Charge | null {
const source = txn.source
if (!source || typeof source === 'string') return null
return source.object === 'charge' ? (source as Stripe.Charge) : null
}
/**
* Swedish-first display title per balance-transaction type. Deterministic
* from immutable Stripe data (charges never change payer name after
* creation), so the same transaction always derives the same description:
* important because the content-dedup bridge keys off it.
*/
function describeBalanceTxn(txn: BalanceTxnLike): string {
switch (txn.type) {
case 'charge':
case 'payment': {
const charge = chargeOf(txn)
const detail =
charge?.billing_details?.name?.trim() ||
charge?.description?.trim() ||
sourceId(txn.source)
return detail ? `Stripe-betalning ${detail}` : 'Stripe-betalning'
}
case 'refund':
case 'payment_refund':
case 'payment_failure_refund':
return 'Stripe-återbetalning'
case 'adjustment':
// Disputes surface as adjustments; the reporting_category tells them apart.
if (txn.reporting_category === 'dispute') return 'Stripe-tvist'
return txn.description ? `Stripe-justering: ${txn.description}` : 'Stripe-justering'
case 'payout':
// Mirrors the payout journal entry description ("Stripe-utbetalning
// po_...") so the linked pair reads as one event.
return `Stripe-utbetalning ${sourceId(txn.source) ?? txn.id}`
default:
return txn.description ? `Stripe: ${txn.description}` : `Stripe ${txn.type}`
}
}
/**
* Payment rail per balance-transaction type: Stripe's `type` is a structured
* discriminator, so the feed sets transaction_method explicitly instead of
* letting the ingest boundary guess from the description string. Charges and
* their refunds travel the card rail; payouts are transfers to the bank
* account; Stripe's own billing/tax deductions are fees; disputes surface as
* adjustments. Unknown types return null (unclassified).
*/
function methodForBalanceTxn(txn: BalanceTxnLike): TransactionMethod | null {
// Widened to string: live Stripe sends types the SDK union doesn't model
// (e.g. 'tax' for automatic-tax deductions).
switch (txn.type as string) {
case 'charge':
case 'payment':
case 'refund':
case 'payment_refund':
case 'payment_failure_refund':
return 'card'
case 'payout':
return 'transfer'
case 'stripe_fee':
case 'stripe_fx_fee':
case 'tax':
return 'fee'
case 'adjustment':
return 'adjustment'
default:
return null
}
}
/**
* Map one balance transaction to its feed row(s): a main row for the gross
* amount and, when Stripe deducted a fee, a separate negative fee row. Dates
* use `created` (when the money moved: the economic event), NOT
* `available_on` (Stripe's internal settlement schedule, days later): booked
* entries, invoice matching, and month boundaries all want the payment date.
*/
export function mapBalanceTransaction(
stripeAccountId: string,
txn: BalanceTxnLike,
): RawTransaction[] {
const date = isoDate(txn.created)
const currency = txn.currency.toUpperCase()
const description = describeBalanceTxn(txn)
const rows: RawTransaction[] = [
{
date,
description,
amount: round(txn.amount / 100),
currency,
external_id: stripeExternalId(stripeAccountId, txn.id),
import_source: STRIPE_IMPORT_SOURCE,
transaction_method: methodForBalanceTxn(txn),
},
]
if (txn.fee) {
rows.push({
date,
description: `Stripe-avgift (${description})`,
amount: round(-txn.fee / 100),
currency,
external_id: stripeFeeExternalId(stripeAccountId, txn.id),
import_source: STRIPE_IMPORT_SOURCE,
transaction_method: 'fee',
})
}
return rows
}
/**
* Link a booked payout's feed rows to the payout journal entry: the payout
* row itself plus every fee row among the payout's balance transactions (the
* payout entry books those fees in aggregate: 6570 + reverse charge). Called
* from processPayoutPaidEvent at booking time AND from the sync when it
* imports rows for an already-booked payout; idempotent either way (only
* unlinked rows are claimed) and a no-op for companies without the feed.
*/
export async function linkPayoutFeedRows(
supabase: SupabaseClient,
companyId: string,
stripeAccountId: string,
journalEntryId: string,
txns: Array<Pick<BalanceTxnLike, 'id' | 'type' | 'fee'>>,
log: Logger = defaultLog,
): Promise<number> {
const externalIds: string[] = []
for (const txn of txns) {
if (txn.type === 'payout') externalIds.push(stripeExternalId(stripeAccountId, txn.id))
if (txn.fee) externalIds.push(stripeFeeExternalId(stripeAccountId, txn.id))
}
if (externalIds.length === 0) return 0
const { data, error } = await supabase
.from('transactions')
.update({ journal_entry_id: journalEntryId })
.eq('company_id', companyId)
.in('external_id', externalIds)
.is('journal_entry_id', null)
.select('id')
if (error) {
// Non-fatal by contract: the payout booking itself must never unwind
// because feed-row linking failed. The sync's next run retries.
log.warn('failed to link payout feed rows', {
companyId,
journalEntryId,
error: error.message,
})
return 0
}
return data?.length ?? 0
}
/**
* Window start (epoch seconds) for the balance-transaction list call. With a
* cursor: cursor minus the 24h overlap. First run: BACKFILL_DAYS back,
* floored at the day AFTER the company lock date (rows on/before it are
* unbookable by the enforce_company_lock_date trigger).
*/
async function resolveWindowStartSeconds(
supabase: SupabaseClient,
connection: StripeConnection,
): Promise<number> {
if (connection.last_balance_txn_synced_at) {
const cursorSec = Math.floor(Date.parse(connection.last_balance_txn_synced_at) / 1000)
return Math.max(0, cursorSec - CURSOR_OVERLAP_SECONDS)
}
let startMs = Date.now() - BACKFILL_DAYS * 86_400_000
const { data: settings } = await supabase
.from('company_settings')
.select('bookkeeping_locked_through')
.eq('company_id', connection.company_id)
.maybeSingle()
const lockThrough = (settings as { bookkeeping_locked_through?: string | null } | null)
?.bookkeeping_locked_through
if (lockThrough) {
const firstBookableMs = Date.parse(`${lockThrough}T00:00:00Z`) + 86_400_000
if (Number.isFinite(firstBookableMs)) startMs = Math.max(startMs, firstBookableMs)
}
return Math.max(0, Math.floor(startMs / 1000))
}
/**
* Make sure the Stripe balance cash account exists (ledger 1686, source
* manual so a later remap/promotion follows the normal cash-account rules)
* and, on the first run, that 1686 exists in the chart of accounts: the
* booking dialog and AccountPicker only list chart accounts.
*/
async function ensureStripeBalanceAccount(
supabase: SupabaseClient,
connection: StripeConnection,
firstRun: boolean,
log: Logger,
): Promise<void> {
await ensureManualCashAccount(
supabase,
connection.company_id,
STRIPE_LEDGER_ACCOUNT,
'SEK',
'Stripe-saldo',
)
if (firstRun) {
const sync = await syncMappedAccounts(
supabase,
connection.company_id,
connection.user_id,
[
{
sourceAccount: STRIPE_LEDGER_ACCOUNT,
sourceName: STRIPE_LEDGER_ACCOUNT_NAME,
targetAccount: STRIPE_LEDGER_ACCOUNT,
targetName: STRIPE_LEDGER_ACCOUNT_NAME,
confidence: 1,
matchType: 'exact',
isOverride: false,
},
],
false,
)
if (sync.error) {
// Rows still import and bind to the cash account; only the chart
// listing is affected (the account can be added manually), so this is
// deliberately non-fatal.
log.warn('chart sync for 1686 failed', {
companyId: connection.company_id,
error: sync.error,
})
}
}
}
/** Gross rows of charges the checkout flow already settled → settlement entry. */
async function linkSettledCharges(
supabase: SupabaseClient,
connection: StripeConnection,
txns: BalanceTxnLike[],
log: Logger,
): Promise<number> {
const grossIdByPaymentIntent = new Map<string, string>()
for (const txn of txns) {
if (txn.type !== 'charge' && txn.type !== 'payment') continue
const charge = chargeOf(txn)
const pi =
typeof charge?.payment_intent === 'string'
? charge.payment_intent
: charge?.payment_intent?.id
if (!pi) continue
grossIdByPaymentIntent.set(pi, stripeExternalId(connection.stripe_account_id!, txn.id))
}
if (grossIdByPaymentIntent.size === 0) return 0
const { data: events, error } = await supabase
.from('stripe_payment_events')
.select('payment_intent_id, journal_entry_id')
.eq('connection_id', connection.id)
.eq('status', 'matched_booked')
.not('journal_entry_id', 'is', null)
.in('payment_intent_id', [...grossIdByPaymentIntent.keys()])
if (error) {
log.warn('settled-charge lookup failed; rows stay unlinked this run', {
connectionId: connection.id,
error: error.message,
})
return 0
}
if (!events || events.length === 0) return 0
const externalIdsByEntry = new Map<string, string[]>()
for (const event of events as Array<{
payment_intent_id: string | null
journal_entry_id: string | null
}>) {
if (!event.payment_intent_id || !event.journal_entry_id) continue
const externalId = grossIdByPaymentIntent.get(event.payment_intent_id)
if (!externalId) continue
const ids = externalIdsByEntry.get(event.journal_entry_id)
if (ids) ids.push(externalId)
else externalIdsByEntry.set(event.journal_entry_id, [externalId])
}
let linked = 0
for (const [journalEntryId, externalIds] of externalIdsByEntry) {
const { data, error: linkError } = await supabase
.from('transactions')
.update({ journal_entry_id: journalEntryId })
.eq('company_id', connection.company_id)
.in('external_id', externalIds)
.is('journal_entry_id', null)
.select('id')
if (linkError) {
log.warn('settled-charge link failed', {
connectionId: connection.id,
journalEntryId,
error: linkError.message,
})
continue
}
linked += data?.length ?? 0
}
return linked
}
/**
* Payout rows in this chunk whose payout the payout flow already booked:
* link the payout row + the payout's fee rows to the payout entry. One extra
* Stripe list call per booked payout (bounded by payouts in the window; the
* balance transaction itself does not reference its payout).
*/
async function linkBookedPayouts(
supabase: SupabaseClient,
connection: StripeConnection,
txns: BalanceTxnLike[],
stripe: Stripe,
requestOptions: Stripe.RequestOptions,
log: Logger,
): Promise<number> {
const payoutIds: string[] = []
for (const txn of txns) {
if (txn.type !== 'payout') continue
const id = sourceId(txn.source)
if (id) payoutIds.push(id)
}
if (payoutIds.length === 0) return 0
const { data: payouts, error } = await supabase
.from('stripe_payouts')
.select('payout_id, journal_entry_id')
.eq('connection_id', connection.id)
.eq('status', 'booked')
.not('journal_entry_id', 'is', null)
.in('payout_id', payoutIds)
if (error) {
log.warn('booked-payout lookup failed; rows stay unlinked this run', {
connectionId: connection.id,
error: error.message,
})
return 0
}
let linked = 0
for (const payout of (payouts ?? []) as Array<{
payout_id: string
journal_entry_id: string
}>) {
const payoutTxns = await stripe.balanceTransactions
.list({ payout: payout.payout_id, limit: 100 }, requestOptions)
.autoPagingToArray({ limit: 1000 })
linked += await linkPayoutFeedRows(
supabase,
connection.company_id,
connection.stripe_account_id!,
payout.journal_entry_id,
payoutTxns,
log,
)
}
return linked
}
export async function syncStripeBalanceTransactions(
supabase: SupabaseClient,
connection: StripeConnection,
log: Logger = defaultLog,
/**
* Absolute deadline (epoch ms) from the caller's time budget. Enforced
* between ingest chunks: the cursor advances only over processed chunks, so
* the next run resumes exactly where this one stopped.
*/
deadlineMs?: number,
): Promise<StripeTransactionSyncSummary> {
const summary: StripeTransactionSyncSummary = {
fetched: 0,
imported: 0,
duplicates: 0,
linked: 0,
errors: 0,
}
if (!connection.stripe_account_id || connection.status !== 'active') return summary
const stripe = getStripe()
const requestOptions = connectedAccountOptions(connection.stripe_account_id)
const firstRun = !connection.last_balance_txn_synced_at
const gte = await resolveWindowStartSeconds(supabase, connection)
let txns: BalanceTxnLike[]
try {
txns = await stripe.balanceTransactions
.list({ created: { gte }, limit: 100, expand: ['data.source'] }, requestOptions)
.autoPagingToArray({ limit: MAX_TXNS_PER_RUN })
} catch (err) {
if (isRevokedConnectionError(err)) {
// The event sync polls every 15 minutes and owns the revoked-status
// transition + audit event; this nightly pass just reports and stops.
summary.revoked = true
return summary
}
throw err
}
summary.fetched = txns.length
if (txns.length === 0) return summary
// Oldest first: cursor advancement stays chronological, and a payout's
// charges are always ingested before (or with) the payout row their fee
// linking depends on.
txns.sort((a, b) => a.created - b.created)
await ensureStripeBalanceAccount(supabase, connection, firstRun, log)
for (const chunk of chunked(txns, INGEST_CHUNK_SIZE)) {
if (deadlineMs !== undefined && Date.now() >= deadlineMs) {
summary.deadlineReached = true
log.info('time budget exhausted; stopping balance-transaction batch', {
connectionId: connection.id,
processed: summary.imported + summary.duplicates,
remaining: summary.fetched - summary.imported - summary.duplicates,
})
break
}
const rows = chunk.flatMap((txn) =>
mapBalanceTransaction(connection.stripe_account_id!, txn),
)
// Auto-categorization is skipped on purpose: for Stripe money the
// deterministic settle/payout flows own booking; everything else is a
// human decision in the inbox. Invoice matching still runs (suggestions
// only), and FX enrichment covers non-SEK rows.
const result = await ingestTransactions(
supabase,
connection.company_id,
connection.user_id,
rows,
{ settlementAccount: STRIPE_LEDGER_ACCOUNT, skipAutoCategorization: true },
)
summary.imported += result.imported
summary.duplicates += result.duplicates
summary.errors += result.errors
summary.linked += await linkSettledCharges(supabase, connection, chunk, log)
summary.linked += await linkBookedPayouts(
supabase,
connection,
chunk,
stripe,
requestOptions,
log,
)
// Persist the cursor after each chunk so a crash or deadline stop resumes
// from the last fully-processed chunk (the 24h overlap absorbs the rest).
const maxCreated = chunk[chunk.length - 1].created
await supabase
.from('stripe_connections')
.update({ last_balance_txn_synced_at: new Date(maxCreated * 1000).toISOString() })
.eq('id', connection.id)
connection.last_balance_txn_synced_at = new Date(maxCreated * 1000).toISOString()
}
log.info('stripe balance-transaction sync done', {
connectionId: connection.id,
...summary,
})
return summary
}