Skip to content

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

Open
kdenney wants to merge 39 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 39 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: REQUEST CHANGES

Reviewed the invoice-preview projection at commit 72c51a856 against the stacked base billing/PM-39925/invoice-preview-scaffolding. Focus was the builder, DiscountMapper, ProrationMapper, the Stripe client's pagination splice, and the self-host DI guard, plus the StripeAdapter line-item addition in Core. All twelve previously opened threads remain resolved and none were reopened. One new finding: ProrationMapper.MonthsRemaining derives the prorated span from invoice.PeriodEnd, which only holds under always_invoice and degenerates to 1 on the invoice shape the PR's own live-captured fixture uses.

Code Review Details
  • ⚠️ : Proration Months collapses to 1 when the preview is the next scheduled invoice, because the span is measured against invoice.PeriodEnd rather than the proration line's own period
    • src/Libraries/Invoicing/InvoicePreviews/ProrationMapper.cs:38

Dependency Changes

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

Not a net-new dependency — Core.csproj already carries the same pinned [52.1.0] direct reference, and every dependent packages.lock.json was regenerated consistently.

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.
⚠️ Please upload report for BASE (billing/PM-39925/invoice-preview-scaffolding@353dad2). Learn more about missing BASE report.

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.41%           
===============================================================================
  Files                                                   ?     2422           
  Lines                                                   ?   104438           
  Branches                                                ?     9484           
===============================================================================
  Hits                                                    ?    66225           
  Misses                                                  ?    35941           
  Partials                                                ?     2272           

☔ 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 one changed file — .claude/skills/writing-server-code/SKILL.md (87 lines, new skill) — read from the .claude-pr/ snapshot of the pull request's version, since claude-code-action replaces the working-tree .claude/ with base-branch content. No critical findings, nothing that weakens security, and no failed script check. Eight quality findings are listed below.

Stacked-PR note — read before acting. This pull request targets billing/PM-39925/invoice-preview-scaffolding, not main, and that base branch already contains this file unchanged. gh pr diff 8209 accordingly does not list it. The file entered the stack in commit 353dad2b2, whose subject is about packages.lock.json version alignment. So while the changed-file list supplied to this run is authoritative and the skill was reviewed in full, the findings below are not regressions introduced by this pull request — they belong to the parent PR in the stack and are best fixed there. Two secondary observations: the skill file rides in a commit whose message does not mention it, and it is the only non-billing change in an otherwise billing-scoped stack.

Critical

None.

Major

  • .claude/skills/writing-server-code/SKILL.md:46 — The bullet Controller actions return ActionResult<T> — not IActionResult or bare T is listed under "the most frequently violated conventions", but it inverts the repository's actual convention. Verified in src/Api: 1 of 80 *Controller.cs files uses ActionResult< at all, against 58 Task<IActionResult> returns and ~193 bare Task<...ResponseModel> returns. As written, Claude will produce non-idiomatic controllers and "correct" conforming ones during unrelated edits. Fix: delete the bullet, or restate the observed convention (return the response model directly; use IActionResult/ActionResult<T> only when the action varies its status code) and note that new src/Libraries/ endpoints use ADR-0031 minimal APIs rather than controllers.
  • .claude/skills/writing-server-code/SKILL.md:22Use IFusionCache instead of IDistributedCache is stated unconditionally, but the doc it cites (src/Core/Utilities/CACHING.md) is a decision tree that explicitly prescribes plain IDistributedCache in several branches — long-lived data via the "persistent" keyed service (lines 23, 27, 69), workloads where "backplane is a liability" (line 37), high-cardinality/low-per-key-read sets where "L1 becomes overhead" (line 89), and sustained-high-write workloads that "saturate Redis pub/sub" (line 94). A flat rule in a skill will beat the nuance of a doc Claude may never open. Fix: reword to defer to the decision tree — ExtendedCache/IFusionCache for shared read-heavy data, plain IDistributedCache for high-cardinality, write-heavy, or oversized working sets — and instruct that the tree be read before choosing.
  • .claude/skills/writing-server-code/SKILL.md:3 — The trigger Use when working in the server repo matches every task in this repository, so the skill fires on .sql files, stored procedures, and EF migrations already owned by the sibling writing-database-queries, implementing-dapper-queries, and implementing-ef-core skills. Unlike exploring-bitwarden-data, which scopes itself with an explicit "Not for..." clause, this description carries no negative boundary. Fix: replace the repo-wide clause with C#/.NET-specific triggers (C# under src/ or test/, CQS commands/queries, DI registration, xUnit tests) and append a scoping clause: "Not for schema, migrations, stored procedures, or repository data access — use writing-database-queries, implementing-dapper-queries, or implementing-ef-core."

