* feat(transactions): structured transaction_method instead of channel-in-the-name
Swedish bank feeds embed the payment channel in the description string
("Vercel Jul Överföring via internet", "ANTHROPIC* ... Kortköp/uttag"):
the PSD2 remittance array is joined into one string and the ISO 20022
type codes were dropped at insert. This promotes the channel to data:
- transactions.transaction_method (text + CHECK closed vocabulary: card,
transfer, bankgiro, plusgiro, swish, autogiro, e_invoice, international,
deposit, withdrawal, salary, fee, interest, adjustment) plus verbatim
bank_transaction_code / proprietary_bank_transaction_code evidence
columns (data_quality_master Appendix B "Layer-A capture").
- classifyTransactionMethod() in lib/transactions/transaction-method.ts:
explicit source method (Stripe txn.type) > trailing Swedish channel
phrase > ISO 20022 family/subfamily > proprietary-code keywords > MCC.
It also splits the clean display title off the description.
- Ingest stores the clean title as description and the full bank string
as original_description; dedup is untouched (external_id is date+öre,
the content bridge reads original_description and is prefix-based, and
a trailing strip leaves a prefix). Enable Banking passes the codes
through; the Stripe feed sets methods from its balance-txn types.
- Backfill migration classifies existing rows from the description text
(+ MCC and Stripe prefixes) and strips unedited titles; user-edited
titles are never rewritten.
- mapping-engine also matches original_description so user rules written
against the full bank text keep firing.
- UI: the inbox row shows the clean name; clicking it now folds out
"Betalsätt: Kortköp" etc. (sv/en), making every classified row
expandable.
A card purchase implies a physical receipt, a Bankgiro/e-invoice payment
implies a supplier invoice: downstream automations can now branch on the
rail instead of regexing display strings.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(transactions): anchor counterparty-template identity on original_description
Audit follow-up to the phrase-strip change: counterparty template lookup
AND learning derived their key from merchant_name || description. With
the working title now stripped ("SPOTIFY AB Kortköp" -> "SPOTIFY AB"),
templates learned from the full bank string would only re-match via the
occurrence-gated single-token tier, and single-token counterparties with
fewer than 3 bookings would silently stop matching.
Both sides now read merchant_name || original_description || description:
the immutable bank original is identical across eras (and across user
renames), so every stored key and alias keeps matching exactly. Same
anchoring rationale as buildMerchantHistory in category-suggestions.
Existing tests that relied on the fixture's default original_description
now state it explicitly; two new regression tests pin the era stability
(lookup via alias on the full string, learning key derivation).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(transactions): review follow-ups on method classification
- methodFromCodes: two-pass subfamily-then-family scan so a SALA/XBCT
refinement on the proprietary code beats a bare family match on the
ISO code, matching the documented precedence; pinned by a test.
- mapping-engine: regression tests for merchant/description patterns
that only match original_description, including the invalid-regex
substring fallback and the no-match default.
- Stripe: regression test for the SDK-unmodeled 'tax' balance-txn type
mapping to 'fee'.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(transactions): scope method classification to feed rows + adjective guard
Multi-bank risk hardening before the backfill ships:
- Feed-row scope: classification and title stripping now require a real
import feed (import_source present, not manual/mcp), both at the
ingest boundary (USER_CREATED_IMPORT_SOURCES, now exported) and in
every backfill statement. User-authored titles like "Egen insättning"
on manual/MCP rows are never classified and never rewritten.
- Adjective guard (TS + SQL): a strip that would leave the title ending
in a possessive/scope adjective (egen/eget/privat/intern/extern ...)
is skipped, so "Egen insättning" stays whole even on bank-feed rows;
the method column still classifies (deposit).
- Unknown bank phrasings remain untouched by construction: an unmatched
phrase means no method and no rewrite, so the worst case for any bank
whose vocabulary we have not seen is the status quo.
Pinned by new unit + pg-real cases (user-created exclusion for
NULL/manual/mcp, adjective guard, feed defaults in the pg fixture).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* chore(migrations): re-timestamp transaction_method migrations after rebase
Main gained migrations dated 20260729-20260730 (already applied to prod)
while this branch carried 20260728 versions, which would have applied
out-of-order on merge. The files have never reached prod, so renaming to
current timestamps is safe and removes any dependence on the integration's
out-of-order handling. All code/doc references updated; the pg test reads
the backfill by its new filename.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* chore(migrations): bump transaction_method versions past prod's max
Main's newest applied migration is 20260730090000 (future-leaning
timestamp), so the previous 202607300731xx rename still sorted before
prod's tail and risked a silent skip on merge-time apply. Versions are
now 20260730100000/20260730100100, strictly after everything applied to
prod. References updated; full migration stream replays clean.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(transactions): final review round: keyboard guard + bank_connection_id feed marker
- TransactionInboxCard: row-level Enter/Space handling now ignores events
bubbling from nested controls, so keyboard activation of Bokför / the
overflow menu is no longer cancelled by the (now much more common)
expandable row.
- Feed predicate parity with isImportedTransaction(): a live
bank_connection_id marks a feed row even when import_source is unset
(the oldest PSD2 rows predate that column), in both the ingest
classifier and every backfill statement: those legacy rows now get
classified instead of being skipped as user-created.
- pg fixture typing uses the TransactionMethod union.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* chore(migrations): re-timestamp transaction_method migrations past prod's 20260807 tail
Prod max applied is 20260807170000 (verified by name via list_migrations);
the 20260730-stamped pair would sort before it. References in code,
tests, and DECISIONS.md updated to the new versions.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(migrations): enforce, not assume, original_description preservation in the title strip
The strip UPDATE now fills a NULL original_description from the
pre-strip description in the same statement. Prod has zero such rows
(0/25,566 feed-scope rows, verified read-only), and 20260605120000's
backfill plus ingest make the NULL case unreachable on any DB that
replayed history, but the migration should not depend on that history
to avoid losing the only copy of a bank string.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* docs: record the compliance-review triage of the backfill's booked-row title strip
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Jakob Wennberg <jakob.wennberg@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
592 lines
21 KiB
TypeScript
592 lines
21 KiB
TypeScript
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
|
|
}
|
|
|
|
function chunked<T>(items: T[], size: number): T[][] {
|
|
const out: T[][] = []
|
|
for (let i = 0; i < items.length; i += size) out.push(items.slice(i, i + size))
|
|
return out
|
|
}
|
|
|
|
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
|
|
}
|