Skip to content

Commit 9d6a482

Browse files
authored
feat(billing): manage product prices through UpdateProduct (#1810)
1 parent 1b1f3a3 commit 9d6a482

4 files changed

Lines changed: 447 additions & 5 deletions

File tree

billing/product/service.go

Lines changed: 190 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,20 @@ func (s *Service) Update(ctx context.Context, product Product) (Product, error)
164164
return Product{}, err
165165
}
166166

167+
// read and validate the desired prices before mutating anything, so an
168+
// invalid price fails the whole update rather than leaving the product
169+
// half-changed. An empty list leaves the prices untouched.
170+
var currentPrices []Price
171+
if len(product.Prices) > 0 {
172+
currentPrices, err = s.GetPriceByProductID(ctx, existingProduct.ID)
173+
if err != nil {
174+
return Product{}, err
175+
}
176+
if err := validateDesiredPrices(currentPrices, product.Prices); err != nil {
177+
return Product{}, err
178+
}
179+
}
180+
167181
// only following fields will be updated
168182
if len(product.Title) > 0 {
169183
existingProduct.Title = product.Title
@@ -217,6 +231,15 @@ func (s *Service) Update(ctx context.Context, product Product) (Product, error)
217231
return Product{}, err
218232
}
219233

234+
// apply the validated price convergence. The desired list is authoritative:
235+
// a new name is created, an inactive name listed again is activated, and an
236+
// active price the list no longer names is deactivated.
237+
if len(product.Prices) > 0 {
238+
if err := s.applyPriceConvergence(ctx, updatedProduct.ID, currentPrices, product.Prices); err != nil {
239+
return Product{}, err
240+
}
241+
}
242+
220243
// populate product with price and features
221244
updatedProduct, err = s.populateProduct(ctx, updatedProduct)
222245
if err != nil {
@@ -226,10 +249,177 @@ func (s *Service) Update(ctx context.Context, product Product) (Product, error)
226249
return updatedProduct, nil
227250
}
228251

252+
// priceKey is the canonical lookup name for a price. Names are matched
253+
// case-insensitively and ignoring surrounding whitespace. Validation,
254+
// convergence, and the stored name all use this key, so the three never
255+
// disagree on what counts as the same price.
256+
func priceKey(name string) string {
257+
return strings.ToLower(strings.TrimSpace(name))
258+
}
259+
260+
// validateDesiredPrices checks the desired price list against the product's
261+
// current prices without touching anything, so an invalid list fails the update
262+
// before it mutates the product. Names must be present and unique, and a name
263+
// that already exists must keep its immutable fields, since provider prices
264+
// cannot be changed in place.
265+
func validateDesiredPrices(current, desired []Price) error {
266+
currentByName := make(map[string]Price, len(current))
267+
for _, p := range current {
268+
currentByName[priceKey(p.Name)] = p
269+
}
270+
seen := make(map[string]struct{}, len(desired))
271+
for _, want := range desired {
272+
name := priceKey(want.Name)
273+
if name == "" {
274+
return fmt.Errorf("%w: a price must have a name", ErrInvalidDetail)
275+
}
276+
if _, dup := seen[name]; dup {
277+
return fmt.Errorf("%w: price %q is listed more than once", ErrInvalidDetail, name)
278+
}
279+
seen[name] = struct{}{}
280+
if existing, ok := currentByName[name]; ok {
281+
if err := checkImmutablePriceFields(existing, want); err != nil {
282+
return err
283+
}
284+
}
285+
}
286+
return nil
287+
}
288+
289+
// checkImmutablePriceFields rejects a change to a field a provider price cannot
290+
// change in place. A new amount, currency, interval, billing scheme, or usage
291+
// type has to be a new price under a new name. Both sides are normalized first,
292+
// so a field that only differs because of a default does not read as a change.
293+
func checkImmutablePriceFields(existing, want Price) error {
294+
e := normalizePrice(existing)
295+
w := normalizePrice(want)
296+
switch {
297+
case e.Amount != w.Amount:
298+
return fmt.Errorf("%w: price %q amount cannot change from %d to %d; provider prices are immutable, add a new price with a different name",
299+
ErrInvalidDetail, w.Name, e.Amount, w.Amount)
300+
case e.Currency != w.Currency:
301+
return fmt.Errorf("%w: price %q currency cannot change from %q to %q; add a new price with a different name",
302+
ErrInvalidDetail, w.Name, e.Currency, w.Currency)
303+
case e.Interval != w.Interval:
304+
return fmt.Errorf("%w: price %q interval cannot change from %q to %q; add a new price with a different name",
305+
ErrInvalidDetail, w.Name, e.Interval, w.Interval)
306+
case e.BillingScheme != w.BillingScheme:
307+
return fmt.Errorf("%w: price %q billing scheme cannot change; add a new price with a different name", ErrInvalidDetail, w.Name)
308+
case e.UsageType != w.UsageType:
309+
return fmt.Errorf("%w: price %q usage type cannot change; add a new price with a different name", ErrInvalidDetail, w.Name)
310+
case e.UsageType == PriceUsageTypeMetered && e.MeteredAggregate != w.MeteredAggregate:
311+
return fmt.Errorf("%w: price %q metered aggregate cannot change; add a new price with a different name", ErrInvalidDetail, w.Name)
312+
}
313+
return nil
314+
}
315+
316+
// normalizePrice fills the defaults CreatePrice would apply, trims and
317+
// lowercases the name, and lowercases the interval, so two prices compare the
318+
// way they are stored.
319+
func normalizePrice(p Price) Price {
320+
if p.BillingScheme == "" {
321+
p.BillingScheme = BillingSchemeFlat
322+
}
323+
if p.Currency == "" {
324+
p.Currency = "usd"
325+
}
326+
if p.UsageType == "" {
327+
p.UsageType = PriceUsageTypeLicensed
328+
}
329+
p.Interval = strings.ToLower(p.Interval)
330+
p.Name = priceKey(p.Name)
331+
return p
332+
}
333+
334+
// applyPriceConvergence makes the product's prices match the desired list. The
335+
// list must already have passed validateDesiredPrices. A name the product does
336+
// not have is created, an inactive name listed again is activated, and an active
337+
// price the list no longer names is deactivated. Adds and activations run before
338+
// deactivations, so the product always has the new price before an old one goes
339+
// inactive.
340+
func (s *Service) applyPriceConvergence(ctx context.Context, productID string, current, desired []Price) error {
341+
currentByName := make(map[string]Price, len(current))
342+
for _, p := range current {
343+
currentByName[priceKey(p.Name)] = p
344+
}
345+
desiredNames := make(map[string]struct{}, len(desired))
346+
for _, want := range desired {
347+
desiredNames[priceKey(want.Name)] = struct{}{}
348+
}
349+
350+
for _, want := range desired {
351+
existing, ok := currentByName[priceKey(want.Name)]
352+
if !ok {
353+
want.ProductID = productID
354+
want.Name = priceKey(want.Name)
355+
if _, err := s.CreatePrice(ctx, want); err != nil {
356+
return err
357+
}
358+
continue
359+
}
360+
if !existing.IsActive() {
361+
if err := s.activatePrice(ctx, existing); err != nil {
362+
return err
363+
}
364+
}
365+
}
366+
367+
for _, p := range current {
368+
if _, wanted := desiredNames[priceKey(p.Name)]; wanted {
369+
continue
370+
}
371+
if !p.IsActive() {
372+
continue
373+
}
374+
if err := s.deactivatePrice(ctx, p); err != nil {
375+
return err
376+
}
377+
}
378+
return nil
379+
}
380+
381+
// deactivatePrice marks a price inactive. Provider prices are immutable and
382+
// cannot be deleted, so a price is taken out of use by setting it inactive in
383+
// the provider and the repo rather than removed.
384+
func (s *Service) deactivatePrice(ctx context.Context, price Price) error {
385+
return s.setPriceActive(ctx, price, false)
386+
}
387+
388+
// activatePrice brings an inactive price back into use when the desired list
389+
// names it again.
390+
func (s *Service) activatePrice(ctx context.Context, price Price) error {
391+
return s.setPriceActive(ctx, price, true)
392+
}
393+
394+
// setPriceActive flips a price's active flag in the provider and its state in
395+
// the repo. A price with no provider id (nothing was created upstream) skips the
396+
// provider call.
397+
func (s *Service) setPriceActive(ctx context.Context, price Price, active bool) error {
398+
if price.ProviderID != "" {
399+
if _, err := s.stripeClient.Prices.Update(price.ProviderID, &stripe.PriceParams{
400+
Params: stripe.Params{Context: ctx},
401+
Active: stripe.Bool(active),
402+
}); err != nil {
403+
return err
404+
}
405+
}
406+
if active {
407+
price.State = PriceStateActive
408+
} else {
409+
price.State = PriceStateInactive
410+
}
411+
_, err := s.priceRepository.UpdateByID(ctx, price)
412+
return err
413+
}
414+
229415
func (s *Service) AddPlan(ctx context.Context, productOb Product, planID string) error {
230416
var err error
231417
if !slices.Contains(productOb.PlanIDs, planID) {
232418
productOb.PlanIDs = append(productOb.PlanIDs, planID)
419+
// AddPlan only links a plan to the product. Clear the populated price
420+
// list so Update leaves the product's prices untouched instead of
421+
// re-converging them.
422+
productOb.Prices = nil
233423
_, err = s.Update(ctx, productOb)
234424
if err != nil {
235425
return err

0 commit comments

Comments
 (0)