d840257c0c
* fix(mcp-oauth): allow ChatGPT connector callbacks and resume OAuth after login Add chatgpt.com/connector/oauth/* (per-instance) and the legacy chatgpt.com/connector_platform_oauth_redirect to the built-in OAuth redirect allowlist so ChatGPT MCP connectors can register and authorize. Fix the login page dropping the ?next= destination: an OAuth-initiated visit that required login previously ended on the dashboard and the connection flow silently died. Login now resumes to the sanitized next path (hard navigation, since the consent page is route-handler HTML), carries it through the MFA step-up as returnTo, and /mfa/verify hard-navigates for /api/ destinations. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(transactions): dedup incoming feed rows against booked hand-entered twins Users who bookkeep via MCP/chat first and connect their bank afterwards got the same movement twice: the synced row's external_id lives in a different namespace, the free-form manual title never text-bridges the bank's raw string, and the cross-channel mirror deliberately excluded manual/mcp rows. Extend the mirror with a booked-hand-entered track: an incoming feed row is skipped when a BOOKED manual/mcp row shares its (date, ore) bucket count- symmetrically. Gates beyond the feed-vs-feed mirror: stored row must be booked (staged rows never consume an import), currencies must not contradict (bucket key is date+ore only), the cash-account guard applies to the count exactly as to consumption, and symmetry uses the Layer-1-unmatched incoming count so an already-stored row cannot inflate it. Consumption stamps the batch cash_account_id onto an account-unbound hand row, so one hand row can never consume feed rows on other accounts in later syncs. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(bookkeeping): inline verifikat rattelse (strike lines + text/date edit) Second sanctioned correction track under BFL 5 kap 5/9 pp, Fortnox-style: strike lines inside a posted verifikat with replacements in the same voucher, and correct description/entry_date without an andringsverifikat. Envelope: posted entries, open unlocked periods, company lock date, same-period date moves, structural/FX/doc-linked lines excluded, and a reconciliation guard preserving per-account net on bank/reskontra sides of externally linked entries. Every rattelse writes an immutable who/when row (journal_entry_rattelse_log, WORM, archived as rakenskapsinformation) and struck originals render struck-through in the verifikat; list rows and the detail header carry a Rattad marker. CLAUDE.md hard rule 1 and the swedish-accounting-compliance skill are amended to state the two-track rule. Staging carries the DDL; prod gets it on merge. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat: live saldo in booking form, prior-year window comparison, hideable assistant FAB - Manual journal entry: saldo column now shows before -> after computed from the typed debit/credit amounts (direction feedback while booking) - Resultatrapport: a narrowed date range now compares against the same window shifted one year back (#862), merged across fiscal periods for brutet rakenskapsar; P&L rows report window activity instead of rolled-forward YTD closing - Assistant FAB: per-user hide toggle (user_preferences.hide_assistant_fab, settings > assistant), sidebar entry unaffected; collapsed sessions keep their reopen handle Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(stripe): sync balance transactions as a bank feed on 1686 Import the connected Stripe balance into the transactions inbox, opt-in per connection (transaction_sync_enabled on stripe_connections): - Balance transactions map to feed rows with the two-row gross+fee split and frozen external_id formats (stripe_{acct}_{txn} / _fee), dated on created, bound to a provisioned "Stripe-saldo" cash account on 1686 so booking settles against the clearing account by construction. - Double-booking protection: settled payment-link charges import pre-linked to their settlement entry; payout rows import pre-linked to the payout entry; processPayoutPaidEvent claims the payout's fee rows at booking time (linkPayoutFeedRows, idempotent from both directions). - Cursor last_balance_txn_synced_at with 24h overlap; first run backfills 90 days floored at the day after the company lock date. - Nightly cron /api/extensions/stripe/transactions/cron (03:30), transaction-sync toggle route, "Synka nu" covers both feeds, settings panel toggle with last-synced/backfill note, sv+en strings. - Migration 20260723200000 (applied to staging). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(transactions): offer match-to-voucher on unbooked history rows Unbooked transactions with is_business already set (e.g. left behind when a voucher was removed without a full uncategorize) land in the history list instead of the inbox, where the match-against-existing-voucher action did not exist, leaving them with no path back to voucher matching. Add the same menu item to the history list for unbooked rows. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(transactions): enhance ownership checks and error handling in journal entry routes --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
559 lines
20 KiB
TypeScript
559 lines
20 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 } 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}`
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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,
|
|
},
|
|
]
|
|
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,
|
|
})
|
|
}
|
|
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
|
|
}
|