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 }