Minor

  • .claude/skills/writing-server-code/SKILL.md:22CACHING.md is linked as an external https://github.com/bitwarden/server/blob/main/... URL although the file exists in-tree at src/Core/Utilities/CACHING.md. Line 40 of this same skill asserts the linked docs cannot be fetched at runtime, so this form makes the canonical doc unreachable by a plain Read, and it pins to main rather than the checked-out revision. Line 32 already handles LIBRARY.md correctly. Fix: use the relative path [CACHING.md](../../../src/Core/Utilities/CACHING.md), matching line 32.
  • .claude/skills/writing-server-code/SKILL.md:40Claude cannot fetch the linked docs at runtime is inaccurate (WebFetch exists, and repo-local docs are readable), and it undercuts this same skill's instructions on lines 22 and 32 that tell Claude to go read linked docs. Fix: drop the clause, or replace it with "these are inlined because they are the most frequently violated."
  • .claude/skills/writing-server-code/SKILL.md:44use ! (null-forgiving) when you know a value isn't null promotes suppressing the compiler on developer belief, which is the assumption that produces NullReferenceException. The bullet also bundles three unrelated claims (NRT enabled, !, required) under one ADR-0024 citation when only the first is what ADR-0024 covers. Fix: split the bullet, and prefer real null checks, nullable annotations, and required, reserving ! for invariants genuinely outside the compiler's reach — with a one-line why comment, consistent with line 36.
  • .claude/skills/writing-server-code/SKILL.md:44Nullable reference types are enabled is unqualified, but Directory.Build.props:11-12 sets <Nullable>annotations</Nullable> for test projects and enable only for non-test projects, so the claim misleads for exactly the xUnit work line 47 directs. Fix: add "(enable in src/; test projects default to annotations)".
  • .claude/skills/writing-server-code/SKILL.md:49-82 — The three code blocks (GUID, DI, namespace) restate the Critical Rules bullets on lines 42-45 one-to-one and consume roughly a third of the file, while the skill's headline concept — CQS — gets prose only, with no file layout, interface placement, or registration/test skeleton. Fix: trim the duplicate examples and spend the budget on a minimal CreateXCommand skeleton (interface, implementation, TryAddScoped registration, [Theory, BitAutoData] test with SutProvider<T>); move it to examples/ and link it if it outgrows a screenful.

