Skip to content

feat(integrations): Stripe native integration + general integration framework#14

Merged
hoaxnerd merged 28 commits into
mainfrom
feat/stripe-integration
Jun 27, 2026
Merged

feat(integrations): Stripe native integration + general integration framework#14
hoaxnerd merged 28 commits into
mainfrom
feat/stripe-integration

Conversation

@hoaxnerd

Copy link
Copy Markdown
Owner

Workstream 3 — Stripe native integration + a general integration framework

Builds an integration framework (ingest-first, riding the foundation domain spine) with Stripe as the first connector. Cash-ledger ingest only; MRR/revenue-streams is a designed-for seam (deferred).

What it does

  • Connect a Stripe account via a pasted read-only Restricted API Key → validated against GET /v1/balance → stored encrypted (integrationCredentials, AES-256-GCM via SECRETS_ENCRYPTION_KEY).
  • Sync balance_transactions → normalized into the transactions ledger:
    • Classified on reporting_category (not type — disputes are adjustment).
    • Gross + fee split: charge gross → revenue; Stripe fee → a separate "Payment processing fees" expense (created once per company).
    • Payouts/transfers/reserves excluded (no double-count); refunds/disputes as contra.
    • Multi-currency uses settlement net; Decimal at the money boundary; zero-decimal currencies handled.
    • Idempotent: dedup on (companyId, externalId) + onConflictDoNothing.
  • Backfill on connect (fire-and-forget) + hourly incremental SYSTEM_JOB with a 7-day trailing-window reconciliation and a monotonic cursor.
  • Settings → Integrations connect/health UI (last-sync + error state); synced rows surface read-only on the Transactions page with the integration badge.
  • Context-only AI surface: a contributor tells the assistant what's connected (no new tools).
  • Self-host gets the integrations capability on by default (polling is outbound-only); cloud stays plan-gated.

Framework

New app-level integrationRegistry (apps/web/src/lib/integrations/) of IntegrationConnector descriptors with credentialSpec + an optional source (backfill/incremental). Outbound (actions) and inter-integration (dependsOn) are typed seams, unimplemented. The legacy engine ProviderRegistry is deprecated.

Schema

  • Additive migration 0014 — new integration_credentials table (non-destructive; applies to PGlite + Postgres).

Verification

  • Branch green: web 892, db 250, engine 751 tests; type-check 0; next build ✓; both editions.
  • Final whole-branch review: READY, no blockers.
  • Manual gauntlet (all pass): in-browser self-host smoke (connect → 114 tx with the gross+fee split → idempotent re-sync → disconnect leaves actuals); packaged fat-artifact E2E; CLI/MCP exposure (45 tools, no leak); cloud-mode boot.
  • The browser smoke caught a Next.js route slug conflict ([id] vs [type]) that compiled but broke boot — fixed by relocating to connect/[type] / sync/[type] / test/[type].

Follow-ups (ship-as-v1, tracked)

Cosmetic double "Connected" badge; legacy generic-POST plan-gate consistency; tighten the catalog drift test to equality; disconnect-error toast; remove dead withStripeBackoff; restore "Coming Soon" tiles if desired; replace the help-text scope parser with a scopes[] field.

🤖 Generated with Claude Code

hoaxnerd and others added 23 commits June 26, 2026 13:05
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…m source)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…sconnect

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…elf-host integrations default-on

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
… card

Derive the Settings → Integrations catalog from integrationRegistry.catalog()
via catalogForSettings() (single source of truth; drift test guards it). Add a
client StripeConnectCard that paste-connects a restricted key (POST
/api/integrations/stripe/connect), shows the scope checklist from the
connector's credentialSpec help, surfaces inline errors, and renders
status/last-sync/Disconnect when connected. Wire the card into the Stripe row,
gated on caps.integrations.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Lift the CSV-import dedup+insert path into apps/web/src/lib/integrations/ingest.ts
so the Stripe connector (next task) can reuse the same (companyId, externalId)
dedup + in-batch Set + chunked insert. CSV import now builds its prepared rows
then delegates dedup+insert to ingestRecords; categorization and account
resolution stay in the route. Amounts stay strings across the money boundary.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…gory, gross+fee, exclusions)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ransactions

