Skip to content

feat(payments): per-entity payment providers + Stripe Connect (reseller SaaS Mode) - #18

Merged
rrader26 merged 2 commits into
mainfrom
feat/per-entity-payment-provider
Jul 28, 2026
Merged

feat(payments): per-entity payment providers + Stripe Connect (reseller SaaS Mode)#18
rrader26 merged 2 commits into
mainfrom
feat/per-entity-payment-provider

Conversation

@rrader26

@rrader26 rrader26 commented Jul 28, 2026

Copy link
Copy Markdown

Per-entity payment providers + Stripe Connect (reseller "SaaS Mode")

Lets a reseller/agency collect on their own Stripe for their sub-accounts' customers, while org-level providers behave exactly as before. Backing feature: ThinkfleetAI/growth-os#1243.

Today payment providers are organization-scoped only; a customer resolves its provider by organization_id + payment_provider_code, so an agency's billing entity is charged on the parent org's single Stripe. This PR adds (a) optional per-billing-entity provider scoping and (b) Stripe Connect so the provider can be a reseller's own connected account.

Part 1 — per-entity provider scoping

  • payment_providers.billing_entity_id — new nullable FK. NULL = org-level = today's behavior.
  • PaymentProviders::FindService gains optional billing_entity_id: prefers an entity-scoped provider, falls back to org-level. Scope untouched when absent, so webhook routing (by org + code) is unchanged.
  • Customers::PaymentProviderFinder passes customer.billing_entity_id.
  • StripeService#create_or_update + GraphQL StripeInput accept billing_entity_code.
  • Provider codes stay org-unique → webhook-by-code stays unambiguous.

Part 2 — Stripe Connect (connected accounts)

  • StripeProvider: connected_account_id accessor, connected?, and stripe_request_options — returns {api_key:, stripe_account:} for a connected account, {api_key:} otherwise. For a connected provider, secret_key holds the platform key and calls run on the connected account via the Stripe-Account header (Standard Connect account type).
  • create_or_update + GraphQL accept connected_account_id (set on creation, scoped to the billing entity).
  • Threaded stripe_request_options through every Stripe call path so a connected provider acts on its own account: payment intents, customer create/update, checkout sessions, refunds, payment-method retrieve/list/check, funding instructions, the setup-intent webhook, and webhook register/refresh. A shared helper lives on PaymentProviders::Stripe::BaseService.
  • white_label/reset_service (the agency-pays-us wholesale path) intentionally stays org-level.

Why this is safe

Every change is gated on a NULL check: no billing_entity_id and no connected_account_idbyte-identical to current behavior. No entity-scoped or connected providers exist yet, so existing org-level Stripe is completely unaffected; a bug could only affect the new reseller feature.

⚠️ Verification — must pass before merge

  • RSpec in CI — not run locally (repo needs Ruby 4.0.2; local is 2.6).
  • Stripe test-mode end-to-end for a connected account: create provider with connected_account_id, register webhook on the connected account, run a checkout + payment intent + refund + payment-method retrieval, confirm they land on the connected account and webhooks validate.
  • db/structure.sql was hand-edited (Part 1 column/index/FK); regenerate via bin/rails db:migrate && db:schema:dump to normalize the FK name.

Follow-ups (separate)

  • lago-api#19 lifts the org multi-entity limit (flobyte:seed).
  • growth-os: agency Connect OAuth onboarding → store acct_…; create the entity-scoped provider; route sub-account customers (growth-os#1246, merged, handles the entity routing).
  • Application fee (our cut) — deferred to P3.

rrader2890 and others added 2 commits July 28, 2026 16:20
Foundation for reseller "SaaS Mode": let an agency collect on their OWN Stripe
for their billing entity's customers, while org-level providers behave exactly
as before.

- Add nullable payment_providers.billing_entity_id (NULL = org-level = current
  behavior). Migration + structure.sql (hand-edited; regenerate in CI).
- BaseProvider belongs_to :billing_entity, optional.
- FindService: optional billing_entity_id — prefer entity-scoped provider, fall
  back to org-level; scope untouched when absent (webhooks/management unchanged).
- Customers::PaymentProviderFinder passes customer.billing_entity_id.
- StripeService#create_or_update accepts billing_entity_code (resolved via
  BillingEntities::ResolveService) to scope a NEW provider; exposed on GraphQL
  StripeInput.

