[PM-39925] feat: add the invoice preview projection to Bit.Invoicing - #8209
[PM-39925] feat: add the invoice preview projection to Bit.Invoicing#8209kdenney wants to merge 42 commits into
Conversation
🤖 Bitwarden Claude Code ReviewOverall Assessment: APPROVE Reviewed the invoice-preview projection at commit Code Review DetailsNo findings. Notes considered and dismissed during validation:
Dependency Changes
Not a net-new dependency: |
Codecov Report❌ Patch coverage is 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. 🚀 New features to boost your workflow:
|
925c754 to
3474e23
Compare
3474e23 to
73a92e9
Compare
73a92e9 to
785c017
Compare
785c017 to
1605dea
Compare
Claude Code validationResult: Pass Validated Provenance — read this before acting on the findingsThe supplied changed-file list does not match this pull request's actual diff. The list names
This is a stacked pull request, and the skill edit belongs to the parent ( 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. CriticalNone. MajorNone. MinorAll five are style-level (SUGGESTED / OPTIONAL) and, per the provenance note, pre-existing relative to PR 8209.
Noted but not filed as a finding: What passed
Checks run
Configuration was read from the |
1605dea to
1fa9ea1
Compare
1fa9ea1 to
3a77505
Compare
amorask-bitwarden
left a comment
There was a problem hiding this comment.
Just a few ⛏️ items. Please ensure the correct purchasable_reference metadata is applied to the Stripe prices for Test / Prod as well.
…to lineItemsByReference
…s and Invoicing libraries
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.
2304a41 to
eaeb91c
Compare
🎟️ Tracking
https://bitwarden.atlassian.net/browse/PM-39925
📔 Objective
Fills in
Bit.Invoicingwith 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-neutralInvoicePreviewrecord family for the cart screens to render.IInvoicePreviewService; the builder, mappers, reference table, and Stripe client are internal, registered withTryAddSingleton.purchasable_referencevalue 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 toStripe.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.DiscountMappersplits coupons into cart-level and item-level buckets, matching item-scoped coupons onto their lines byDiscountId(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.ProrationMapperfolds each product's proration lines into a single credit, charge, and total row.100m, never integer100.purchasable_referencemetadata key and its reference values to Core'sStripeConstants, 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:
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.InvoicePreviewDiscountrecord rather than extending Core'sBitwardenDiscount. A required appliedAmountwould breakBitwardenDiscount'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).InvoicePreviewOptions. The publicIInvoicePreviewServicetakes Stripe types (InvoiceCreatePreviewOptions,Subscription) directly.Bit.Invoicingis 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.StripeExceptionis not wrapped in this library. Vendor-exception-to-domain translation belongs to the futureBit.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.pricelooks like it exceeds Stripe's documented 4-level expand limit, but it doesn't:.datalist accessors and inline sub-hashes (pricing,price_details) don't count as levels. Verified live againstcreate_previewAPI — 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 CLI • Give Feedback 💬