Files
descrybe/apps/api/internal/billing/stripe_sync_packs.go
T
greeneclipse 8580c996c3 Initial commit of Descrybe v2 without local scratch artifacts.
Drop one-shot tmp/axe scripts and agent i18n scratch so the Gitea tree is deployable.
2026-08-09 22:47:43 +02:00

128 lines
4.2 KiB
Go

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=<id> 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
}