128 lines
4.2 KiB
Go
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
|
||
|
|
}
|