484 lines
17 KiB
Go
484 lines
17 KiB
Go
package service
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"strings"
|
|
|
|
"github.com/QuantumNous/new-api/model"
|
|
"github.com/QuantumNous/new-api/setting"
|
|
pancake "github.com/waffo-com/waffo-pancake-sdk-go"
|
|
)
|
|
|
|
// WaffoPancakePriceSnapshot is the per-session price override sent with checkout.
|
|
type WaffoPancakePriceSnapshot struct {
|
|
Amount string
|
|
TaxCategory string
|
|
}
|
|
|
|
// WaffoPancakeCreateSessionParams is the input to CreateWaffoPancakeCheckoutSession.
|
|
// BuyerIdentity must be stable per user (see WaffoPancakeBuyerIdentityFromUserID).
|
|
// OrderMerchantExternalID = our trade_no; Pancake echoes it back in webhooks.
|
|
type WaffoPancakeCreateSessionParams struct {
|
|
ProductID string
|
|
BuyerIdentity string
|
|
PriceSnapshot *WaffoPancakePriceSnapshot
|
|
BuyerEmail string
|
|
ExpiresInSeconds *int
|
|
OrderMerchantExternalID string
|
|
}
|
|
|
|
// WaffoPancakeCheckoutSession is the response of CreateWaffoPancakeCheckoutSession.
|
|
// CheckoutURL already carries the `#token=...` fragment; Token / TokenExpiresAt
|
|
// are exposed separately for self-service flows driven from new-api's own UI.
|
|
type WaffoPancakeCheckoutSession struct {
|
|
SessionID string
|
|
CheckoutURL string
|
|
ExpiresAt string
|
|
OrderID string
|
|
Token string
|
|
TokenExpiresAt string
|
|
}
|
|
|
|
// WaffoPancakeWebhookEvent mirrors the SDK's WebhookEvent shape using plain
|
|
// strings so controllers don't have to import the SDK package.
|
|
type WaffoPancakeWebhookEvent struct {
|
|
ID string
|
|
Timestamp string
|
|
EventType string
|
|
EventID string
|
|
StoreID string
|
|
Mode string
|
|
Data WaffoPancakeWebhookData
|
|
}
|
|
|
|
type WaffoPancakeWebhookData struct {
|
|
// OrderID = Pancake ORD_* (logs); OrderMerchantExternalID = our trade_no (lookup).
|
|
OrderID string
|
|
OrderMerchantExternalID string
|
|
BuyerEmail string
|
|
Currency string
|
|
Amount string
|
|
TaxAmount string
|
|
ProductName string
|
|
MerchantProvidedBuyerIdentity string
|
|
}
|
|
|
|
// NormalizedEventType returns the event type or empty string for a nil event.
|
|
func (e *WaffoPancakeWebhookEvent) NormalizedEventType() string {
|
|
if e == nil {
|
|
return ""
|
|
}
|
|
return e.EventType
|
|
}
|
|
|
|
// newWaffoPancakeClient builds an SDK client from persisted settings. The
|
|
// runtime checkout / webhook paths use this; configuration endpoints use
|
|
// newWaffoPancakeClientFromCreds so the operator can verify typed-but-not-
|
|
// yet-saved credentials.
|
|
func newWaffoPancakeClient() (*pancake.Client, error) {
|
|
return pancake.New(pancake.Config{
|
|
MerchantID: setting.WaffoPancakeMerchantID,
|
|
PrivateKey: setting.WaffoPancakePrivateKey,
|
|
})
|
|
}
|
|
|
|
func newWaffoPancakeClientFromCreds(merchantID, privateKey string) (*pancake.Client, error) {
|
|
if strings.TrimSpace(merchantID) == "" || strings.TrimSpace(privateKey) == "" {
|
|
return nil, fmt.Errorf("merchant id and private key are required")
|
|
}
|
|
return pancake.New(pancake.Config{
|
|
MerchantID: merchantID,
|
|
PrivateKey: privateKey,
|
|
})
|
|
}
|
|
|
|
// CreateWaffoPancakeCheckoutSession creates an Authenticated-mode checkout
|
|
// session: the order is bound to BuyerIdentity (stable per user) so it stays
|
|
// attributable even if the buyer edits the email on Waffo's checkout form.
|
|
func CreateWaffoPancakeCheckoutSession(ctx context.Context, params *WaffoPancakeCreateSessionParams) (*WaffoPancakeCheckoutSession, error) {
|
|
if params == nil {
|
|
return nil, fmt.Errorf("missing checkout params")
|
|
}
|
|
if strings.TrimSpace(params.BuyerIdentity) == "" {
|
|
return nil, fmt.Errorf("missing buyer identity")
|
|
}
|
|
if strings.TrimSpace(params.OrderMerchantExternalID) == "" {
|
|
return nil, fmt.Errorf("missing order merchant external id")
|
|
}
|
|
client, err := newWaffoPancakeClient()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("build Waffo Pancake client: %w", err)
|
|
}
|
|
|
|
sdkParams := pancake.AuthenticatedCheckoutParams{
|
|
CreateCheckoutSessionParams: pancake.CreateCheckoutSessionParams{
|
|
ProductID: params.ProductID,
|
|
Currency: "USD",
|
|
BuyerEmail: optionalString(params.BuyerEmail),
|
|
ExpiresInSeconds: params.ExpiresInSeconds,
|
|
OrderMerchantExternalID: optionalString(params.OrderMerchantExternalID),
|
|
},
|
|
BuyerIdentity: params.BuyerIdentity,
|
|
}
|
|
if params.PriceSnapshot != nil {
|
|
sdkParams.PriceSnapshot = &pancake.PriceInfo{
|
|
Amount: params.PriceSnapshot.Amount,
|
|
TaxCategory: pancake.TaxCategory(params.PriceSnapshot.TaxCategory),
|
|
}
|
|
}
|
|
|
|
session, err := client.Checkout.Authenticated.Create(ctx, sdkParams)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if session == nil || strings.TrimSpace(session.CheckoutURL) == "" || strings.TrimSpace(session.SessionID) == "" {
|
|
return nil, fmt.Errorf("Waffo Pancake returned empty checkout session")
|
|
}
|
|
return &WaffoPancakeCheckoutSession{
|
|
SessionID: session.SessionID,
|
|
CheckoutURL: session.CheckoutURL,
|
|
ExpiresAt: session.ExpiresAt,
|
|
Token: session.Token,
|
|
TokenExpiresAt: session.TokenExpiresAt,
|
|
}, nil
|
|
}
|
|
|
|
func optionalString(s string) *string {
|
|
if strings.TrimSpace(s) == "" {
|
|
return nil
|
|
}
|
|
v := s
|
|
return &v
|
|
}
|
|
|
|
// WaffoPancakeBuyerIdentityFromUserID renders the canonical buyer identity
|
|
// for checkout. Webhook handlers compare against the value rendered here to
|
|
// reject identity mismatches, so both call sites must use this function.
|
|
func WaffoPancakeBuyerIdentityFromUserID(userID int) string {
|
|
return fmt.Sprintf("new-api-user-%d", userID)
|
|
}
|
|
|
|
// VerifyConfiguredWaffoPancakeWebhook verifies the signature header. The SDK
|
|
// picks the matching test / prod public key from the payload's `mode` field.
|
|
func VerifyConfiguredWaffoPancakeWebhook(payload string, signatureHeader string) (*WaffoPancakeWebhookEvent, error) {
|
|
evt, err := pancake.VerifyWebhookTyped[pancake.WebhookEventData](payload, signatureHeader, nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
identity := ""
|
|
if evt.Data.MerchantProvidedBuyerIdentity != nil {
|
|
identity = *evt.Data.MerchantProvidedBuyerIdentity
|
|
}
|
|
externalID := ""
|
|
if evt.Data.OrderMerchantExternalID != nil {
|
|
externalID = *evt.Data.OrderMerchantExternalID
|
|
}
|
|
return &WaffoPancakeWebhookEvent{
|
|
ID: evt.ID,
|
|
Timestamp: evt.Timestamp,
|
|
EventType: evt.EventType,
|
|
EventID: evt.EventID,
|
|
StoreID: evt.StoreID,
|
|
Mode: string(evt.Mode),
|
|
Data: WaffoPancakeWebhookData{
|
|
OrderID: evt.Data.OrderID,
|
|
OrderMerchantExternalID: externalID,
|
|
BuyerEmail: evt.Data.BuyerEmail,
|
|
Currency: evt.Data.Currency,
|
|
Amount: evt.Data.Amount,
|
|
TaxAmount: evt.Data.TaxAmount,
|
|
ProductName: evt.Data.ProductName,
|
|
MerchantProvidedBuyerIdentity: identity,
|
|
},
|
|
}, nil
|
|
}
|
|
|
|
// ResolveWaffoPancakeTradeNo maps a verified webhook event to a local TopUp
|
|
// trade_no via OrderMerchantExternalID, and rejects buyer-identity mismatches.
|
|
func ResolveWaffoPancakeTradeNo(event *WaffoPancakeWebhookEvent) (string, error) {
|
|
if event == nil {
|
|
return "", fmt.Errorf("missing webhook event")
|
|
}
|
|
tradeNo := strings.TrimSpace(event.Data.OrderMerchantExternalID)
|
|
if tradeNo == "" {
|
|
return "", fmt.Errorf("missing webhook orderMerchantExternalId")
|
|
}
|
|
topUp := model.GetTopUpByTradeNo(tradeNo)
|
|
if topUp == nil || topUp.PaymentProvider != model.PaymentProviderWaffoPancake {
|
|
return "", fmt.Errorf("waffo pancake order not found for tradeNo=%s", tradeNo)
|
|
}
|
|
expectedIdentity := WaffoPancakeBuyerIdentityFromUserID(topUp.UserId)
|
|
actualIdentity := strings.TrimSpace(event.Data.MerchantProvidedBuyerIdentity)
|
|
if actualIdentity != expectedIdentity {
|
|
return "", fmt.Errorf(
|
|
"waffo pancake buyer identity mismatch for tradeNo=%s: expected=%q actual=%q",
|
|
tradeNo,
|
|
expectedIdentity,
|
|
actualIdentity,
|
|
)
|
|
}
|
|
return tradeNo, nil
|
|
}
|
|
|
|
// ResolveWaffoPancakeSubscriptionTradeNo is the SubscriptionOrder counterpart
|
|
// of ResolveWaffoPancakeTradeNo.
|
|
func ResolveWaffoPancakeSubscriptionTradeNo(event *WaffoPancakeWebhookEvent) (string, error) {
|
|
if event == nil {
|
|
return "", fmt.Errorf("missing webhook event")
|
|
}
|
|
tradeNo := strings.TrimSpace(event.Data.OrderMerchantExternalID)
|
|
if tradeNo == "" {
|
|
return "", fmt.Errorf("missing webhook orderMerchantExternalId")
|
|
}
|
|
order := model.GetSubscriptionOrderByTradeNo(tradeNo)
|
|
if order == nil || order.PaymentProvider != model.PaymentProviderWaffoPancake {
|
|
return "", fmt.Errorf("waffo pancake subscription order not found for tradeNo=%s", tradeNo)
|
|
}
|
|
expectedIdentity := WaffoPancakeBuyerIdentityFromUserID(order.UserId)
|
|
actualIdentity := strings.TrimSpace(event.Data.MerchantProvidedBuyerIdentity)
|
|
if actualIdentity != expectedIdentity {
|
|
return "", fmt.Errorf(
|
|
"waffo pancake buyer identity mismatch for subscription tradeNo=%s: expected=%q actual=%q",
|
|
tradeNo,
|
|
expectedIdentity,
|
|
actualIdentity,
|
|
)
|
|
}
|
|
return tradeNo, nil
|
|
}
|
|
|
|
// Deterministic default names for "+ Create": stable bodies mean stable
|
|
// X-Idempotency-Key, which lets Pancake dedupe retries server-side.
|
|
const (
|
|
defaultWaffoPancakeStoreName = "new-api-store"
|
|
defaultWaffoPancakeProductName = "new-api-charge-product"
|
|
)
|
|
|
|
// CreateWaffoPancakePrimaryStore creates a Pancake Store using in-flight
|
|
// (not-yet-persisted) credentials and returns the new store ID.
|
|
func CreateWaffoPancakePrimaryStore(ctx context.Context, merchantID, privateKey string) (string, error) {
|
|
client, err := newWaffoPancakeClientFromCreds(merchantID, privateKey)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
storeRes, err := client.Stores.Create(ctx, pancake.CreateStoreParams{
|
|
Name: defaultWaffoPancakeStoreName,
|
|
})
|
|
if err != nil {
|
|
return "", fmt.Errorf("create Waffo Pancake store: %w", err)
|
|
}
|
|
return storeRes.Store.ID, nil
|
|
}
|
|
|
|
// CreateWaffoPancakeProductForPlan mints (and publishes) a Pancake
|
|
// OnetimeProduct priced at `amount` USD, used as a subscription plan's
|
|
// SubscriptionPlan.WaffoPancakeProductId.
|
|
//
|
|
// OnetimeProduct (not SubscriptionProduct) because new-api has no renewal-
|
|
// event handling; Pancake auto-renewing without new-api extending user
|
|
// access would be a UX divergence. Revisit if renewal handling is added.
|
|
func CreateWaffoPancakeProductForPlan(ctx context.Context, merchantID, privateKey, storeID, name, amount, returnURL string) (string, error) {
|
|
storeID = strings.TrimSpace(storeID)
|
|
if storeID == "" {
|
|
return "", fmt.Errorf("store id is required to create a product")
|
|
}
|
|
name = strings.TrimSpace(name)
|
|
if name == "" {
|
|
return "", fmt.Errorf("plan name is required")
|
|
}
|
|
amount = strings.TrimSpace(amount)
|
|
if amount == "" {
|
|
return "", fmt.Errorf("plan price is required")
|
|
}
|
|
client, err := newWaffoPancakeClientFromCreds(merchantID, privateKey)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
prodRes, err := client.OnetimeProducts.Create(ctx, pancake.CreateOnetimeProductParams{
|
|
StoreID: storeID,
|
|
Name: name,
|
|
Prices: pancake.Prices{
|
|
"USD": {
|
|
Amount: amount,
|
|
TaxCategory: pancake.TaxCategory("saas"),
|
|
},
|
|
},
|
|
SuccessURL: optionalString(strings.TrimSpace(returnURL)),
|
|
})
|
|
if err != nil {
|
|
return "", fmt.Errorf("create Waffo Pancake plan product: %w", err)
|
|
}
|
|
productID := prodRes.Product.ID
|
|
if _, err := client.OnetimeProducts.Publish(ctx, pancake.PublishOnetimeProductParams{ID: productID}); err != nil {
|
|
return "", fmt.Errorf("publish Waffo Pancake plan product: %w", err)
|
|
}
|
|
return productID, nil
|
|
}
|
|
|
|
// CreateWaffoPancakePrimaryProduct mints (and publishes) the wallet-top-up
|
|
// OnetimeProduct under storeID. Per-checkout price overrides via PriceSnapshot
|
|
// are what make the "1.00" seed price irrelevant at runtime.
|
|
func CreateWaffoPancakePrimaryProduct(ctx context.Context, merchantID, privateKey, storeID, returnURL string) (string, error) {
|
|
storeID = strings.TrimSpace(storeID)
|
|
if storeID == "" {
|
|
return "", fmt.Errorf("store id is required to create a product")
|
|
}
|
|
client, err := newWaffoPancakeClientFromCreds(merchantID, privateKey)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
prodRes, err := client.OnetimeProducts.Create(ctx, pancake.CreateOnetimeProductParams{
|
|
StoreID: storeID,
|
|
Name: defaultWaffoPancakeProductName,
|
|
Prices: pancake.Prices{
|
|
"USD": {
|
|
Amount: "1.00", // overridden at checkout via PriceSnapshot
|
|
TaxCategory: pancake.TaxCategory("saas"),
|
|
},
|
|
},
|
|
SuccessURL: optionalString(strings.TrimSpace(returnURL)),
|
|
})
|
|
if err != nil {
|
|
return "", fmt.Errorf("create Waffo Pancake product: %w", err)
|
|
}
|
|
productID := prodRes.Product.ID
|
|
if _, err := client.OnetimeProducts.Publish(ctx, pancake.PublishOnetimeProductParams{ID: productID}); err != nil {
|
|
return "", fmt.Errorf("publish Waffo Pancake product: %w", err)
|
|
}
|
|
return productID, nil
|
|
}
|
|
|
|
// WaffoPancakePairResult is the response of CreateWaffoPancakePrimaryPair.
|
|
// When OrphanStore is true the store was created but the product wasn't,
|
|
// so the caller can surface a partial-failure message with StoreID.
|
|
type WaffoPancakePairResult struct {
|
|
StoreID string
|
|
StoreName string
|
|
ProductID string
|
|
ProductName string
|
|
OrphanStore bool
|
|
}
|
|
|
|
// CreateWaffoPancakePrimaryPair mints a Store + OnetimeProduct in one
|
|
// round-trip — the canonical "+ Create" entry point. Nothing is persisted
|
|
// to settings; the operator's final Save commits the chosen IDs.
|
|
func CreateWaffoPancakePrimaryPair(ctx context.Context, merchantID, privateKey, returnURL string) (*WaffoPancakePairResult, error) {
|
|
storeID, err := CreateWaffoPancakePrimaryStore(ctx, merchantID, privateKey)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
productID, err := CreateWaffoPancakePrimaryProduct(ctx, merchantID, privateKey, storeID, returnURL)
|
|
if err != nil {
|
|
return &WaffoPancakePairResult{
|
|
StoreID: storeID,
|
|
StoreName: defaultWaffoPancakeStoreName,
|
|
OrphanStore: true,
|
|
}, fmt.Errorf("store created at %s but product creation failed: %w", storeID, err)
|
|
}
|
|
return &WaffoPancakePairResult{
|
|
StoreID: storeID,
|
|
StoreName: defaultWaffoPancakeStoreName,
|
|
ProductID: productID,
|
|
ProductName: defaultWaffoPancakeProductName,
|
|
}, nil
|
|
}
|
|
|
|
// SaveWaffoPancakeConfig persists the operator-controlled fields atomically
|
|
// at the end of the configuration flow via model.UpdateOptionsBulk (single
|
|
// DB transaction). A blank privateKey is treated as "keep current"
|
|
// (Stripe-style API-secret UX) and is omitted from the bulk payload.
|
|
func SaveWaffoPancakeConfig(ctx context.Context, merchantID, privateKey, returnURL, storeID, productID string) error {
|
|
merchantID = strings.TrimSpace(merchantID)
|
|
storeID = strings.TrimSpace(storeID)
|
|
productID = strings.TrimSpace(productID)
|
|
if merchantID == "" || storeID == "" || productID == "" {
|
|
return fmt.Errorf("merchant id, store id, and product id are required to save")
|
|
}
|
|
values := map[string]string{
|
|
"WaffoPancakeMerchantID": merchantID,
|
|
"WaffoPancakeReturnURL": strings.TrimSpace(returnURL),
|
|
"WaffoPancakeStoreID": storeID,
|
|
"WaffoPancakeProductID": productID,
|
|
}
|
|
if pk := strings.TrimSpace(privateKey); pk != "" {
|
|
values["WaffoPancakePrivateKey"] = pk
|
|
}
|
|
if err := model.UpdateOptionsBulk(values); err != nil {
|
|
return fmt.Errorf("persist Waffo Pancake config: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
type WaffoPancakeCatalogProduct struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
Status string `json:"status"`
|
|
}
|
|
|
|
// WaffoPancakeCatalogStore nests its OnetimeProducts so the UI can render a
|
|
// dependent store→product select without a second round-trip.
|
|
type WaffoPancakeCatalogStore struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
Status string `json:"status"`
|
|
ProdEnabled bool `json:"prodEnabled"`
|
|
OnetimeProducts []WaffoPancakeCatalogProduct `json:"onetimeProducts"`
|
|
}
|
|
|
|
type WaffoPancakeCatalog struct {
|
|
Stores []WaffoPancakeCatalogStore `json:"stores"`
|
|
}
|
|
|
|
// ListWaffoPancakeCatalog queries Pancake's GraphQL `stores` for the
|
|
// merchant's stores + onetime products. A successful call also proves
|
|
// the supplied credentials authenticate (doubles as a credential probe).
|
|
func ListWaffoPancakeCatalog(ctx context.Context, merchantID, privateKey string) (*WaffoPancakeCatalog, error) {
|
|
client, err := newWaffoPancakeClientFromCreds(merchantID, privateKey)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
type queryShape struct {
|
|
Stores []WaffoPancakeCatalogStore `json:"stores"`
|
|
}
|
|
// `limit: 100` because the API returns a single store when limit is
|
|
// omitted, even for multi-store merchants. Bump to paginated fetches
|
|
// (via `offset`) if real catalogs ever cross the cap.
|
|
resp, err := pancake.GraphQLQuery[queryShape](ctx, client, pancake.GraphQLParams{
|
|
Query: `query {
|
|
stores(limit: 100) {
|
|
id
|
|
name
|
|
status
|
|
prodEnabled
|
|
onetimeProducts {
|
|
id
|
|
name
|
|
status
|
|
}
|
|
}
|
|
}`,
|
|
})
|
|
if err != nil {
|
|
return nil, fmt.Errorf("query Waffo Pancake catalog: %w", err)
|
|
}
|
|
if len(resp.Errors) > 0 {
|
|
return nil, fmt.Errorf("waffo pancake catalog query returned %d errors: %s",
|
|
len(resp.Errors), resp.Errors[0].Message)
|
|
}
|
|
// Drop non-active products. Operators should only see items they can
|
|
// actually bind without later hitting "product unavailable" at checkout.
|
|
stores := resp.Data.Stores
|
|
for i := range stores {
|
|
active := stores[i].OnetimeProducts[:0]
|
|
for _, p := range stores[i].OnetimeProducts {
|
|
if strings.EqualFold(strings.TrimSpace(p.Status), "active") {
|
|
active = append(active, p)
|
|
}
|
|
}
|
|
stores[i].OnetimeProducts = active
|
|
}
|
|
return &WaffoPancakeCatalog{Stores: stores}, nil
|
|
}
|