Replace the C2.3 placeholder generators with real implementations that
auto-paginate over Stripe balance_transactions (expand: data.source) and
yield* the pure mapBalanceTransaction output per txn. incremental filters
created > cursor.created (strict gt, unix seconds) so the high-water-mark
txn isn't re-emitted; null cursor sweeps fully. Descriptor + validate
unchanged. TDD: source.test.ts mocks getStripe, asserts charge->2 / payout->0
yields and the list params (created gt vs omitted).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ect + manual sync

Wire the connector source → account resolver → ingest core into real ingestion.

- accounts.ts: resolveAccountId(companyId, hint) — revenue/refund/dispute →
  the company's income/revenue account; payment_processing_fees → an expense
  account named "Payment processing fees" (find case-insensitive, else create
  once, idempotent).
- sync.ts: runIntegrationSync(companyId, type, { mode }) — loads creds, reads
  the cursor from integrations.metadata, iterates backfill/incremental, maps
  each MappedRecord → IngestRow (amount as STRING, Decimal at the boundary,
  source:"integration"), calls ingestRecords (dedups → re-run inserts 0),
  advances the cursor to max record date, and read-modify-writes metadata.sync
  (cursor/lastSyncAt/lastError/lastRecordCount) + lastSyncAt. Errors record
  lastError and rethrow.
- sync/route.ts: POST gated exactly like connect → incremental sync.

Folds in two C1.5 review fixes on connect/route.ts:
- (A) connect no longer sets lastSyncAt — a SYNC owns it now.
- (B) one coherent integrations.metadata shape { livemode, sync? }; connect and
  sync each merge (read-modify-write) so neither clobbers the other's fields.
  Connect also kicks off a fire-and-forget backfill (not awaited).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ees account on multi-fee backfill)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… edit/delete)

Guard test confirming that source:"integration" (Stripe-synced) rows: render
their description text, show the "integration" SourcePill badge, and receive
no Edit/Delete buttons — only manual rows get those affordances.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
Add runAllIntegrationSyncs() periodic driver: selects all active
integrations across companies and runs an incremental runIntegrationSync
for each, error-isolated (one failure is counted and does not stop the
rest; the driver never throws). Register it as the "integration-sync"
SYSTEM_JOB on an hourly (0 * * * *) UTC schedule.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…S2322)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ogging

Wrap the outer db.select() in a try/catch so any DB error returns
{ synced: 0, failed: 0 } instead of propagating out of the SYSTEM_JOB's
run(). Replace console.error with structured logger (logger("integration-sync")).
Add regression test: db.select() rejection → resolves { synced:0, failed:0 }.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…h UI

Incremental sync now re-scans a 7-day trailing window (effective floor =
min(cursor, now-7d)) so late mutations whose balance_transaction predates the
cursor (refunds/disputes of old charges) are reliably re-ingested; idempotent
via the existing (companyId, externalId) upsert. Backfill unchanged.

Cursor made MONOTONIC: max(prevCursor, maxRecordDate) so a trailing re-scan of
older records can never rewind the high-water mark (was a regression in C2.4).

Surface connection health in the Stripe connect card: inline error indicator
(reuses danger tokens + AlertCircle, role=alert) when metadata.sync.lastError
is set, alongside the existing last-synced time.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…rror) live

The connected Stripe row showed a generic inline Disconnect tile, so the
StripeConnectCard's health view (last-sync + sync-error indicator) was never
user-reachable, and the data layer didn't carry the last sync error.

- Data: add `lastError` to `ConnectedIntegration`; project it from
  `metadata.sync.lastError` (written by runIntegrationSync) via
  `toConnectedIntegrations`, applied where the settings page derives the list
  from the verbatim `/api/integrations` rows. `lastSyncAt` stays sourced from
  the existing column.
- Render: for a connected Stripe row, render `<StripeConnectCard connected />`
  (with last-sync + error + Disconnect) instead of the generic tile; wire
  Disconnect to `disconnectIntegration(id)`. Non-Stripe rows and the
  not-connected connect card are unchanged. Still gated on caps.integrations.
