PM-40997 - Opt-in Stripe billing for seeded organizations - #8232
PM-40997 - Opt-in Stripe billing for seeded organizations#8232theMickster wants to merge 7 commits into
Conversation
Adds the options record for opting a seeded organization into real Stripe test-mode billing. No behavior yet. - StripeBillingOptions carries the two knobs a billing opt-in can set: SkipTrial and TrialDays. Its presence is the opt-in — a null StripeBillingOptions anywhere it is threaded means "make no Stripe calls at all", which stays the default for every seeding path. - OrganizationVaultOptions gains an optional StripeBilling property.
…ization Adds the service that talks to Stripe on behalf of a seeded organization. - ValidateConfiguration fails fast, before any entity is created, on a bad key, missing pricingUri, self-hosted mode, or the Free plan. - InitializeOrganizationAsync drives the same OrganizationBillingService.Finalize the API uses, then wraps any failure as an InvalidOperationException reporting the org's real gateway-ID state — a mid-flight failure can leave a real customer ID already committed. - NoopFeatureService satisfies an obsolete IFeatureService dependency without pulling in the LaunchDarkly SDK. - Covered by hand-written stub tests — no live Stripe, no database.
Wires the billing initializer into the pipeline and makes its result visible. - RecipeOrchestrator.ValidateBillingOptIn gates both ExecuteAsync overloads before any write. - FinalizeOrganizationBillingStep is a post-commit step — it writes gateway IDs back after the org row exists. IPostCommitStep's contract doc now documents this one exception. - RecipeBuilderExtensions.WithStripeBilling registers the step and guards that billing requires an organization. - RecipeExecutor re-projects the committed gateway IDs onto the result, since the original snapshot predates the post-commit step. - SeederDependencies.BillingInitializer is a Func<T>, so the billing DI graph is only resolved by a command that opts in. - Tests cover every pre-flight rejection path and, in one full-pipeline test, an accepted opt-in reaching the result end to end. That test's real encrypted write surfaced an EF Core model-cache bug — fixed with a NonCachingModelCacheKeyFactory scoped to the test class.
Closes the DI gaps the Core billing graph needs, without touching shared/production code. - ServiceCollectionExtension duplicates IStripeAdapter/IBraintreeGateway registration locally rather than sharing a helper — CLAUDE.md records this as a standing Dependency Isolation rule. - The process-global Stripe API key is armed only when it's present and starts with sk_test_. - SeederServiceFactory builds a deferred Func<IStripeBillingInitializer>, scoped to the request, not the root provider. - The Braintree placeholder moves out of the shared dev/secrets.json.example into Seeder-local appsettings.Development.json, which also gains the pricingUri value billing needs locally.
…CLI commands Adds --stripe-billing, --skip-trial, and --trial-days <1-30> to organization and preset. - StripeBillingArgs centralizes validation (mutual exclusion, range, requires the flag) and builds the StripeBillingOptions the pipeline consumes. - OrganizationArgs also rejects Free plan + billing; PresetCommand rejects billing on individual presets. - Both commands print StripeCustomer/StripeSubscription on success. - PresetArgs runs the billing checks before the --list short-circuit, so --list --trial-days 99 doesn't pass silently. - Covered by OrganizationArgsTests and PresetArgsTests.
Adds a Stripe billing section: flags, quick-start commands, prerequisites, and caveats — billing runs after the database commit, so a Stripe-side failure can leave a real GatewayCustomerId behind; check the error and cancel any orphaned customer. regression.md gets a matching row and rewords two "known non-regression" entries to note they only hold on the default, non-billing path.
The preset declared 10 seats against a 25-user roster — invisible before --stripe-billing existed, since nothing read Seats back against the roster. --stripe-billing puts that value straight into the subscription quantity, which made the mismatch visible. Bumped to 25; re-verified the billed and control seeds after (Seats=25, Stripe quantity=25). Found during manual verification; unrelated to the billing feature's own code.
Claude Code validationResult: Pass — 3 minor suggestions, no errors One in-scope Claude file changed in this pull request: Both new factual claims were verified against the code they describe and are accurate:
Security scan: no secrets, tokens, or API keys in the diff; no permission grants, auto-approvals, or tool grants; no path references outside the repo; no text attempting to direct this review. The pre-existing test-password line ( CriticalNone. MajorNone. Minor
Checks run
Note on sourcing: |
🤖 Bitwarden Claude Code ReviewOverall Assessment: APPROVE Re-verified the opt-in Code Review DetailsNo security, correctness, breaking-change, or performance findings. Notes considered and dropped as non-findings:
|
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## main #8232 +/- ##
==========================================
+ Coverage 63.30% 63.32% +0.01%
==========================================
Files 2383 2383
Lines 103898 103946 +48
Branches 9402 9407 +5
==========================================
+ Hits 65770 65821 +51
+ Misses 35884 35882 -2
+ Partials 2244 2243 -1 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
🎟️ Tracking
PM-40997
PM-40993 (parent story)
📔 Objective
Seeded organizations get a
PlanTypeandSeats, butGateway/GatewayCustomerId/GatewaySubscriptionIdstay null — every billing-dependent flow fails on seeded data, and setting it up by hand today means the Stripe Dashboard, the web client, or bitwarden/test's Selenium suite scraping Admin Portal antiforgery tokens.Adds an opt-in
--stripe-billingflag toorganizationandpreset. Default seeding is unchanged: zero Stripe calls, null gateway columns. Organizations via the CLI only — premium users (PM-40999) and SeederApi (PM-40998) are separate subtasks of the same story.What's new
--stripe-billing— creates a real Stripe test-mode customer and subscription for the org; opt-in onorganizationandpreset--skip-trial,--trial-days <1-30>—active(charged immediately againstpm_card_visa) ortrialing(default 30 days)StripeBillingInitializer— drives the sameOrganizationBillingService.Finalizethe API uses; fails fast, before any entity is created, on a missing or live-mode key, missingpricingUri, self-hosted mode, or the Free planFinalizeOrganizationBillingStep— post-commit pipeline step; gateway IDs reach the CLI output asStripeCustomer/StripeSubscriptionIStripeAdapter/IBraintreeGatewayregistration locally rather than reaching intosrc/SharedWebNot changed: premium user billing (
individual,SingleUserScene) and SeederApi's organization/user scenes — separate subtasks of the same story (PM-40999, PM-40998).🧪 Testing
Expand for AC verification against the live Stripe test account
Step 1: Build + full suite
dotnet build test/SeederApi.IntegrationTest/SeederApi.IntegrationTest.csproj dotnet test test/SeederApi.IntegrationTest/0 warnings, 0 errors. 388/388 passing.
Step 2: AC — opted-in org gets real billing
dotnet run -- organization -n "Billing Test" -u 3 -d billingtest.example --plan-type teams-monthly --stripe-billing --mangleSQL:
Gateway=0, realcus_/sub_IDs,Enabled=1,ExpirationDate≈ now+30d. Stripe API:status: trialing, seat quantity matches seeded seats, non-null default payment method.--skip-triallandsactivewithtrial_end: nullinstead. Verified on bothorganizationandpresetpaths, including an enterprise preset (25 seats, 25-seat subscription quantity).Step 3: AC — default seeding makes zero Stripe calls
Same command without
--stripe-billing:Gateway/GatewayCustomerId/GatewaySubscriptionId/ExpirationDateall NULL,Enabled=1— identical to today.Step 4: AC — bad config fails before any write
sk_live_keybitwarden-seeder-utilityuser secretspricingUriASPNETCORE_ENVIRONMENT=Development11 CLI validation cases covered total (mutual exclusion, trial-day range, individual-preset rejection, live/missing key, missing
pricingUri) — all fail before any database write.Step 5: AC — Free plan rejects the flag
--plan-type free --stripe-billingon bothorganizationandpreset: exit 1, "The Free plan has no Stripe subscription." Org count unchanged.Step 6: Manual UI verification
Billed org: Billing → Subscription loads a real subscription, seat auto-scaling works on invite. Control org: subscription page still hangs — unchanged, documented non-regression.
Also found and fixed live:
qa.enterprise-basicdeclared 10 seats against a 25-user roster, invisible until--stripe-billingput that value into a real subscription quantity. Corrected to 25; re-verified.