package payment import ( "context" "encoding/json" "errors" "fmt" "math" "strconv" "strings" "kra/app/system/internal/biz" "github.com/go-pay/gopay" "github.com/go-pay/gopay/paypal" ) type paypalAdapter struct{} func (a *paypalAdapter) client(c map[string]any) (*paypal.Client, error) { environment := strings.ToLower(strings.TrimSpace(text(c, "environment"))) isProd := environment != "sandbox" options := []paypal.Option{paypal.WithoutAutoRefreshToken()} if baseURL := strings.TrimRight(firstAny(c, "api_base_url", "base_url", "proxy_url"), "/"); baseURL != "" { options = append(options, paypal.WithProxyUrl(baseURL, baseURL)) } client, err := paypal.NewClient(text(c, "client_id"), text(c, "client_secret"), isProd, options...) if err != nil { return nil, fmt.Errorf("PayPal 客户端初始化失败: %w", err) } client.SetRequestHeader("Prefer", "return=representation") return client, nil } func (a *paypalAdapter) Create(ctx context.Context, req *biz.PaymentRequest, c map[string]any) (*biz.PaymentResult, error) { if req == nil { return nil, errors.New("PayPal 下单参数为空") } client, err := a.client(c) if err != nil { return nil, err } currency := strings.ToUpper(strings.TrimSpace(req.Currency)) value, err := paypalFormatAmount(req.Amount, currency, c) if err != nil { return nil, err } purchaseUnit := &paypal.PurchaseUnit{ ReferenceId: req.TradeNo, Description: req.Subject, CustomId: req.TradeNo, InvoiceId: req.TradeNo, Amount: &paypal.Amount{CurrencyCode: currency, Value: value}, } bm := gopay.BodyMap{ "intent": "CAPTURE", "purchase_units": []*paypal.PurchaseUnit{purchaseUnit}, } if applicationContext := paypalApplicationContext(req, c); len(applicationContext) > 0 { bm["application_context"] = applicationContext } mergeGoPayExtras(bm, req.Extra, "intent", "purchase_units", "application_context") client.SetRequestHeader("PayPal-Request-Id", req.TradeNo) rsp, err := client.CreateOrder(ctx, bm) if err != nil { return nil, err } if rsp == nil || rsp.Code != paypal.Success || rsp.Response == nil { return nil, paypalResponseError("下单", paypalResponseCode(rsp), paypalCreateError(rsp)) } order := rsp.Response if strings.TrimSpace(order.Id) == "" { return nil, errors.New("PayPal 下单成功但缺少 order ID") } return paypalCreateResult(req, order, c) } func (a *paypalAdapter) Query(ctx context.Context, orderID string, c map[string]any) (*biz.PaymentResult, error) { orderID = strings.TrimSpace(orderID) if orderID == "" { return nil, errors.New("PayPal 查单缺少 order ID") } client, err := a.client(c) if err != nil { return nil, err } rsp, err := client.OrderDetail(ctx, orderID, nil) if err != nil { return nil, err } if rsp == nil || rsp.Code != paypal.Success || rsp.Response == nil { return nil, paypalResponseError("查单", paypalResponseCode(rsp), paypalOrderError(rsp)) } if rsp.Response.Id != "" && rsp.Response.Id != orderID { return nil, errors.New("PayPal 查单响应的 order ID 不匹配") } order := rsp.Response if _, err = paypalOrderIdentity(order); err != nil { return nil, err } if paypalAutoCaptureEnabled(c) && strings.EqualFold(strings.TrimSpace(order.Status), "APPROVED") { order, err = paypalCaptureOrder(ctx, client, orderID, c) if err != nil { return nil, err } } return paypalOrderResult(order, orderID, "", c) } // paypalCaptureOrder completes an order after the buyer approves it. PayPal's // CAPTURE intent deliberately separates approval from settlement; treating an // APPROVED order as paid would allow fulfillment before funds are captured. func paypalCaptureOrder(ctx context.Context, client *paypal.Client, orderID string, c map[string]any) (*paypal.OrderDetail, error) { if client == nil || strings.TrimSpace(orderID) == "" { return nil, errors.New("PayPal 捕获订单参数为空") } bm := gopay.BodyMap{} mergeGoPayConfigExtras(bm, c, "capture_extra") client.SetRequestHeader("PayPal-Request-Id", strings.TrimSpace(orderID)+"-capture") rsp, err := client.OrderCapture(ctx, orderID, bm) if err != nil { return nil, err } if rsp == nil || rsp.Code != paypal.Success || rsp.Response == nil { return nil, paypalResponseError("捕获订单", paypalResponseCode(rsp), paypalCaptureError(rsp)) } if returnedID := strings.TrimSpace(rsp.Response.Id); returnedID != "" && returnedID != strings.TrimSpace(orderID) { return nil, errors.New("PayPal 捕获响应的 order ID 不匹配") } return rsp.Response, nil } func paypalAutoCaptureEnabled(c map[string]any) bool { value, ok := c["auto_capture"] if !ok || value == nil { return true } switch parsed := value.(type) { case bool: return parsed case string: if result, err := strconv.ParseBool(strings.TrimSpace(parsed)); err == nil { return result } case json.Number: return parsed != "0" case float64: return parsed != 0 case int: return parsed != 0 case int64: return parsed != 0 } return true } func (a *paypalAdapter) Refund(ctx context.Context, req *biz.PaymentRefundRequest, c map[string]any) (*biz.PaymentResult, error) { if req == nil { return nil, errors.New("PayPal 退款请求为空") } tradeNo := strings.TrimSpace(req.TradeNo) refundNo := strings.TrimSpace(req.RefundNo) if tradeNo == "" || refundNo == "" || req.Amount <= 0 { return nil, errors.New("PayPal 退款缺少商户订单号、退款单号或有效金额") } effective := *req effective.TradeNo = tradeNo effective.RefundNo = refundNo req = &effective client, err := a.client(c) if err != nil { return nil, err } captureID, err := paypalRefundCaptureID(ctx, client, req) if err != nil { return nil, err } currency := strings.ToUpper(strings.TrimSpace(req.Currency)) value, err := paypalFormatAmount(req.Amount, currency, c) if err != nil { return nil, err } bm := gopay.BodyMap{ "invoice_id": refundNo, "amount": &paypal.Amount{ CurrencyCode: currency, Value: value, }, } if note := strings.TrimSpace(text(c, "refund_note")); note != "" { bm["note_to_payer"] = note } mergeGoPayConfigExtras(bm, c, "refund_extra", "amount", "invoice_id", "capture_id", "order_id", "query_id", "provider_trade_no") client.SetRequestHeader("PayPal-Request-Id", refundNo) rsp, err := client.PaymentCaptureRefund(ctx, captureID, bm) if err != nil { return nil, err } if rsp == nil || rsp.Code != paypal.Success || rsp.Response == nil { return nil, paypalResponseError("退款", paypalResponseCode(rsp), paypalRefundError(rsp)) } return paypalRefundResult(req, rsp.Response, c) } func paypalRefundResult(req *biz.PaymentRefundRequest, refund *paypal.PaymentCaptureRefund, c map[string]any) (*biz.PaymentResult, error) { if req == nil || refund == nil { return nil, errors.New("PayPal 退款响应为空") } if invoiceID := strings.TrimSpace(refund.InvoiceId); invoiceID == "" { return nil, errors.New("PayPal 退款响应缺少 invoice_id") } else if invoiceID != strings.TrimSpace(req.RefundNo) { return nil, errors.New("PayPal 退款响应的 invoice_id 不匹配") } providerRefundID := strings.TrimSpace(refund.Id) if providerRefundID == "" { return nil, errors.New("PayPal 退款响应缺少 refund ID") } currency := strings.ToUpper(strings.TrimSpace(req.Currency)) result := &biz.PaymentResult{ Provider: biz.PaymentPayPal, Status: normalizePayPalRefundState(refund.Status), TradeNo: req.TradeNo, ProviderTradeNo: providerRefundID, Amount: req.Amount, Currency: currency, Payload: mustJSON(refund), } if refund.Amount == nil { return nil, errors.New("PayPal 退款响应缺少金额") } parsed, err := paypalParseAmount(refund.Amount, c) if err != nil { return nil, err } if parsed != req.Amount { return nil, errors.New("PayPal 退款响应金额不匹配") } responseCurrency := strings.ToUpper(strings.TrimSpace(refund.Amount.CurrencyCode)) if responseCurrency == "" { return nil, errors.New("PayPal 退款响应缺少币种") } if currency != "" && responseCurrency != currency { return nil, errors.New("PayPal 退款响应币种不匹配") } result.Amount = parsed result.Currency = responseCurrency return result, nil } func paypalCreateResult(req *biz.PaymentRequest, order *paypal.OrderDetail, c map[string]any) (*biz.PaymentResult, error) { if req == nil || order == nil { return nil, errors.New("PayPal 下单响应为空") } tradeNo, err := paypalOrderIdentity(order) if err != nil { return nil, err } if tradeNo != "" && tradeNo != strings.TrimSpace(req.TradeNo) { return nil, errors.New("PayPal 下单响应的商户订单号不匹配") } amount, currency, found, err := paypalOrderAmount(order, c) if err != nil { return nil, err } if found { if amount != req.Amount { return nil, errors.New("PayPal 下单响应金额不匹配") } if expected := strings.ToUpper(strings.TrimSpace(req.Currency)); currency == "" || currency != expected { return nil, errors.New("PayPal 下单响应币种不匹配") } } result, err := paypalOrderResult(order, order.Id, req.TradeNo, c) if err != nil { return nil, err } result.TradeNo = req.TradeNo return result, nil } func (a *paypalAdapter) Callback(ctx context.Context, callback *biz.PaymentCallback, c map[string]any) (*biz.PaymentResult, error) { if callback == nil || len(callback.Body) == 0 { return nil, errors.New("PayPal 回调为空") } webhookID := strings.TrimSpace(text(c, "webhook_id")) if webhookID == "" { return nil, errors.New("PayPal 回调验签缺少 webhook_id") } var event paypal.WebhookEvent if err := json.Unmarshal(callback.Body, &event); err != nil { return nil, fmt.Errorf("PayPal 回调 JSON 无效: %w", err) } if strings.TrimSpace(event.Id) == "" || len(event.Resource) == 0 { return nil, errors.New("PayPal 回调事件不完整") } var webhookEvent any if err := json.Unmarshal(callback.Body, &webhookEvent); err != nil { return nil, err } verifyBody := gopay.BodyMap{ "auth_algo": paypalCallbackHeader(callback, "Paypal-Auth-Algo"), "cert_url": paypalCallbackHeader(callback, "Paypal-Cert-Url"), "transmission_id": paypalCallbackHeader(callback, "Paypal-Transmission-Id"), "transmission_sig": paypalCallbackHeader(callback, "Paypal-Transmission-Sig"), "transmission_time": paypalCallbackHeader(callback, "Paypal-Transmission-Time"), "webhook_id": webhookID, "webhook_event": webhookEvent, } client, err := a.client(c) if err != nil { return nil, err } verified, err := client.VerifyWebhookSignature(ctx, verifyBody) if err != nil { return nil, err } if verified == nil || !strings.EqualFold(verified.VerificationStatus, "SUCCESS") { return nil, errors.New("PayPal 回调签名校验失败") } result, err := paypalWebhookResult(&event) if err != nil { return nil, err } result.Payload = append([]byte(nil), callback.Body...) result.EventID = event.Id return result, nil } func paypalApplicationContext(req *biz.PaymentRequest, c map[string]any) map[string]any { applicationContext := map[string]any{} if configured, ok := req.Extra["application_context"].(map[string]any); ok { for key, value := range configured { applicationContext[key] = value } } for _, key := range []string{"brand_name", "locale", "landing_page", "shipping_preference", "user_action"} { if _, exists := applicationContext[key]; !exists { if value := strings.TrimSpace(text(c, key)); value != "" { applicationContext[key] = value } } } if returnURL := strings.TrimSpace(req.ReturnURL); returnURL != "" { applicationContext["return_url"] = returnURL } cancelURL := firstAny(c, "cancel_url", "return_url") if cancelURL == "" { cancelURL = strings.TrimSpace(req.ReturnURL) } if cancelURL != "" { applicationContext["cancel_url"] = cancelURL } return applicationContext } func paypalOrderResult(order *paypal.OrderDetail, queryID, fallbackTradeNo string, c map[string]any) (*biz.PaymentResult, error) { if order == nil { return nil, errors.New("PayPal 订单结果为空") } tradeNo, err := paypalOrderIdentity(order) if err != nil { return nil, err } if tradeNo == "" { tradeNo = fallbackTradeNo } capture := paypalOrderCapture(order) result := &biz.PaymentResult{ Provider: biz.PaymentPayPal, Status: paypalOrderCaptureState(order), TradeNo: tradeNo, QueryID: queryID, Payload: mustJSON(order), } if capture != nil { result.ProviderTradeNo = capture.Id } amount, currency, found, err := paypalOrderAmount(order, c) if err != nil { return nil, err } if found { result.Amount = amount result.Currency = currency } return result, nil } func paypalOrderTradeNo(order *paypal.OrderDetail) string { tradeNo, _ := paypalOrderIdentity(order) return tradeNo } func paypalOrderIdentity(order *paypal.OrderDetail) (string, error) { if order == nil { return "", nil } identity := "" for _, unit := range order.PurchaseUnits { if unit == nil { continue } for _, value := range []string{unit.InvoiceId, unit.CustomId, unit.ReferenceId} { value = strings.TrimSpace(value) if value == "" { continue } if identity == "" { identity = value continue } if value != identity { return "", errors.New("PayPal 订单响应包含冲突的商户订单号") } } } return identity, nil } func paypalOrderCapture(order *paypal.OrderDetail) *paypal.Capture { if order == nil { return nil } for _, unit := range order.PurchaseUnits { if unit == nil || unit.Payments == nil { continue } for _, capture := range unit.Payments.Captures { if capture == nil || strings.TrimSpace(capture.Id) == "" { continue } if strings.EqualFold(capture.Status, "COMPLETED") { return capture } } } return nil } func paypalOrderCaptureState(order *paypal.OrderDetail) string { if order == nil { return "pending" } pending, failed, seen := false, false, false for _, unit := range order.PurchaseUnits { if unit == nil || unit.Payments == nil { continue } for _, capture := range unit.Payments.Captures { if capture == nil || strings.TrimSpace(capture.Id) == "" { continue } seen = true switch normalizePayPalCaptureState(capture.Status) { case "success": return "success" case "pending": pending = true case "failed": failed = true } } } if pending || !seen { if strings.EqualFold(order.Status, "VOIDED") { return "failed" } return "pending" } if failed { return "failed" } return "pending" } func paypalOrderAmount(order *paypal.OrderDetail, c map[string]any) (int64, string, bool, error) { var total int64 var currency string found := false for _, unit := range order.PurchaseUnits { if unit == nil || unit.Amount == nil || strings.TrimSpace(unit.Amount.Value) == "" { continue } unitCurrency := strings.ToUpper(strings.TrimSpace(unit.Amount.CurrencyCode)) amount, err := paypalParseAmount(unit.Amount, c) if err != nil { return 0, "", false, err } if found && currency != unitCurrency { return 0, "", false, errors.New("PayPal 订单包含不同币种的 purchase unit") } if amount > math.MaxInt64-total { return 0, "", false, errors.New("PayPal 订单金额超出范围") } currency = unitCurrency total += amount found = true } return total, currency, found, nil } func paypalRefundCaptureID(ctx context.Context, client *paypal.Client, req *biz.PaymentRefundRequest) (string, error) { if req == nil { return "", errors.New("PayPal 退款请求为空") } if captureID := strings.TrimSpace(req.ProviderTradeNo); captureID != "" { return captureID, nil } orderID := strings.TrimSpace(req.QueryID) if orderID == "" { return "", errors.New("PayPal 退款缺少已持久化的 capture ID") } rsp, err := client.OrderDetail(ctx, orderID, nil) if err != nil { return "", err } if rsp == nil || rsp.Code != paypal.Success || rsp.Response == nil { return "", paypalResponseError("退款前查单", paypalResponseCode(rsp), paypalOrderError(rsp)) } if rsp.Response.Id != "" && rsp.Response.Id != orderID { return "", errors.New("PayPal 退款查单响应的 order ID 不匹配") } returnedTradeNo, identityErr := paypalOrderIdentity(rsp.Response) if identityErr != nil { return "", identityErr } if returnedTradeNo == "" || returnedTradeNo != req.TradeNo { return "", errors.New("PayPal 退款订单与本地商户订单号不匹配") } capture := paypalOrderCapture(rsp.Response) if capture == nil || strings.TrimSpace(capture.Id) == "" { return "", errors.New("PayPal 退款订单缺少 capture ID") } return capture.Id, nil } func paypalWebhookResult(event *paypal.WebhookEvent) (*biz.PaymentResult, error) { var resource map[string]any if err := json.Unmarshal(event.Resource, &resource); err != nil { return nil, fmt.Errorf("PayPal 回调 resource 无效: %w", err) } eventType := strings.ToUpper(strings.TrimSpace(event.EventType)) tradeNo := firstNonEmpty(paypalMapString(resource, "invoice_id"), paypalMapString(resource, "custom_id")) queryID := paypalNestedMapString(resource, "supplementary_data", "related_ids", "order_id") providerTradeNo := "" status := normalizePayPalCaptureState(paypalMapString(resource, "status")) if strings.HasPrefix(eventType, "CHECKOUT.ORDER.") { var order paypal.OrderDetail if err := json.Unmarshal(event.Resource, &order); err != nil { return nil, err } identity, identityErr := paypalOrderIdentity(&order) if identityErr != nil { return nil, identityErr } tradeNo = identity queryID = order.Id if capture := paypalOrderCapture(&order); capture != nil { providerTradeNo = capture.Id } status = paypalOrderCaptureState(&order) } else { providerTradeNo = paypalMapString(resource, "id") } if tradeNo == "" { tradeNo = paypalPurchaseUnitTradeNo(resource) } if tradeNo == "" || queryID == "" { return nil, errors.New("PayPal 回调缺少商户订单号或 order ID") } return &biz.PaymentResult{ Provider: biz.PaymentPayPal, Status: status, TradeNo: tradeNo, ProviderTradeNo: providerTradeNo, QueryID: queryID, }, nil } func paypalPurchaseUnitTradeNo(resource map[string]any) string { units, _ := resource["purchase_units"].([]any) for _, value := range units { unit, _ := value.(map[string]any) if tradeNo := firstNonEmpty(paypalMapString(unit, "invoice_id"), paypalMapString(unit, "custom_id"), paypalMapString(unit, "reference_id")); tradeNo != "" { return tradeNo } } return "" } func paypalCallbackHeader(callback *biz.PaymentCallback, name string) string { for key, value := range callback.Headers { if strings.EqualFold(key, name) { return strings.TrimSpace(value) } } return "" } func paypalFormatAmount(amount int64, currency string, c map[string]any) (string, error) { scale, err := paypalAmountScale(currency, c) if err != nil { return "", err } return formatDecimalAmount(amount, scale) } func paypalParseAmount(amount *paypal.Amount, c map[string]any) (int64, error) { if amount == nil { return 0, errors.New("PayPal 金额为空") } scale, err := paypalAmountScale(amount.CurrencyCode, c) if err != nil { return 0, err } return parseDecimalAmount(amount.Value, scale) } func paypalAmountScale(currency string, c map[string]any) (int64, error) { currency = strings.ToUpper(strings.TrimSpace(currency)) for _, key := range []string{"amount_scales", "currency_scales"} { if scales, ok := c[key].(map[string]any); ok { for configuredCurrency, raw := range scales { if !strings.EqualFold(configuredCurrency, currency) { continue } scale, err := strconv.ParseInt(strings.TrimSpace(fmt.Sprint(raw)), 10, 64) if err != nil || scale <= 0 { return 0, fmt.Errorf("PayPal %s 金额换算比例无效", currency) } if _, err = formatDecimalAmount(scale, scale); err != nil { return 0, fmt.Errorf("PayPal %s 金额换算比例无效: %w", currency, err) } return scale, nil } } } if scale := configuredInt64(c, "amount_scale", 0); scale > 0 { if _, err := formatDecimalAmount(scale, scale); err != nil { return 0, fmt.Errorf("PayPal 金额换算比例无效: %w", err) } return scale, nil } switch currency { case "HUF", "JPY", "TWD": return 1, nil default: return 100, nil } } func normalizePayPalOrderState(state string) string { switch strings.ToUpper(strings.TrimSpace(state)) { case "COMPLETED": return "success" case "VOIDED": return "failed" default: return "pending" } } func normalizePayPalCaptureState(state string) string { switch strings.ToUpper(strings.TrimSpace(state)) { case "COMPLETED", "PARTIALLY_REFUNDED", "REFUNDED": return "success" case "DECLINED", "FAILED", "DENIED", "VOIDED": return "failed" default: return "pending" } } func normalizePayPalRefundState(state string) string { switch strings.ToUpper(strings.TrimSpace(state)) { case "COMPLETED": return "success" case "CANCELLED", "FAILED": return "failed" default: return "pending" } } func paypalMapString(values map[string]any, key string) string { value := values[key] if value == nil { return "" } return strings.TrimSpace(fmt.Sprint(value)) } func paypalNestedMapString(values map[string]any, keys ...string) string { var current any = values for _, key := range keys { object, ok := current.(map[string]any) if !ok { return "" } current = object[key] } if current == nil { return "" } return strings.TrimSpace(fmt.Sprint(current)) } func firstNonEmpty(values ...string) string { for _, value := range values { if value = strings.TrimSpace(value); value != "" && value != "" { return value } } return "" } func paypalResponseError(operation string, code int, detail string) error { detail = strings.TrimSpace(detail) if detail == "" { detail = "未知错误" } if code == 0 { return fmt.Errorf("PayPal %s失败: %s", operation, detail) } return fmt.Errorf("PayPal %s失败(%d): %s", operation, code, detail) } func paypalResponseCode(value any) int { switch rsp := value.(type) { case *paypal.CreateOrderRsp: if rsp != nil { return rsp.Code } case *paypal.OrderDetailRsp: if rsp != nil { return rsp.Code } case *paypal.PaymentCaptureRefundRsp: if rsp != nil { return rsp.Code } } return 0 } func paypalCreateError(rsp *paypal.CreateOrderRsp) string { if rsp == nil { return "响应为空" } return paypalErrorDetail(rsp.Error, rsp.ErrorResponse) } func paypalOrderError(rsp *paypal.OrderDetailRsp) string { if rsp == nil { return "响应为空" } return paypalErrorDetail(rsp.Error, rsp.ErrorResponse) } func paypalCaptureError(rsp *paypal.OrderCaptureRsp) string { if rsp == nil { return "响应为空" } return paypalErrorDetail(rsp.Error, rsp.ErrorResponse) } func paypalRefundError(rsp *paypal.PaymentCaptureRefundRsp) string { if rsp == nil { return "响应为空" } return paypalErrorDetail(rsp.Error, rsp.ErrorResponse) } func paypalErrorDetail(raw string, response *paypal.ErrorResponse) string { if response != nil { if value := firstNonEmpty(response.Message, response.Name); value != "" { return value } } return raw }