feat(billing): manage product prices through UpdateProduct - #1810
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
📝 WalkthroughSummary by CodeRabbit
WalkthroughProduct updates now validate and converge desired prices across Stripe and the repository, while plan linking skips convergence. The billing API preserves optional metadata and maps invalid details to invalid-argument errors. Sparse price updates no longer clear stored state. ChangesProduct price convergence
Estimated code review effort: 4 (Complex) | ~45 minutes Possibly related PRs
🚥 Pre-merge checks | ✅ 2✅ Passed checks (2 passed)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
Coverage Report for CI Build 30427012558Coverage increased (+0.1%) to 47.032%Details
Uncovered Changes
Coverage Regressions39 previously-covered lines in 2 files lost coverage.
Coverage Stats
💛 - Coveralls |
00a76a4 to
0061bcc
Compare
0061bcc to
94ebfbd
Compare
94ebfbd to
ae9e1bc
Compare
There was a problem hiding this comment.
Actionable comments posted: 3
🧹 Nitpick comments (2)
billing/product/service.go (1)
385-401: 🩺 Stability & Availability | 🔵 TrivialProvider and repo state can diverge on partial failure.
setPriceActiveupdates Stripe first and the repo second; ifUpdateByIDfails (or the process dies in between), the provider price is deactivated while the DB still reports it active, and the same applies mid-way throughapplyPriceConvergence. Consider a reconciliation job or logging the price ID and provider ID on failure so the drift is detectable.billing/product/service_test.go (1)
569-586: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winConsider a case that pins the add-before-retire ordering.
applyPriceConvergencedocuments that creates/activations run before deactivations, but no test asserts it.mock.InOrder(or aRunhook recording call order) on the create andUpdateByIDexpectations would lock that invariant in. A whitespace-in-name case would also be worth adding alongside the normalization fix flagged inbilling/product/service.go.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro Plus
Run ID: 53257abb-e1c9-4afe-b626-be07b3d2f867
📒 Files selected for processing (11)
billing/checkout/service.gobilling/invoice/service.gobilling/plan/plan.gobilling/plan/plan_test.gobilling/plan/service.gobilling/product/product.gobilling/product/service.gobilling/product/service_test.gobilling/subscription/service.gointernal/api/v1beta1connect/billing_product.gointernal/store/postgres/billing_price_repository.go
|
Also addressed the two nitpicks:
|
…prices in UpdateProduct
…ence and guard metered aggregate
19d4b23 to
892185c
Compare
There was a problem hiding this comment.
🧹 Nitpick comments (2)
billing/product/service_test.go (1)
523-567: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winAssert the sentinel error, not just non-nil.
These rejection subtests pass for any error (including an unrelated mock/plumbing failure). Since
internal/api/v1beta1connect/billing_product.gomaps onlyproduct.ErrInvalidDetailtoCodeInvalidArgument, asserting the sentinel here locks in that cross-layer contract.♻️ Example for the amount-change subtest
- if _, err := svc.Update(ctx, desired(changed)); err == nil { - t.Fatalf("Update() expected an error for an in-place amount change, got nil") - } + _, err := svc.Update(ctx, desired(changed)) + if !errors.Is(err, product.ErrInvalidDetail) { + t.Fatalf("Update() error = %v, want %v", err, product.ErrInvalidDetail) + }Same applies to the currency, duplicate-name, empty-name, and metered-aggregate subtests.
internal/api/v1beta1connect/billing_product.go (1)
165-171: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winMap
product.ErrProductNotFoundto 404 in UpdateProduct.
BillingProductRepository.GetByIDreturnsproduct.ErrProductNotFoundon missing rows, so this branch can wrap it inconnect.CodeInternal/500. Switch on the exact sentinel so a bad/existing product ID is returned as 404 rather than an internal service error.♻️ Suggested extension
code := transformProductToPB(updatedProduct) - if errors.Is(err, product.ErrInvalidDetail) { + switch { + case errors.Is(err, product.ErrInvalidDetail): code = connect.CodeInvalidArgument + case errors.Is(err, product.ErrProductNotFound): + code = connect.CodeNotFound }
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro Plus
Run ID: 73463afb-bffa-4b4d-9d88-ce3404f4802d
📒 Files selected for processing (4)
billing/product/service.gobilling/product/service_test.gointernal/api/v1beta1connect/billing_product.gointernal/store/postgres/billing_price_repository.go
🚧 Files skipped from review as they are similar to previous changes (2)
- internal/store/postgres/billing_price_repository.go
- billing/product/service.go
What
Teach
UpdateProductto manage a product's prices. When the request carries a non-empty list of prices,product.Updateconverges the product's prices to that list. An empty list leaves prices untouched.Builds on #1811 (merged)
The consumer-side hardening in #1811 (ignore inactive prices in checkout, plan matching, and invoicing) is merged to main, so this PR now stands alone and its diff is only the UpdateProduct changes.
How it behaves
The price list is authoritative when it is not empty. A price is identified by its name within the product.
Changes
product.Updatereads and validates the desired prices up front (validateDesiredPrices), then applies convergence (applyPriceConvergence);reactivatePriceanddeactivatePriceshare onesetPriceActivehelper that skips the provider call when there is no provider id.UpdateByIDpersists thestatecolumn, so a price can be retired or reactivated.UpdateProducthandler reads the full price fields and reports a rejected price asInvalidArgument.AddPlanno longer triggers price convergence when it only links a plan to a product.Tests
Updateprice convergence is covered: add a price, retire an omitted active price, reactivate a re-listed retired price, reject an in-place amount or currency change, reject a duplicate or empty name, an empty list leaves prices untouched, and applying an unchanged list makes no changes. Validation errors are asserted to happen before any product mutation.