| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898 |
- package service
- import (
- "context"
- "errors"
- "fmt"
- "log/slog"
- "math"
- "net/url"
- "strconv"
- "strings"
- "time"
- dbent "github.com/Wei-Shaw/sub2api/ent"
- "github.com/Wei-Shaw/sub2api/ent/paymentorder"
- "github.com/Wei-Shaw/sub2api/internal/payment"
- "github.com/Wei-Shaw/sub2api/internal/payment/provider"
- infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
- "github.com/Wei-Shaw/sub2api/internal/pkg/servertiming"
- "github.com/shopspring/decimal"
- )
- // --- Order Creation ---
- func (s *PaymentService) CreateOrder(ctx context.Context, req CreateOrderRequest) (*CreateOrderResponse, error) {
- if req.OrderType == "" {
- req.OrderType = payment.OrderTypeBalance
- }
- if normalized := NormalizeVisibleMethod(req.PaymentType); normalized != "" {
- req.PaymentType = normalized
- }
- cfg, err := s.configService.GetPaymentConfig(ctx)
- if err != nil {
- return nil, fmt.Errorf("get payment config: %w", err)
- }
- if !cfg.Enabled {
- return nil, infraerrors.Forbidden("PAYMENT_DISABLED", "payment system is disabled")
- }
- plan, err := s.validateOrderInput(ctx, req, cfg)
- if err != nil {
- return nil, err
- }
- if err := s.checkCancelRateLimit(ctx, req.UserID, cfg); err != nil {
- return nil, err
- }
- user, err := s.userRepo.GetByID(ctx, req.UserID)
- if err != nil {
- return nil, fmt.Errorf("get user: %w", err)
- }
- if user.Status != payment.EntityStatusActive {
- return nil, infraerrors.Forbidden("USER_INACTIVE", "user account is disabled")
- }
- if s.notificationEmailService != nil {
- s.notificationEmailService.RememberRecipientLocale(ctx, req.UserID, user.Email, req.Locale)
- }
- orderAmount := req.Amount
- limitAmount := req.Amount
- if plan != nil {
- orderAmount = plan.Price
- limitAmount = plan.Price
- } else if req.OrderType == payment.OrderTypeBalance {
- orderAmount = calculateCreditedBalance(req.Amount, cfg.BalanceRechargeMultiplier)
- }
- feeRate := cfg.RechargeFeeRate
- methodCurrency := payment.DefaultPaymentCurrency
- if s.configService != nil {
- methodCurrency, err = s.configService.ValidateMethodCurrencyConsistency(ctx, req.PaymentType)
- if err != nil {
- return nil, err
- }
- }
- payAmountStr, payAmount, err := calculateCreateOrderPayAmountForOrderType(limitAmount, feeRate, methodCurrency, req.OrderType, cfg.SubscriptionUSDToCNYRate)
- if err != nil {
- return nil, err
- }
- sel, err := s.selectCreateOrderInstance(ctx, req, cfg, payAmount)
- if err != nil {
- return nil, err
- }
- if err := s.validateSelectedCreateOrderInstance(ctx, req, sel); err != nil {
- return nil, err
- }
- selectedCurrency := payment.DefaultPaymentCurrency
- if sel != nil {
- selectedCurrency = paymentProviderConfigCurrency(sel.ProviderKey, sel.Config)
- }
- if selectedCurrency != methodCurrency {
- payAmountStr, payAmount, err = calculateCreateOrderPayAmountForOrderType(limitAmount, feeRate, selectedCurrency, req.OrderType, cfg.SubscriptionUSDToCNYRate)
- if err != nil {
- return nil, err
- }
- }
- if err := validateSelectedCreateOrderAmountCurrency(payAmountStr, sel); err != nil {
- return nil, err
- }
- oauthResp, err := s.maybeBuildWeChatOAuthRequiredResponseForSelection(ctx, req, limitAmount, payAmount, feeRate, sel)
- if err != nil {
- return nil, err
- }
- if oauthResp != nil {
- return oauthResp, nil
- }
- order, err := s.createOrderInTx(ctx, req, user, plan, cfg, orderAmount, limitAmount, feeRate, payAmount, sel)
- if err != nil {
- return nil, err
- }
- resp, err := s.invokeProvider(ctx, order, req, cfg, limitAmount, payAmountStr, payAmount, plan, sel)
- if err != nil {
- _, _ = s.entClient.PaymentOrder.UpdateOneID(order.ID).
- SetStatus(OrderStatusFailed).
- Save(ctx)
- return nil, err
- }
- return resp, nil
- }
- func (s *PaymentService) validateOrderInput(ctx context.Context, req CreateOrderRequest, cfg *PaymentConfig) (*dbent.SubscriptionPlan, error) {
- if req.OrderType == payment.OrderTypeBalance && cfg.BalanceDisabled {
- return nil, infraerrors.Forbidden("BALANCE_PAYMENT_DISABLED", "balance recharge has been disabled")
- }
- if req.OrderType == payment.OrderTypeSubscription {
- return s.validateSubOrder(ctx, req)
- }
- if math.IsNaN(req.Amount) || math.IsInf(req.Amount, 0) || req.Amount <= 0 {
- return nil, infraerrors.BadRequest("INVALID_AMOUNT", "amount must be a positive number")
- }
- if (cfg.MinAmount > 0 && req.Amount < cfg.MinAmount) || (cfg.MaxAmount > 0 && req.Amount > cfg.MaxAmount) {
- return nil, infraerrors.BadRequest("INVALID_AMOUNT", "amount out of range").
- WithMetadata(map[string]string{"min": fmt.Sprintf("%.2f", cfg.MinAmount), "max": fmt.Sprintf("%.2f", cfg.MaxAmount)})
- }
- return nil, nil
- }
- func (s *PaymentService) validateSubOrder(ctx context.Context, req CreateOrderRequest) (*dbent.SubscriptionPlan, error) {
- if req.PlanID == 0 {
- return nil, infraerrors.BadRequest("INVALID_INPUT", "subscription order requires a plan")
- }
- plan, err := s.configService.GetPlan(ctx, req.PlanID)
- if err != nil || !plan.ForSale {
- return nil, infraerrors.NotFound("PLAN_NOT_AVAILABLE", "plan not found or not for sale")
- }
- group, err := s.groupRepo.GetByID(ctx, plan.GroupID)
- if err != nil || group.Status != payment.EntityStatusActive {
- return nil, infraerrors.NotFound("GROUP_NOT_FOUND", "subscription group is no longer available")
- }
- if !group.IsSubscriptionType() {
- return nil, infraerrors.BadRequest("GROUP_TYPE_MISMATCH", "group is not a subscription type")
- }
- return plan, nil
- }
- func (s *PaymentService) createOrderInTx(ctx context.Context, req CreateOrderRequest, user *User, plan *dbent.SubscriptionPlan, cfg *PaymentConfig, orderAmount, limitAmount, feeRate, payAmount float64, sel *payment.InstanceSelection) (*dbent.PaymentOrder, error) {
- tx, err := s.entClient.Tx(ctx)
- if err != nil {
- return nil, fmt.Errorf("begin transaction: %w", err)
- }
- defer func() { _ = tx.Rollback() }()
- if err := s.checkPendingLimit(ctx, tx, req.UserID, cfg.MaxPendingOrders); err != nil {
- return nil, err
- }
- if err := s.checkDailyLimit(ctx, tx, req.UserID, limitAmount, cfg.DailyLimit); err != nil {
- return nil, err
- }
- tm := cfg.OrderTimeoutMin
- if tm <= 0 {
- tm = defaultOrderTimeoutMin
- }
- exp := time.Now().Add(time.Duration(tm) * time.Minute)
- outTradeNo, err := s.allocateOutTradeNo(ctx, tx)
- if err != nil {
- return nil, err
- }
- providerSnapshot := buildPaymentOrderProviderSnapshot(sel, req)
- selectedInstanceID := ""
- selectedProviderKey := ""
- if sel != nil {
- selectedInstanceID = strings.TrimSpace(sel.InstanceID)
- selectedProviderKey = strings.TrimSpace(sel.ProviderKey)
- }
- b := tx.PaymentOrder.Create().
- SetUserID(req.UserID).
- SetUserEmail(user.Email).
- SetUserName(user.Username).
- SetNillableUserNotes(psNilIfEmpty(user.Notes)).
- SetAmount(orderAmount).
- SetPayAmount(payAmount).
- SetFeeRate(feeRate).
- SetRechargeCode("").
- SetOutTradeNo(outTradeNo).
- SetPaymentType(req.PaymentType).
- SetPaymentTradeNo("").
- SetOrderType(req.OrderType).
- SetStatus(OrderStatusPending).
- SetExpiresAt(exp).
- SetClientIP(req.ClientIP).
- SetSrcHost(req.SrcHost)
- if req.SrcURL != "" {
- b.SetSrcURL(req.SrcURL)
- }
- if selectedInstanceID != "" {
- b.SetProviderInstanceID(selectedInstanceID)
- }
- if selectedProviderKey != "" {
- b.SetProviderKey(selectedProviderKey)
- }
- if providerSnapshot != nil {
- b.SetProviderSnapshot(providerSnapshot)
- }
- if plan != nil {
- b.SetPlanID(plan.ID).SetSubscriptionGroupID(plan.GroupID).SetSubscriptionDays(psComputeValidityDays(plan.ValidityDays, plan.ValidityUnit))
- }
- order, err := b.Save(ctx)
- if err != nil {
- return nil, fmt.Errorf("create order: %w", err)
- }
- code := fmt.Sprintf("PAY-%d-%d", order.ID, time.Now().UnixNano()%100000)
- order, err = tx.PaymentOrder.UpdateOneID(order.ID).SetRechargeCode(code).Save(ctx)
- if err != nil {
- return nil, fmt.Errorf("set recharge code: %w", err)
- }
- if err := tx.Commit(); err != nil {
- return nil, fmt.Errorf("commit order transaction: %w", err)
- }
- return order, nil
- }
- func (s *PaymentService) allocateOutTradeNo(ctx context.Context, tx *dbent.Tx) (string, error) {
- const maxAttempts = 5
- for attempt := 0; attempt < maxAttempts; attempt++ {
- candidate := generateOutTradeNo()
- exists, err := tx.PaymentOrder.Query().Where(paymentorder.OutTradeNo(candidate)).Exist(ctx)
- if err != nil {
- return "", fmt.Errorf("check out_trade_no uniqueness: %w", err)
- }
- if !exists {
- return candidate, nil
- }
- }
- return "", fmt.Errorf("generate unique out_trade_no: exhausted %d attempts", maxAttempts)
- }
- func (s *PaymentService) checkPendingLimit(ctx context.Context, tx *dbent.Tx, userID int64, max int) error {
- if max <= 0 {
- max = defaultMaxPendingOrders
- }
- c, err := tx.PaymentOrder.Query().Where(paymentorder.UserIDEQ(userID), paymentorder.StatusEQ(OrderStatusPending)).Count(ctx)
- if err != nil {
- return fmt.Errorf("count pending orders: %w", err)
- }
- if c >= max {
- return infraerrors.TooManyRequests("TOO_MANY_PENDING", "too_many_pending").
- WithMetadata(map[string]string{"max": strconv.Itoa(max)})
- }
- return nil
- }
- func buildPaymentOrderProviderSnapshot(sel *payment.InstanceSelection, req CreateOrderRequest) map[string]any {
- if sel == nil {
- return nil
- }
- snapshot := map[string]any{}
- snapshot["schema_version"] = 2
- instanceID := strings.TrimSpace(sel.InstanceID)
- if instanceID != "" {
- snapshot["provider_instance_id"] = instanceID
- }
- providerKey := strings.TrimSpace(sel.ProviderKey)
- if providerKey != "" {
- snapshot["provider_key"] = providerKey
- }
- paymentMode := strings.TrimSpace(sel.PaymentMode)
- if paymentMode != "" {
- snapshot["payment_mode"] = paymentMode
- }
- if providerKey == payment.TypeWxpay {
- if merchantAppID := paymentOrderSnapshotWxpayAppID(sel, req); merchantAppID != "" {
- snapshot["merchant_app_id"] = merchantAppID
- }
- if merchantID := strings.TrimSpace(sel.Config["mchId"]); merchantID != "" {
- snapshot["merchant_id"] = merchantID
- }
- snapshot["currency"] = payment.DefaultPaymentCurrency
- }
- if providerKey == payment.TypeAlipay {
- if merchantAppID := strings.TrimSpace(sel.Config["appId"]); merchantAppID != "" {
- snapshot["merchant_app_id"] = merchantAppID
- }
- }
- if providerKey == payment.TypeEasyPay {
- if merchantID := strings.TrimSpace(sel.Config["pid"]); merchantID != "" {
- snapshot["merchant_id"] = merchantID
- }
- }
- if providerKey == payment.TypeStripe {
- snapshot["currency"] = paymentProviderConfigCurrency(providerKey, sel.Config)
- }
- if providerKey == payment.TypeAirwallex {
- if accountID := strings.TrimSpace(sel.Config["accountId"]); accountID != "" {
- snapshot["merchant_id"] = accountID
- }
- snapshot["currency"] = paymentProviderConfigCurrency(providerKey, sel.Config)
- }
- if len(snapshot) == 1 {
- return nil
- }
- return snapshot
- }
- func paymentOrderSnapshotWxpayAppID(sel *payment.InstanceSelection, req CreateOrderRequest) string {
- if sel == nil || strings.TrimSpace(sel.ProviderKey) != payment.TypeWxpay {
- return ""
- }
- if strings.TrimSpace(req.OpenID) != "" {
- return strings.TrimSpace(provider.ResolveWxpayJSAPIAppID(sel.Config))
- }
- return strings.TrimSpace(sel.Config["appId"])
- }
- func (s *PaymentService) checkDailyLimit(ctx context.Context, tx *dbent.Tx, userID int64, amount, limit float64) error {
- if limit <= 0 {
- return nil
- }
- ts := psStartOfDayUTC(time.Now())
- orders, err := tx.PaymentOrder.Query().Where(paymentorder.UserIDEQ(userID), paymentorder.StatusIn(OrderStatusPaid, OrderStatusRecharging, OrderStatusCompleted), paymentorder.PaidAtGTE(ts)).All(ctx)
- if err != nil {
- return fmt.Errorf("query daily usage: %w", err)
- }
- var used float64
- for _, o := range orders {
- if o.OrderType == payment.OrderTypeBalance {
- used += o.PayAmount
- continue
- }
- used += o.Amount
- }
- if used+amount > limit {
- return infraerrors.TooManyRequests("DAILY_LIMIT_EXCEEDED", "daily_limit_exceeded").
- WithMetadata(map[string]string{"remaining": fmt.Sprintf("%.2f", math.Max(0, limit-used))})
- }
- return nil
- }
- func (s *PaymentService) selectCreateOrderInstance(ctx context.Context, req CreateOrderRequest, cfg *PaymentConfig, payAmount float64) (*payment.InstanceSelection, error) {
- selectCtx, err := s.prepareCreateOrderSelectionContext(ctx, req)
- if err != nil {
- return nil, err
- }
- sel, err := s.loadBalancer.SelectInstance(selectCtx, "", req.PaymentType, payment.Strategy(cfg.LoadBalanceStrategy), payAmount)
- if err != nil {
- return nil, infraerrors.ServiceUnavailable("PAYMENT_GATEWAY_ERROR", "method_not_configured").
- WithMetadata(map[string]string{"payment_type": req.PaymentType})
- }
- if sel == nil {
- return nil, infraerrors.TooManyRequests("NO_AVAILABLE_INSTANCE", "no_available_instance")
- }
- return sel, nil
- }
- func (s *PaymentService) prepareCreateOrderSelectionContext(ctx context.Context, req CreateOrderRequest) (context.Context, error) {
- if !requestNeedsWeChatJSAPICompatibility(req) {
- return ctx, nil
- }
- if !s.usesOfficialWxpayVisibleMethod(ctx) {
- return ctx, nil
- }
- expectedAppID, _, err := s.getWeChatPaymentOAuthCredential(ctx)
- if err != nil {
- return nil, err
- }
- return payment.WithWxpayJSAPIAppID(ctx, expectedAppID), nil
- }
- func requestNeedsWeChatJSAPICompatibility(req CreateOrderRequest) bool {
- if payment.GetBasePaymentType(req.PaymentType) != payment.TypeWxpay {
- return false
- }
- return req.IsWeChatBrowser || strings.TrimSpace(req.OpenID) != ""
- }
- func (s *PaymentService) usesOfficialWxpayVisibleMethod(ctx context.Context) bool {
- if s == nil || s.configService == nil {
- return false
- }
- inst, err := s.configService.resolveEnabledVisibleMethodInstance(ctx, payment.TypeWxpay)
- if err != nil {
- return false
- }
- if inst == nil {
- return false
- }
- return inst.ProviderKey == payment.TypeWxpay
- }
- func (s *PaymentService) invokeProvider(ctx context.Context, order *dbent.PaymentOrder, req CreateOrderRequest, cfg *PaymentConfig, limitAmount float64, payAmountStr string, payAmount float64, plan *dbent.SubscriptionPlan, sel *payment.InstanceSelection) (*CreateOrderResponse, error) {
- prov, err := provider.CreateProvider(sel.ProviderKey, sel.InstanceID, sel.Config)
- if err != nil {
- slog.Error("[PaymentService] CreateProvider failed", "provider", sel.ProviderKey, "instance", sel.InstanceID, "error", err)
- // If the provider returned a structured ApplicationError (e.g. WXPAY_CONFIG_MISSING_KEY),
- // pass it through with provider context added to metadata. Otherwise wrap as PAYMENT_PROVIDER_MISCONFIGURED.
- if appErr := new(infraerrors.ApplicationError); errors.As(err, &appErr) {
- md := map[string]string{"provider": sel.ProviderKey, "instance_id": sel.InstanceID}
- for k, v := range appErr.Metadata {
- md[k] = v
- }
- return nil, appErr.WithMetadata(md)
- }
- return nil, infraerrors.ServiceUnavailable("PAYMENT_PROVIDER_MISCONFIGURED", "provider_misconfigured").
- WithMetadata(map[string]string{"provider": sel.ProviderKey, "instance_id": sel.InstanceID})
- }
- subject := s.buildPaymentSubject(plan, limitAmount, cfg, sel)
- outTradeNo := order.OutTradeNo
- canonicalReturnURL, err := CanonicalizeReturnURL(req.ReturnURL, req.SrcHost, req.SrcURL)
- if err != nil {
- return nil, err
- }
- resumeToken := ""
- if resume := s.paymentResume(); resume != nil {
- if canonicalReturnURL != "" && resume.isSigningConfigured() {
- resumeToken, err = resume.CreateToken(ResumeTokenClaims{
- OrderID: order.ID,
- UserID: order.UserID,
- ProviderInstanceID: sel.InstanceID,
- ProviderKey: sel.ProviderKey,
- PaymentType: req.PaymentType,
- CanonicalReturnURL: canonicalReturnURL,
- })
- if err != nil {
- return nil, fmt.Errorf("create payment resume token: %w", err)
- }
- }
- }
- providerReturnURL, err := buildPaymentReturnURL(canonicalReturnURL, order.ID, outTradeNo, resumeToken)
- if err != nil {
- return nil, err
- }
- providerReq := buildProviderCreatePaymentRequest(CreateOrderRequest{
- PaymentType: req.PaymentType,
- OpenID: req.OpenID,
- ClientIP: req.ClientIP,
- IsMobile: req.IsMobile,
- ReturnURL: providerReturnURL,
- }, sel, outTradeNo, payAmountStr, subject)
- providerReq.AlipayMobilePrecreate = shouldUseAlipayMobilePrecreate(req, cfg, sel)
- finishProviderCall := servertiming.ObserveDependency(ctx, "payment")
- pr, err := prov.CreatePayment(ctx, providerReq)
- finishProviderCall()
- if err != nil {
- slog.Error("[PaymentService] CreatePayment failed", "provider", sel.ProviderKey, "instance", sel.InstanceID, "error", err)
- if appErr := new(infraerrors.ApplicationError); errors.As(err, &appErr) {
- return nil, appErr
- }
- return nil, classifyCreatePaymentError(req, sel.ProviderKey, err)
- }
- sanitizeCreatePaymentResponseDetails(pr)
- _, err = s.entClient.PaymentOrder.UpdateOneID(order.ID).
- SetNillablePaymentTradeNo(psNilIfEmpty(pr.TradeNo)).
- SetNillablePayURL(psNilIfEmpty(pr.PayURL)).
- SetNillableQrCode(psNilIfEmpty(pr.QRCode)).
- SetNillableProviderInstanceID(psNilIfEmpty(sel.InstanceID)).
- SetNillableProviderKey(psNilIfEmpty(sel.ProviderKey)).
- Save(ctx)
- if err != nil {
- return nil, fmt.Errorf("update order with payment details: %w", err)
- }
- s.writeAuditLog(ctx, order.ID, "ORDER_CREATED", fmt.Sprintf("user:%d", req.UserID), map[string]any{
- "paymentAmount": req.Amount,
- "creditedAmount": order.Amount,
- "payAmount": order.PayAmount,
- "paymentType": req.PaymentType,
- "orderType": req.OrderType,
- "paymentSource": NormalizePaymentSource(req.PaymentSource),
- })
- resultType := pr.ResultType
- if resultType == "" {
- resultType = payment.CreatePaymentResultOrderCreated
- }
- resp := buildCreateOrderResponse(order, req, payAmount, sel, pr, resultType)
- resp.ResumeToken = resumeToken
- resp.AlipayMobilePrecreateDeepLink = providerReq.AlipayMobilePrecreate && strings.TrimSpace(pr.QRCode) != ""
- return resp, nil
- }
- func shouldUseAlipayMobilePrecreate(req CreateOrderRequest, cfg *PaymentConfig, sel *payment.InstanceSelection) bool {
- return cfg != nil &&
- cfg.AlipayMobilePrecreateDeepLink &&
- req.IsMobile &&
- sel != nil &&
- strings.EqualFold(strings.TrimSpace(sel.ProviderKey), payment.TypeAlipay)
- }
- func sanitizeCreatePaymentResponseDetails(pr *payment.CreatePaymentResponse) {
- if pr == nil {
- return
- }
- pr.TradeNo = removePostgresTextNUL(pr.TradeNo)
- pr.PayURL = removePostgresTextNUL(pr.PayURL)
- pr.QRCode = removePostgresTextNUL(pr.QRCode)
- }
- func removePostgresTextNUL(value string) string {
- if !strings.ContainsRune(value, 0) {
- return value
- }
- return strings.ReplaceAll(value, "\x00", "")
- }
- func buildProviderCreatePaymentRequest(req CreateOrderRequest, sel *payment.InstanceSelection, orderID, amount, subject string) payment.CreatePaymentRequest {
- return payment.CreatePaymentRequest{
- OrderID: orderID,
- Amount: amount,
- PaymentType: req.PaymentType,
- Subject: subject,
- ReturnURL: req.ReturnURL,
- OpenID: strings.TrimSpace(req.OpenID),
- ClientIP: req.ClientIP,
- IsMobile: req.IsMobile,
- InstanceSubMethods: selectedInstanceSupportedTypes(sel),
- }
- }
- func selectedInstanceSupportedTypes(sel *payment.InstanceSelection) string {
- if sel == nil {
- return ""
- }
- return sel.SupportedTypes
- }
- func (s *PaymentService) buildPaymentSubject(plan *dbent.SubscriptionPlan, limitAmount float64, cfg *PaymentConfig, sel *payment.InstanceSelection) string {
- if plan != nil {
- productName := plan.ProductName
- if productName == "" {
- productName = "Sub2API Subscription " + plan.Name
- }
- return applyPaymentProductNameAffix(productName, cfg)
- }
- currency := payment.DefaultPaymentCurrency
- if sel != nil {
- currency = paymentProviderConfigCurrency(sel.ProviderKey, sel.Config)
- }
- amountStr := payment.FormatAmountForCurrency(limitAmount, currency)
- if hasPaymentProductNameAffix(cfg) {
- return applyPaymentProductNameAffix(amountStr, cfg)
- }
- return "Sub2API " + amountStr + " " + currency
- }
- func hasPaymentProductNameAffix(cfg *PaymentConfig) bool {
- if cfg == nil {
- return false
- }
- pf := strings.TrimSpace(cfg.ProductNamePrefix)
- sf := strings.TrimSpace(cfg.ProductNameSuffix)
- return pf != "" || sf != ""
- }
- func applyPaymentProductNameAffix(productName string, cfg *PaymentConfig) string {
- if !hasPaymentProductNameAffix(cfg) {
- return productName
- }
- pf := strings.TrimSpace(cfg.ProductNamePrefix)
- sf := strings.TrimSpace(cfg.ProductNameSuffix)
- return strings.TrimSpace(pf + " " + productName + " " + sf)
- }
- func (s *PaymentService) maybeBuildWeChatOAuthRequiredResponse(ctx context.Context, req CreateOrderRequest, amount, payAmount, feeRate float64) (*CreateOrderResponse, error) {
- return s.maybeBuildWeChatOAuthRequiredResponseForSelection(ctx, req, amount, payAmount, feeRate, nil)
- }
- func (s *PaymentService) maybeBuildWeChatOAuthRequiredResponseForSelection(ctx context.Context, req CreateOrderRequest, amount, payAmount, feeRate float64, sel *payment.InstanceSelection) (*CreateOrderResponse, error) {
- if sel != nil && sel.ProviderKey != "" && sel.ProviderKey != payment.TypeWxpay {
- return nil, nil
- }
- if strings.TrimSpace(req.OpenID) != "" || !req.IsWeChatBrowser || payment.GetBasePaymentType(req.PaymentType) != payment.TypeWxpay {
- return nil, nil
- }
- return s.buildWeChatOAuthRequiredResponse(ctx, req, amount, payAmount, feeRate)
- }
- func (s *PaymentService) buildWeChatOAuthRequiredResponse(ctx context.Context, req CreateOrderRequest, amount, payAmount, feeRate float64) (*CreateOrderResponse, error) {
- appID, _, err := s.getWeChatPaymentOAuthCredential(ctx)
- if err != nil {
- return nil, err
- }
- if err := s.paymentResume().ensureSigningKey(); err != nil {
- return nil, err
- }
- authorizeURL, err := buildWeChatPaymentOAuthStartURL(req, "snsapi_base")
- if err != nil {
- return nil, err
- }
- return &CreateOrderResponse{
- Amount: amount,
- PayAmount: payAmount,
- FeeRate: feeRate,
- ResultType: payment.CreatePaymentResultOAuthRequired,
- PaymentType: req.PaymentType,
- OAuth: &payment.WechatOAuthInfo{
- AuthorizeURL: authorizeURL,
- AppID: appID,
- Scope: "snsapi_base",
- RedirectURL: "/auth/wechat/payment/callback",
- },
- }, nil
- }
- func (s *PaymentService) validateSelectedCreateOrderInstance(ctx context.Context, req CreateOrderRequest, sel *payment.InstanceSelection) error {
- if !requiresWeChatJSAPICompatibleSelection(req, sel) {
- return nil
- }
- expectedAppID, _, err := s.getWeChatPaymentOAuthCredential(ctx)
- if err != nil {
- return err
- }
- selectedAppID := provider.ResolveWxpayJSAPIAppID(sel.Config)
- if selectedAppID == "" || selectedAppID != expectedAppID {
- return infraerrors.TooManyRequests("NO_AVAILABLE_INSTANCE", "selected payment instance is not compatible with the current WeChat OAuth app")
- }
- return nil
- }
- func calculateCreateOrderPayAmount(limitAmount, feeRate float64, currency string) (string, float64, error) {
- if err := validateCreateOrderAmountCurrency(limitAmount, currency); err != nil {
- return "", 0, err
- }
- payAmountStr := payment.CalculatePayAmountForCurrency(limitAmount, feeRate, currency)
- if _, err := payment.AmountToMinorUnit(payAmountStr, currency); err != nil {
- return "", 0, infraerrors.BadRequest("INVALID_AMOUNT", err.Error()).
- WithMetadata(map[string]string{"currency": currency})
- }
- payAmount, err := strconv.ParseFloat(payAmountStr, 64)
- if err != nil {
- return "", 0, infraerrors.BadRequest("INVALID_AMOUNT", "invalid payment amount").
- WithMetadata(map[string]string{"currency": currency})
- }
- return payAmountStr, payAmount, nil
- }
- func calculateCreateOrderPayAmountForOrderType(limitAmount, feeRate float64, currency, orderType string, usdToCnyRate float64) (string, float64, error) {
- paymentAmount := limitAmount
- if orderType == payment.OrderTypeSubscription {
- paymentAmount = calculateSubscriptionGatewayBaseAmount(limitAmount, usdToCnyRate, currency)
- }
- return calculateCreateOrderPayAmount(paymentAmount, feeRate, currency)
- }
- // calculateSubscriptionGatewayBaseAmount 计算订阅订单的网关扣款基数。
- // 换算是显式 opt-in:仅当管理员配置了订阅汇率(rate > 0,1 USD = rate CNY)
- // 且网关币种为 CNY 时,按 price × rate 换算;未配置时保持 price 直付的存量行为。
- func calculateSubscriptionGatewayBaseAmount(amount, usdToCnyRate float64, currency string) float64 {
- rate := normalizeSubscriptionUSDToCNYRate(usdToCnyRate)
- if rate <= 0 || currency != payment.DefaultPaymentCurrency {
- return amount
- }
- return decimal.NewFromFloat(amount).
- Mul(decimal.NewFromFloat(rate)).
- Round(int32(payment.CurrencyMaxFractionDigits(currency))).
- InexactFloat64()
- }
- func validateCreateOrderAmountCurrency(amount float64, currency string) error {
- amountStr := strconv.FormatFloat(amount, 'f', -1, 64)
- if _, err := payment.AmountToMinorUnit(amountStr, currency); err != nil {
- return infraerrors.BadRequest("INVALID_AMOUNT", err.Error()).
- WithMetadata(map[string]string{"currency": currency})
- }
- return nil
- }
- func validateSelectedCreateOrderAmountCurrency(payAmount string, sel *payment.InstanceSelection) error {
- if sel == nil {
- return nil
- }
- currency := paymentProviderConfigCurrency(sel.ProviderKey, sel.Config)
- if _, err := payment.AmountToMinorUnit(payAmount, currency); err != nil {
- return infraerrors.BadRequest("INVALID_AMOUNT", err.Error()).
- WithMetadata(map[string]string{"currency": currency})
- }
- return nil
- }
- func requiresWeChatJSAPICompatibleSelection(req CreateOrderRequest, sel *payment.InstanceSelection) bool {
- if sel == nil || sel.ProviderKey != payment.TypeWxpay || payment.GetBasePaymentType(req.PaymentType) != payment.TypeWxpay {
- return false
- }
- return req.IsWeChatBrowser || strings.TrimSpace(req.OpenID) != ""
- }
- func (s *PaymentService) getWeChatPaymentOAuthCredential(ctx context.Context) (string, string, error) {
- if s == nil || s.configService == nil || s.configService.settingRepo == nil {
- return "", "", infraerrors.ServiceUnavailable(
- "WECHAT_PAYMENT_MP_NOT_CONFIGURED",
- "wechat in-app payment requires a complete WeChat MP OAuth credential",
- )
- }
- cfg, err := (&SettingService{settingRepo: s.configService.settingRepo}).GetWeChatConnectOAuthConfig(ctx)
- appID := strings.TrimSpace(cfg.AppIDForMode("mp"))
- appSecret := strings.TrimSpace(cfg.AppSecretForMode("mp"))
- if err != nil || !cfg.SupportsMode("mp") || appID == "" || appSecret == "" {
- return "", "", infraerrors.ServiceUnavailable(
- "WECHAT_PAYMENT_MP_NOT_CONFIGURED",
- "wechat in-app payment requires a complete WeChat MP OAuth credential",
- )
- }
- return appID, appSecret, nil
- }
- func classifyCreatePaymentError(req CreateOrderRequest, providerKey string, err error) error {
- if err == nil {
- return nil
- }
- if providerKey == payment.TypeWxpay &&
- payment.GetBasePaymentType(req.PaymentType) == payment.TypeWxpay &&
- strings.Contains(err.Error(), "wxpay h5 payments are not authorized for this merchant") {
- return infraerrors.ServiceUnavailable(
- "WECHAT_H5_NOT_AUTHORIZED",
- "wechat h5 payment is not available for this merchant",
- ).WithMetadata(map[string]string{
- "action": "open_in_wechat_or_scan_qr",
- })
- }
- return infraerrors.ServiceUnavailable("PAYMENT_GATEWAY_ERROR", fmt.Sprintf("payment gateway error: %s", err.Error()))
- }
- func buildCreateOrderResponse(order *dbent.PaymentOrder, req CreateOrderRequest, payAmount float64, sel *payment.InstanceSelection, pr *payment.CreatePaymentResponse, resultType payment.CreatePaymentResultType) *CreateOrderResponse {
- return &CreateOrderResponse{
- OrderID: order.ID,
- Amount: order.Amount,
- PayAmount: payAmount,
- FeeRate: order.FeeRate,
- Status: OrderStatusPending,
- ResultType: resultType,
- PaymentType: req.PaymentType,
- OutTradeNo: order.OutTradeNo,
- PayURL: pr.PayURL,
- QRCode: pr.QRCode,
- ClientSecret: pr.ClientSecret,
- IntentID: pr.IntentID,
- Currency: pr.Currency,
- CountryCode: pr.CountryCode,
- PaymentEnv: pr.PaymentEnv,
- OAuth: pr.OAuth,
- JSAPI: pr.JSAPI,
- JSAPIPayload: pr.JSAPI,
- ExpiresAt: order.ExpiresAt,
- PaymentMode: sel.PaymentMode,
- }
- }
- func buildWeChatPaymentOAuthStartURL(req CreateOrderRequest, scope string) (string, error) {
- u, err := url.Parse("/api/v1/auth/oauth/wechat/payment/start")
- if err != nil {
- return "", fmt.Errorf("build wechat payment oauth start url: %w", err)
- }
- q := u.Query()
- q.Set("payment_type", strings.TrimSpace(req.PaymentType))
- if req.Amount > 0 {
- q.Set("amount", strconv.FormatFloat(req.Amount, 'f', -1, 64))
- }
- if orderType := strings.TrimSpace(req.OrderType); orderType != "" {
- q.Set("order_type", orderType)
- }
- if req.PlanID > 0 {
- q.Set("plan_id", strconv.FormatInt(req.PlanID, 10))
- }
- if scope = strings.TrimSpace(scope); scope != "" {
- q.Set("scope", scope)
- }
- if redirectTo := paymentRedirectPathFromURL(req.SrcURL); redirectTo != "" {
- q.Set("redirect", redirectTo)
- }
- u.RawQuery = q.Encode()
- return u.String(), nil
- }
- func paymentRedirectPathFromURL(rawURL string) string {
- rawURL = strings.TrimSpace(rawURL)
- if rawURL == "" {
- return "/purchase"
- }
- if strings.HasPrefix(rawURL, "/") && !strings.HasPrefix(rawURL, "//") {
- return normalizePaymentRedirectPath(rawURL)
- }
- u, err := url.Parse(rawURL)
- if err != nil {
- return "/purchase"
- }
- path := strings.TrimSpace(u.EscapedPath())
- if path == "" {
- path = strings.TrimSpace(u.Path)
- }
- if path == "" || !strings.HasPrefix(path, "/") || strings.HasPrefix(path, "//") {
- return "/purchase"
- }
- if strings.TrimSpace(u.RawQuery) != "" {
- path += "?" + u.RawQuery
- }
- return normalizePaymentRedirectPath(path)
- }
- func normalizePaymentRedirectPath(path string) string {
- path = strings.TrimSpace(path)
- if path == "" {
- return "/purchase"
- }
- if path == "/payment" {
- return "/purchase"
- }
- if strings.HasPrefix(path, "/payment?") {
- return "/purchase" + strings.TrimPrefix(path, "/payment")
- }
- return path
- }
- // --- Order Queries ---
- func (s *PaymentService) GetOrder(ctx context.Context, orderID, userID int64) (*dbent.PaymentOrder, error) {
- o, err := s.entClient.PaymentOrder.Get(ctx, orderID)
- if err != nil {
- return nil, infraerrors.NotFound("NOT_FOUND", "order not found")
- }
- if o.UserID != userID {
- return nil, infraerrors.Forbidden("FORBIDDEN", "no permission for this order")
- }
- return o, nil
- }
- func (s *PaymentService) GetOrderByID(ctx context.Context, orderID int64) (*dbent.PaymentOrder, error) {
- o, err := s.entClient.PaymentOrder.Get(ctx, orderID)
- if err != nil {
- return nil, infraerrors.NotFound("NOT_FOUND", "order not found")
- }
- return o, nil
- }
- func (s *PaymentService) GetUserOrders(ctx context.Context, userID int64, p OrderListParams) ([]*dbent.PaymentOrder, int, error) {
- q := s.entClient.PaymentOrder.Query().Where(paymentorder.UserIDEQ(userID))
- if p.Status != "" {
- q = q.Where(paymentorder.StatusEQ(p.Status))
- }
- if p.OrderType != "" {
- q = q.Where(paymentorder.OrderTypeEQ(p.OrderType))
- }
- if p.PaymentType != "" {
- q = q.Where(paymentorder.PaymentTypeEQ(p.PaymentType))
- }
- total, err := q.Clone().Count(ctx)
- if err != nil {
- return nil, 0, fmt.Errorf("count user orders: %w", err)
- }
- ps, pg := applyPagination(p.PageSize, p.Page)
- orders, err := q.Order(dbent.Desc(paymentorder.FieldCreatedAt)).Limit(ps).Offset((pg - 1) * ps).All(ctx)
- if err != nil {
- return nil, 0, fmt.Errorf("query user orders: %w", err)
- }
- return orders, total, nil
- }
- // AdminListOrders returns a paginated list of orders. If userID > 0, filters by user.
- func (s *PaymentService) AdminListOrders(ctx context.Context, userID int64, p OrderListParams) ([]*dbent.PaymentOrder, int, error) {
- q := s.entClient.PaymentOrder.Query()
- if userID > 0 {
- q = q.Where(paymentorder.UserIDEQ(userID))
- }
- if p.Status != "" {
- q = q.Where(paymentorder.StatusEQ(p.Status))
- }
- if p.OrderType != "" {
- q = q.Where(paymentorder.OrderTypeEQ(p.OrderType))
- }
- if p.PaymentType != "" {
- q = q.Where(paymentorder.PaymentTypeEQ(p.PaymentType))
- }
- if p.Keyword != "" {
- q = q.Where(paymentorder.Or(
- paymentorder.OutTradeNoContainsFold(p.Keyword),
- paymentorder.UserEmailContainsFold(p.Keyword),
- paymentorder.UserNameContainsFold(p.Keyword),
- ))
- }
- total, err := q.Clone().Count(ctx)
- if err != nil {
- return nil, 0, fmt.Errorf("count admin orders: %w", err)
- }
- ps, pg := applyPagination(p.PageSize, p.Page)
- orders, err := q.Order(dbent.Desc(paymentorder.FieldCreatedAt)).Limit(ps).Offset((pg - 1) * ps).All(ctx)
- if err != nil {
- return nil, 0, fmt.Errorf("query admin orders: %w", err)
- }
- return orders, total, nil
- }
|