package provider import ( "context" "fmt" "net/url" "strconv" "strings" "sync" "time" "github.com/Wei-Shaw/sub2api/internal/payment" "github.com/smartwalle/alipay/v3" ) // Alipay product codes. const ( alipayProductCodePreCreate = "FACE_TO_FACE_PAYMENT" alipayProductCodeWapPay = "QUICK_WAP_WAY" alipayProductCodePagePay = "FAST_INSTANT_TRADE_PAY" ) // Alipay response constants. const ( alipayFundChangeYes = "Y" alipayErrTradeNotExist = "ACQ.TRADE_NOT_EXIST" alipayRefundSuffix = "-refund" ) var ( alipayTradeWapPay = func(client *alipay.Client, param alipay.TradeWapPay) (*url.URL, error) { return client.TradeWapPay(param) } alipayTradePreCreate = func(ctx context.Context, client *alipay.Client, param alipay.TradePreCreate) (*alipay.TradePreCreateRsp, error) { return client.TradePreCreate(ctx, param) } alipayTradePagePay = func(client *alipay.Client, param alipay.TradePagePay) (*url.URL, error) { return client.TradePagePay(param) } ) // Alipay implements payment.Provider and payment.CancelableProvider using the smartwalle/alipay SDK. type Alipay struct { instanceID string config map[string]string // appId, privateKey, publicKey (or alipayPublicKey), notifyUrl, returnUrl mu sync.Mutex client *alipay.Client } // NewAlipay creates a new Alipay provider instance. func NewAlipay(instanceID string, config map[string]string) (*Alipay, error) { required := []string{"appId", "privateKey"} for _, k := range required { if config[k] == "" { return nil, fmt.Errorf("alipay config missing required key: %s", k) } } return &Alipay{ instanceID: instanceID, config: config, }, nil } func (a *Alipay) getClient() (*alipay.Client, error) { a.mu.Lock() defer a.mu.Unlock() if a.client != nil { return a.client, nil } client, err := alipay.New(a.config["appId"], a.config["privateKey"], true) if err != nil { return nil, fmt.Errorf("alipay init client: %w", err) } pubKey := a.config["publicKey"] if pubKey == "" { pubKey = a.config["alipayPublicKey"] } if pubKey == "" { return nil, fmt.Errorf("alipay config missing required key: publicKey (or alipayPublicKey)") } if err := client.LoadAliPayPublicKey(pubKey); err != nil { return nil, fmt.Errorf("alipay load public key: %w", err) } a.client = client return a.client, nil } func (a *Alipay) Name() string { return "Alipay" } func (a *Alipay) ProviderKey() string { return payment.TypeAlipay } func (a *Alipay) SupportedTypes() []payment.PaymentType { return []payment.PaymentType{payment.TypeAlipay} } func (a *Alipay) MerchantIdentityMetadata() map[string]string { if a == nil { return nil } appID := strings.TrimSpace(a.config["appId"]) if appID == "" { return nil } return map[string]string{"app_id": appID} } // CreatePayment creates an Alipay payment using the following routing: // - Mobile (H5): alipay.trade.wap.pay — browser redirect into Alipay. // - Desktop: prefer alipay.trade.precreate to get a scan payload directly. // - Desktop fallback: if precreate is unavailable for the merchant, fall back // to alipay.trade.page.pay and expose both pay_url and qr_code so the // frontend can render a QR while still allowing direct page open. func (a *Alipay) CreatePayment(ctx context.Context, req payment.CreatePaymentRequest) (*payment.CreatePaymentResponse, error) { client, err := a.getClient() if err != nil { return nil, err } notifyURL := a.config["notifyUrl"] if req.NotifyURL != "" { notifyURL = req.NotifyURL } returnURL := a.config["returnUrl"] if req.ReturnURL != "" { returnURL = req.ReturnURL } if req.IsMobile { return a.createWapTrade(client, req, notifyURL, returnURL) } return a.createDesktopTrade(ctx, client, req, notifyURL, returnURL) } func (a *Alipay) createWapTrade(client *alipay.Client, req payment.CreatePaymentRequest, notifyURL, returnURL string) (*payment.CreatePaymentResponse, error) { param := alipay.TradeWapPay{} param.OutTradeNo = req.OrderID param.TotalAmount = req.Amount param.Subject = req.Subject param.ProductCode = alipayProductCodeWapPay param.NotifyURL = notifyURL param.ReturnURL = returnURL payURL, err := alipayTradeWapPay(client, param) if err != nil { return nil, fmt.Errorf("alipay TradeWapPay: %w", err) } return &payment.CreatePaymentResponse{ TradeNo: req.OrderID, PayURL: payURL.String(), }, nil } func (a *Alipay) createDesktopTrade(ctx context.Context, client *alipay.Client, req payment.CreatePaymentRequest, notifyURL, returnURL string) (*payment.CreatePaymentResponse, error) { resp, precreateErr := a.createPrecreateTrade(ctx, client, req, notifyURL) if precreateErr == nil { return resp, nil } resp, pagePayErr := a.createPagePayTrade(client, req, notifyURL, returnURL) if pagePayErr == nil { return resp, nil } return nil, fmt.Errorf("alipay desktop payment failed: precreate=%v; pagepay=%w", precreateErr, pagePayErr) } func (a *Alipay) createPrecreateTrade(ctx context.Context, client *alipay.Client, req payment.CreatePaymentRequest, notifyURL string) (*payment.CreatePaymentResponse, error) { param := alipay.TradePreCreate{} param.OutTradeNo = req.OrderID param.TotalAmount = req.Amount param.Subject = req.Subject param.ProductCode = alipayProductCodePreCreate param.NotifyURL = notifyURL rsp, err := alipayTradePreCreate(ctx, client, param) if err != nil { return nil, fmt.Errorf("alipay TradePreCreate: %w", err) } if rsp == nil { return nil, fmt.Errorf("alipay TradePreCreate: empty response") } if rsp.IsFailure() { return nil, fmt.Errorf("alipay TradePreCreate failed: %s", rsp.Error.Error()) } if strings.TrimSpace(rsp.QRCode) == "" { return nil, fmt.Errorf("alipay TradePreCreate: empty qr_code") } return &payment.CreatePaymentResponse{ TradeNo: req.OrderID, QRCode: rsp.QRCode, }, nil } func (a *Alipay) createPagePayTrade(client *alipay.Client, req payment.CreatePaymentRequest, notifyURL, returnURL string) (*payment.CreatePaymentResponse, error) { param := alipay.TradePagePay{} param.OutTradeNo = req.OrderID param.TotalAmount = req.Amount param.Subject = req.Subject param.ProductCode = alipayProductCodePagePay param.NotifyURL = notifyURL param.ReturnURL = returnURL payURL, err := alipayTradePagePay(client, param) if err != nil { return nil, fmt.Errorf("alipay TradePagePay: %w", err) } return &payment.CreatePaymentResponse{ TradeNo: req.OrderID, PayURL: payURL.String(), QRCode: payURL.String(), }, nil } // QueryOrder queries the trade status via Alipay. func (a *Alipay) QueryOrder(ctx context.Context, tradeNo string) (*payment.QueryOrderResponse, error) { client, err := a.getClient() if err != nil { return nil, err } result, err := client.TradeQuery(ctx, alipay.TradeQuery{OutTradeNo: tradeNo}) if err != nil { if isTradeNotExist(err) { return &payment.QueryOrderResponse{ TradeNo: tradeNo, Status: payment.ProviderStatusPending, }, nil } return nil, fmt.Errorf("alipay TradeQuery: %w", err) } status := payment.ProviderStatusPending switch result.TradeStatus { case alipay.TradeStatusSuccess, alipay.TradeStatusFinished: status = payment.ProviderStatusPaid case alipay.TradeStatusClosed: status = payment.ProviderStatusFailed } amount, err := strconv.ParseFloat(result.TotalAmount, 64) if err != nil { amount, err = parseAlipayAmount( result.TotalAmount, result.ReceiptAmount, result.BuyerPayAmount, result.InvoiceAmount, ) if err != nil { return nil, fmt.Errorf("alipay parse amount: %w", err) } } return &payment.QueryOrderResponse{ TradeNo: result.TradeNo, Status: status, Amount: amount, PaidAt: result.SendPayDate, Metadata: a.MerchantIdentityMetadata(), }, nil } // VerifyNotification decodes and verifies an Alipay async notification. func (a *Alipay) VerifyNotification(ctx context.Context, rawBody string, _ map[string]string) (*payment.PaymentNotification, error) { client, err := a.getClient() if err != nil { return nil, err } values, err := url.ParseQuery(rawBody) if err != nil { return nil, fmt.Errorf("alipay parse notification: %w", err) } notification, err := client.DecodeNotification(ctx, values) if err != nil { return nil, fmt.Errorf("alipay verify notification: %w", err) } status := payment.ProviderStatusFailed if notification.TradeStatus == alipay.TradeStatusSuccess || notification.TradeStatus == alipay.TradeStatusFinished { status = payment.ProviderStatusSuccess } amount, err := strconv.ParseFloat(notification.TotalAmount, 64) if err != nil { amount, err = parseAlipayAmount( notification.TotalAmount, notification.ReceiptAmount, notification.BuyerPayAmount, ) if err != nil { return nil, fmt.Errorf("alipay parse notification amount: %w", err) } } metadata := a.MerchantIdentityMetadata() if appID := strings.TrimSpace(notification.AppId); appID != "" { if metadata == nil { metadata = map[string]string{} } metadata["app_id"] = appID } return &payment.PaymentNotification{ TradeNo: notification.TradeNo, OrderID: notification.OutTradeNo, Amount: amount, Status: status, RawData: rawBody, Metadata: metadata, }, nil } // Refund requests a refund through Alipay. func (a *Alipay) Refund(ctx context.Context, req payment.RefundRequest) (*payment.RefundResponse, error) { client, err := a.getClient() if err != nil { return nil, err } result, err := client.TradeRefund(ctx, alipay.TradeRefund{ OutTradeNo: req.OrderID, RefundAmount: req.Amount, RefundReason: req.Reason, OutRequestNo: fmt.Sprintf("%s-refund-%d", req.OrderID, time.Now().UnixNano()), }) if err != nil { return nil, fmt.Errorf("alipay TradeRefund: %w", err) } refundStatus := payment.ProviderStatusPending if result.FundChange == alipayFundChangeYes { refundStatus = payment.ProviderStatusSuccess } refundID := result.TradeNo if refundID == "" { refundID = req.OrderID + alipayRefundSuffix } return &payment.RefundResponse{ RefundID: refundID, Status: refundStatus, }, nil } // CancelPayment closes a pending trade on Alipay. func (a *Alipay) CancelPayment(ctx context.Context, tradeNo string) error { client, err := a.getClient() if err != nil { return err } _, err = client.TradeClose(ctx, alipay.TradeClose{OutTradeNo: tradeNo}) if err != nil { if isTradeNotExist(err) { return nil } return fmt.Errorf("alipay TradeClose: %w", err) } return nil } func isTradeNotExist(err error) bool { if err == nil { return false } return strings.Contains(err.Error(), alipayErrTradeNotExist) } func parseAlipayAmount(values ...string) (float64, error) { for _, raw := range values { raw = strings.TrimSpace(raw) if raw == "" { continue } amount, err := strconv.ParseFloat(raw, 64) if err == nil { return amount, nil } } return 0, fmt.Errorf("no valid amount field") } // Ensure interface compliance. var ( _ payment.Provider = (*Alipay)(nil) _ payment.CancelableProvider = (*Alipay)(nil) _ payment.MerchantIdentityProvider = (*Alipay)(nil) )