Add/stripe connect transactions (#1139)
* 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>
This commit is contained in:
co-authored by
Claude Fable 5
parent
be9d630347
commit
d840257c0c
@@ -15,7 +15,14 @@ vi.mock('@/lib/bookkeeping/engine', () => ({
|
||||
findFiscalPeriod: vi.fn(),
|
||||
}))
|
||||
|
||||
// The feed-row claim is transaction-sync's concern; here we only assert the
|
||||
// payout flow invokes it with the booked entry and the payout's balance txns.
|
||||
vi.mock('../lib/transaction-sync', () => ({
|
||||
linkPayoutFeedRows: vi.fn().mockResolvedValue(0),
|
||||
}))
|
||||
|
||||
import { createJournalEntry, findFiscalPeriod } from '@/lib/bookkeeping/engine'
|
||||
import { linkPayoutFeedRows } from '../lib/transaction-sync'
|
||||
import { processPayoutPaidEvent } from '../lib/payouts'
|
||||
import type { StripeConnection } from '../types'
|
||||
|
||||
@@ -30,6 +37,8 @@ const CONNECTION: StripeConnection = {
|
||||
display_name: null,
|
||||
last_event_created_at: null,
|
||||
last_event_id: null,
|
||||
transaction_sync_enabled: false,
|
||||
last_balance_txn_synced_at: null,
|
||||
error_message: null,
|
||||
connected_at: '2026-07-01T00:00:00.000Z',
|
||||
disconnected_at: null,
|
||||
@@ -120,6 +129,46 @@ describe('processPayoutPaidEvent', () => {
|
||||
expect(Math.round(debits * 100)).toBe(Math.round(credits * 100))
|
||||
})
|
||||
|
||||
it('claims the payout feed rows against the booked entry', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: [{ id: 'po-row-1' }] }) // claim
|
||||
enqueue({ data: { vat_registered: true } }) // company_settings
|
||||
enqueue({ data: null }) // finalize row
|
||||
|
||||
await processPayoutPaidEvent(
|
||||
supabase as unknown as SupabaseClient,
|
||||
CONNECTION,
|
||||
makePayoutEvent(),
|
||||
)
|
||||
|
||||
expect(vi.mocked(linkPayoutFeedRows)).toHaveBeenCalledTimes(1)
|
||||
const [, companyId, accountId, journalEntryId, txns] =
|
||||
vi.mocked(linkPayoutFeedRows).mock.calls[0]
|
||||
expect(companyId).toBe('company-1')
|
||||
expect(accountId).toBe('acct_1')
|
||||
expect(journalEntryId).toBe('je-po-1')
|
||||
expect(txns).toEqual(CLEAN_TXNS)
|
||||
})
|
||||
|
||||
it('does not claim feed rows when the payout is not auto-booked', async () => {
|
||||
stubBalanceTxns([
|
||||
...CLEAN_TXNS,
|
||||
{ type: 'refund', amount: -10000, fee: 0, currency: 'sek' },
|
||||
])
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: [{ id: 'po-row-1' }] })
|
||||
enqueue({ data: { vat_registered: true } })
|
||||
enqueue({ data: null })
|
||||
|
||||
await processPayoutPaidEvent(
|
||||
supabase as unknown as SupabaseClient,
|
||||
CONNECTION,
|
||||
makePayoutEvent(),
|
||||
)
|
||||
|
||||
expect(vi.mocked(linkPayoutFeedRows)).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('skips a payout already claimed by an earlier run', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: [] }) // claim conflict
|
||||
|
||||
@@ -28,6 +28,8 @@ const CONNECTION: StripeConnection = {
|
||||
display_name: 'Test AB',
|
||||
last_event_created_at: null,
|
||||
last_event_id: null,
|
||||
transaction_sync_enabled: false,
|
||||
last_balance_txn_synced_at: null,
|
||||
error_message: null,
|
||||
connected_at: '2026-07-01T00:00:00.000Z',
|
||||
disconnected_at: null,
|
||||
|
||||
@@ -0,0 +1,484 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
|
||||
const balanceTransactionsList = vi.fn()
|
||||
|
||||
vi.mock('@/lib/stripe/client', () => ({
|
||||
getStripe: () => ({ balanceTransactions: { list: balanceTransactionsList } }),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/transactions/ingest', () => ({
|
||||
ingestTransactions: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/cash-accounts/service', () => ({
|
||||
ensureManualCashAccount: vi.fn().mockResolvedValue('cash-account-1'),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/import/account-sync', () => ({
|
||||
syncMappedAccounts: vi.fn().mockResolvedValue({ error: null }),
|
||||
}))
|
||||
|
||||
import { ingestTransactions } from '@/lib/transactions/ingest'
|
||||
import { ensureManualCashAccount } from '@/lib/cash-accounts/service'
|
||||
import {
|
||||
BACKFILL_DAYS,
|
||||
STRIPE_IMPORT_SOURCE,
|
||||
STRIPE_LEDGER_ACCOUNT,
|
||||
linkPayoutFeedRows,
|
||||
mapBalanceTransaction,
|
||||
stripeExternalId,
|
||||
stripeFeeExternalId,
|
||||
syncStripeBalanceTransactions,
|
||||
type BalanceTxnLike,
|
||||
} from '../lib/transaction-sync'
|
||||
import type { StripeConnection } from '../types'
|
||||
|
||||
const CONNECTION: StripeConnection = {
|
||||
id: 'conn-1',
|
||||
company_id: 'company-1',
|
||||
user_id: 'user-1',
|
||||
stripe_account_id: 'acct_1',
|
||||
livemode: false,
|
||||
status: 'active',
|
||||
oauth_state: null,
|
||||
display_name: null,
|
||||
last_event_created_at: null,
|
||||
last_event_id: null,
|
||||
transaction_sync_enabled: true,
|
||||
last_balance_txn_synced_at: null,
|
||||
error_message: null,
|
||||
connected_at: '2026-07-01T00:00:00.000Z',
|
||||
disconnected_at: null,
|
||||
created_at: '2026-07-01T00:00:00.000Z',
|
||||
updated_at: '2026-07-01T00:00:00.000Z',
|
||||
}
|
||||
|
||||
// 2026-07-10T12:00:00Z
|
||||
const CREATED = 1_783_425_600
|
||||
const CREATED_DATE = new Date(CREATED * 1000).toISOString().split('T')[0]
|
||||
|
||||
function makeCharge(overrides: Partial<BalanceTxnLike> = {}): BalanceTxnLike {
|
||||
return {
|
||||
id: 'txn_charge_1',
|
||||
type: 'charge',
|
||||
amount: 50_000,
|
||||
fee: 1_450,
|
||||
currency: 'sek',
|
||||
created: CREATED,
|
||||
description: 'Payment for invoice',
|
||||
source: {
|
||||
id: 'ch_1',
|
||||
object: 'charge',
|
||||
payment_intent: 'pi_1',
|
||||
billing_details: { name: 'Anna Andersson' },
|
||||
} as unknown as BalanceTxnLike['source'],
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function makePayoutTxn(overrides: Partial<BalanceTxnLike> = {}): BalanceTxnLike {
|
||||
return {
|
||||
id: 'txn_payout_1',
|
||||
type: 'payout',
|
||||
amount: -48_550,
|
||||
fee: 0,
|
||||
currency: 'sek',
|
||||
created: CREATED + 3600,
|
||||
description: 'STRIPE PAYOUT',
|
||||
source: { id: 'po_1', object: 'payout' } as unknown as BalanceTxnLike['source'],
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function stubList(byWindow: BalanceTxnLike[], byPayout: BalanceTxnLike[] = []) {
|
||||
balanceTransactionsList.mockImplementation((params: Record<string, unknown>) => ({
|
||||
autoPagingToArray: vi.fn().mockResolvedValue('payout' in params ? byPayout : byWindow),
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
* Arg-capturing Supabase mock: each from(table) consumes the next queued
|
||||
* result for that table and records every chained call, so tests can assert
|
||||
* filters and update payloads (createQueuedMockSupabase discards args).
|
||||
*/
|
||||
interface CapturedQuery {
|
||||
table: string
|
||||
ops: Array<{ method: string; args: unknown[] }>
|
||||
}
|
||||
|
||||
function createCaptureSupabase(resultsByTable: Record<string, unknown[]> = {}) {
|
||||
const queries: CapturedQuery[] = []
|
||||
const queues = new Map<string, unknown[]>(
|
||||
Object.entries(resultsByTable).map(([table, results]) => [table, [...results]]),
|
||||
)
|
||||
const supabase = {
|
||||
from(table: string) {
|
||||
const captured: CapturedQuery = { table, ops: [] }
|
||||
queries.push(captured)
|
||||
const queue = queues.get(table)
|
||||
const result = queue && queue.length > 0 ? queue.shift() : { data: null, error: null }
|
||||
const chain: Record<string, unknown> = {}
|
||||
const recorder =
|
||||
(method: string) =>
|
||||
(...args: unknown[]) => {
|
||||
captured.ops.push({ method, args })
|
||||
return chain
|
||||
}
|
||||
for (const method of [
|
||||
'select', 'update', 'insert', 'upsert', 'eq', 'neq', 'in', 'is', 'not',
|
||||
'order', 'limit', 'gte', 'lte', 'maybeSingle', 'single',
|
||||
]) {
|
||||
chain[method] = recorder(method)
|
||||
}
|
||||
chain.then = (resolve: (v: unknown) => unknown) => Promise.resolve(result).then(resolve)
|
||||
return chain
|
||||
},
|
||||
}
|
||||
const queriesFor = (table: string) => queries.filter((q) => q.table === table)
|
||||
const op = (query: CapturedQuery, method: string) =>
|
||||
query.ops.find((o) => o.method === method)
|
||||
return { supabase: supabase as unknown as SupabaseClient, queries, queriesFor, op }
|
||||
}
|
||||
|
||||
function listWindowGte(): number {
|
||||
const params = balanceTransactionsList.mock.calls[0][0] as {
|
||||
created: { gte: number }
|
||||
}
|
||||
return params.created.gte
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(new Date('2026-07-23T10:00:00.000Z'))
|
||||
vi.mocked(ingestTransactions).mockResolvedValue({
|
||||
imported: 0,
|
||||
duplicates: 0,
|
||||
reconciled: 0,
|
||||
auto_categorized: 0,
|
||||
auto_matched_invoices: 0,
|
||||
errors: 0,
|
||||
transaction_ids: [],
|
||||
} as never)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
describe('external id formats', () => {
|
||||
// ⚠️ FROZEN FORMATS: these strings are stored keys in
|
||||
// transactions.external_id. If either assertion fails, you are about to
|
||||
// orphan every previously imported Stripe row and re-import the whole feed
|
||||
// (the June 2026 Enable Banking incident, again). Do NOT update the
|
||||
// expected values without a coordinated backfill of existing rows.
|
||||
it('main row id is stripe_{acct}_{txn}', () => {
|
||||
expect(stripeExternalId('acct_1', 'txn_abc')).toBe('stripe_acct_1_txn_abc')
|
||||
})
|
||||
|
||||
it('fee row id is stripe_{acct}_{txn}_fee', () => {
|
||||
expect(stripeFeeExternalId('acct_1', 'txn_abc')).toBe('stripe_acct_1_txn_abc_fee')
|
||||
})
|
||||
})
|
||||
|
||||
describe('mapBalanceTransaction', () => {
|
||||
it('splits a charge into a gross row and a negative fee row', () => {
|
||||
const rows = mapBalanceTransaction('acct_1', makeCharge())
|
||||
|
||||
expect(rows).toHaveLength(2)
|
||||
expect(rows[0]).toMatchObject({
|
||||
date: CREATED_DATE,
|
||||
amount: 500,
|
||||
currency: 'SEK',
|
||||
external_id: 'stripe_acct_1_txn_charge_1',
|
||||
import_source: STRIPE_IMPORT_SOURCE,
|
||||
description: 'Stripe-betalning Anna Andersson',
|
||||
})
|
||||
expect(rows[1]).toMatchObject({
|
||||
date: CREATED_DATE,
|
||||
amount: -14.5,
|
||||
currency: 'SEK',
|
||||
external_id: 'stripe_acct_1_txn_charge_1_fee',
|
||||
description: 'Stripe-avgift (Stripe-betalning Anna Andersson)',
|
||||
})
|
||||
})
|
||||
|
||||
it('uses the charge description when billing details carry no name', () => {
|
||||
const rows = mapBalanceTransaction(
|
||||
'acct_1',
|
||||
makeCharge({
|
||||
source: {
|
||||
id: 'ch_1',
|
||||
object: 'charge',
|
||||
description: 'Order 1042',
|
||||
billing_details: { name: null },
|
||||
} as unknown as BalanceTxnLike['source'],
|
||||
}),
|
||||
)
|
||||
expect(rows[0].description).toBe('Stripe-betalning Order 1042')
|
||||
})
|
||||
|
||||
it('maps a zero-fee refund to a single negative row', () => {
|
||||
const rows = mapBalanceTransaction('acct_1', {
|
||||
id: 'txn_refund_1',
|
||||
type: 'refund',
|
||||
amount: -20_000,
|
||||
fee: 0,
|
||||
currency: 'sek',
|
||||
created: CREATED,
|
||||
description: 'REFUND FOR CHARGE',
|
||||
})
|
||||
expect(rows).toHaveLength(1)
|
||||
expect(rows[0]).toMatchObject({
|
||||
amount: -200,
|
||||
description: 'Stripe-återbetalning',
|
||||
external_id: 'stripe_acct_1_txn_refund_1',
|
||||
})
|
||||
})
|
||||
|
||||
it('maps the payout row with the po_ id in the description (matches the payout entry)', () => {
|
||||
const rows = mapBalanceTransaction('acct_1', makePayoutTxn())
|
||||
expect(rows).toHaveLength(1)
|
||||
expect(rows[0]).toMatchObject({
|
||||
amount: -485.5,
|
||||
description: 'Stripe-utbetalning po_1',
|
||||
external_id: 'stripe_acct_1_txn_payout_1',
|
||||
})
|
||||
})
|
||||
|
||||
it('labels dispute adjustments as tvist via reporting_category', () => {
|
||||
const rows = mapBalanceTransaction('acct_1', {
|
||||
id: 'txn_adj_1',
|
||||
type: 'adjustment',
|
||||
amount: -50_000,
|
||||
fee: 1_500,
|
||||
currency: 'sek',
|
||||
created: CREATED,
|
||||
description: 'Chargeback withdrawal for ch_1',
|
||||
reporting_category: 'dispute',
|
||||
})
|
||||
expect(rows).toHaveLength(2)
|
||||
expect(rows[0].description).toBe('Stripe-tvist')
|
||||
expect(rows[1]).toMatchObject({
|
||||
amount: -15,
|
||||
external_id: 'stripe_acct_1_txn_adj_1_fee',
|
||||
})
|
||||
})
|
||||
|
||||
it('dates rows on created, not available_on semantics', () => {
|
||||
// created is the only date input: a mapped row for a txn created on the
|
||||
// 10th must land on the 10th even though Stripe settles days later.
|
||||
const rows = mapBalanceTransaction('acct_1', makeCharge())
|
||||
expect(rows.every((r) => r.date === CREATED_DATE)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('linkPayoutFeedRows', () => {
|
||||
it('links the payout row and every fee row to the payout entry, unlinked rows only', async () => {
|
||||
const { supabase, queriesFor, op } = createCaptureSupabase({
|
||||
transactions: [{ data: [{ id: 't1' }, { id: 't2' }, { id: 't3' }], error: null }],
|
||||
})
|
||||
|
||||
const linked = await linkPayoutFeedRows(supabase, 'company-1', 'acct_1', 'je-po-1', [
|
||||
{ id: 'txn_c1', type: 'charge', fee: 1450 },
|
||||
{ id: 'txn_c2', type: 'charge', fee: 0 },
|
||||
{ id: 'txn_p1', type: 'payout', fee: 0 },
|
||||
])
|
||||
|
||||
expect(linked).toBe(3)
|
||||
const query = queriesFor('transactions')[0]
|
||||
expect(op(query, 'update')!.args[0]).toEqual({ journal_entry_id: 'je-po-1' })
|
||||
expect(op(query, 'in')!.args).toEqual([
|
||||
'external_id',
|
||||
['stripe_acct_1_txn_c1_fee', 'stripe_acct_1_txn_p1'],
|
||||
])
|
||||
expect(op(query, 'is')!.args).toEqual(['journal_entry_id', null])
|
||||
expect(op(query, 'eq')!.args).toEqual(['company_id', 'company-1'])
|
||||
})
|
||||
|
||||
it('is a no-op without fee or payout rows', async () => {
|
||||
const { supabase, queries } = createCaptureSupabase()
|
||||
const linked = await linkPayoutFeedRows(supabase, 'company-1', 'acct_1', 'je-1', [
|
||||
{ id: 'txn_c1', type: 'charge', fee: 0 },
|
||||
])
|
||||
expect(linked).toBe(0)
|
||||
expect(queries).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('syncStripeBalanceTransactions', () => {
|
||||
it('backfills 90 days on the first run', async () => {
|
||||
stubList([makeCharge()])
|
||||
const { supabase } = createCaptureSupabase({
|
||||
company_settings: [{ data: { bookkeeping_locked_through: null }, error: null }],
|
||||
})
|
||||
|
||||
await syncStripeBalanceTransactions(supabase, { ...CONNECTION })
|
||||
|
||||
const expected = Math.floor(
|
||||
(Date.parse('2026-07-23T10:00:00.000Z') - BACKFILL_DAYS * 86_400_000) / 1000,
|
||||
)
|
||||
expect(listWindowGte()).toBe(expected)
|
||||
})
|
||||
|
||||
it('floors the first-run backfill at the day after the company lock date', async () => {
|
||||
stubList([makeCharge()])
|
||||
const { supabase } = createCaptureSupabase({
|
||||
company_settings: [{ data: { bookkeeping_locked_through: '2026-06-30' }, error: null }],
|
||||
})
|
||||
|
||||
await syncStripeBalanceTransactions(supabase, { ...CONNECTION })
|
||||
|
||||
expect(listWindowGte()).toBe(Math.floor(Date.parse('2026-07-01T00:00:00Z') / 1000))
|
||||
})
|
||||
|
||||
it('polls from the cursor minus the 24h overlap on later runs', async () => {
|
||||
stubList([makeCharge()])
|
||||
const { supabase, queriesFor } = createCaptureSupabase()
|
||||
|
||||
await syncStripeBalanceTransactions(supabase, {
|
||||
...CONNECTION,
|
||||
last_balance_txn_synced_at: '2026-07-20T00:00:00.000Z',
|
||||
})
|
||||
|
||||
expect(listWindowGte()).toBe(
|
||||
Math.floor(Date.parse('2026-07-20T00:00:00Z') / 1000) - 86_400,
|
||||
)
|
||||
// No cursor → no company_settings (lock date) lookup.
|
||||
expect(queriesFor('company_settings')).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('ingests the mapped rows onto the 1686 cash account without auto-categorization', async () => {
|
||||
stubList([makeCharge()])
|
||||
const { supabase } = createCaptureSupabase({
|
||||
company_settings: [{ data: null, error: null }],
|
||||
})
|
||||
vi.mocked(ingestTransactions).mockResolvedValue({
|
||||
imported: 2, duplicates: 0, reconciled: 0, auto_categorized: 0,
|
||||
auto_matched_invoices: 0, errors: 0, transaction_ids: ['t1', 't2'],
|
||||
} as never)
|
||||
|
||||
const summary = await syncStripeBalanceTransactions(supabase, { ...CONNECTION })
|
||||
|
||||
expect(summary).toMatchObject({ fetched: 1, imported: 2, duplicates: 0, errors: 0 })
|
||||
expect(vi.mocked(ensureManualCashAccount)).toHaveBeenCalledWith(
|
||||
supabase, 'company-1', STRIPE_LEDGER_ACCOUNT, 'SEK', 'Stripe-saldo',
|
||||
)
|
||||
const [, companyId, userId, rows, options] =
|
||||
vi.mocked(ingestTransactions).mock.calls[0]
|
||||
expect(companyId).toBe('company-1')
|
||||
expect(userId).toBe('user-1')
|
||||
expect((rows as unknown[]).length).toBe(2)
|
||||
expect(options).toEqual({
|
||||
settlementAccount: STRIPE_LEDGER_ACCOUNT,
|
||||
skipAutoCategorization: true,
|
||||
})
|
||||
})
|
||||
|
||||
it('advances the cursor to the newest processed transaction', async () => {
|
||||
stubList([makeCharge(), makePayoutTxn()])
|
||||
const { supabase, queriesFor, op } = createCaptureSupabase({
|
||||
company_settings: [{ data: null, error: null }],
|
||||
})
|
||||
|
||||
await syncStripeBalanceTransactions(supabase, { ...CONNECTION })
|
||||
|
||||
const cursorUpdate = queriesFor('stripe_connections')[0]
|
||||
expect(op(cursorUpdate, 'update')!.args[0]).toEqual({
|
||||
last_balance_txn_synced_at: new Date((CREATED + 3600) * 1000).toISOString(),
|
||||
})
|
||||
expect(op(cursorUpdate, 'eq')!.args).toEqual(['id', 'conn-1'])
|
||||
})
|
||||
|
||||
it('pre-links gross rows of charges the checkout flow already settled', async () => {
|
||||
stubList([makeCharge()])
|
||||
const { supabase, queriesFor, op } = createCaptureSupabase({
|
||||
company_settings: [{ data: null, error: null }],
|
||||
stripe_payment_events: [
|
||||
{ data: [{ payment_intent_id: 'pi_1', journal_entry_id: 'je-settle-1' }], error: null },
|
||||
],
|
||||
transactions: [{ data: [{ id: 't1' }], error: null }],
|
||||
})
|
||||
|
||||
const summary = await syncStripeBalanceTransactions(supabase, { ...CONNECTION })
|
||||
|
||||
expect(summary.linked).toBe(1)
|
||||
const eventsQuery = queriesFor('stripe_payment_events')[0]
|
||||
expect(op(eventsQuery, 'in')!.args).toEqual(['payment_intent_id', ['pi_1']])
|
||||
const linkQuery = queriesFor('transactions')[0]
|
||||
expect(op(linkQuery, 'update')!.args[0]).toEqual({ journal_entry_id: 'je-settle-1' })
|
||||
expect(op(linkQuery, 'in')!.args).toEqual(['external_id', ['stripe_acct_1_txn_charge_1']])
|
||||
expect(op(linkQuery, 'is')!.args).toEqual(['journal_entry_id', null])
|
||||
})
|
||||
|
||||
it('claims fee rows and the payout row of an already-booked payout', async () => {
|
||||
const charge = makeCharge()
|
||||
const payoutTxn = makePayoutTxn()
|
||||
stubList([charge, payoutTxn], [charge, payoutTxn])
|
||||
const { supabase, queriesFor, op } = createCaptureSupabase({
|
||||
company_settings: [{ data: null, error: null }],
|
||||
stripe_payment_events: [{ data: [], error: null }],
|
||||
stripe_payouts: [
|
||||
{ data: [{ payout_id: 'po_1', journal_entry_id: 'je-po-1' }], error: null },
|
||||
],
|
||||
transactions: [{ data: [{ id: 't1' }, { id: 't2' }], error: null }],
|
||||
})
|
||||
|
||||
const summary = await syncStripeBalanceTransactions(supabase, { ...CONNECTION })
|
||||
|
||||
expect(summary.linked).toBe(2)
|
||||
const payoutQuery = queriesFor('stripe_payouts')[0]
|
||||
expect(op(payoutQuery, 'in')!.args).toEqual(['payout_id', ['po_1']])
|
||||
// The per-payout balance transaction list resolves the fee rows.
|
||||
const payoutListCall = balanceTransactionsList.mock.calls.find(
|
||||
(c) => 'payout' in (c[0] as Record<string, unknown>),
|
||||
)
|
||||
expect(payoutListCall?.[0]).toMatchObject({ payout: 'po_1' })
|
||||
const linkQuery = queriesFor('transactions')[0]
|
||||
expect(op(linkQuery, 'update')!.args[0]).toEqual({ journal_entry_id: 'je-po-1' })
|
||||
expect(op(linkQuery, 'in')!.args).toEqual([
|
||||
'external_id',
|
||||
['stripe_acct_1_txn_charge_1_fee', 'stripe_acct_1_txn_payout_1'],
|
||||
])
|
||||
})
|
||||
|
||||
it('stops before ingesting when the deadline already passed and reports it', async () => {
|
||||
stubList([makeCharge()])
|
||||
const { supabase } = createCaptureSupabase({
|
||||
company_settings: [{ data: null, error: null }],
|
||||
})
|
||||
|
||||
const summary = await syncStripeBalanceTransactions(
|
||||
supabase, { ...CONNECTION }, undefined, Date.now() - 1,
|
||||
)
|
||||
|
||||
expect(summary.deadlineReached).toBe(true)
|
||||
expect(vi.mocked(ingestTransactions)).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('reports a revoked connection without throwing', async () => {
|
||||
balanceTransactionsList.mockImplementation(() => ({
|
||||
autoPagingToArray: vi.fn().mockRejectedValue({ type: 'StripePermissionError' }),
|
||||
}))
|
||||
const { supabase } = createCaptureSupabase({
|
||||
company_settings: [{ data: null, error: null }],
|
||||
})
|
||||
|
||||
const summary = await syncStripeBalanceTransactions(supabase, { ...CONNECTION })
|
||||
|
||||
expect(summary.revoked).toBe(true)
|
||||
expect(summary.fetched).toBe(0)
|
||||
})
|
||||
|
||||
it('does nothing for a non-active connection', async () => {
|
||||
const { supabase, queries } = createCaptureSupabase()
|
||||
const summary = await syncStripeBalanceTransactions(supabase, {
|
||||
...CONNECTION,
|
||||
status: 'revoked',
|
||||
})
|
||||
expect(summary.fetched).toBe(0)
|
||||
expect(queries).toHaveLength(0)
|
||||
expect(balanceTransactionsList).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -6,6 +6,7 @@ import { useRouter, useSearchParams } from 'next/navigation'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { EmptyState } from '@/components/ui/empty-state'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
@@ -45,6 +46,7 @@ export default function StripeSettingsPanel() {
|
||||
const [disconnecting, setDisconnecting] = useState(false)
|
||||
const [confirmDisconnect, setConfirmDisconnect] = useState(false)
|
||||
const [syncing, setSyncing] = useState(false)
|
||||
const [togglingTransactionSync, setTogglingTransactionSync] = useState(false)
|
||||
const [needsReviewCount, setNeedsReviewCount] = useState(0)
|
||||
const [needsReview, setNeedsReview] = useState<StripeReviewEvent[]>([])
|
||||
|
||||
@@ -129,6 +131,7 @@ export default function StripeSettingsPanel() {
|
||||
const data = (await res.json().catch(() => ({}))) as {
|
||||
settled?: number
|
||||
needsReview?: number
|
||||
transactions?: { imported?: number; linked?: number }
|
||||
error?: string
|
||||
}
|
||||
if (!res.ok) {
|
||||
@@ -139,12 +142,18 @@ export default function StripeSettingsPanel() {
|
||||
})
|
||||
return
|
||||
}
|
||||
const paymentsLine = t('sync_done_description', {
|
||||
settled: data.settled ?? 0,
|
||||
review: data.needsReview ?? 0,
|
||||
})
|
||||
toast({
|
||||
title: t('sync_done_title'),
|
||||
description: t('sync_done_description', {
|
||||
settled: data.settled ?? 0,
|
||||
review: data.needsReview ?? 0,
|
||||
}),
|
||||
description: data.transactions
|
||||
? `${paymentsLine} ${t('sync_done_transactions', {
|
||||
imported: data.transactions.imported ?? 0,
|
||||
linked: data.transactions.linked ?? 0,
|
||||
})}`
|
||||
: paymentsLine,
|
||||
})
|
||||
await loadStatus()
|
||||
} finally {
|
||||
@@ -152,6 +161,36 @@ export default function StripeSettingsPanel() {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleToggleTransactionSync(enabled: boolean) {
|
||||
setTogglingTransactionSync(true)
|
||||
try {
|
||||
const res = await fetch('/api/extensions/ext/stripe/transaction-sync', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ enabled }),
|
||||
})
|
||||
if (!res.ok) {
|
||||
const data = (await res.json().catch(() => ({}))) as { error?: string }
|
||||
toast({
|
||||
title: t('transaction_sync_toggle_failed'),
|
||||
description: data.error,
|
||||
variant: 'destructive',
|
||||
})
|
||||
return
|
||||
}
|
||||
toast({
|
||||
title: enabled
|
||||
? t('transaction_sync_enabled_toast')
|
||||
: t('transaction_sync_disabled_toast'),
|
||||
})
|
||||
await loadStatus()
|
||||
} catch {
|
||||
toast({ title: t('transaction_sync_toggle_failed'), variant: 'destructive' })
|
||||
} finally {
|
||||
setTogglingTransactionSync(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDisconnect() {
|
||||
if (!connection) return
|
||||
setDisconnecting(true)
|
||||
@@ -178,6 +217,25 @@ export default function StripeSettingsPanel() {
|
||||
}
|
||||
}
|
||||
|
||||
// Hosted: Stripe Connect is not launched yet, so the settings surface is
|
||||
// "coming soon" even where the platform credentials are configured (test
|
||||
// mode): users must not be able to connect or toggle transaction sync until
|
||||
// launch. Self-hosted admins run their own keys and keep the full panel.
|
||||
const isSelfHosted = process.env.NEXT_PUBLIC_SELF_HOSTED === 'true'
|
||||
if (!isSelfHosted) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="p-0">
|
||||
<EmptyState
|
||||
icon={CreditCard}
|
||||
title={t('coming_soon_title')}
|
||||
description={t('coming_soon_description')}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Card>
|
||||
@@ -191,31 +249,15 @@ export default function StripeSettingsPanel() {
|
||||
}
|
||||
|
||||
if (!configured) {
|
||||
// Hosted: the Connect platform isn't live yet, so the whole integration
|
||||
// presents as "coming soon" (every server path is already a no-op without
|
||||
// STRIPE_CONNECT_CLIENT_ID). Self-hosted admins get the honest
|
||||
// configuration message instead: for them it's a setup task, not a launch.
|
||||
const isSelfHosted = process.env.NEXT_PUBLIC_SELF_HOSTED === 'true'
|
||||
if (isSelfHosted) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">{t('title')}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-0">
|
||||
<p className="text-sm text-muted-foreground">{t('not_configured')}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
// Self-hosted without STRIPE_CONNECT_CLIENT_ID: honest configuration
|
||||
// message, for these admins it's a setup task, not a launch.
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="p-0">
|
||||
<EmptyState
|
||||
icon={CreditCard}
|
||||
title={t('coming_soon_title')}
|
||||
description={t('coming_soon_description')}
|
||||
/>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">{t('title')}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-0">
|
||||
<p className="text-sm text-muted-foreground">{t('not_configured')}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
@@ -306,6 +348,36 @@ export default function StripeSettingsPanel() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isActive && connection && (
|
||||
<div className="flex flex-wrap items-start justify-between gap-4 rounded-lg border border-border p-4">
|
||||
<div className="min-w-0 max-w-prose space-y-1">
|
||||
<p className="text-sm font-medium">{t('transaction_sync_title')}</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t('transaction_sync_description')}
|
||||
</p>
|
||||
{connection.transaction_sync_enabled ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{connection.last_balance_txn_synced_at
|
||||
? t('transaction_sync_last_synced', {
|
||||
date: formatDateLong(connection.last_balance_txn_synced_at),
|
||||
})
|
||||
: t('transaction_sync_never_synced')}
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('transaction_sync_backfill_note')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<Switch
|
||||
checked={connection.transaction_sync_enabled}
|
||||
onCheckedChange={handleToggleTransactionSync}
|
||||
disabled={togglingTransactionSync}
|
||||
aria-label={t('transaction_sync_title')}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isActive && needsReviewCount > 0 && (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
handleInvoicePaid,
|
||||
} from './lib/payment-links'
|
||||
import { syncStripeConnection } from './lib/sync'
|
||||
import { syncStripeBalanceTransactions } from './lib/transaction-sync'
|
||||
import { createServiceClientNoCookies } from '@/lib/auth/api-keys'
|
||||
import type { StripeConnection, StripeStatusResponse } from './types'
|
||||
|
||||
@@ -87,7 +88,7 @@ export const stripeExtension: Extension = {
|
||||
const { data: rows } = await supabase
|
||||
.from('stripe_connections')
|
||||
.select(
|
||||
'id, status, stripe_account_id, livemode, display_name, error_message, connected_at, last_event_created_at',
|
||||
'id, status, stripe_account_id, livemode, display_name, error_message, connected_at, last_event_created_at, transaction_sync_enabled, last_balance_txn_synced_at',
|
||||
)
|
||||
.eq('company_id', ctx.companyId)
|
||||
.order('created_at', { ascending: false })
|
||||
@@ -173,11 +174,15 @@ export const stripeExtension: Extension = {
|
||||
|
||||
try {
|
||||
const serviceClient = createServiceClientNoCookies()
|
||||
const summary = await syncStripeConnection(
|
||||
serviceClient,
|
||||
connection as StripeConnection,
|
||||
)
|
||||
return NextResponse.json({ success: true, ...summary })
|
||||
const typedConnection = connection as StripeConnection
|
||||
const summary = await syncStripeConnection(serviceClient, typedConnection)
|
||||
// The manual button covers both feeds: when the balance-transaction
|
||||
// feed is enabled, "Synka nu" also pulls it (same module as the
|
||||
// nightly cron, no separate rate limit needed: one user action).
|
||||
const transactions = typedConnection.transaction_sync_enabled
|
||||
? await syncStripeBalanceTransactions(serviceClient, typedConnection)
|
||||
: undefined
|
||||
return NextResponse.json({ success: true, ...summary, transactions })
|
||||
} catch (error) {
|
||||
log.error('[stripe] Manual sync failed', {
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
@@ -190,6 +195,62 @@ export const stripeExtension: Extension = {
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/transaction-sync',
|
||||
handler: async (request: Request, ctx?: ExtensionContext) => {
|
||||
const supabase = ctx?.supabase ?? await (await import('@/lib/supabase/server')).createClient()
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
if (!ctx?.companyId) {
|
||||
return NextResponse.json({ error: 'Company context required' }, { status: 400 })
|
||||
}
|
||||
const companyId = ctx.companyId
|
||||
|
||||
const capabilityBlocked = await requireCapability(
|
||||
supabase,
|
||||
companyId,
|
||||
CAPABILITY.stripe_payments,
|
||||
)
|
||||
if (capabilityBlocked) return capabilityBlocked
|
||||
|
||||
const rl = await checkRateLimit({
|
||||
prefix: 'stripe:transaction-sync-toggle',
|
||||
identifier: user.id,
|
||||
...RATE_LIMIT_SYNC,
|
||||
})
|
||||
if (!rl.ok) return rl.response!
|
||||
|
||||
const body = (await request.json().catch(() => ({}))) as { enabled?: unknown }
|
||||
if (typeof body.enabled !== 'boolean') {
|
||||
return NextResponse.json(
|
||||
{ error: 'enabled (boolean) krävs.' },
|
||||
{ status: 400 },
|
||||
)
|
||||
}
|
||||
|
||||
const { data: updated, error: updateError } = await supabase
|
||||
.from('stripe_connections')
|
||||
.update({ transaction_sync_enabled: body.enabled })
|
||||
.eq('company_id', companyId)
|
||||
.eq('status', 'active')
|
||||
.select('id')
|
||||
|
||||
if (updateError) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Kunde inte spara inställningen. Försök igen.' },
|
||||
{ status: 500 },
|
||||
)
|
||||
}
|
||||
if (!updated || updated.length === 0) {
|
||||
return NextResponse.json({ error: 'Inget anslutet Stripe-konto.' }, { status: 404 })
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true, enabled: body.enabled })
|
||||
},
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/connect',
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
import { createLogger, type Logger } from '@/lib/logger'
|
||||
import type { CreateJournalEntryInput, CreateJournalEntryLineInput } from '@/types'
|
||||
import { connectedAccountOptions } from './connect'
|
||||
import { linkPayoutFeedRows } from './transaction-sync'
|
||||
import type { StripeConnection } from '../types'
|
||||
|
||||
const defaultLog = createLogger('stripe/payouts')
|
||||
@@ -231,6 +232,29 @@ async function evaluateAndBook(
|
||||
if (!entry) {
|
||||
return { status: 'needs_review', reason: 'booking_returned_null', gross, fees }
|
||||
}
|
||||
// Claim this payout's transaction-feed rows (the payout row + the fee
|
||||
// rows of its charges): the entry just booked carries exactly that money,
|
||||
// so leaving them unbooked in the inbox would invite double-booking the
|
||||
// fees. No-op for companies without the balance-transaction feed. The
|
||||
// entry is already posted, so a linking failure must NOT flip the payout
|
||||
// to needs_review: swallow and let the nightly sync retry (idempotent).
|
||||
try {
|
||||
await linkPayoutFeedRows(
|
||||
supabase,
|
||||
connection.company_id,
|
||||
connection.stripe_account_id!,
|
||||
entry.id,
|
||||
txns,
|
||||
log,
|
||||
)
|
||||
} catch (linkErr) {
|
||||
log.warn('payout feed-row linking failed after booking', {
|
||||
connectionId: connection.id,
|
||||
payoutId: payout.id,
|
||||
journalEntryId: entry.id,
|
||||
message: linkErr instanceof Error ? linkErr.message : String(linkErr),
|
||||
})
|
||||
}
|
||||
log.info('booked stripe payout', {
|
||||
connectionId: connection.id,
|
||||
payoutId: payout.id,
|
||||
|
||||
@@ -0,0 +1,558 @@
|
||||
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
|
||||
}
|
||||
@@ -10,6 +10,10 @@ export interface StripeConnection {
|
||||
display_name: string | null
|
||||
last_event_created_at: string | null
|
||||
last_event_id: string | null
|
||||
/** Opt-in: import the account's balance transactions as an inbox feed. */
|
||||
transaction_sync_enabled: boolean
|
||||
/** Balance-transaction polling cursor (max `created` processed). */
|
||||
last_balance_txn_synced_at: string | null
|
||||
error_message: string | null
|
||||
connected_at: string | null
|
||||
disconnected_at: string | null
|
||||
@@ -40,6 +44,8 @@ export interface StripeStatusResponse {
|
||||
| 'error_message'
|
||||
| 'connected_at'
|
||||
| 'last_event_created_at'
|
||||
| 'transaction_sync_enabled'
|
||||
| 'last_balance_txn_synced_at'
|
||||
> | null
|
||||
needs_review_count?: number
|
||||
needs_review?: StripeReviewEvent[]
|
||||
|
||||
Reference in New Issue
Block a user