Skip to content

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

Open
kdenney wants to merge 42 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 42 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

Reviewed the invoice-preview projection at commit eaeb91c33 against the stacked base billing/PM-39925/invoice-preview-scaffolding, covering InvoicePreviewBuilder, DiscountMapper, ProrationMapper, PurchasableReferences, the Stripe preview client, the new IStripeAdapter.ListInvoiceLineItemsAsync paging method, DI registration, and the projection models. Traced the cent-to-dollar conversions, the proration and discount partitioning paths, the duplicate/unknown reference handling, and the Secrets Manager section guard. All twelve previously opened threads remain resolved and the behavior they describe is present in the current code. No findings at or above the reporting threshold.

Code Review Details

No findings.

Notes considered and dismissed during validation:

  • InvoicePreviewClient overwrites any caller-supplied options.Expand, which is asserted as intended behavior in InvoicePreviewClientTests.
  • BuildPasswordManagerItems throwing on a missing pm-seat line is an explicit design decision agreed in an earlier resolved thread.
  • Preview-invoice line pagination by invoice.Id was raised and verified in an earlier resolved thread.

Dependency Changes

Package Change Ecosystem
Stripe.net Transitive → Direct in Invoicing.csproj ([52.1.0]) NuGet

Not a net-new dependency: Stripe.net is already a direct, pinned reference in Core.csproj at the identical [52.1.0]. Lock files across the solution regenerated consistently, with src/Libraries/Invoicing/packages.lock.json correctly reclassifying the entry from Transitive to Direct at the same resolved version and content hash.

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.39%. Comparing base (a47db11) to head (eaeb91c).

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.31%   63.39%   +0.07%     
================================================================================
  Files                                              2406     2420      +14     
  Lines                                            104091   104400     +309     
  Branches                                           9429     9472      +43     
================================================================================
+ Hits                                              65904    66182     +278     
- Misses                                            35931    35946      +15     
- Partials                                           2256     2272      +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 .claude/skills/writing-server-code/SKILL.md, the single path supplied as this pull request's Claude material, against base branch billing/PM-39925/invoice-preview-scaffolding (merge base a47db11fc). The skill review found no critical and no security-relevant issues; five style-level findings are listed below. An always-on secret scan and a prompt-injection (CWE-1427) check both came back negative.

Provenance — read this before acting on the findings

The supplied changed-file list does not match this pull request's actual diff. The list names .claude/skills/writing-server-code/SKILL.md, but that file is not in PR 8209's diff:

  • GET /repos/bitwarden/server/pulls/8209/files returns 52 files, none of them Claude material (no plugins/, .claude/, CLAUDE.md, agent, skill, command, or hook path).
  • git diff a47db11fc..HEAD -- .claude/skills/writing-server-code/SKILL.md is empty.
  • The file's most recent commit is a47db11fc docs(billing): move comment discipline into the writing-server-code skill, which is the tip of the base branch — shared history, not this branch's work.

This is a stacked pull request, and the skill edit belongs to the parent (#8208, per that commit's message). The most likely cause is that the changed-file list was computed before the base branch advanced to a47db11fc.

Consequence: under the "report only what the changeset introduced or worsened" rule, PR 8209 introduces no Claude material at all, so nothing below is attributable to it. The findings are recorded because the file was routed for review, and they are accurate about the file's current content — but they should be raised against the parent pull request, not this one. They do not gate this pull request.

Critical

None.

Major

None.

Minor

All five are style-level (SUGGESTED / OPTIONAL) and, per the provenance note, pre-existing relative to PR 8209.

  • .claude/skills/writing-server-code/SKILL.md:34### Comment discipline sits under ## Architectural Rationale, but comment style is a code-style convention, not architectural rationale; the section's other children (CQS, caching, COMB IDs, library shape) each explain a structural decision and its why. Fix: move lines 34–36 into ## Critical Rules as a bullet, or promote to a top-level ## Comment discipline section between lines 37 and 38.
  • .claude/skills/writing-server-code/SKILL.md:40 — the stated rationale for inlining is factually wrong: "Claude cannot fetch the linked docs at runtime, so these are inlined here". Claude Code has WebFetch, and line 32 explicitly instructs reading a local file. A rationale a reader can falsify undermines the rules that follow. Fix: drop the second sentence, or restate it accurately, e.g. "These are the most frequently violated conventions, inlined so they apply without a fetch."
  • .claude/skills/writing-server-code/SKILL.md:34 — heading capitalization is inconsistent: ### Library shape (line 30) and ### Comment discipline (line 34) are sentence case, while ### Command Query Separation (CQS) (8), ### Caching (20), and ### GUID Generation (26) are title case. Fix: pick one convention across the file.
  • .claude/skills/writing-server-code/SKILL.md:36 — three distinct rules (no comment on self-evident code; non-doc comments record rationale and stay to one line; /// XML docs state contract for public members) are packed into one 62-word sentence chain, where comparable guidance in this file uses bullets with bold lead-ins. Fix: split into three bullets to match ## Critical Rules formatting.
  • .claude/skills/writing-server-code/SKILL.md:6 — the body opens at ## Architectural Rationale with no H1 and no one-line scope statement. Sibling skills vary here, so this is a consistency nit. Fix: add a single orienting sentence above line 6.

Noted but not filed as a finding: Use when working in the server repo in the description is a very broad trigger that will match nearly any task in this repository. For a repo-wide conventions skill that is plausibly intentional, and the following clauses narrow it to concrete artifacts.

What passed

  • Frontmatter — valid YAML, name + description only; name: writing-server-code matches its directory; no deprecated when_to_use.
  • Description — 254 characters, declarative summary plus a concrete Use when… trigger clause, consistent with the four sibling skills under .claude/skills/.
  • Size and progressive disclosure — 511 words of body, well under the 3,000-word guidance; the absence of a references/ directory is correct at this size, not a gap.
  • Referenced files../../../src/Libraries/LIBRARY.md resolves to a real file; the https:// links are well-formed, and the GitHub one has a valid local counterpart at src/Core/Utilities/CACHING.md.
  • Secrets — no credentials, tokens, keys, or sensitive paths.
  • Prompt injection (CWE-1427) — negative. No text addressed to a reviewer, no claimed repository policy aimed at the review, no attempt to direct or suppress review behavior. The file's imperative content is ordinary in-genre skill guidance for authoring C# in this repo.

Checks run

Check Status
Plugin structure Skipped — no changed plugins. Also run as a dedicated workflow step; see the job log for its result.
Marketplace Skipped — no changed plugin or .claude-plugin/ paths. Also run as a dedicated workflow step; see the job log.
Version bump Skipped — no component plugins changed. Also run as a dedicated workflow step; see the job log.
Plugin validation (AI) Skipped — no changed plugins, so plugin-dev:plugin-validator had nothing to validate.
Skill review (AI) Passedplugin-dev:skill-reviewer reviewed writing-server-code/SKILL.md; no critical or major findings.
Configuration & security Skipped — no eligible files. The only in-scope path is a SKILL.md, which this check deliberately excludes (owned by the skill review) to avoid duplicate findings. No CLAUDE.md, agent, command, hook, or settings file changed. The always-on secret and injection scans were still run against the skill file and are reported above.

Configuration was read from the .claude-pr/ snapshot of the pull request's versions, as required for repository-root Claude paths in workflow mode; findings are reported at their original repo-relative paths.

@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 27 commits August 21, 2026 11:14
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.
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