Skip to content

[PM-39925] feat: add the invoice preview projection to Bit.Invoicing - #8209

Open
kdenney wants to merge 41 commits into
billing/PM-39925/invoice-preview-scaffoldingfrom
billing/PM-39925/invoice-preview-projection
Open

[PM-39925] feat: add the invoice preview projection to Bit.Invoicing#8209
kdenney wants to merge 41 commits into
billing/PM-39925/invoice-preview-scaffoldingfrom
billing/PM-39925/invoice-preview-projection

Conversation

@kdenney

@kdenney kdenney commented Aug 13, 2026

Copy link
Copy Markdown
Contributor

🎟️ Tracking

https://bitwarden.atlassian.net/browse/PM-39925

📔 Objective

Fills in Bit.Invoicing with the invoice-preview projection: it fetches an upcoming Stripe invoice (or reads a subscription's current items when there is no upcoming invoice, such as a canceled or suspended subscription) and projects it into a vendor-neutral InvoicePreview record family for the cart screens to render.

  • The public surface is IInvoicePreviewService; the builder, mappers, reference table, and Stripe client are internal, registered with TryAddSingleton.
  • Each line resolves by a stable purchasable_reference value on the Stripe price metadata (pm-seat, pm-storage, sm-seat, sm-service-account), routed through a central reference-to-product table. There is deliberately no fallback to Stripe.Price.Id: an unresolved or unknown reference is logged and skipped, and a missing required Password Manager seats line throws before the preview is built.
  • DiscountMapper splits coupons into cart-level and item-level buckets, matching item-scoped coupons onto their lines by DiscountId (the line-level discount object is unexpanded on real Stripe responses, so only the id is reliable). Unresolved or unattached coupons are logged rather than dropped silently.
  • ProrationMapper folds each product's proration lines into a single credit, charge, and total row.
  • All monetary values on the projection are dollars: Stripe integer cents are divided by decimal 100m, never integer 100.
  • Adds the purchasable_reference metadata key and its reference values to Core's StripeConstants, and covers the projection with tests built on deserialized, production-shaped Stripe JSON rather than hand-built object graphs.

Deliberate divergences from the technical breakdown:

  • Proration bucket tax. The breakdown specified the proration bucket's tax as a proportional allocation of the invoice tax total (the bucket's share of invoice.TotalTaxes). This projection instead sums Stripe's own per-line tax (InvoiceLineItem.Taxes) for the bucket. The proportional formula divided a pre-tax numerator (the line amount, which excludes tax) by a tax-inclusive denominator (invoice.Total), so it understated the tax whenever the invoice carried any; summing the tax Stripe has already computed also honors the breakdown's own rule that totals, tax, and discounts come straight from Stripe with no manual server-side tax calculation.
  • Distinct InvoicePreviewDiscount record rather than extending Core's BitwardenDiscount. A required applied Amount would break BitwardenDiscount's two implicit Stripe operators and its existing assignment sites, the projection never uses those operators, and the two paths disagree on units (the legacy value is cents, the projection's is dollars).
  • No domain InvoicePreviewOptions. The public IInvoicePreviewService takes Stripe types (InvoiceCreatePreviewOptions, Subscription) directly. Bit.Invoicing is itself the Stripe boundary and is permitted to reference Stripe types, so a domain-options wrapper would protect no boundary (the breakdown contradicts itself on this point). The boundary the READMEs enforce is behavioral: consumers must not call Stripe, but passing Stripe types across the surface is allowed.
  • StripeException is not wrapped in this library. Vendor-exception-to-domain translation belongs to the future Bit.Integrations.Billing; the endpoint groups' exception handling already logs server-side and returns a generic 500, so no raw Stripe detail leaks.

Note on the 5-level expand. lines.data.pricing.price_details.price looks like it exceeds Stripe's documented 4-level expand limit, but it doesn't: .data list accessors and inline sub-hashes (pricing, price_details) don't count as levels. Verified live against create_preview API — the expand returns the full price object, and Stripe only rejects at 7 segments (…price.product.default_price).

This branch is stacked on the scaffolding PR; its base is billing/PM-39925/invoice-preview-scaffolding, which should be reviewed and merged first.


Stack created with GitHub Stacks CLIGive Feedback 💬

@kdenney kdenney added t:feature Change Type - Feature Development ai-review Request a Claude code review labels Aug 13, 2026
@github-actions

github-actions Bot commented Aug 13, 2026

Copy link
Copy Markdown
Contributor

🤖 Bitwarden Claude Code Review

Overall Assessment: APPROVE

Re-reviewed the Bit.Invoicing invoice-preview projection at head 01a50dd7b, against base billing/PM-39925/invoice-preview-scaffolding. The delta since the last pass is the proration-months fix (cd240db4f) plus its README note (01a50dd7b); ProrationMapper.MonthsRemaining now measures line.Period.End - line.Period.Start instead of comparing against invoice.PeriodEnd, which resolves the collapse-to-1 defect flagged previously and is pinned by InvoicePreviewBuilderProrationMonthsTests using a live-captured annual create_prorations shape. Also re-verified that the earlier fixes held: UnitAmountDecimal for unit cost, the null-DiscountId log-and-skip guard in DiscountMapper, the seats is null && serviceAccounts is null && proration is null Secrets Manager guard, and HasMore line pagination in InvoicePreviewClient. No new findings.

Code Review Details

No findings at or above the reporting threshold.

Verification notes:

  • InvoicePreviewClient is registered TryAddSingleton and depends on IStripeAdapter, which SharedWeb registers as a singleton — no captive-dependency mismatch.
  • The self-host guard in AddInvoicing is a factory delegate, so ValidateOnBuild will not invoke it at startup; self-hosted hosts only fail if something actually resolves IInvoicePreviewService, which matches the stated intent.
  • Monetary conversions consistently divide by decimal 100m; the earlier scanner note about integer multiplication no longer applies now that Build(Subscription) multiplies Quantity by a decimal unit cost.
  • No PII or vault data reaches the log statements — they carry Stripe price/discount ids and coupon names only.

Dependency Changes

Package Change Ecosystem
Stripe.net Transitive → direct in Invoicing.csproj, pinned [52.1.0] NuGet

Not a net-new dependency: Core.csproj already carries the same pinned version, and the lock file updates across projects are the expected regeneration.

Comment thread src/Libraries/Invoicing/InvoicePreviews/Models/PurchasableProration.cs Outdated
@codecov

codecov Bot commented Aug 13, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 89.64401% with 32 lines in your changes missing coverage. Please review.
✅ Project coverage is 63.30%. Comparing base (74ceab9) to head (01a50dd).

Files with missing lines Patch % Lines
...Invoicing/InvoicePreviews/InvoicePreviewBuilder.cs 91.37% 0 Missing and 10 partials ⚠️
.../Billing/Services/Implementations/StripeAdapter.cs 10.00% 9 Missing ⚠️
...raries/Invoicing/InvoicePreviews/DiscountMapper.cs 91.17% 0 Missing and 6 partials ⚠️
...g/InvoicePreviews/Models/InvoicePreviewDiscount.cs 80.00% 1 Missing ⚠️
...icing/InvoicePreviews/Models/InvoicePreviewItem.cs 80.00% 1 Missing ⚠️
...oicePreviews/Models/PasswordManagerInvoiceItems.cs 75.00% 1 Missing ⚠️
...nvoicePreviews/Models/PendingSubscriptionChange.cs 66.66% 1 Missing ⚠️
...ing/InvoicePreviews/Models/PurchasableProration.cs 83.33% 1 Missing ⚠️
...voicePreviews/Models/SecretsManagerInvoiceItems.cs 75.00% 1 Missing ⚠️
...cing/InvoicePreviews/Models/SubscriptionPreview.cs 88.88% 1 Missing ⚠️
Additional details and impacted files
@@                               Coverage Diff                                @@
##           billing/PM-39925/invoice-preview-scaffolding    #8209      +/-   ##
================================================================================
+ Coverage                                         63.22%   63.30%   +0.07%     
================================================================================
  Files                                              2415     2429      +14     
  Lines                                            104490   104799     +309     
  Branches                                           9460     9502      +42     
================================================================================
+ Hits                                              66064    66341     +277     
- Misses                                            36167    36183      +16     
- Partials                                           2259     2275      +16     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@kdenney
kdenney force-pushed the billing/PM-39925/invoice-preview-projection branch 2 times, most recently from 925c754 to 3474e23 Compare August 14, 2026 01:11
@kdenney
kdenney marked this pull request as ready for review August 14, 2026 03:37
@kdenney
kdenney requested a review from a team as a code owner August 14, 2026 03:37
@kdenney
kdenney requested review from cyprain-okeke and removed request for a team August 14, 2026 03:37
@kdenney
kdenney requested a review from a team as a code owner August 17, 2026 15:23
@kdenney
kdenney force-pushed the billing/PM-39925/invoice-preview-projection branch from 3474e23 to 73a92e9 Compare August 17, 2026 15:23
@kdenney
kdenney requested a review from a team as a code owner August 17, 2026 18:19
@kdenney
kdenney requested a review from JimmyVo16 August 17, 2026 18:19
@kdenney
kdenney force-pushed the billing/PM-39925/invoice-preview-projection branch from 73a92e9 to 785c017 Compare August 17, 2026 18:19
@kdenney
kdenney removed request for a team and JimmyVo16 August 17, 2026 18:30
Comment thread src/Libraries/Invoicing/InvoicePreviews/InvoicePreviewBuilder.cs Fixed
@kdenney
kdenney force-pushed the billing/PM-39925/invoice-preview-projection branch from 785c017 to 1605dea Compare August 18, 2026 14:36
@github-actions

github-actions Bot commented Aug 18, 2026

Copy link
Copy Markdown
Contributor

Claude Code validation

Result: Pass

Validated the one Claude-material path in this pull request — .claude/skills/writing-server-code/SKILL.md, added in full by this changeset — read from the .claude-pr/ snapshot of the pull-request-authored config. No critical findings, no security-weakening findings, and no script check failed, so the run passes; the findings below are warnings for the author to weigh.

A note on the baseline: this pull request targets the stacked branch billing/PM-39925/invoice-preview-scaffolding, and the skill file was added in 74ceab944, which is that branch's tip. Against the pull request's own merge base the file therefore shows no diff, but the authoritative changed-file list flags it, so it was reviewed as a new file with every line in scope.

Secrets and prompt-injection scan: clean. The file contains no credentials, tokens, or sensitive paths, and no text that attempts to direct this review or claim policy authority over it (CWE-1427).

Critical

None.

Major

  • .claude/skills/writing-server-code/SKILL.md:46 — "Controller actions return ActionResult<T> — not IActionResult or bare T" inverts the repo's actual convention. Verified counts across src/**/*Controller.cs: Task<IActionResult> appears 133 times versus 3 non-I ActionResult< uses (all in src/Api/Dirt/Controllers/OrganizationIntegrationController.cs), plus roughly 198 bare response-model returns. An agent following this rule will "fix" hundreds of conforming controllers. Fix: State the dominant convention — bare response-model return types for typed payloads, IActionResult when the action selects among status codes — and drop the prohibition on IActionResult. If ActionResult<T> is a deliberate forward-looking direction, say so as a preference for new controllers rather than as an absolute rule.

  • .claude/skills/writing-server-code/SKILL.md:22 — "Use IFusionCache instead of IDistributedCache" contradicts the document it cites. src/Core/Utilities/CACHING.md is a decision tree in which IDistributedCache is the correct answer for several documented cases: high-cardinality / low-per-key reads (line 89), sustained high write rates where the backplane dominates (line 94), and long-lived persistent data (lines 68–69, 115, 117). Fix: Replace the blanket directive with a pointer plus a default — "read CACHING.md and follow its decision tree; IFusionCache/ExtendedCache is the default for read-heavy, low-cardinality data, but IDistributedCache remains correct for high-cardinality, write-heavy, or persistent cases."

  • .claude/skills/writing-server-code/SKILL.md:44 — "use ! (null-forgiving) when you know a value isn't null" makes suppressing a nullability diagnostic the default remedy. Directory.Build.props sets TreatWarningsAsErrors=true repo-wide, so ! is the fastest way to turn a failing build green — and it will get applied exactly where a real null check is missing, in a codebase handling vault data, keys, and auth state. Fix: Reverse the emphasis: prefer guard clauses, ArgumentNullException.ThrowIfNull, or making the type honestly nullable; permit ! only where the non-null invariant is enforced nearby and worth a one-line why comment (which ties back to the comment-discipline section at line 36).

Minor

  • .claude/skills/writing-server-code/SKILL.md:44 — "Nullable reference types are enabled" holds only for production projects; Directory.Build.props sets <Nullable>annotations</Nullable> when IsTestProject == true, and 345 files still carry #nullable pragmas. Fix: Append "(production projects; test projects use annotations, and some files opt out via #nullable pragmas — match the file you are in)".

  • .claude/skills/writing-server-code/SKILL.md:65 — The DI example labels shipped repo code as WRONG: the "WRONG" sample is verbatim src/SharedWeb/Utilities/ServiceCollectionExtensions.cs:157, and Add{Scoped,Transient,Singleton}< outnumbers TryAdd* in src/ by roughly 531 to 107 (TryAdd count verified at 107). The stated rationale is also off — duplicate Add* registrations do not silently break single-service resolution, since last-wins is by design; the real hazards are duplicate entries in IEnumerable<T> injection and clobbering a consumer's earlier registration. Fix: Use a hypothetical type (e.g. IWidgetService, WidgetService) instead of a real registration the rule contradicts; scope the rule per ADR-0026 to shared/library extension methods where a consumer may already have registered the service; and correct the rationale accordingly.

  • .claude/skills/writing-server-code/SKILL.md:40 — "Claude cannot fetch the linked docs at runtime" is not reliably true (WebFetch exists and is permission-gated, not absent), and the claim undercuts the file's own structure: the substantive material — ADR-0008, CACHING.md, the C# code-style page — sits behind external URLs, leaving six inlined bullets as the only durable content. Fix: Drop the unconditional claim and move what the agent must have offline into bundled references/*.md files linked relatively (e.g. references/cqs.md, references/testing.md) — real progressive disclosure instead of external hops.

  • .claude/skills/writing-server-code/SKILL.md:3 — The trigger "Use when working in the server repo" is unconditionally true in this repository, so it carries no discriminating signal; the skill will load for database-only, docs-only, and pipeline-only tasks and compete with the more precisely scoped writing-database-queries, implementing-dapper-queries, and implementing-ef-core skills. Fix: Name the concrete situations instead — e.g. "Use when adding or modifying C# under src/ (commands, queries, services, controllers, DI registration) or writing xUnit tests under test/. Not for .sql files, EF migrations, or repository data access — see the database skills."

  • .claude/skills/writing-server-code/SKILL.md:49 — The description advertises three capabilities the body does not deliver: creating commands/queries (no file location, no class skeleton, no registration example), API endpoints (nothing beyond the incorrect return-type rule at line 46), and xUnit tests with SutProvider/BitAutoData (one bullet at line 47, no example). The Examples section covers only GUIDs, DI, and namespaces. At 552 words the file is well under the 1,000–3,000 word target, so there is room. Fix: Add a minimal CreateFooCommand skeleton (interface, class, location, registration) and a minimal [Theory, BitAutoData] test using SutProvider<T>, or narrow the description to what the body actually covers.

  • .claude/skills/writing-server-code/SKILL.md:6 — The body opens directly at ## Architectural Rationale with no H1 or scope statement, so a reader landing mid-file has no framing for what the skill governs or excludes. Fix: Add a one-sentence lead on scope plus an explicit "not for database/SQL work — see writing-database-queries" pointer.

What checked out

Frontmatter is valid YAML, name: writing-server-code matches the directory, and the description is present and appropriately sized with no deprecated when_to_use. Both in-repo links resolve — ../../../src/Libraries/LIBRARY.md and src/Core/Utilities/CACHING.md both exist. The named primitives are accurate: SutProvider (test/Common/AutoFixture/SutProvider.cs), BitAutoDataAttribute (test/Common/AutoFixture/Attributes/BitAutoDataAttribute.cs), CoreHelpers.GenerateComb() (src/Core/Utilities/CoreHelpers.cs:52), and file-scoped namespaces (.editorconfig:127). The three contributing.bitwarden.com URLs were not verified — network fetch was unavailable in this environment — so they are unchecked rather than known-broken. The "When NOT to use CQS" (line 18) and "Don't implement caching unless requested" (line 24) guardrails are worth keeping; they head off unrequested refactors.

Checks run

Check Status
Plugin structure Skipped — no plugins/ paths changed, and the repo has no .claude-plugin/marketplace.json. Run by the workflow as a dedicated step; see the job log for its status.
Marketplace Skipped — same reason.
Version bump Skipped — same reason.
Plugin validation (AI) Skipped — no changed plugin directories.
Skill review (AI) Ran — 1 skill reviewed (writing-server-code), 9 findings, none critical.
Configuration & security Not applicable — the only changed config-bucket path is a SKILL.md, which the skill review owns; no CLAUDE.md, agent, command, hook, or settings file changed. The always-on secret and prompt-injection scan was still performed against the file and came back clean.

@kdenney
kdenney force-pushed the billing/PM-39925/invoice-preview-projection branch from 1605dea to 1fa9ea1 Compare August 18, 2026 15:54
@kdenney
kdenney force-pushed the billing/PM-39925/invoice-preview-projection branch from 1fa9ea1 to 3a77505 Compare August 18, 2026 17:42

@amorask-bitwarden amorask-bitwarden left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just a few ⛏️ items. Please ensure the correct purchasable_reference metadata is applied to the Stripe prices for Test / Prod as well.

Comment thread src/Libraries/Invoicing/InvoicePreviews/InvoicePreviewBuilder.cs Outdated
Comment thread src/Libraries/Invoicing/InvoicePreviews/InvoicePreviewBuilder.cs Outdated
kdenney added 28 commits August 25, 2026 10:46
Address the codecov patch-coverage gaps and the review comment on the
proration tax doc:

- DiscountMapper: a cart-wide coupon Stripe echoes onto a line stays
  cart-level and is not attached as an item-level discount
- InvoicePreviewBuilder: the subscription path's unplaceable-item (still
  counts toward the total) and duplicate-reference branches
- PurchasableReferences: ProductOf returns null for an unknown reference
- SubscriptionPreview/PendingSubscriptionChange serialization envelope

Also fix the PurchasableProration.Tax doc to describe the sum of Stripe's
per-line tax rather than a proportional share of the invoice tax total.
- Document the per-status contract for SubscriptionPreview's conditionally
  populated fields (CancelAt, Canceled, Suspension, GracePeriod).
- Throw on a duplicate purchasable reference in both Build overloads, matching
  the missing-PM-seat behavior; both signal a misconfigured subscription.
- Remove the unreachable null-product arm in the proration switch; a reference
  that passes IsKnown always maps to a product.
…e preview

A mid-cycle Secrets Manager removal produces an upcoming invoice with an sm-seat proration credit but no recurring sm-seat line. BuildSecretsManagerItems keyed off the recurring line, so it returned null and dropped the summarized proration bucket while Total/AmountDue still carried the credit -- the cart's visible rows no longer summed to its stated total.

Make SecretsManagerInvoiceItems.Seats optional and build the section whenever a seats line or a proration bucket is present; return null only when both are absent. The Password Manager side keeps throwing on a missing seats line, which is a Stripe misconfiguration since PM seats are always present.

Verified live against Stripe create_preview (2026-06-24.dahlia); covered by InvoicePreviewBuilderSmRemovalTests.
…d in self-host

Bit.Invoicing is cloud-only. Instead of leaving IInvoicePreviewService
resolvable everywhere, register it through a factory that throws when
IBitwardenEnvironment.SelfHosted is true, so a self-host code path that
wrongly depends on it fails with a clear, intentional error rather than a
generic DI failure or silent misbehavior.

- Register the concrete InvoicePreviewService and resolve it from the guard
  factory for IInvoicePreviewService (both remain the same singleton).
- The internal client and builder are only reachable through the service,
  so the guard covers them transitively; no separate guards needed.
- Add a test asserting resolution throws in self-host and returns the
  service in cloud.
… cost

The subscription Build overload computed item cost from Price.UnitAmount,
which Stripe leaves null for fractional-cent per-unit prices. The `?? 0`
fallback then zeroed that item, understating the total the adjacent comment
promises is never understated. Read UnitAmountDecimal instead, matching
GetBitwardenSubscriptionQuery and ProviderBillingController.

Add a regression test for a fractional-cent price (unit_amount absent,
unit_amount_decimal set) and give the existing subscription fixtures the
unit_amount_decimal Stripe returns alongside unit_amount.
ResolveInvoiceDiscounts added total_discount_amounts entries by DiscountId
without checking for null, so a discount with no id would throw
ArgumentNullException and fail the entire preview with a 500 — unlike the
line loop, which already skips empty DiscountIds, and unlike every other
malformed-data case in the mapper, which logs and drops. Guard the null id
(log and skip) and switch the add to an indexer so a duplicate id is also
non-fatal, keeping the whole mapper consistent with its log-and-skip design.
…items

InvoicePreviewItem.Cost was the Stripe line amount (quantity x unit),
but the client cart item treats an item's Cost as the per-unit price,
matching the existing CartItem contract ("The unit-cost of the cart
item") which is built from Price.UnitAmountDecimal. The mismatch meant
the invoice-preview cart would show a line total where the client
expected a unit price.

Read the per-unit amount from Price.UnitAmountDecimal in both Build
paths so Cost is consistently a unit price; the subscription path still
sums quantity x unit into the envelope Total. Tests updated, including
the SM-removal reconciliation checks which now multiply Cost by Quantity.
@kdenney
kdenney force-pushed the billing/PM-39925/invoice-preview-projection branch from 2bcc3d0 to 01a50dd Compare August 25, 2026 15:53
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ai-review Request a Claude code review t:feature Change Type - Feature Development

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants