package billing import ( "context" "fmt" "net/http" "net/url" "strconv" "strings" ) // SyncCreditPackResult is one pack after Stripe Product/Price ensure. type SyncCreditPackResult struct { PackID string `json:"pack_id"` ProductID string `json:"product_id"` PriceID string `json:"price_id"` Created bool `json:"created"` Credits int `json:"credits"` PriceUSD int `json:"price_usd"` } // SyncCreditPackProducts creates/updates Stripe Products + one-time Prices for // DefaultCreditPacks. Packs are additional one-time products (Checkout mode=payment), // not subscription add-ons. Metadata descrybe_pack= identifies each product. func (s *StripeService) SyncCreditPackProducts(ctx context.Context) ([]SyncCreditPackResult, error) { ctx, cfg, err := s.bindCfg(ctx) if err != nil { return nil, err } if cfg.MockMode() || strings.TrimSpace(cfg.SecretKey) == "" { return nil, ErrStripeNotConfigured } out := make([]SyncCreditPackResult, 0, len(DefaultCreditPacks())) for _, pack := range DefaultCreditPacks() { productID, createdProduct, err := s.ensureCreditPackProduct(ctx, pack) if err != nil { return out, fmt.Errorf("pack %s product: %w", pack.ID, err) } priceID, createdPrice, err := s.ensureCreditPackPrice(ctx, productID, pack) if err != nil { return out, fmt.Errorf("pack %s price: %w", pack.ID, err) } out = append(out, SyncCreditPackResult{ PackID: pack.ID, ProductID: productID, PriceID: priceID, Created: createdProduct || createdPrice, Credits: pack.Credits, PriceUSD: pack.PriceUSD, }) } return out, nil } func (s *StripeService) ensureCreditPackProduct(ctx context.Context, pack CreditPack) (productID string, created bool, err error) { q := url.QueryEscape(fmt.Sprintf("active:'true' AND metadata['descrybe_pack']:'%s'", pack.ID)) var search struct { Data []struct { ID string `json:"id"` } `json:"data"` } if err := s.stripeGET(ctx, "https://api.stripe.com/v1/products/search?query="+q+"&limit=1", &search); err != nil { return "", false, err } if len(search.Data) > 0 && strings.TrimSpace(search.Data[0].ID) != "" { return search.Data[0].ID, false, nil } form := url.Values{} form.Set("name", "Descrybe AI credits — "+pack.Name) form.Set("description", pack.Description) form.Set("metadata[descrybe_pack]", pack.ID) form.Set("metadata[credits]", strconv.Itoa(pack.Credits)) form.Set("metadata[ai_products]", strconv.Itoa(pack.AIProducts)) form.Set("metadata[price_usd]", strconv.Itoa(pack.PriceUSD)) var product struct { ID string `json:"id"` } if err := s.stripeForm(ctx, http.MethodPost, "https://api.stripe.com/v1/products", form, &product); err != nil { return "", false, err } if strings.TrimSpace(product.ID) == "" { return "", false, fmt.Errorf("stripe product missing id") } return product.ID, true, nil } func (s *StripeService) ensureCreditPackPrice(ctx context.Context, productID string, pack CreditPack) (priceID string, created bool, err error) { // Reuse an active one-time price on this product that matches unit amount. wantCents := pack.PriceUSD * 100 var list struct { Data []struct { ID string `json:"id"` UnitAmount int64 `json:"unit_amount"` Currency string `json:"currency"` Type string `json:"type"` Active bool `json:"active"` } `json:"data"` } endpoint := "https://api.stripe.com/v1/prices?product=" + url.QueryEscape(productID) + "&active=true&limit=20" if err := s.stripeGET(ctx, endpoint, &list); err != nil { return "", false, err } for _, p := range list.Data { if p.Active && p.Type == "one_time" && strings.EqualFold(p.Currency, "usd") && int(p.UnitAmount) == wantCents { return p.ID, false, nil } } form := url.Values{} form.Set("product", productID) form.Set("currency", "usd") form.Set("unit_amount", strconv.Itoa(wantCents)) form.Set("metadata[descrybe_pack]", pack.ID) form.Set("metadata[credits]", strconv.Itoa(pack.Credits)) var price struct { ID string `json:"id"` } if err := s.stripeForm(ctx, http.MethodPost, "https://api.stripe.com/v1/prices", form, &price); err != nil { return "", false, err } if strings.TrimSpace(price.ID) == "" { return "", false, fmt.Errorf("stripe price missing id") } return price.ID, true, nil }