- Test: integrations-tab reachability — lastError set -> role=alert renders;
  healthy -> formatted last-sync renders, no alert.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adds a context-only integrations domain whose only surface is an AI
context contributor. It injects a "Connected data sources" section into
the system prompt listing each ACTIVE integration with a relative
last-sync time and the integration-sourced transaction count, so the
assistant knows what's connected and how fresh it is.

No new tools, handlers, prompt sections, or nav. core:true keeps it
always-on and edition-agnostic; it adds nothing when no integration is
connected. Graceful degradation: zero active integrations or any DB
error returns [] and never throws out of sections().

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ot per-bullet)

Move the single integration-sourced transaction count from per-bullet to a
section footer so the count is accurate regardless of number of integrations.
Each bullet now shows only the integration label and last-sync time; the total
appears once at the section level.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…of app registry

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… date-safe last-sync (M1/M2/M4)

M1: runIntegrationSync fires trackDataMutation("expenses") after inserts>0
(safe in fire-and-forget backfill); the manual sync route revalidates the
"accounts" + "expense-details" tags (request context, wrapped defensively).
M2: ingestRecords adds .onConflictDoNothing on (companyId, externalId) as a
DB-layer backstop so a concurrent backfill + hourly job can't abort a chunk.
M4: no change — fmtDate/formatDate already accept string|Date (confirmed).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… SDK maxNetworkRetries handles 429s)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…he Connections page

Relocate the Integrations UI (Stripe connect + catalog) out of the Settings
page and into the Connections page as a third, capability-gated tab
(Connected MCPs · Your MCP · Integrations). The Integrations segment appears
only when caps.integrations is on; ?tab=integrations falls back to "connected"
when it is off.

No behavior change to connect/sync/disconnect. The integrations data + mutation
logic moves verbatim into a new client container under
components/integrations/, which reads ?connect=stripe to auto-open the Stripe
card. Old /settings?connect=stripe deep-links now redirect to
/connections?tab=integrations&connect=stripe so the card still auto-opens.

Files relocated under apps/web/src/components/integrations/ (integrations-tab,
stripe-connect-card, integrations-data, plus the moved tests); new
integrations-tab-container and connections-tabs unit test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ettings subtitle

Post-smoke cleanup for the Settings→Connections move:
- The relocated /connections integrations e2e asserted Plaid/QuickBooks
  coming-soon tiles, but no coming-soon connectors are registered today
  (only stripeConnector), so those tiles never render. Assert only the
  real Available tiles (Stripe + CSV Import); restoring coming-soon tiles
  is a separate tracked follow-up.
- Settings subtitle no longer claims 'integrations' (that UI now lives on
  the Connections page).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…gs when there is no Coming Soon

- Order the catalog connectors-first ([...fromRegistry, ...STATIC_AVAILABLE])
  so the live Stripe connector leads the Available list, CSV Import follows.
- Show the 'Available' / 'Coming Soon' headings only when coming-soon
  connectors are actually registered. Today none are, so the list renders
  unlabelled (a single ungrouped set needs no header); the headings return
  automatically if coming-soon connectors are added later.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… Sync

Three integration-UX improvements on the transactions list + connect card:

1. Source column shows the specific provider (e.g. Stripe), derived from the
   externalId prefix via the registry, instead of the generic 'integration'.
2. Stripe ingest now attributes a Vendor: the paying customer for charge/
   refund/dispute rows (billing name → expanded customer name → email), and
   'Stripe' for processing-fee rows. Connector expands data.source.customer.
3. Connected Stripe card gains a 'Sync now' button (design-system secondary
   Button) beside Disconnect — POSTs the incremental sync route and revalidates
   'Last synced', reporting how many new transactions landed.

Vendor populates on fresh ingest (idempotent re-sync won't rewrite existing
rows). Source label is display-derived, so it applies to existing rows too.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@hoaxnerd hoaxnerd merged commit 2ec4616 into main Jun 27, 2026
14 checks passed
@sonarqubecloud

Copy link
Copy Markdown

@hoaxnerd hoaxnerd mentioned this pull request Jun 27, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant