package abono import ( "context" "errors" "fmt" "slices" "gorm.io/gorm" "amadan.net/rastrillo/abono/internal/stripeapi" ) // Adopt takes an existing Stripe subscription as subject's holding of plan: // a WordPress site's member being imported, or an unrecognised subscription // an owner places on a plan. The subscription's price becomes a legacy // price of plan (sold to nobody new) when no plan has it yet; a price that // belongs to another plan is refused, since moving it would change what // every subscription on it means. // // Every other subscription abono has recorded on that price and not yet on // plan is synced again in the same transaction, so everyone paying that // price holds the plan, and the app has been told, by the time Adopt // returns (ruling R40), as the owner's page says. // // With subject "", the holder is found as a sync finds it: the customer's // recorded subject, then Holder for the customer's address. With a // subject, that subject is pinned: Checkout's reference and the // subscription's abono_subject metadata, which may still name whoever // bought it, never move it back (ruling R36). The row is recorded whatever // the subscription's status, and OnChange runs in the same transaction. func (s *Service) Adopt(ctx context.Context, subscriptionID, subject, plan string) error { if !s.On() { return errOff } if subscriptionID == "" || plan == "" { return errors.New("abono: Adopt needs a subscription and a plan") } // The price decides which other subscriptions are locked with this // one. It is read again inside the locks; should it have changed in // between (a plan switch at that moment), the set is worked out again. price, err := s.recordedPrice(ctx, subscriptionID) if err != nil { return err } for range 3 { if price == "" { sub, err := s.stripe.GetSubscription(ctx, subscriptionID) if err != nil { return err } if price, _ = sub.PriceID(); price == "" { return fmt.Errorf("abono: subscription %q has no price", subscriptionID) } } now, err := s.adopt(ctx, subscriptionID, subject, plan, price) if !errors.Is(err, errPriceMoved) { return err } price = now } return fmt.Errorf("abono: subscription %q kept changing its price while being adopted", subscriptionID) } // errPriceMoved is adopt finding, inside the locks, that the subscription // is no longer on the price it locked for. var errPriceMoved = errors.New("abono: the subscription's price changed") // recordedPrice is the price abono last recorded for subscription id, or // "" when there is no row. func (s *Service) recordedPrice(ctx context.Context, id string) (string, error) { var prices []string err := s.cfg.DB.WithContext(ctx).Raw(`SELECT stripe_price_id FROM abono_subscriptions WHERE stripe_subscription_id = ?`, id). Scan(&prices).Error if err != nil || len(prices) == 0 { return "", err } return prices[0], nil } // adopt is Adopt for a subscription believed to be on price. It locks the // subscription and every other recorded one on price not yet on plan, in // id order (so two adoptions cannot wait on each other), fetches each from // Stripe inside its lock (ruling R35), and writes them all in one // transaction. When the subscription turns out to be on another price it // returns errPriceMoved and that price. func (s *Service) adopt(ctx context.Context, subscriptionID, subject, plan, price string) (string, error) { var others []string if err := s.cfg.DB.WithContext(ctx).Raw(`SELECT stripe_subscription_id FROM abono_subscriptions WHERE stripe_price_id = ? AND stripe_subscription_id <> ? AND plan <> ? ORDER BY stripe_subscription_id`, price, subscriptionID, plan).Scan(&others).Error; err != nil { return "", err } ids := append([]string{subscriptionID}, others...) slices.Sort(ids) for _, id := range ids { unlock, err := s.subs.lock(ctx, id) if err != nil { return "", err } defer unlock() } sub, err := s.stripe.GetSubscription(ctx, subscriptionID) if err != nil { return "", err } now, _ := sub.PriceID() switch { case now == "": return "", fmt.Errorf("abono: subscription %q has no price", subscriptionID) case now != price: return now, errPriceMoved } interval, count := sub.Recurring() var rest []stripeapi.Subscription for _, id := range others { o, err := s.stripe.GetSubscription(ctx, id) switch { case stripeapi.NotFound(err): continue // gone from Stripe; nothing to bring onto the plan case err != nil: return "", err } rest = append(rest, o) } return "", s.tx(ctx, func(tx *gorm.DB) error { var n int64 if err := tx.Table("abono_offers").Where("plan = ?", plan).Count(&n).Error; err != nil { return err } if n == 0 { return fmt.Errorf("abono: no offer for plan %q: %w", plan, ErrNotFound) } on, err := planOf(tx, price) switch { case err != nil: return err case on == "": if err := tx.Exec(`INSERT INTO abono_prices (stripe_price_id, plan, sold, interval, interval_count) VALUES (?, ?, 0, ?, ?)`, price, plan, interval, count).Error; err != nil { return err } case on != plan: return fmt.Errorf("abono: price %q belongs to plan %q, not %q", price, on, plan) } if _, err := s.syncTx(ctx, tx, sub, Hint{Subject: subject}, true); err != nil { return err } // Everyone else on the price, as their next event would sync them. for _, o := range rest { if _, err := s.syncTx(ctx, tx, o, subscriptionHint(o), false); err != nil { return fmt.Errorf("abono: adopt: bringing subscription %s onto %q: %w", o.ID, plan, err) } } return nil }) }