Provider codes stay org-unique (webhook-by-code routing unambiguous). No
money-movement or webhook code changed. RSpec to run in CI (local Ruby 2.6).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Builds on per-entity payment providers: an agency's own Stripe (a Connect
connected account, acct_…) can now collect for their billing entity's
customers. Fully backward-compatible — a provider with no connected account
behaves byte-identically to before.

- StripeProvider: settings accessor `connected_account_id`, `connected?`, and
  `stripe_request_options` (returns {api_key:, stripe_account:} for a connected
  account; {api_key:} otherwise). For a connected provider, secret_key holds the
  PLATFORM key and calls run on the connected account via Stripe-Account.
- StripeService#create_or_update + GraphQL StripeInput accept
  connected_account_id (set on creation, scoped to the billing entity).
- Threaded stripe_request_options through EVERY Stripe call path so connected
  providers act on their account: payment intents, customer create/update,
  checkout sessions, refunds, payment-method retrieve/list/check, funding
  instructions, setup-intent webhook, and webhook register/refresh. Added a
  shared helper on PaymentProviders::Stripe::BaseService.
- white_label/reset_service (agency-pays-us wholesale) intentionally stays
  org-level.

NOT run locally (repo requires Ruby 4.0.2; local is 2.6). Needs RSpec in CI +
a Stripe test-mode end-to-end before merge.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@rrader26 rrader26 changed the title feat(payments): optional per-billing-entity payment providers (reseller SaaS Mode foundation) feat(payments): per-entity payment providers + Stripe Connect (reseller SaaS Mode) Jul 28, 2026
@rrader26
rrader26 marked this pull request as ready for review July 28, 2026 20:56
@rrader26
rrader26 merged commit d75415e into main Jul 28, 2026
1 of 12 checks passed
rrader26 pushed a commit that referenced this pull request Jul 28, 2026
… lint

- 20260728000000 (#18): use disable_ddl_transaction! + concurrent index +
  add_foreign_key validate:false, matching the repo convention
  (AddBillingEntityToWallets). The prior form failed `rails db:migrate` under
  strong_migrations (index inside a txn) — this blocked deploys.
- spec: declare subject before let (RSpec/LeadingSubject).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
rrader26 added a commit that referenced this pull request Jul 28, 2026
… provider (#20)

* feat(payments): REST endpoint to register a reseller's Stripe Connect provider

POST /api/v1/payment_providers/stripe_connect (api_key auth) creates an
entity-scoped Stripe provider from a reseller's connected account, so the
agency's sub-account retail invoices collect on their OWN Stripe.

- Reuses the organization's platform Stripe key (no secret over the wire);
  calls run on the connected account via Stripe-Account (per #18).
- Idempotent by provider code. Requires a billing_entity_code + acct_.

Consumed by growth-os resellerService.registerConnectedProviderInLago. Needs
RSpec in CI (local Ruby 2.6 vs 4.0.2). Part of ThinkfleetAI/growth-os#1243.

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

* test(payments): request spec for stripe_connect provider endpoint

Covers: creating an entity-scoped connected Stripe provider on the org's
platform key, the resulting connected stripe_request_options (Stripe-Account
header), not-found when the org has no platform Stripe provider, and failure on
an unknown billing entity code.

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

* fix(payments): make billing_entity migration strong_migrations-safe + lint

- 20260728000000 (#18): use disable_ddl_transaction! + concurrent index +
  add_foreign_key validate:false, matching the repo convention
  (AddBillingEntityToWallets). The prior form failed `rails db:migrate` under
  strong_migrations (index inside a txn) — this blocked deploys.
- spec: declare subject before let (RSpec/LeadingSubject).

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

* fix(payments): drop DB-level FK on payment_providers.billing_entity_id

The schema-dump check failed because a hand-maintained FK can't match Rails'
deterministic constraint name/order. billing_entity_id is an optional,
app-managed reference — keep the column + concurrent index, drop the FK. Aligns
the migration and structure.sql with the generated schema.

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

---------

Co-authored-by: rrader2890 <ryan@scheduleventures.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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.

2 participants