Verified clean

  • No prompt injection (CWE-1427). The file contains only C#/.NET conventions. Line 40's mention of "Claude" is rationale for inlining; lines 18 and 24 ("Don't refactor to CQS unless explicitly asked", "Don't implement caching unless requested") are legitimate scope limits, not attempts to steer tooling or this review.
  • No credentials, tokens, internal hostnames, or sensitive filesystem paths.
  • Frontmatter valid. Well-formed YAML; name: writing-server-code matches the directory; description present; correctly omits the deprecated when_to_use.
  • Relative link on line 32 resolves. ../../../src/Libraries/LIBRARY.md resolves from the skill directory to src/Libraries/LIBRARY.md, which exists, and the described contents match.
  • Other factual claims hold. CoreHelpers.GenerateComb() exists (src/Core/Utilities/CoreHelpers.cs:52); BitAutoDataAttribute and SutProvider exist under test/Common/AutoFixture/; the TryAdd* rule matches ADR-0026.
  • Style matches sibling skills (no H1, opening ##), so the absent H1 is not reported as a defect.

Checks run

Check Status
Plugin structure Skipped — no changed plugins, and the repository has no root .claude-plugin/marketplace.json
Marketplace Skipped — no changed plugins or marketplace paths, and no root .claude-plugin/marketplace.json
Version bump Skipped — no component plugins changed, and no root .claude-plugin/marketplace.json
Plugin validation (AI) Skipped — the changeset touches no plugins/ path
Skill review (AI) Passed with findings — plugin-dev:skill-reviewer reviewed .claude/skills/writing-server-code/SKILL.md in full
Configuration & security Skipped — the sole config-bucket path is a SKILL.md, which this check deliberately excludes to avoid duplicating the skill review

The three script checks (validate-plugin-structure.sh, validate-marketplace.sh, validate-version-bump.sh) are not run by this review in any case; the workflow runs them as dedicated steps and reports through the job log and check status.

Verdict rationale: Pass — no critical finding, no finding that widens a permission, tool grant, or hook capability, no new path from contributor input to a shell, and no failed script check. The three major findings are correctness warnings on guidance prose and should be fixed, but on the stack's parent PR rather than here.

@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 26 commits August 24, 2026 12:31
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 a72b6a3 to 72c51a8 Compare August 24, 2026 17:31
Comment on lines +37 to +38
// 30-day months, minimum one, matching the legacy proration display.
var days = (lineEnd.Value - invoice.PeriodEnd).TotalDays;

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.

⚠️ IMPORTANT: Months collapses to 1 on the invoice shape this PR captured live, because lineEnd == invoice.PeriodEnd there.

Details and fix

lineEnd - invoice.PeriodEnd only measures the prorated span when the preview is an immediate proration invoice — i.e. when proration_behavior = always_invoice makes invoice.period_end equal "now". That is exactly the assumption the legacy code documents:

// Use invoice periodEnd here instead of UtcNow ... the previewInvoice's periodEnd is the
// same as UtcNow anyway because of the proration behavior (always_invoice)
NewPlanProratedMonths = CalculateNewPlanProratedMonths(invoicePreview.PeriodEnd, passwordManagerItem.CurrentPeriodEnd)

(src/Core/Billing/Premium/Commands/PreviewPremiumUpgradeProrationCommand.cs:130-132, which sets ProrationBehavior = AlwaysInvoice on its own options.)

IInvoicePreviewService takes caller-supplied InvoiceCreatePreviewOptions, so it does not control the proration behavior. With the default create_prorations, the preview is the next scheduled invoice and invoice.period_end is the current period end — the same instant the proration line ends. That is the shape in this PR's own live-captured fixture:

"period_end": 1789769920,
"lines": { "data": [
  { "amount": -1548, ..., "period": { "start": 1788387520, "end": 1789769920 } },

(test/Libraries/Invoicing.Test/InvoicePreviewBuilderSmRemovalTests.cs:20-25)

1789769920 - 1789769920 = 0 days → Math.Max(1, 0)Months = 1. For a monthly plan that happens to be right; for an annual mid-cycle change with 7 months left it would still render 1. ProrationMapperTests only exercises synthetic invoices where the line end is later than the invoice end, so nothing catches this, and InvoicePreviewBuilderSmRemovalTests does not assert Months.

The proration line already carries the span, so reading it is shape-independent — it equals the remaining term under both always_invoice and create_prorations:

var period = lines.Select(line => line.Period).FirstOrDefault(p => p is not null);
if (period is null)
{
    return 0;
}

// 30-day months, minimum one, matching the legacy proration display.
var days = (period.End - period.Start).TotalDays;
return Math.Max(1, (int)Math.Round(days / 30, MidpointRounding.AwayFromZero));

A test asserting Months on the in_preview_sm_removal fixture would lock the behavior in either way.

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