@@ -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+
229415func (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