diff --git a/backend/internal/handler/payment_webhook_handler.go b/backend/internal/handler/payment_webhook_handler.go index be987fbbcdd2..cee93cd2c22d 100644 --- a/backend/internal/handler/payment_webhook_handler.go +++ b/backend/internal/handler/payment_webhook_handler.go @@ -67,6 +67,12 @@ func (h *PaymentWebhookHandler) AirwallexWebhook(c *gin.Context) { h.handleNotify(c, payment.TypeAirwallex) } +// CreemWebhook 处理 Creem Webhook 事件。 +// POST /api/v1/payment/webhook/creem +func (h *PaymentWebhookHandler) CreemWebhook(c *gin.Context) { + h.handleNotify(c, payment.TypeCreem) +} + // handleNotify is the shared logic for all provider webhook handlers. func (h *PaymentWebhookHandler) handleNotify(c *gin.Context, providerKey string) { var rawBody string @@ -172,6 +178,21 @@ func extractOutTradeNo(rawBody, providerKey string) string { if err := json.Unmarshal([]byte(rawBody), &payload); err == nil { return strings.TrimSpace(payload.Data.Object.MerchantOrderID) } + case payment.TypeCreem: + var payload struct { + Object struct { + RequestID string `json:"request_id"` + } `json:"object"` + Data struct { + RequestID string `json:"request_id"` + } `json:"data"` + } + if err := json.Unmarshal([]byte(rawBody), &payload); err == nil { + if id := strings.TrimSpace(payload.Object.RequestID); id != "" { + return id + } + return strings.TrimSpace(payload.Data.RequestID) + } } // For other providers (Stripe, Alipay direct, WxPay direct), the registry // typically has only one instance, so no instance lookup is needed. @@ -214,12 +235,12 @@ const ( // writeSuccessResponse 返回各支付服务商要求的成功响应。 // 微信支付需要 JSON {"code":"SUCCESS","message":"成功"}; -// Stripe 和空中云汇接受空 200,其它服务商接受纯文本 "success"。 +// Stripe、空中云汇和 Creem 接受空 200,其它服务商接受纯文本 "success"。 func writeSuccessResponse(c *gin.Context, providerKey string) { switch providerKey { case payment.TypeWxpay: c.JSON(http.StatusOK, wxpaySuccessResponse{Code: wxpaySuccessCode, Message: wxpaySuccessMessage}) - case payment.TypeStripe, payment.TypeAirwallex: + case payment.TypeStripe, payment.TypeAirwallex, payment.TypeCreem: c.String(http.StatusOK, "") default: c.String(http.StatusOK, "success") diff --git a/backend/internal/handler/payment_webhook_handler_test.go b/backend/internal/handler/payment_webhook_handler_test.go index 26f5a465485f..12e15090abf3 100644 --- a/backend/internal/handler/payment_webhook_handler_test.go +++ b/backend/internal/handler/payment_webhook_handler_test.go @@ -54,6 +54,13 @@ func TestWriteSuccessResponse(t *testing.T) { wantContentType: "text/plain", wantBody: "", }, + { + name: "creem returns empty 200", + providerKey: payment.TypeCreem, + wantCode: http.StatusOK, + wantContentType: "text/plain", + wantBody: "", + }, { name: "easypay returns plain text success", providerKey: "easypay", @@ -178,6 +185,18 @@ func TestExtractOutTradeNo(t *testing.T) { rawBody: `{"name":"payment_intent.succeeded","data":{"object":{"merchant_order_id":"sub2_awx_123"}}}`, want: "sub2_awx_123", }, + { + name: "creem checkout.completed payload", + providerKey: payment.TypeCreem, + rawBody: `{"eventType":"checkout.completed","object":{"id":"ch_1","request_id":"sub2_creem_1"}}`, + want: "sub2_creem_1", + }, + { + name: "creem type/data envelope", + providerKey: payment.TypeCreem, + rawBody: `{"type":"checkout.completed","data":{"id":"ch_1","request_id":"sub2_creem_2"}}`, + want: "sub2_creem_2", + }, } for _, tt := range tests { diff --git a/backend/internal/payment/currency.go b/backend/internal/payment/currency.go index 53ba608b9a04..25a2d1ff3a17 100644 --- a/backend/internal/payment/currency.go +++ b/backend/internal/payment/currency.go @@ -64,6 +64,22 @@ func NormalizePaymentCurrency(raw string) (string, error) { return currency, nil } +// CreemDefaultCurrency 是 Creem 实例未配置币种时使用的币种。Creem 商品只支持 USD 和 EUR, +// 不能沿用全局默认的 CNY。 +const CreemDefaultCurrency = "USD" + +// NormalizeCreemCurrency 校验 Creem 实例币种,空值回落到 USD。 +func NormalizeCreemCurrency(raw string) (string, error) { + currency := strings.ToUpper(strings.TrimSpace(raw)) + if currency == "" { + return CreemDefaultCurrency, nil + } + if currency != "USD" && currency != "EUR" { + return "", fmt.Errorf("creem currency must be USD or EUR") + } + return currency, nil +} + func CurrencyMinorUnit(currency string) int { return paymentCurrencyAmountUnitFor(currency).apiMinorUnit } diff --git a/backend/internal/payment/provider/creem.go b/backend/internal/payment/provider/creem.go new file mode 100644 index 000000000000..838ee4d70538 --- /dev/null +++ b/backend/internal/payment/provider/creem.go @@ -0,0 +1,688 @@ +package provider + +import ( + "bytes" + "context" + "crypto/hmac" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "net" + "net/http" + "net/url" + "strconv" + "strings" + "sync" + "time" + + "github.com/Wei-Shaw/sub2api/internal/payment" + "github.com/shopspring/decimal" +) + +const ( + creemProdAPIBase = "https://api.creem.io" + creemTestAPIBase = "https://test-api.creem.io" + creemTestKeyPrefix = "creem_test_" + creemHTTPTimeout = 15 * time.Second + creemMaxResponseSize = 1 << 20 + creemMaxErrorSummary = 512 + creemWebhookTolerance = 5 * time.Minute + creemProductCacheTTL = 10 * time.Minute + creemMinCustomPriceCent = 100 + creemMaxCustomPriceCent = 99999999 + + creemEventCheckoutCompleted = "checkout.completed" + + creemOrderStatusPaid = "paid" + + creemTransactionStatusRefunded = "refunded" + + creemRefundStatusSucceeded = "succeeded" + creemRefundStatusPending = "pending" + creemRefundStatusRequiresAction = "requiresaction" + creemRefundStatusFailed = "failed" + creemRefundStatusCanceled = "canceled" +) + +// Creem 是 Merchant of Record 收款服务商。充值金额不固定,所以管理员在 Creem 后台建一个 +// 一次性(onetime)商品,每笔订单用 custom_price 覆盖单价,用户跳转到 Creem 托管收银台付款。 +type Creem struct { + instanceID string + config map[string]string + httpClient *http.Client +} + +type creemProductCacheEntry struct { + product creemProduct + expiresAt time.Time +} + +var creemProductCache sync.Map + +// 退款发起后复查交易状态的次数和间隔;测试里调小。 +var ( + creemRefundRecheckAttempts = 3 + creemRefundRecheckInterval = time.Second +) + +func NewCreem(instanceID string, config map[string]string) (*Creem, error) { + for _, k := range []string{"apiKey", "webhookSecret", "productId"} { + if strings.TrimSpace(config[k]) == "" { + return nil, fmt.Errorf("creem config missing required key: %s", k) + } + } + cfg := cloneStringMap(config) + cfg["apiKey"] = strings.TrimSpace(cfg["apiKey"]) + cfg["productId"] = strings.TrimSpace(cfg["productId"]) + apiBase, err := normalizeCreemAPIBase(cfg["apiBase"], cfg["apiKey"]) + if err != nil { + return nil, err + } + cfg["apiBase"] = apiBase + currency, err := payment.NormalizeCreemCurrency(cfg["currency"]) + if err != nil { + return nil, fmt.Errorf("creem config currency: %w", err) + } + cfg["currency"] = currency + return &Creem{ + instanceID: instanceID, + config: cfg, + httpClient: &http.Client{Timeout: creemHTTPTimeout}, + }, nil +} + +// normalizeCreemAPIBase 未填写时按 API Key 前缀自动选择环境:creem_test_ 走测试环境。 +func normalizeCreemAPIBase(raw, apiKey string) (string, error) { + base := strings.TrimSpace(raw) + if base == "" { + if strings.HasPrefix(apiKey, creemTestKeyPrefix) { + return creemTestAPIBase, nil + } + return creemProdAPIBase, nil + } + parsed, err := url.Parse(base) + if err != nil || parsed.Scheme != "https" || parsed.Host == "" { + return "", fmt.Errorf("creem apiBase must be an HTTPS URL") + } + host := strings.ToLower(parsed.Host) + if host != "api.creem.io" && host != "test-api.creem.io" { + return "", fmt.Errorf("creem apiBase host must be api.creem.io or test-api.creem.io") + } + path := strings.TrimRight(parsed.Path, "/") + if path != "" && path != "/v1" { + return "", fmt.Errorf("creem apiBase path must be empty or /v1") + } + isTestKey := strings.HasPrefix(apiKey, creemTestKeyPrefix) + if isTestKey != (host == "test-api.creem.io") { + return "", fmt.Errorf("creem apiBase does not match the API key environment: test keys (creem_test_) use https://test-api.creem.io, live keys use https://api.creem.io") + } + return "https://" + host, nil +} + +func (c *Creem) Name() string { return "Creem" } +func (c *Creem) ProviderKey() string { return payment.TypeCreem } +func (c *Creem) SupportedTypes() []payment.PaymentType { + return []payment.PaymentType{payment.TypeCreem} +} + +// RefundsLatePayments 返回 true:Creem 没有作废 checkout 的接口,订单过期后链接仍能付款, +// 这种付款由服务层自动全额退回。 +func (c *Creem) RefundsLatePayments() bool { return true } + +func (c *Creem) MerchantIdentityMetadata() map[string]string { + if c == nil { + return nil + } + return map[string]string{"currency": c.currency()} +} + +func (c *Creem) currency() string { + if c == nil { + return payment.CreemDefaultCurrency + } + currency, err := payment.NormalizeCreemCurrency(c.config["currency"]) + if err != nil { + return payment.CreemDefaultCurrency + } + return currency +} + +func (c *Creem) CreatePayment(ctx context.Context, req payment.CreatePaymentRequest) (*payment.CreatePaymentResponse, error) { + cents, err := creemAmountToCents(req.Amount) + if err != nil { + return nil, fmt.Errorf("creem create payment: %w", err) + } + if cents < creemMinCustomPriceCent || cents > creemMaxCustomPriceCent { + return nil, fmt.Errorf("creem create payment: amount %s is outside Creem's custom price range (1.00 - 999999.99)", req.Amount) + } + if strings.TrimSpace(req.OrderID) == "" { + return nil, fmt.Errorf("creem create payment: missing order id") + } + if _, err := c.product(ctx); err != nil { + return nil, fmt.Errorf("creem create payment: %w", err) + } + + payload := creemCreateCheckoutRequest{ + RequestID: req.OrderID, + ProductID: c.config["productId"], + Units: 1, + CustomPrice: cents, + SuccessURL: creemSuccessURL(req.ReturnURL), + Metadata: map[string]string{"order_id": req.OrderID}, + } + var checkout creemCheckout + if err := c.doJSON(ctx, http.MethodPost, "/v1/checkouts", payload, &checkout); err != nil { + return nil, fmt.Errorf("creem create payment: %w", err) + } + if strings.TrimSpace(checkout.ID) == "" || strings.TrimSpace(checkout.CheckoutURL) == "" { + return nil, fmt.Errorf("creem create payment: missing checkout id or checkout_url") + } + return &payment.CreatePaymentResponse{ + TradeNo: checkout.ID, + PayURL: checkout.CheckoutURL, + Currency: c.currency(), + }, nil +} + +// product 读取并校验配置的商品:必须是启用中的一次性商品、币种与实例一致、价格含税。 +// 价外税(exclusive)会让买家实付高于订单金额,履约时的金额核对会失败,所以提前拒绝。 +func (c *Creem) product(ctx context.Context) (creemProduct, error) { + cacheKey := c.productCacheKey() + if raw, ok := creemProductCache.Load(cacheKey); ok { + if entry, ok := raw.(creemProductCacheEntry); ok && time.Now().Before(entry.expiresAt) { + return entry.product, nil + } + } + + var product creemProduct + if err := c.doJSON(ctx, http.MethodGet, "/v1/products/"+url.PathEscape(c.config["productId"]), nil, &product); err != nil { + return creemProduct{}, fmt.Errorf("load product %s: %w", c.config["productId"], err) + } + if !strings.EqualFold(product.Status, "active") { + return creemProduct{}, fmt.Errorf("product %s is not active (status %s)", product.ID, product.Status) + } + if !strings.EqualFold(product.BillingType, "onetime") { + return creemProduct{}, fmt.Errorf("product %s must be a one-time product, got billing_type %s", product.ID, product.BillingType) + } + if !strings.EqualFold(product.Currency, c.currency()) { + return creemProduct{}, fmt.Errorf("product %s currency %s does not match configured currency %s", product.ID, strings.ToUpper(product.Currency), c.currency()) + } + if strings.EqualFold(product.TaxMode, "exclusive") { + return creemProduct{}, fmt.Errorf("product %s uses tax_mode exclusive; set it to inclusive so the buyer pays exactly the order amount", product.ID) + } + creemProductCache.Store(cacheKey, creemProductCacheEntry{product: product, expiresAt: time.Now().Add(creemProductCacheTTL)}) + return product, nil +} + +func (c *Creem) productCacheKey() string { + sum := sha256.Sum256([]byte(c.config["apiKey"])) + return c.config["apiBase"] + "|" + c.config["productId"] + "|" + c.currency() + "|" + hex.EncodeToString(sum[:8]) +} + +func (c *Creem) QueryOrder(ctx context.Context, tradeNo string) (*payment.QueryOrderResponse, error) { + checkout, err := c.getCheckout(ctx, tradeNo) + if err != nil { + return nil, fmt.Errorf("creem query order: %w", err) + } + resp := &payment.QueryOrderResponse{ + TradeNo: checkout.ID, + Status: payment.ProviderStatusPending, + Metadata: c.checkoutMetadata(checkout), + } + if order := checkout.Order; order != nil { + resp.Amount = order.paidAmount() + if strings.EqualFold(order.Status, creemOrderStatusPaid) { + resp.Status = payment.ProviderStatusPaid + if t, err := time.Parse(time.RFC3339, strings.TrimSpace(order.UpdatedAt)); err == nil { + resp.PaidAt = t.Format(time.RFC3339) + } + } + } + return resp, nil +} + +func (c *Creem) getCheckout(ctx context.Context, checkoutID string) (*creemCheckout, error) { + checkoutID = strings.TrimSpace(checkoutID) + if checkoutID == "" { + return nil, fmt.Errorf("missing checkout id") + } + var checkout creemCheckout + if err := c.doJSON(ctx, http.MethodGet, "/v1/checkouts?checkout_id="+url.QueryEscape(checkoutID), nil, &checkout); err != nil { + return nil, err + } + if strings.TrimSpace(checkout.ID) == "" { + return nil, fmt.Errorf("checkout %s not found", checkoutID) + } + return &checkout, nil +} + +func (c *Creem) VerifyNotification(_ context.Context, rawBody string, headers map[string]string) (*payment.PaymentNotification, error) { + if err := verifyCreemWebhookSignature(rawBody, headers, c.config["webhookSecret"], time.Now()); err != nil { + return nil, err + } + + var event creemWebhookEvent + if err := json.Unmarshal([]byte(rawBody), &event); err != nil { + return nil, fmt.Errorf("creem parse webhook: %w", err) + } + if event.eventType() != creemEventCheckoutCompleted { + return nil, nil + } + + var checkout creemCheckout + if err := json.Unmarshal(event.object(), &checkout); err != nil { + return nil, fmt.Errorf("creem parse checkout: %w", err) + } + orderID := checkout.merchantOrderID() + if strings.TrimSpace(checkout.ID) == "" || orderID == "" { + return nil, fmt.Errorf("creem webhook missing checkout id or request_id") + } + if checkout.Order == nil { + return nil, fmt.Errorf("creem checkout.completed webhook missing order") + } + status := payment.ProviderStatusFailed + if strings.EqualFold(checkout.Order.Status, creemOrderStatusPaid) { + status = payment.NotificationStatusSuccess + } + + return &payment.PaymentNotification{ + TradeNo: checkout.ID, + OrderID: orderID, + Amount: checkout.Order.paidAmount(), + Status: status, + RawData: rawBody, + Metadata: c.checkoutMetadata(&checkout), + }, nil +} + +// Refund 只支持全额退款:Creem 的退款接口只接收 transaction_id,没有金额参数。 +func (c *Creem) Refund(ctx context.Context, req payment.RefundRequest) (*payment.RefundResponse, error) { + checkout, err := c.getCheckout(ctx, req.TradeNo) + if err != nil { + return nil, fmt.Errorf("creem refund: %w", err) + } + if checkout.Order == nil || strings.TrimSpace(checkout.Order.Transaction) == "" { + return nil, fmt.Errorf("creem refund: checkout %s has no paid transaction", checkout.ID) + } + requested, err := creemAmountToCents(req.Amount) + if err != nil { + return nil, fmt.Errorf("creem refund: %w", err) + } + if paid := checkout.Order.paidCents(); requested != paid { + return nil, fmt.Errorf("creem refund: only full refunds are supported (requested %s, paid %s)", + decimal.NewFromInt(requested).Shift(-2).StringFixed(2), decimal.NewFromInt(paid).Shift(-2).StringFixed(2)) + } + + transactionID := checkout.Order.Transaction + + // 幂等:Creem 对已全额退款的交易再发退款会返回 400(no remaining amount)。上一次退款若在 + // Creem 侧已成功、本地却因超时等原因回滚,重试时必须识别出来,而不是再失败一次。 + var transaction creemTransaction + if err := c.doJSON(ctx, http.MethodGet, "/v1/transactions?transaction_id="+url.QueryEscape(transactionID), nil, &transaction); err != nil { + return nil, fmt.Errorf("creem refund: load transaction %s: %w", transactionID, err) + } + if strings.EqualFold(transaction.Status, creemTransactionStatusRefunded) { + return &payment.RefundResponse{RefundID: transactionID, Status: payment.ProviderStatusSuccess}, nil + } + + // 退款接口的响应只有 status 一个字段,没有退款单 id,用交易 id 作为退款标识。 + var refund creemRefund + payload := creemCreateRefundRequest{TransactionID: transactionID} + if err := c.doJSON(ctx, http.MethodPost, "/v1/refunds", payload, &refund); err != nil { + return nil, fmt.Errorf("creem refund: %w", err) + } + status := creemRefundProviderStatus(refund.Status) + if status == payment.ProviderStatusPending && c.waitTransactionRefunded(ctx, transactionID) { + status = payment.ProviderStatusSuccess + } + return &payment.RefundResponse{ + RefundID: transactionID, + Status: status, + }, nil +} + +// waitTransactionRefunded 在发起退款后短暂复查交易状态。Creem 的退款接口总是先回 pending, +// 交易通常几秒内变成 refunded(实测 < 6s);复查几次可避免每笔退款都停在 REFUND_PENDING +// 等管理员手动查询。复查失败不影响已发起的退款,订单仍可通过 QueryRefund 收敛。 +func (c *Creem) waitTransactionRefunded(ctx context.Context, transactionID string) bool { + for i := 0; i < creemRefundRecheckAttempts; i++ { + select { + case <-ctx.Done(): + return false + case <-time.After(creemRefundRecheckInterval): + } + var tx creemTransaction + if err := c.doJSON(ctx, http.MethodGet, "/v1/transactions?transaction_id="+url.QueryEscape(transactionID), nil, &tx); err == nil && + strings.EqualFold(tx.Status, creemTransactionStatusRefunded) { + return true + } + } + return false +} + +// QueryRefund 让 REFUND_PENDING 订单收敛:Creem 退款没有退款单 id,按订单交易的状态判断, +// 交易变为 refunded 即退款完成,其余状态仍视为处理中。 +func (c *Creem) QueryRefund(ctx context.Context, req payment.RefundQueryRequest) (*payment.RefundResponse, error) { + checkout, err := c.getCheckout(ctx, req.TradeNo) + if err != nil { + return nil, fmt.Errorf("creem query refund: %w", err) + } + if checkout.Order == nil || strings.TrimSpace(checkout.Order.Transaction) == "" { + return nil, fmt.Errorf("creem query refund: checkout %s has no paid transaction", checkout.ID) + } + transactionID := checkout.Order.Transaction + var transaction creemTransaction + if err := c.doJSON(ctx, http.MethodGet, "/v1/transactions?transaction_id="+url.QueryEscape(transactionID), nil, &transaction); err != nil { + return nil, fmt.Errorf("creem query refund: load transaction %s: %w", transactionID, err) + } + status := payment.ProviderStatusPending + if strings.EqualFold(transaction.Status, creemTransactionStatusRefunded) { + status = payment.ProviderStatusSuccess + } + return &payment.RefundResponse{RefundID: transactionID, Status: status}, nil +} + +func (c *Creem) checkoutMetadata(checkout *creemCheckout) map[string]string { + metadata := map[string]string{} + if checkout == nil { + return metadata + } + if productID := checkout.productID(); productID != "" { + metadata["product_id"] = productID + } + if order := checkout.Order; order != nil { + metadata["currency"] = strings.ToUpper(strings.TrimSpace(order.Currency)) + metadata["status"] = strings.ToLower(strings.TrimSpace(order.Status)) + if order.ID != "" { + metadata["order_id"] = order.ID + } + if order.Transaction != "" { + metadata["transaction_id"] = order.Transaction + } + } + return metadata +} + +func (c *Creem) doJSON(ctx context.Context, method, path string, payload any, out any) error { + var bodyReader io.Reader + if payload != nil { + body, err := json.Marshal(payload) + if err != nil { + return err + } + bodyReader = bytes.NewReader(body) + } + req, err := http.NewRequestWithContext(ctx, method, c.config["apiBase"]+path, bodyReader) + if err != nil { + return err + } + req.Header.Set("Accept", "application/json") + if payload != nil { + req.Header.Set("Content-Type", "application/json") + } + req.Header.Set("x-api-key", c.config["apiKey"]) + + client := c.httpClient + if client == nil { + client = &http.Client{Timeout: creemHTTPTimeout} + } + resp, err := client.Do(req) + if err != nil { + return err + } + defer func() { _ = resp.Body.Close() }() + body, err := io.ReadAll(io.LimitReader(resp.Body, creemMaxResponseSize)) + if err != nil { + return err + } + if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices { + if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden { + return fmt.Errorf("HTTP %d: %s; Creem rejected the API key, check that it matches the environment (creem_test_ keys use test mode)", resp.StatusCode, summarizeCreemResponse(body)) + } + return fmt.Errorf("HTTP %d: %s", resp.StatusCode, summarizeCreemResponse(body)) + } + if out == nil || len(bytes.TrimSpace(body)) == 0 { + return nil + } + if err := json.Unmarshal(body, out); err != nil { + return fmt.Errorf("parse response: %w", err) + } + return nil +} + +func creemRefundProviderStatus(status string) string { + switch strings.ToLower(strings.TrimSpace(status)) { + case creemRefundStatusSucceeded: + return payment.ProviderStatusSuccess + case creemRefundStatusFailed, creemRefundStatusCanceled: + return payment.ProviderStatusFailed + case creemRefundStatusPending, creemRefundStatusRequiresAction: + return payment.ProviderStatusPending + default: + return payment.ProviderStatusPending + } +} + +// creemSuccessURL 过滤 Creem 不接受的回跳地址:实测 Creem 会以 "URL must be valid" 拒绝主机名是 +// IP 的地址(如 http://127.0.0.1:8080/...),整笔下单随之失败。这类地址不传,付款完成后用户停留在 +// Creem 的完成页,订单仍由 webhook 入账。 +func creemSuccessURL(raw string) string { + raw = strings.TrimSpace(raw) + parsed, err := url.Parse(raw) + if err != nil || parsed.Host == "" || net.ParseIP(parsed.Hostname()) != nil { + return "" + } + return raw +} + +func creemAmountToCents(raw string) (int64, error) { + amount, err := decimal.NewFromString(strings.TrimSpace(raw)) + if err != nil || amount.LessThanOrEqual(decimal.Zero) { + return 0, fmt.Errorf("invalid amount %s", raw) + } + cents := amount.Shift(2) + if !cents.Equal(cents.Truncate(0)) { + return 0, fmt.Errorf("amount %s has more than 2 decimal places", raw) + } + return cents.IntPart(), nil +} + +// verifyCreemWebhookSignature 与官方 SDK 的 verifyWebhookSignature 一致:优先校验 Standard Webhooks +// 头(webhook-id / webhook-timestamp / webhook-signature),没有这些头时回落到旧版 creem-signature +// (原始请求体的 HMAC-SHA256 十六进制摘要)。 +func verifyCreemWebhookSignature(rawBody string, headers map[string]string, secret string, now time.Time) error { + secret = strings.TrimSpace(secret) + if secret == "" { + return fmt.Errorf("creem webhookSecret not configured") + } + if now.IsZero() { + now = time.Now() + } + + id := strings.TrimSpace(headers["webhook-id"]) + timestamp := strings.TrimSpace(headers["webhook-timestamp"]) + signatures := strings.TrimSpace(headers["webhook-signature"]) + if id != "" && timestamp != "" && signatures != "" { + return verifyCreemStandardWebhook(rawBody, id, timestamp, signatures, secret, now) + } + + signature := strings.TrimSpace(headers["creem-signature"]) + if signature == "" { + signature = strings.TrimSpace(headers["x-creem-signature"]) + } + if signature == "" { + return fmt.Errorf("creem notification missing creem-signature header") + } + signature = strings.ToLower(strings.TrimPrefix(signature, "sha256=")) + mac := hmac.New(sha256.New, []byte(secret)) + _, _ = mac.Write([]byte(rawBody)) + expected := hex.EncodeToString(mac.Sum(nil)) + if !hmac.Equal([]byte(expected), []byte(signature)) { + return fmt.Errorf("creem invalid signature") + } + return nil +} + +func verifyCreemStandardWebhook(rawBody, id, timestamp, signatures, secret string, now time.Time) error { + seconds, err := strconv.ParseInt(timestamp, 10, 64) + if err != nil || seconds <= 0 { + return fmt.Errorf("creem invalid webhook timestamp") + } + if diff := now.Sub(time.Unix(seconds, 0)).Abs(); diff > creemWebhookTolerance { + return fmt.Errorf("creem webhook timestamp outside tolerance") + } + key, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(secret, "whsec_")) + if err != nil { + return fmt.Errorf("creem webhookSecret is not a valid Standard Webhooks secret: %w", err) + } + mac := hmac.New(sha256.New, key) + _, _ = mac.Write([]byte(id + "." + timestamp + "." + rawBody)) + expected := base64.StdEncoding.EncodeToString(mac.Sum(nil)) + for _, versioned := range strings.Fields(signatures) { + version, signature, ok := strings.Cut(versioned, ",") + if ok && version == "v1" && hmac.Equal([]byte(signature), []byte(expected)) { + return nil + } + } + return fmt.Errorf("creem invalid signature") +} + +func summarizeCreemResponse(body []byte) string { + summary := strings.Join(strings.Fields(string(body)), " ") + if summary == "" { + return "" + } + if len(summary) > creemMaxErrorSummary { + return summary[:creemMaxErrorSummary] + "..." + } + return summary +} + +type creemCreateCheckoutRequest struct { + RequestID string `json:"request_id"` + ProductID string `json:"product_id"` + Units int `json:"units"` + CustomPrice int64 `json:"custom_price"` + SuccessURL string `json:"success_url,omitempty"` + Metadata map[string]string `json:"metadata,omitempty"` +} + +type creemCreateRefundRequest struct { + TransactionID string `json:"transaction_id"` +} + +type creemProduct struct { + ID string `json:"id"` + Status string `json:"status"` + BillingType string `json:"billing_type"` + Currency string `json:"currency"` + TaxMode string `json:"tax_mode"` +} + +type creemCheckout struct { + ID string `json:"id"` + Status string `json:"status"` + RequestID string `json:"request_id"` + Product json.RawMessage `json:"product"` + CheckoutURL string `json:"checkout_url"` + Order *creemOrder `json:"order"` + Metadata map[string]any `json:"metadata"` +} + +// merchantOrderID 返回下单时写入的 out_trade_no:request_id 是主来源,metadata.order_id 兜底。 +func (c *creemCheckout) merchantOrderID() string { + if id := strings.TrimSpace(c.RequestID); id != "" { + return id + } + if raw, ok := c.Metadata["order_id"].(string); ok { + return strings.TrimSpace(raw) + } + return "" +} + +// productID 兼容 product 字段为裸 id 或展开对象两种形状。 +func (c *creemCheckout) productID() string { + return creemRelatedID(c.Product) +} + +func creemRelatedID(raw json.RawMessage) string { + if len(bytes.TrimSpace(raw)) == 0 { + return "" + } + var id string + if err := json.Unmarshal(raw, &id); err == nil { + return strings.TrimSpace(id) + } + var obj struct { + ID string `json:"id"` + } + if err := json.Unmarshal(raw, &obj); err == nil { + return strings.TrimSpace(obj.ID) + } + return "" +} + +type creemOrder struct { + ID string `json:"id"` + Transaction string `json:"transaction"` + Amount int64 `json:"amount"` + AmountPaid *int64 `json:"amount_paid"` + Currency string `json:"currency"` + Status string `json:"status"` + UpdatedAt string `json:"updated_at"` +} + +// paidCents 优先取实付金额:含税价商品的实付等于标价,用了折扣码则会低于订单金额, +// 让履约时的金额核对拦下来,而不是按原价入账。 +func (o *creemOrder) paidCents() int64 { + if o.AmountPaid != nil && *o.AmountPaid > 0 { + return *o.AmountPaid + } + return o.Amount +} + +func (o *creemOrder) paidAmount() float64 { + return decimal.NewFromInt(o.paidCents()).Shift(-2).InexactFloat64() +} + +type creemRefund struct { + Status string `json:"status"` +} + +type creemTransaction struct { + ID string `json:"id"` + Status string `json:"status"` +} + +// creemWebhookEvent 兼容两种事件信封:{eventType, object} 和 {type, data}。 +type creemWebhookEvent struct { + ID string `json:"id"` + EventType string `json:"eventType"` + Type string `json:"type"` + Object json.RawMessage `json:"object"` + Data json.RawMessage `json:"data"` +} + +func (e creemWebhookEvent) eventType() string { + if t := strings.TrimSpace(e.EventType); t != "" { + return t + } + return strings.TrimSpace(e.Type) +} + +func (e creemWebhookEvent) object() json.RawMessage { + if len(bytes.TrimSpace(e.Object)) > 0 { + return e.Object + } + return e.Data +} + +var ( + _ payment.Provider = (*Creem)(nil) + _ payment.MerchantIdentityProvider = (*Creem)(nil) + _ payment.LatePaymentRefunder = (*Creem)(nil) + _ payment.RefundQueryProvider = (*Creem)(nil) +) diff --git a/backend/internal/payment/provider/creem_test.go b/backend/internal/payment/provider/creem_test.go new file mode 100644 index 000000000000..3a61a1bd4603 --- /dev/null +++ b/backend/internal/payment/provider/creem_test.go @@ -0,0 +1,423 @@ +//go:build unit + +package provider + +import ( + "context" + "crypto/hmac" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strconv" + "sync/atomic" + "testing" + "time" + + "github.com/Wei-Shaw/sub2api/internal/payment" + "github.com/stretchr/testify/require" +) + +const creemTestProductJSON = `{"id":"prod_1","status":"active","billing_type":"onetime","currency":"USD","tax_mode":"inclusive"}` + +func TestNewCreemValidatesConfig(t *testing.T) { + t.Parallel() + + _, err := NewCreem("1", map[string]string{"apiKey": "creem_test_x", "webhookSecret": "s"}) + require.ErrorContains(t, err, "productId") + + _, err = NewCreem("1", map[string]string{"apiKey": "creem_test_x", "webhookSecret": "s", "productId": "prod_1", "apiBase": "https://evil.example.com"}) + require.ErrorContains(t, err, "apiBase host") + + _, err = NewCreem("1", map[string]string{"apiKey": "creem_test_x", "webhookSecret": "s", "productId": "prod_1", "apiBase": creemProdAPIBase}) + require.ErrorContains(t, err, "does not match the API key environment") + + _, err = NewCreem("1", map[string]string{"apiKey": "creem_test_x", "webhookSecret": "s", "productId": "prod_1", "currency": "CNY"}) + require.ErrorContains(t, err, "USD or EUR") + + prov, err := NewCreem("1", map[string]string{"apiKey": "creem_test_x", "webhookSecret": "s", "productId": "prod_1"}) + require.NoError(t, err) + require.Equal(t, payment.TypeCreem, prov.ProviderKey()) + require.Equal(t, []payment.PaymentType{payment.TypeCreem}, prov.SupportedTypes()) + require.Equal(t, creemTestAPIBase, prov.config["apiBase"]) + require.Equal(t, "USD", prov.config["currency"]) + + live, err := NewCreem("1", map[string]string{"apiKey": "creem_live", "webhookSecret": "s", "productId": "prod_1", "apiBase": "https://api.creem.io/v1/"}) + require.NoError(t, err) + require.Equal(t, creemProdAPIBase, live.config["apiBase"]) +} + +func TestCreemCreatePaymentSendsCustomPriceAndOrderID(t *testing.T) { + t.Parallel() + + var checkoutBody map[string]any + server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, "creem_test_key", r.Header.Get("x-api-key")) + switch { + case r.Method == http.MethodGet && r.URL.Path == "/v1/products/prod_1": + _, _ = w.Write([]byte(creemTestProductJSON)) + case r.Method == http.MethodPost && r.URL.Path == "/v1/checkouts": + body, err := io.ReadAll(r.Body) + require.NoError(t, err) + require.NoError(t, json.Unmarshal(body, &checkoutBody)) + _, _ = w.Write([]byte(`{"id":"ch_1","status":"pending","checkout_url":"https://creem.io/test/checkout/prod_1/ch_1"}`)) + default: + http.NotFound(w, r) + } + })) + defer server.Close() + + prov := mustTestCreemProvider(t, server, nil) + resp, err := prov.CreatePayment(context.Background(), payment.CreatePaymentRequest{ + OrderID: "sub2_order", + Amount: "12.34", + ReturnURL: "https://merchant.example.com/payment/result?order_id=7", + }) + require.NoError(t, err) + require.Equal(t, "ch_1", resp.TradeNo) + require.Equal(t, "https://creem.io/test/checkout/prod_1/ch_1", resp.PayURL) + require.Equal(t, "USD", resp.Currency) + require.Empty(t, resp.ClientSecret) + + require.Equal(t, "sub2_order", checkoutBody["request_id"]) + require.Equal(t, "prod_1", checkoutBody["product_id"]) + require.EqualValues(t, 1234, checkoutBody["custom_price"]) + require.EqualValues(t, 1, checkoutBody["units"]) + require.Equal(t, "https://merchant.example.com/payment/result?order_id=7", checkoutBody["success_url"]) + require.Equal(t, map[string]any{"order_id": "sub2_order"}, checkoutBody["metadata"]) +} + +func TestCreemSuccessURLDropsIPHosts(t *testing.T) { + t.Parallel() + + require.Equal(t, "", creemSuccessURL("http://127.0.0.1:18080/payment/result?order_id=1")) + require.Equal(t, "", creemSuccessURL("http://[::1]:8080/payment/result")) + require.Equal(t, "", creemSuccessURL("")) + require.Equal(t, "http://localhost:18080/payment/result", creemSuccessURL("http://localhost:18080/payment/result")) + require.Equal(t, "https://sub2api.example.com/payment/result?order_id=1", creemSuccessURL(" https://sub2api.example.com/payment/result?order_id=1 ")) +} + +func TestCreemCreatePaymentRejectsAmountsCreemCannotCharge(t *testing.T) { + t.Parallel() + + var calls atomic.Int32 + server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calls.Add(1) + http.NotFound(w, r) + })) + defer server.Close() + prov := mustTestCreemProvider(t, server, nil) + + for _, amount := range []string{"0.99", "1.005", "-1", "abc", "1000000.00"} { + _, err := prov.CreatePayment(context.Background(), payment.CreatePaymentRequest{OrderID: "o", Amount: amount}) + require.Error(t, err, amount) + } + require.Zero(t, calls.Load(), "invalid amounts must be rejected before calling Creem") +} + +func TestCreemCreatePaymentRejectsMisconfiguredProduct(t *testing.T) { + t.Parallel() + + cases := map[string]struct { + product string + want string + }{ + "exclusive tax": {`{"id":"prod_1","status":"active","billing_type":"onetime","currency":"USD","tax_mode":"exclusive"}`, "tax_mode exclusive"}, + "recurring": {`{"id":"prod_1","status":"active","billing_type":"recurring","currency":"USD","tax_mode":"inclusive"}`, "one-time product"}, + "currency": {`{"id":"prod_1","status":"active","billing_type":"onetime","currency":"EUR","tax_mode":"inclusive"}`, "currency EUR does not match"}, + "archived": {`{"id":"prod_1","status":"archived","billing_type":"onetime","currency":"USD","tax_mode":"inclusive"}`, "not active"}, + } + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + t.Parallel() + server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/v1/products/prod_1" { + _, _ = w.Write([]byte(tc.product)) + return + } + t.Errorf("unexpected request %s %s", r.Method, r.URL.Path) + http.NotFound(w, r) + })) + defer server.Close() + prov := mustTestCreemProvider(t, server, map[string]string{"apiKey": "creem_test_" + name}) + _, err := prov.CreatePayment(context.Background(), payment.CreatePaymentRequest{OrderID: "o", Amount: "10.00"}) + require.ErrorContains(t, err, tc.want) + }) + } +} + +func TestCreemVerifyNotificationLegacySignature(t *testing.T) { + t.Parallel() + + prov := mustTestCreemProvider(t, nil, nil) + body := creemCheckoutCompletedBody("paid", 1234, 1234, "USD") + headers := map[string]string{"creem-signature": creemLegacySignature(body, "whsec_legacy")} + + n, err := prov.VerifyNotification(context.Background(), body, headers) + require.NoError(t, err) + require.NotNil(t, n) + require.Equal(t, "ch_1", n.TradeNo) + require.Equal(t, "sub2_order", n.OrderID) + require.Equal(t, payment.NotificationStatusSuccess, n.Status) + require.InDelta(t, 12.34, n.Amount, 0.0001) + require.Equal(t, "USD", n.Metadata["currency"]) + require.Equal(t, "paid", n.Metadata["status"]) + require.Equal(t, "tran_1", n.Metadata["transaction_id"]) + + headers["creem-signature"] = creemLegacySignature(body, "other") + _, err = prov.VerifyNotification(context.Background(), body, headers) + require.ErrorContains(t, err, "invalid signature") + + _, err = prov.VerifyNotification(context.Background(), body, map[string]string{}) + require.ErrorContains(t, err, "missing creem-signature") +} + +func TestCreemVerifyNotificationStandardWebhookSignature(t *testing.T) { + t.Parallel() + + key := []byte("0123456789abcdef0123456789abcdef") + secret := "whsec_" + base64.StdEncoding.EncodeToString(key) + prov := mustTestCreemProvider(t, nil, map[string]string{"webhookSecret": secret}) + body := creemCheckoutCompletedBody("paid", 500, 500, "USD") + + now := time.Now() + headers := creemStandardHeaders(body, "msg_1", now, key) + n, err := prov.VerifyNotification(context.Background(), body, headers) + require.NoError(t, err) + require.Equal(t, "sub2_order", n.OrderID) + + stale := creemStandardHeaders(body, "msg_1", now.Add(-10*time.Minute), key) + _, err = prov.VerifyNotification(context.Background(), body, stale) + require.ErrorContains(t, err, "outside tolerance") + + tampered := creemStandardHeaders(body, "msg_1", now, key) + _, err = prov.VerifyNotification(context.Background(), body+" ", tampered) + require.ErrorContains(t, err, "invalid signature") +} + +func TestCreemVerifyNotificationReportsDiscountedAmountAndIgnoresOtherEvents(t *testing.T) { + t.Parallel() + + prov := mustTestCreemProvider(t, nil, nil) + + // 用了折扣码:实付低于订单金额,必须上报实付,交给履约金额核对拦截。 + body := creemCheckoutCompletedBody("paid", 1000, 800, "USD") + n, err := prov.VerifyNotification(context.Background(), body, map[string]string{"creem-signature": creemLegacySignature(body, "whsec_legacy")}) + require.NoError(t, err) + require.InDelta(t, 8.00, n.Amount, 0.0001) + + other := `{"id":"evt_2","eventType":"refund.created","created_at":1,"object":{"id":"ref_1"}}` + n, err = prov.VerifyNotification(context.Background(), other, map[string]string{"creem-signature": creemLegacySignature(other, "whsec_legacy")}) + require.NoError(t, err) + require.Nil(t, n) + + // 新版事件信封 {type, data} 同样能解析。 + envelope := `{"id":"evt_3","type":"checkout.completed","data":{"id":"ch_9","request_id":"sub2_x","order":{"id":"ord_9","transaction":"tran_9","amount":200,"amount_paid":200,"currency":"USD","status":"paid"}}}` + n, err = prov.VerifyNotification(context.Background(), envelope, map[string]string{"creem-signature": creemLegacySignature(envelope, "whsec_legacy")}) + require.NoError(t, err) + require.Equal(t, "ch_9", n.TradeNo) + require.Equal(t, "sub2_x", n.OrderID) +} + +func TestCreemQueryOrder(t *testing.T) { + t.Parallel() + + server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, "/v1/checkouts", r.URL.Path) + switch r.URL.Query().Get("checkout_id") { + case "ch_paid": + _, _ = w.Write([]byte(`{"id":"ch_paid","status":"completed","request_id":"sub2_order","order":{"id":"ord_1","transaction":"tran_1","amount":1234,"amount_paid":1234,"currency":"USD","status":"paid","updated_at":"2026-10-28T10:00:00.000Z"}}`)) + case "ch_pending": + _, _ = w.Write([]byte(`{"id":"ch_pending","status":"pending","request_id":"sub2_order"}`)) + default: + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"status":404,"message":["Checkout not found"]}`)) + } + })) + defer server.Close() + prov := mustTestCreemProvider(t, server, nil) + + paid, err := prov.QueryOrder(context.Background(), "ch_paid") + require.NoError(t, err) + require.Equal(t, payment.ProviderStatusPaid, paid.Status) + require.InDelta(t, 12.34, paid.Amount, 0.0001) + require.Equal(t, "2026-10-28T10:00:00Z", paid.PaidAt) + + pending, err := prov.QueryOrder(context.Background(), "ch_pending") + require.NoError(t, err) + require.Equal(t, payment.ProviderStatusPending, pending.Status) + + _, err = prov.QueryOrder(context.Background(), "ch_missing") + require.ErrorContains(t, err, "Checkout not found") +} + +func TestCreemRefundIsFullAmountOnly(t *testing.T) { + t.Parallel() + + var refundBodies []string + server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodGet && r.URL.Path == "/v1/checkouts": + _, _ = w.Write([]byte(`{"id":"ch_1","order":{"id":"ord_1","transaction":"tran_1","amount":1234,"amount_paid":1234,"currency":"USD","status":"paid"}}`)) + case r.Method == http.MethodGet && r.URL.Path == "/v1/transactions": + require.Equal(t, "tran_1", r.URL.Query().Get("transaction_id")) + _, _ = w.Write([]byte(`{"id":"tran_1","status":"paid","amount":1234}`)) + case r.Method == http.MethodPost && r.URL.Path == "/v1/refunds": + body, _ := io.ReadAll(r.Body) + refundBodies = append(refundBodies, string(body)) + // 真实响应只有 status,没有退款 id(2026-10 在 test-api.creem.io 实测)。 + _, _ = w.Write([]byte(`{"status":"succeeded"}`)) + default: + http.NotFound(w, r) + } + })) + defer server.Close() + prov := mustTestCreemProvider(t, server, nil) + + _, err := prov.Refund(context.Background(), payment.RefundRequest{TradeNo: "ch_1", Amount: "5.00"}) + require.ErrorContains(t, err, "only full refunds") + require.Empty(t, refundBodies) + + resp, err := prov.Refund(context.Background(), payment.RefundRequest{TradeNo: "ch_1", Amount: "12.34"}) + require.NoError(t, err) + require.Equal(t, "tran_1", resp.RefundID) + require.Equal(t, payment.ProviderStatusSuccess, resp.Status) + require.Equal(t, []string{`{"transaction_id":"tran_1"}`}, refundBodies) + + require.Equal(t, payment.ProviderStatusPending, creemRefundProviderStatus("requiresAction")) + require.Equal(t, payment.ProviderStatusFailed, creemRefundProviderStatus("canceled")) +} + +func TestCreemRefundTreatsAlreadyRefundedTransactionAsSuccess(t *testing.T) { + t.Parallel() + + server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodGet && r.URL.Path == "/v1/checkouts": + _, _ = w.Write([]byte(`{"id":"ch_1","order":{"id":"ord_1","transaction":"tran_1","amount":1234,"amount_paid":1234,"currency":"USD","status":"paid"}}`)) + case r.Method == http.MethodGet && r.URL.Path == "/v1/transactions": + _, _ = w.Write([]byte(`{"id":"tran_1","status":"refunded","amount":1234,"refunded_amount":1234}`)) + case r.Method == http.MethodPost && r.URL.Path == "/v1/refunds": + t.Errorf("must not re-refund an already refunded transaction") + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write([]byte(`{"status":400,"message":"Transaction has no remaining amount to refund."}`)) + default: + http.NotFound(w, r) + } + })) + defer server.Close() + prov := mustTestCreemProvider(t, server, nil) + + resp, err := prov.Refund(context.Background(), payment.RefundRequest{TradeNo: "ch_1", Amount: "12.34"}) + require.NoError(t, err) + require.Equal(t, "tran_1", resp.RefundID) + require.Equal(t, payment.ProviderStatusSuccess, resp.Status) +} + +func TestCreemQueryRefundReportsTransactionState(t *testing.T) { + t.Parallel() + + txStatus := "paid" + server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/v1/checkouts": + require.Equal(t, "ch_1", r.URL.Query().Get("checkout_id")) + _, _ = w.Write([]byte(`{"id":"ch_1","order":{"id":"ord_1","transaction":"tran_1","amount":1000,"amount_paid":1000,"currency":"USD","status":"paid"}}`)) + case "/v1/transactions": + require.Equal(t, "tran_1", r.URL.Query().Get("transaction_id")) + _, _ = w.Write([]byte(`{"id":"tran_1","status":"` + txStatus + `"}`)) + default: + http.NotFound(w, r) + } + })) + defer server.Close() + prov := mustTestCreemProvider(t, server, nil) + + resp, err := prov.QueryRefund(context.Background(), payment.RefundQueryRequest{TradeNo: "ch_1"}) + require.NoError(t, err) + require.Equal(t, payment.ProviderStatusPending, resp.Status, "a refund not yet reflected on the transaction stays pending") + + txStatus = "refunded" + resp, err = prov.QueryRefund(context.Background(), payment.RefundQueryRequest{TradeNo: "ch_1"}) + require.NoError(t, err) + require.Equal(t, payment.ProviderStatusSuccess, resp.Status) + require.Equal(t, "tran_1", resp.RefundID) +} + +func TestCreemRefundSettlesWhenTransactionTurnsRefundedShortlyAfter(t *testing.T) { + creemRefundRecheckInterval = time.Millisecond + t.Cleanup(func() { creemRefundRecheckInterval = time.Second }) + + txReads := 0 + refundPosted := false + server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.URL.Path == "/v1/checkouts": + _, _ = w.Write([]byte(`{"id":"ch_1","order":{"id":"ord_1","transaction":"tran_1","amount":1000,"amount_paid":1000,"currency":"USD","status":"paid"}}`)) + case r.URL.Path == "/v1/transactions": + txReads++ + status := "paid" + // 发起退款后第二次复查才看到 refunded,模拟 Creem 异步处理。 + if refundPosted && txReads >= 3 { + status = "refunded" + } + _, _ = w.Write([]byte(`{"id":"tran_1","status":"` + status + `"}`)) + case r.Method == http.MethodPost && r.URL.Path == "/v1/refunds": + refundPosted = true + _, _ = w.Write([]byte(`{"status":"pending"}`)) + default: + http.NotFound(w, r) + } + })) + defer server.Close() + prov := mustTestCreemProvider(t, server, nil) + + resp, err := prov.Refund(context.Background(), payment.RefundRequest{TradeNo: "ch_1", Amount: "10.00"}) + require.NoError(t, err) + require.Equal(t, payment.ProviderStatusSuccess, resp.Status, "pending from /v1/refunds is re-checked against the transaction") + require.Equal(t, 3, txReads, "one idempotency check before, two re-checks after") +} + +func mustTestCreemProvider(t *testing.T, server *httptest.Server, overrides map[string]string) *Creem { + t.Helper() + cfg := map[string]string{ + "apiKey": "creem_test_key", + "webhookSecret": "whsec_legacy", + "productId": "prod_1", + } + for k, v := range overrides { + cfg[k] = v + } + prov, err := NewCreem("1", cfg) + require.NoError(t, err) + if server != nil { + prov.config["apiBase"] = server.URL + prov.httpClient = server.Client() + } + return prov +} + +func creemCheckoutCompletedBody(orderStatus string, amount, amountPaid int64, currency string) string { + return `{"id":"evt_1","eventType":"checkout.completed","created_at":1793201489000,"object":{"id":"ch_1","object":"checkout","request_id":"sub2_order","status":"completed","product":{"id":"prod_1"},"metadata":{"order_id":"sub2_order"},"order":{"id":"ord_1","transaction":"tran_1","amount":` + + strconv.FormatInt(amount, 10) + `,"amount_paid":` + strconv.FormatInt(amountPaid, 10) + `,"currency":"` + currency + `","status":"` + orderStatus + `"}}}` +} + +func creemLegacySignature(body, secret string) string { + mac := hmac.New(sha256.New, []byte(secret)) + _, _ = mac.Write([]byte(body)) + return hex.EncodeToString(mac.Sum(nil)) +} + +func creemStandardHeaders(body, id string, at time.Time, key []byte) map[string]string { + ts := strconv.FormatInt(at.Unix(), 10) + mac := hmac.New(sha256.New, key) + _, _ = mac.Write([]byte(id + "." + ts + "." + body)) + return map[string]string{ + "webhook-id": id, + "webhook-timestamp": ts, + "webhook-signature": "v1,bogus v1," + base64.StdEncoding.EncodeToString(mac.Sum(nil)), + } +} diff --git a/backend/internal/payment/provider/factory.go b/backend/internal/payment/provider/factory.go index cc34d535deb5..431ac3d7c5b7 100644 --- a/backend/internal/payment/provider/factory.go +++ b/backend/internal/payment/provider/factory.go @@ -19,6 +19,8 @@ func CreateProvider(providerKey string, instanceID string, config map[string]str return NewStripe(instanceID, config) case payment.TypeAirwallex: return NewAirwallex(instanceID, config) + case payment.TypeCreem: + return NewCreem(instanceID, config) default: return nil, fmt.Errorf("unknown provider key: %s", providerKey) } diff --git a/backend/internal/payment/types.go b/backend/internal/payment/types.go index dc3ec58ff3cd..9ee55e71e38c 100644 --- a/backend/internal/payment/types.go +++ b/backend/internal/payment/types.go @@ -21,6 +21,7 @@ const ( TypeLink PaymentType = "link" TypeEasyPay PaymentType = "easypay" TypeAirwallex PaymentType = "airwallex" + TypeCreem PaymentType = "creem" ) // Order status constants shared across payment and service layers. @@ -89,6 +90,8 @@ func GetBasePaymentType(t string) string { return TypeEasyPay case t == TypeAirwallex: return TypeAirwallex + case t == TypeCreem: + return TypeCreem case t == TypeStripe || t == TypeCard || t == TypeLink: return TypeStripe case len(t) >= len(TypeAlipay) && t[:len(TypeAlipay)] == TypeAlipay: @@ -256,6 +259,13 @@ type CancelableProvider interface { CancelPayment(ctx context.Context, tradeNo string) error } +// LatePaymentRefunder is implemented by providers whose checkout links cannot be +// cancelled when an order expires (e.g. Creem). A payment that arrives after the +// expiry grace period is refunded automatically instead of being left uncredited. +type LatePaymentRefunder interface { + RefundsLatePayments() bool +} + // MerchantIdentityProvider exposes the current non-sensitive merchant identity // derived from provider configuration for snapshot consistency checks. type MerchantIdentityProvider interface { diff --git a/backend/internal/pkg/servertiming/http.go b/backend/internal/pkg/servertiming/http.go index 2ad8a04ae14c..714fbacf6d1e 100644 --- a/backend/internal/pkg/servertiming/http.go +++ b/backend/internal/pkg/servertiming/http.go @@ -96,7 +96,7 @@ func dependencyModule(req *http.Request) string { return "google" case strings.Contains(host, "amazonaws") || strings.Contains(host, "cloudflarestorage") || strings.Contains(host, "s3"): return "s3" - case strings.Contains(host, "stripe") || strings.Contains(host, "airwallex") || strings.Contains(host, "alipay") || strings.Contains(host, "wechatpay") || strings.Contains(host, "paypal"): + case strings.Contains(host, "stripe") || strings.Contains(host, "airwallex") || strings.Contains(host, "creem") || strings.Contains(host, "alipay") || strings.Contains(host, "wechatpay") || strings.Contains(host, "paypal"): return "payment" default: return "http" diff --git a/backend/internal/pkg/servertiming/http_test.go b/backend/internal/pkg/servertiming/http_test.go index d37f37841481..766b928f64de 100644 --- a/backend/internal/pkg/servertiming/http_test.go +++ b/backend/internal/pkg/servertiming/http_test.go @@ -134,6 +134,7 @@ func TestDependencyModuleClassification(t *testing.T) { "https://storage.googleapis.com/bucket/object": "google", "https://bucket.s3.amazonaws.com/object": "s3", "https://api.stripe.com/v1/refunds": "payment", + "https://test-api.creem.io/v1/checkouts": "payment", "https://dependency.example.test/path": "http", } for rawURL, want := range tests { diff --git a/backend/internal/server/routes/payment.go b/backend/internal/server/routes/payment.go index 86536d67ac7a..104ca0d3c07d 100644 --- a/backend/internal/server/routes/payment.go +++ b/backend/internal/server/routes/payment.go @@ -86,6 +86,7 @@ func RegisterPaymentRoutes( webhook.POST("/wxpay", webhookHandler.WxpayNotify) webhook.POST("/stripe", webhookHandler.StripeWebhook) webhook.POST("/airwallex", webhookHandler.AirwallexWebhook) + webhook.POST("/creem", webhookHandler.CreemWebhook) } // --- Admin payment endpoints (admin auth) --- diff --git a/backend/internal/service/payment_config_providers.go b/backend/internal/service/payment_config_providers.go index b4d8f2b22d77..ade9b741842c 100644 --- a/backend/internal/service/payment_config_providers.go +++ b/backend/internal/service/payment_config_providers.go @@ -116,6 +116,7 @@ var providerSensitiveConfigFields = map[string]map[string]struct{}{ payment.TypeWxpay: {"privatekey": {}, "apiv3key": {}, "publickey": {}}, payment.TypeStripe: {"secretkey": {}, "webhooksecret": {}}, payment.TypeAirwallex: {"apikey": {}, "webhooksecret": {}}, + payment.TypeCreem: {"apikey": {}, "webhooksecret": {}}, } // providerPendingOrderProtectedConfigFields lists config keys that cannot be @@ -128,6 +129,7 @@ var providerPendingOrderProtectedConfigFields = map[string]map[string]struct{}{ payment.TypeWxpay: {"privatekey": {}, "apiv3key": {}, "publickey": {}, "appid": {}, "mpappid": {}, "mchid": {}, "publickeyid": {}, "certserial": {}}, payment.TypeStripe: {"secretkey": {}, "webhooksecret": {}, "currency": {}}, payment.TypeAirwallex: {"clientid": {}, "apikey": {}, "webhooksecret": {}, "apibase": {}, "accountid": {}, "currency": {}}, + payment.TypeCreem: {"apikey": {}, "webhooksecret": {}, "apibase": {}, "productid": {}, "currency": {}}, } func isSensitiveProviderConfigField(providerKey, fieldName string) bool { @@ -178,7 +180,7 @@ func (s *PaymentConfigService) countPendingOrdersByPlan(ctx context.Context, pla } var validProviderKeys = map[string]bool{ - payment.TypeEasyPay: true, payment.TypeAlipay: true, payment.TypeWxpay: true, payment.TypeStripe: true, payment.TypeAirwallex: true, + payment.TypeEasyPay: true, payment.TypeAlipay: true, payment.TypeWxpay: true, payment.TypeStripe: true, payment.TypeAirwallex: true, payment.TypeCreem: true, } func (s *PaymentConfigService) CreateProviderInstance(ctx context.Context, req CreateProviderInstanceRequest) (*dbent.PaymentProviderInstance, error) { diff --git a/backend/internal/service/payment_config_providers_test.go b/backend/internal/service/payment_config_providers_test.go index c539449c6924..e59dbacb2f66 100644 --- a/backend/internal/service/payment_config_providers_test.go +++ b/backend/internal/service/payment_config_providers_test.go @@ -51,6 +51,13 @@ func TestValidateProviderRequest(t *testing.T) { supportedTypes: payment.TypeAirwallex, wantErr: false, }, + { + name: "valid creem provider", + providerKey: payment.TypeCreem, + providerName: "Creem Provider", + supportedTypes: payment.TypeCreem, + wantErr: false, + }, { name: "valid alipay provider", providerKey: "alipay", @@ -261,6 +268,13 @@ func TestIsSensitiveProviderConfigField(t *testing.T) { {payment.TypeAirwallex, "accountId", false}, {payment.TypeAirwallex, "currency", false}, + // Creem + {payment.TypeCreem, "apiKey", true}, + {payment.TypeCreem, "webhookSecret", true}, + {payment.TypeCreem, "productId", false}, + {payment.TypeCreem, "apiBase", false}, + {payment.TypeCreem, "currency", false}, + // Unknown provider: never sensitive {"unknown", "secretKey", false}, } diff --git a/backend/internal/service/payment_currency.go b/backend/internal/service/payment_currency.go index 64fe94dd1690..a7d36403abce 100644 --- a/backend/internal/service/payment_currency.go +++ b/backend/internal/service/payment_currency.go @@ -14,6 +14,12 @@ func paymentProviderConfigCurrency(providerKey string, cfg map[string]string) st if err == nil { return currency } + case payment.TypeCreem: + currency, err := payment.NormalizeCreemCurrency(cfg["currency"]) + if err == nil { + return currency + } + return payment.CreemDefaultCurrency } return payment.DefaultPaymentCurrency } diff --git a/backend/internal/service/payment_fulfillment.go b/backend/internal/service/payment_fulfillment.go index 623ee227238b..c9155f417cb2 100644 --- a/backend/internal/service/payment_fulfillment.go +++ b/backend/internal/service/payment_fulfillment.go @@ -194,7 +194,7 @@ func (s *PaymentService) toPaid(ctx context.Context, o *dbent.PaymentOrder, trad return fmt.Errorf("update to PAID: %w", err) } if c == 0 { - return s.alreadyProcessed(ctx, o) + return s.alreadyProcessed(ctx, o, tradeNo, paid) } if previousStatus == OrderStatusCancelled || previousStatus == OrderStatusExpired { slog.Info("order recovered from webhook payment success", @@ -214,7 +214,7 @@ func (s *PaymentService) toPaid(ctx context.Context, o *dbent.PaymentOrder, trad return s.executeFulfillment(ctx, o.ID) } -func (s *PaymentService) alreadyProcessed(ctx context.Context, o *dbent.PaymentOrder) error { +func (s *PaymentService) alreadyProcessed(ctx context.Context, o *dbent.PaymentOrder, tradeNo string, paid float64) error { cur, err := s.entClient.PaymentOrder.Get(ctx, o.ID) if err != nil { return nil @@ -235,12 +235,56 @@ func (s *PaymentService) alreadyProcessed(ctx context.Context, o *dbent.PaymentO "updatedAt": cur.UpdatedAt, "reason": "payment arrived after expiry grace period", }) - return nil + return s.refundLatePayment(ctx, cur, tradeNo, paid) default: return nil } } +// refundLatePayment 退回过期宽限期之后才到达的付款。只对付款链接无法随订单过期作废的服务商 +// (实现 payment.LatePaymentRefunder,如 Creem)生效:否则用户付了钱却拿不到余额。 +// 退款失败时返回错误,让 webhook 返回 5xx 由服务商重试;服务商侧的退款需保证重试幂等。 +func (s *PaymentService) refundLatePayment(ctx context.Context, o *dbent.PaymentOrder, tradeNo string, paid float64) error { + prov, err := s.getOrderProvider(ctx, o) + if err != nil { + slog.Warn("late payment refund skipped: provider unresolved", "orderID", o.ID, "error", err) + return nil + } + refunder, ok := prov.(payment.LatePaymentRefunder) + if !ok || !refunder.RefundsLatePayments() { + return nil + } + if strings.TrimSpace(tradeNo) == "" { + tradeNo = o.PaymentTradeNo + } + amount := payment.FormatAmountForCurrency(paid, PaymentOrderCurrency(o)) + resp, err := prov.Refund(ctx, payment.RefundRequest{ + TradeNo: tradeNo, + OrderID: o.OutTradeNo, + Amount: amount, + Reason: "payment arrived after order expired", + }) + if err == nil { + err = validateRefundProviderResponse(resp) + } + if err != nil { + s.writeAuditLog(ctx, o.ID, "LATE_PAYMENT_REFUND_FAILED", "system", map[string]any{ + "tradeNo": tradeNo, + "amount": amount, + "error": err.Error(), + }) + return fmt.Errorf("refund late payment for expired order %d: %w", o.ID, err) + } + slog.Info("late payment refunded", "orderID", o.ID, "tradeNo", tradeNo, "amount", amount, "provider", prov.ProviderKey()) + s.writeAuditLog(ctx, o.ID, "LATE_PAYMENT_REFUNDED", "system", map[string]any{ + "tradeNo": tradeNo, + "amount": amount, + "refundId": resp.RefundID, + "status": resp.Status, + }) + return nil +} + func (s *PaymentService) executeFulfillment(ctx context.Context, oid int64) error { o, err := s.entClient.PaymentOrder.Get(ctx, oid) if err != nil { diff --git a/backend/internal/service/payment_fulfillment_test.go b/backend/internal/service/payment_fulfillment_test.go index 0a7d1b8895ca..a6d7bfa65492 100644 --- a/backend/internal/service/payment_fulfillment_test.go +++ b/backend/internal/service/payment_fulfillment_test.go @@ -661,6 +661,35 @@ func TestValidateProviderNotificationMetadataRejectsAirwallexSnapshotMismatch(t assert.ErrorContains(t, err, "airwallex currency mismatch") } +func TestValidateProviderNotificationMetadataRejectsCreemMismatch(t *testing.T) { + t.Parallel() + + order := &dbent.PaymentOrder{ + PaymentType: payment.TypeCreem, + ProviderSnapshot: map[string]any{ + "schema_version": 2, + "currency": "USD", + }, + } + + assert.NoError(t, validateProviderNotificationMetadata(order, payment.TypeCreem, map[string]string{ + "currency": "USD", + "status": "paid", + })) + + err := validateProviderNotificationMetadata(order, payment.TypeCreem, map[string]string{ + "currency": "EUR", + "status": "paid", + }) + assert.ErrorContains(t, err, "creem currency mismatch") + + err = validateProviderNotificationMetadata(order, payment.TypeCreem, map[string]string{ + "currency": "USD", + "status": "pending", + }) + assert.ErrorContains(t, err, "creem order status mismatch") +} + func TestValidateProviderNotificationMetadataRejectsStripeCurrencyMismatch(t *testing.T) { t.Parallel() @@ -729,7 +758,7 @@ func TestAlreadyProcessedRecoversStaleRechargingLease(t *testing.T) { subscriptionSvc: NewSubscriptionService(groupRepo, userSubRepoNoop{}, nil, nil, nil), } - require.NoError(t, svc.alreadyProcessed(ctx, order)) + require.NoError(t, svc.alreadyProcessed(ctx, order, order.PaymentTradeNo, order.PayAmount)) reloaded, err := client.PaymentOrder.Get(ctx, order.ID) require.NoError(t, err) require.Equal(t, OrderStatusCompleted, reloaded.Status) diff --git a/backend/internal/service/payment_late_payment_refund_test.go b/backend/internal/service/payment_late_payment_refund_test.go new file mode 100644 index 000000000000..3142e0aa0e60 --- /dev/null +++ b/backend/internal/service/payment_late_payment_refund_test.go @@ -0,0 +1,162 @@ +//go:build unit + +package service + +import ( + "context" + "errors" + "strconv" + "testing" + "time" + + dbent "github.com/Wei-Shaw/sub2api/ent" + "github.com/Wei-Shaw/sub2api/ent/paymentauditlog" + "github.com/Wei-Shaw/sub2api/internal/payment" + "github.com/stretchr/testify/require" +) + +type latePaymentTestProvider struct { + key string + refundsLate bool + refundErr error + refunds []payment.RefundRequest +} + +func (p *latePaymentTestProvider) Name() string { return "late-payment-test" } +func (p *latePaymentTestProvider) ProviderKey() string { return p.key } +func (p *latePaymentTestProvider) SupportedTypes() []payment.PaymentType { + return []payment.PaymentType{p.key} +} +func (p *latePaymentTestProvider) CreatePayment(context.Context, payment.CreatePaymentRequest) (*payment.CreatePaymentResponse, error) { + panic("unexpected call") +} +func (p *latePaymentTestProvider) QueryOrder(context.Context, string) (*payment.QueryOrderResponse, error) { + panic("unexpected call") +} +func (p *latePaymentTestProvider) VerifyNotification(context.Context, string, map[string]string) (*payment.PaymentNotification, error) { + panic("unexpected call") +} +func (p *latePaymentTestProvider) Refund(_ context.Context, req payment.RefundRequest) (*payment.RefundResponse, error) { + p.refunds = append(p.refunds, req) + if p.refundErr != nil { + return nil, p.refundErr + } + return &payment.RefundResponse{RefundID: "tran_late", Status: payment.ProviderStatusSuccess}, nil +} +func (p *latePaymentTestProvider) RefundsLatePayments() bool { return p.refundsLate } + +func createExpiredBeyondGraceOrder(t *testing.T, client *dbent.Client, providerKey, outTradeNo string) *dbent.PaymentOrder { + t.Helper() + ctx := context.Background() + user, err := client.User.Create(). + SetEmail(outTradeNo + "@example.com"). + SetPasswordHash("hash"). + SetUsername(outTradeNo). + Save(ctx) + require.NoError(t, err) + + order, err := client.PaymentOrder.Create(). + SetUserID(user.ID). + SetUserEmail(user.Email). + SetUserName(user.Username). + SetAmount(3). + SetPayAmount(3). + SetFeeRate(0). + SetRechargeCode("LATE-" + outTradeNo). + SetOutTradeNo(outTradeNo). + SetPaymentType(providerKey). + SetPaymentTradeNo("ch_created"). + SetOrderType(payment.OrderTypeBalance). + SetStatus(OrderStatusExpired). + SetExpiresAt(time.Now().Add(-time.Hour)). + SetClientIP("127.0.0.1"). + SetSrcHost("api.example.com"). + Save(ctx) + require.NoError(t, err) + // 过期时间早于宽限期,toPaid 不会再把订单找回。 + _, err = client.PaymentOrder.UpdateOneID(order.ID). + SetUpdatedAt(time.Now().Add(-(paymentGraceMinutes + 10) * time.Minute)). + Save(ctx) + require.NoError(t, err) + return order +} + +func newLatePaymentTestService(client *dbent.Client, prov payment.Provider) *PaymentService { + registry := payment.NewRegistry() + registry.Register(prov) + return &PaymentService{entClient: client, registry: registry, providersLoaded: true} +} + +func auditActions(t *testing.T, client *dbent.Client, orderID int64) []string { + t.Helper() + logs, err := client.PaymentAuditLog.Query().Where(paymentauditlog.OrderIDEQ(strconv.FormatInt(orderID, 10))).All(context.Background()) + require.NoError(t, err) + actions := make([]string, 0, len(logs)) + for _, l := range logs { + actions = append(actions, l.Action) + } + return actions +} + +func TestLatePaymentAfterGraceIsRefundedForLatePaymentRefunders(t *testing.T) { + ctx := context.Background() + client := newPaymentOrderLifecycleTestClient(t) + order := createExpiredBeyondGraceOrder(t, client, payment.TypeCreem, "sub2_late_creem") + prov := &latePaymentTestProvider{key: payment.TypeCreem, refundsLate: true} + svc := newLatePaymentTestService(client, prov) + + err := svc.HandlePaymentNotification(ctx, &payment.PaymentNotification{ + TradeNo: "ch_paid_late", + OrderID: order.OutTradeNo, + Amount: 3, + Status: payment.NotificationStatusSuccess, + }, payment.TypeCreem) + require.NoError(t, err) + + require.Len(t, prov.refunds, 1) + require.Equal(t, "ch_paid_late", prov.refunds[0].TradeNo) + require.Equal(t, order.OutTradeNo, prov.refunds[0].OrderID) + require.Equal(t, "3.00", prov.refunds[0].Amount) + + reloaded, err := client.PaymentOrder.Get(ctx, order.ID) + require.NoError(t, err) + require.Equal(t, OrderStatusExpired, reloaded.Status, "a refunded late payment must not credit the order") + require.Contains(t, auditActions(t, client, order.ID), "LATE_PAYMENT_REFUNDED") +} + +func TestLatePaymentRefundFailureIsReturnedSoTheWebhookIsRetried(t *testing.T) { + ctx := context.Background() + client := newPaymentOrderLifecycleTestClient(t) + order := createExpiredBeyondGraceOrder(t, client, payment.TypeCreem, "sub2_late_creem_fail") + prov := &latePaymentTestProvider{key: payment.TypeCreem, refundsLate: true, refundErr: errors.New("upstream down")} + svc := newLatePaymentTestService(client, prov) + + err := svc.HandlePaymentNotification(ctx, &payment.PaymentNotification{ + TradeNo: "ch_paid_late", + OrderID: order.OutTradeNo, + Amount: 3, + Status: payment.NotificationStatusSuccess, + }, payment.TypeCreem) + require.ErrorContains(t, err, "upstream down") + require.Contains(t, auditActions(t, client, order.ID), "LATE_PAYMENT_REFUND_FAILED") +} + +func TestLatePaymentAfterGraceIsNotRefundedForOtherProviders(t *testing.T) { + ctx := context.Background() + client := newPaymentOrderLifecycleTestClient(t) + order := createExpiredBeyondGraceOrder(t, client, payment.TypeAirwallex, "sub2_late_airwallex") + prov := &latePaymentTestProvider{key: payment.TypeAirwallex, refundsLate: false} + svc := newLatePaymentTestService(client, prov) + + err := svc.HandlePaymentNotification(ctx, &payment.PaymentNotification{ + TradeNo: "int_paid_late", + OrderID: order.OutTradeNo, + Amount: 3, + Status: payment.NotificationStatusSuccess, + }, payment.TypeAirwallex) + require.NoError(t, err) + require.Empty(t, prov.refunds) + actions := auditActions(t, client, order.ID) + require.Contains(t, actions, "PAYMENT_AFTER_EXPIRY") + require.NotContains(t, actions, "LATE_PAYMENT_REFUNDED") +} diff --git a/backend/internal/service/payment_order.go b/backend/internal/service/payment_order.go index 3ddd9fec47cd..4c4683d3f814 100644 --- a/backend/internal/service/payment_order.go +++ b/backend/internal/service/payment_order.go @@ -316,6 +316,9 @@ func buildPaymentOrderProviderSnapshot(sel *payment.InstanceSelection, req Creat } snapshot["currency"] = paymentProviderConfigCurrency(providerKey, sel.Config) } + if providerKey == payment.TypeCreem { + snapshot["currency"] = paymentProviderConfigCurrency(providerKey, sel.Config) + } if len(snapshot) == 1 { return nil diff --git a/backend/internal/service/payment_order_provider_snapshot.go b/backend/internal/service/payment_order_provider_snapshot.go index c5d8f86ff506..c5a56a3e1207 100644 --- a/backend/internal/service/payment_order_provider_snapshot.go +++ b/backend/internal/service/payment_order_provider_snapshot.go @@ -220,6 +220,19 @@ func validateProviderSnapshotMetadata(order *dbent.PaymentOrder, providerKey str if actual := strings.TrimSpace(metadata["status"]); actual != "" && !strings.EqualFold(actual, "SUCCEEDED") { return fmt.Errorf("airwallex status mismatch: expected SUCCEEDED, got %s", actual) } + case payment.TypeCreem: + if expected := strings.TrimSpace(snapshot.Currency); expected != "" { + actual := strings.ToUpper(strings.TrimSpace(metadata["currency"])) + if actual == "" { + return fmt.Errorf("creem notification missing currency") + } + if !strings.EqualFold(expected, actual) { + return fmt.Errorf("creem currency mismatch: expected %s, got %s", expected, actual) + } + } + if actual := strings.TrimSpace(metadata["status"]); actual != "" && !strings.EqualFold(actual, "paid") { + return fmt.Errorf("creem order status mismatch: expected paid, got %s", actual) + } } return nil diff --git a/backend/internal/service/payment_order_provider_snapshot_test.go b/backend/internal/service/payment_order_provider_snapshot_test.go index 19047959ed98..af16b7cdff5d 100644 --- a/backend/internal/service/payment_order_provider_snapshot_test.go +++ b/backend/internal/service/payment_order_provider_snapshot_test.go @@ -187,6 +187,21 @@ func TestBuildPaymentOrderProviderSnapshot_IncludesProviderCurrency(t *testing.T }, CreateOrderRequest{}) require.Equal(t, "USD", airwallexSnapshot["currency"]) require.Equal(t, "acct-78", airwallexSnapshot["merchant_id"]) + + // Creem 只收 USD/EUR,未配置币种时快照必须是 USD,不能沿用全局默认 CNY。 + creemSnapshot := buildPaymentOrderProviderSnapshot(&payment.InstanceSelection{ + InstanceID: "79", + ProviderKey: payment.TypeCreem, + Config: map[string]string{}, + }, CreateOrderRequest{}) + require.Equal(t, "USD", creemSnapshot["currency"]) + + creemEURSnapshot := buildPaymentOrderProviderSnapshot(&payment.InstanceSelection{ + InstanceID: "80", + ProviderKey: payment.TypeCreem, + Config: map[string]string{"currency": "eur"}, + }, CreateOrderRequest{}) + require.Equal(t, "EUR", creemEURSnapshot["currency"]) } func valueOrEmpty(v *string) string { diff --git a/docs/PAYMENT.md b/docs/PAYMENT.md index 4c760166104d..da520b8f25ae 100644 --- a/docs/PAYMENT.md +++ b/docs/PAYMENT.md @@ -25,6 +25,7 @@ Sub2API has a built-in payment system that enables user self-service top-up with | **Alipay (Direct)** | Desktop QR code, mobile Alipay redirect | Direct integration with Alipay Open Platform, returning desktop QR codes and mobile WAP/app launch links | | **WeChat Pay (Direct)** | Native QR, H5, MP/JSAPI Pay | Direct integration with WeChat Pay APIv3 with environment-aware routing | | **Stripe** | Card, Alipay, WeChat Pay, Link, etc. | International payments, multi-currency support | +| **Creem** | Card, Google Pay, Apple Pay, Alipay, etc. (decided by the Creem checkout) | Merchant of Record that handles sales tax/VAT; redirects to the Creem hosted checkout, USD / EUR | > Alipay/WeChat Pay direct and EasyPay can both exist as backend provider instances, but the frontend always exposes only two visible buttons: `Alipay` and `WeChat Pay`. Admins choose exactly one source for each visible method: direct or EasyPay. Direct channels connect to payment APIs directly with lower fees; EasyPay aggregates through third-party platforms with easier setup. @@ -154,6 +155,32 @@ International payment platform supporting multiple payment methods and currencie | **Publishable Key** | Stripe publishable key (`pk_live_...` or `pk_test_...`) | Yes | | **Webhook Secret** | Stripe Webhook signing secret (`whsec_...`) | Yes | +### Creem + +Merchant of Record platform that collects sales tax / VAT on your behalf. Top-up amounts are not fixed, so create one one-time product in the Creem dashboard; each order overrides its unit price with `custom_price`, and the user is redirected to the Creem hosted checkout. + +| Parameter | Description | Required | +|-----------|-------------|----------| +| **API Key** | Creem API key (`creem_test_...` for test mode) | Yes | +| **Webhook Secret** | Creem webhook signing secret | Yes | +| **Creem Product ID** | ID of a one-time product (`prod_...`) | Yes | +| **Currency** | `USD` or `EUR`, defaults to `USD`; must match the product currency | No | +| **API Base URL** | Optional: test keys use `https://test-api.creem.io`, live keys use `https://api.creem.io` | No | + +Product requirements (orders fail with a specific error if they are not met): + +- Billing type **One-time**, status active +- Tax mode **Inclusive**. With exclusive tax the buyer pays more than the order amount, the amount check at fulfillment fails, and the order is not credited automatically +- Currency matches the Currency setting above + +Other limits: + +- Single order amount between 1.00 and 999,999.99 (Creem's `custom_price` range) +- **Only full refunds are supported**; partial refunds are rejected. Creem refunds are asynchronous: the system re-checks for a few seconds and usually settles immediately; if not, the order shows as refund pending and the admin "query refund" action on the order completes it +- Creem has no API to cancel a checkout link. If a user pays with the original link more than 5 minutes (the grace period) after the order expired, the payment is **automatically refunded in full** instead of credited, and the order audit log records `LATE_PAYMENT_REFUNDED` (`LATE_PAYMENT_REFUND_FAILED` on failure; the webhook then returns 5xx so Creem retries). Payments arriving within the grace period are credited as usual +- If the buyer applies a discount code, the amount paid is below the order amount and the order is not credited automatically; an admin needs to review it +- When the site is accessed by IP (e.g. `http://127.0.0.1:8080`), Creem rejects that return URL, so the user stays on Creem's completion page after paying; the order is still credited by the webhook + --- ## Provider Instance Management @@ -195,6 +222,7 @@ When adding a provider, the system auto-generates callback URLs from your site d | **Alipay (Direct)** | `https://your-domain.com/api/v1/payment/webhook/alipay` | | **WeChat Pay (Direct)** | `https://your-domain.com/api/v1/payment/webhook/wxpay` | | **Stripe** | `https://your-domain.com/api/v1/payment/webhook/stripe` | +| **Creem** | `https://your-domain.com/api/v1/payment/webhook/creem` | > Replace `your-domain.com` with your actual domain. For EasyPay / Alipay / WeChat Pay, the callback URL is auto-filled when adding the provider — no manual configuration needed. @@ -206,6 +234,16 @@ When adding a provider, the system auto-generates callback URLs from your site d 4. Subscribe to events: `payment_intent.succeeded`, `payment_intent.payment_failed` 5. Copy the generated Webhook Secret (`whsec_...`) to your provider configuration +### Creem Webhook Setup + +1. Log in to the [Creem Dashboard](https://www.creem.io/dashboard) +2. Go to **Developers → Webhooks** +3. Add an endpoint with the callback URL +4. Subscribe at least to `checkout.completed` +5. Paste the generated signing secret into the provider's Webhook Secret + +> Both the Standard Webhooks signature (`webhook-id` / `webhook-timestamp` / `webhook-signature`) and the legacy `creem-signature` header are supported. + ### Important Notes - Callback URLs must use **HTTPS** (required by Stripe, strongly recommended for others) diff --git a/docs/PAYMENT_CN.md b/docs/PAYMENT_CN.md index 935176958ef1..dbdf52d1a138 100644 --- a/docs/PAYMENT_CN.md +++ b/docs/PAYMENT_CN.md @@ -25,6 +25,7 @@ Sub2API 内置支付系统,支持用户自助充值,无需部署独立的支 | **支付宝官方** | 桌面二维码扫码、移动端支付宝跳转或当面付唤起 | 直接对接支付宝开放平台;移动端默认 WAP,也可选择当面付二维码唤起支付宝 | | **微信官方** | Native 扫码、H5、公众号/JSAPI 支付 | 直接对接微信支付 APIv3,按终端环境自动分流 | | **Stripe** | 银行卡、支付宝、微信支付、Link 等 | 国际支付,支持多币种 | +| **Creem** | 银行卡、Google Pay、Apple Pay、支付宝等(由 Creem 收银台决定) | Merchant of Record,代收各国税费;跳转 Creem 托管收银台,USD / EUR | > 支付宝官方 / 微信官方与易支付可以同时作为后台服务商实例存在,但前台始终只展示 `支付宝`、`微信支付` 两个可见按钮。管理员需要分别为这两个按钮选择唯一支付来源:官方或易支付。官方渠道直接对接 API,资金直达商户账户,手续费更低;易支付通过第三方平台聚合,接入门槛更低。 @@ -162,6 +163,32 @@ Sub2API 内置支付系统,支持用户自助充值,无需部署独立的支 | **Publishable Key** | Stripe 可公开密钥(`pk_live_...` 或 `pk_test_...`) | 是 | | **Webhook Secret** | Stripe Webhook 签名密钥(`whsec_...`) | 是 | +### Creem + +Merchant of Record 收款平台,代收各国销售税 / 增值税。充值金额不固定,所以需要先在 Creem 后台建一个一次性商品,每笔订单用 `custom_price` 按实际金额覆盖单价,用户跳转到 Creem 托管收银台付款。 + +| 参数 | 说明 | 必填 | +|------|------|------| +| **API Key** | Creem API 密钥(`creem_test_...` 为测试模式) | 是 | +| **Webhook 密钥** | Creem Webhook 签名密钥 | 是 | +| **Creem 商品 ID** | 一次性(One-time)商品的 ID(`prod_...`) | 是 | +| **支付币种** | `USD` 或 `EUR`,默认 `USD`,必须与商品币种一致 | 否 | +| **API 基础地址** | 可不填:测试密钥自动走 `https://test-api.creem.io`,正式密钥走 `https://api.creem.io` | 否 | + +商品要求(不满足时下单会直接报错,提示具体原因): + +- 计费类型为 **One-time**(一次性),状态为启用 +- 税费模式为 **Inclusive**(含税价)。价外税会让买家实付高于订单金额,履约时的金额核对不通过,订单不会自动入账 +- 币种与上面的「支付币种」一致 + +其他限制: + +- 单笔金额范围 1.00 – 999,999.99(Creem `custom_price` 的范围) +- **只支持全额退款**,部分退款会被拒绝。Creem 退款是异步的:系统会在几秒内复查,通常直接完成;若仍在处理中,订单显示「退款确认中」,稍后在订单详情点「查询退款结果」即可完成 +- Creem 没有作废付款链接的接口:订单过期超过 5 分钟宽限期后用户仍用原链接付款时,系统会**自动全额退回**这笔付款,不入账,并在订单审计日志记 `LATE_PAYMENT_REFUNDED`(退款失败记 `LATE_PAYMENT_REFUND_FAILED`,Webhook 返回 5xx 由 Creem 重试)。宽限期内到达的付款照常入账 +- 买家使用了折扣码时实付会低于订单金额,订单不会自动入账,需要管理员核对 +- 站点用 IP 访问时(如 `http://127.0.0.1:8080`),Creem 不接受这种回跳地址,付款后用户停留在 Creem 完成页,订单仍由 Webhook 入账 + --- ## 服务商实例管理 @@ -203,6 +230,7 @@ Sub2API 内置支付系统,支持用户自助充值,无需部署独立的支 | **支付宝官方** | `https://your-domain.com/api/v1/payment/webhook/alipay` | | **微信官方** | `https://your-domain.com/api/v1/payment/webhook/wxpay` | | **Stripe** | `https://your-domain.com/api/v1/payment/webhook/stripe` | +| **Creem** | `https://your-domain.com/api/v1/payment/webhook/creem` | > 将 `your-domain.com` 替换为你的实际域名。EasyPay / 支付宝 / 微信的回调地址在添加服务商时自动填入,无需手动配置。 @@ -214,6 +242,16 @@ Sub2API 内置支付系统,支持用户自助充值,无需部署独立的支 4. 订阅事件:`payment_intent.succeeded`、`payment_intent.payment_failed` 5. 将生成的 Webhook Secret(`whsec_...`)填入服务商配置 +### Creem Webhook 设置 + +1. 登录 [Creem Dashboard](https://www.creem.io/dashboard) +2. 进入 **Developers → Webhooks** +3. 添加端点,填写回调地址 +4. 至少订阅事件:`checkout.completed` +5. 将生成的签名密钥填入服务商配置的「Webhook 密钥」 + +> 同时支持新版 Standard Webhooks 签名(`webhook-id` / `webhook-timestamp` / `webhook-signature`)和旧版 `creem-signature` 签名。 + ### 注意事项 - 回调地址必须是 **HTTPS**(Stripe 强制要求,其他服务商强烈推荐) diff --git a/frontend/src/assets/icons/creem.png b/frontend/src/assets/icons/creem.png new file mode 100644 index 000000000000..ababd7912345 Binary files /dev/null and b/frontend/src/assets/icons/creem.png differ diff --git a/frontend/src/components/admin/payment/AdminOrderTable.vue b/frontend/src/components/admin/payment/AdminOrderTable.vue index dbbb5e87e0fc..e4b846010ce9 100644 --- a/frontend/src/components/admin/payment/AdminOrderTable.vue +++ b/frontend/src/components/admin/payment/AdminOrderTable.vue @@ -223,6 +223,7 @@ const paymentTypeFilterOptions = computed(() => [ { value: 'wxpay', label: t('payment.methods.wxpay') }, { value: 'stripe', label: t('payment.methods.stripe') }, { value: 'airwallex', label: t('payment.methods.airwallex') }, + { value: 'creem', label: t('payment.methods.creem') }, ]) const orderTypeFilterOptions = computed(() => [ diff --git a/frontend/src/components/payment/PaymentMethodSelector.vue b/frontend/src/components/payment/PaymentMethodSelector.vue index 25e99f06be5e..017d374b694a 100644 --- a/frontend/src/components/payment/PaymentMethodSelector.vue +++ b/frontend/src/components/payment/PaymentMethodSelector.vue @@ -51,6 +51,7 @@ import wxpayIcon from '@/assets/icons/wxpay.svg' import stripeIcon from '@/assets/icons/stripe.svg' import airwallexIcon from '@/assets/icons/airwallex.svg' import paymentIcon from '@/assets/icons/payment.svg' +import creemIcon from '@/assets/icons/creem.png' export interface PaymentMethodOption { type: string @@ -76,6 +77,7 @@ const METHOD_ICONS: Record = { stripe: stripeIcon, airwallex: airwallexIcon, credit_card: paymentIcon, + creem: creemIcon, } const sortedMethods = computed(() => { @@ -91,6 +93,7 @@ function methodIcon(type: string): string { if (isBuiltInAlipayMethod(type)) return METHOD_ICONS.alipay if (isBuiltInWxpayMethod(type)) return METHOD_ICONS.wxpay if (type === 'airwallex') return METHOD_ICONS.airwallex + if (type === 'creem') return METHOD_ICONS.creem return METHOD_ICONS[type] || paymentIcon } @@ -103,6 +106,7 @@ function methodSelectedClass(type: string): string { if (isBuiltInWxpayMethod(type)) return 'border-[#09BB07] bg-green-50 text-gray-900 shadow-sm dark:bg-green-950 dark:text-gray-100' if (type === 'stripe') return 'border-[#676BE5] bg-indigo-50 text-gray-900 shadow-sm dark:bg-indigo-950 dark:text-gray-100' if (type === 'airwallex') return 'border-[#FF6B3D] bg-orange-50 text-gray-900 shadow-sm dark:border-[#FF8E3C] dark:bg-orange-950 dark:text-gray-100' + if (type === 'creem') return 'border-gray-900 bg-gray-50 text-gray-900 shadow-sm dark:border-gray-200 dark:bg-gray-800 dark:text-gray-100' return 'border-primary-500 bg-primary-50 text-gray-900 shadow-sm dark:bg-primary-950 dark:text-gray-100' } diff --git a/frontend/src/components/payment/PaymentProviderDialog.vue b/frontend/src/components/payment/PaymentProviderDialog.vue index f8b1ba6afb4c..00744abf0f51 100644 --- a/frontend/src/components/payment/PaymentProviderDialog.vue +++ b/frontend/src/components/payment/PaymentProviderDialog.vue @@ -418,6 +418,7 @@ const defaultBaseUrl = typeof window !== 'undefined' ? window.location.origin : const providerWebhookHintMap: Record = { stripe: 'admin.settings.payment.stripeWebhookHint', airwallex: 'admin.settings.payment.airwallexWebhookHint', + creem: 'admin.settings.payment.creemWebhookHint', } const providerWebhookUrl = computed(() => { @@ -538,6 +539,14 @@ const paymentGuide = computed(() => { } } + if (form.provider_key === 'creem') { + return { + summary: t('admin.settings.payment.creemGuideSummary'), + note: t('admin.settings.payment.creemGuideNote'), + items: [], + } + } + return null }) diff --git a/frontend/src/components/payment/ProviderCard.vue b/frontend/src/components/payment/ProviderCard.vue index 5cf52c28cad3..7699dc34eb2d 100644 --- a/frontend/src/components/payment/ProviderCard.vue +++ b/frontend/src/components/payment/ProviderCard.vue @@ -77,6 +77,7 @@ const PROVIDER_KEY_LABELS: Record = { wxpay: 'admin.settings.payment.providerWxpay', stripe: 'admin.settings.payment.providerStripe', airwallex: 'admin.settings.payment.providerAirwallex', + creem: 'admin.settings.payment.providerCreem', } const props = defineProps<{ diff --git a/frontend/src/components/payment/__tests__/paymentFlow.spec.ts b/frontend/src/components/payment/__tests__/paymentFlow.spec.ts index 7a9dd20c393e..9a970fc4a033 100644 --- a/frontend/src/components/payment/__tests__/paymentFlow.spec.ts +++ b/frontend/src/components/payment/__tests__/paymentFlow.spec.ts @@ -141,6 +141,23 @@ describe('decidePaymentLaunch', () => { expect(decision.paymentState.paymentEnv).toBe('demo') }) + it('redirects Creem orders to the hosted checkout URL', () => { + const decision = decidePaymentLaunch(createOrderResult({ + pay_url: 'https://creem.io/test/checkout/prod_1/ch_1', + currency: 'USD', + out_trade_no: 'sub2_creem', + }), { + visibleMethod: 'creem', + orderType: 'balance', + isMobile: false, + }) + + expect(decision.kind).toBe('redirect_waiting') + expect(decision.paymentState.paymentType).toBe('creem') + expect(decision.paymentState.payUrl).toBe('https://creem.io/test/checkout/prod_1/ch_1') + expect(decision.paymentState.currency).toBe('USD') + }) + it('keeps hosted redirect metadata for recovery flows', () => { const decision = decidePaymentLaunch(createOrderResult({ pay_url: 'https://pay.example.com/session/abc', diff --git a/frontend/src/components/payment/__tests__/providerConfig.spec.ts b/frontend/src/components/payment/__tests__/providerConfig.spec.ts index 267693b5cbfd..023be3039ee8 100644 --- a/frontend/src/components/payment/__tests__/providerConfig.spec.ts +++ b/frontend/src/components/payment/__tests__/providerConfig.spec.ts @@ -1,7 +1,10 @@ import { describe, expect, it } from 'vitest' import { + CREEM_CURRENCY_OPTIONS, PAYMENT_CURRENCY_OPTIONS, PROVIDER_CONFIG_FIELDS, + PROVIDER_SUPPORTED_TYPES, + WEBHOOK_PATHS, isBuiltInAlipayMethod, isBuiltInWxpayMethod, parseEasyPayCustomMethods, @@ -48,6 +51,34 @@ describe('PROVIDER_CONFIG_FIELDS.airwallex', () => { }) }) +describe('PROVIDER_CONFIG_FIELDS.creem', () => { + it('requires the credentials and product the backend validates', () => { + for (const key of ['apiKey', 'webhookSecret', 'productId']) { + expect(findField('creem', key)?.optional).toBeFalsy() + } + expect(findField('creem', 'apiKey')?.sensitive).toBe(true) + expect(findField('creem', 'webhookSecret')?.sensitive).toBe(true) + }) + + it('limits currency to USD/EUR and defaults to USD instead of CNY', () => { + const currency = findField('creem', 'currency') + + expect(currency?.defaultValue).toBe('USD') + expect(currency?.options).toBe(CREEM_CURRENCY_OPTIONS) + expect(CREEM_CURRENCY_OPTIONS.map(o => o.value)).toEqual(['USD', 'EUR']) + }) + + it('lets apiBase be left blank so the key prefix picks the environment', () => { + expect(findField('creem', 'apiBase')?.optional).toBe(true) + expect(findField('creem', 'apiBase')?.defaultValue).toBeUndefined() + }) + + it('registers the creem payment type and webhook path', () => { + expect(PROVIDER_SUPPORTED_TYPES.creem).toEqual(['creem']) + expect(WEBHOOK_PATHS.creem).toBe('/api/v1/payment/webhook/creem') + }) +}) + describe('PROVIDER_CONFIG_FIELDS.stripe', () => { it('adds currency config with CNY as the default', () => { const currency = findField('stripe', 'currency') diff --git a/frontend/src/components/payment/paymentFlow.ts b/frontend/src/components/payment/paymentFlow.ts index cd9ccfd38f17..ba2ed96a0f33 100644 --- a/frontend/src/components/payment/paymentFlow.ts +++ b/frontend/src/components/payment/paymentFlow.ts @@ -16,9 +16,10 @@ const VISIBLE_METHOD_ALIASES = { wxpay_direct: 'wxpay', stripe: 'stripe', airwallex: 'airwallex', + creem: 'creem', } as const -export type VisiblePaymentMethod = 'alipay' | 'wxpay' | 'stripe' | 'airwallex' +export type VisiblePaymentMethod = 'alipay' | 'wxpay' | 'stripe' | 'airwallex' | 'creem' export type StripeVisibleMethod = 'alipay' | 'wechat_pay' export type PaymentLaunchKind = | 'qr_waiting' diff --git a/frontend/src/components/payment/providerConfig.ts b/frontend/src/components/payment/providerConfig.ts index 395c32725fa4..9e345a7cf59f 100644 --- a/frontend/src/components/payment/providerConfig.ts +++ b/frontend/src/components/payment/providerConfig.ts @@ -42,13 +42,14 @@ export const PROVIDER_SUPPORTED_TYPES: Record = { wxpay: ['wxpay'], stripe: ['card', 'alipay', 'wxpay', 'link'], airwallex: ['airwallex'], + creem: ['creem'], } /** Available payment modes for EasyPay providers. */ export const EASYPAY_PAYMENT_MODES = ['qrcode', 'popup'] as const /** Fixed display order for user-facing payment methods */ -export const METHOD_ORDER = ['alipay', 'alipay_direct', 'wxpay', 'wxpay_direct', 'stripe', 'airwallex'] as const +export const METHOD_ORDER = ['alipay', 'alipay_direct', 'wxpay', 'wxpay_direct', 'stripe', 'airwallex', 'creem'] as const export function isBuiltInAlipayMethod(type: string): boolean { return type === 'alipay' || type === 'alipay_direct' @@ -81,6 +82,12 @@ export const PAYMENT_CURRENCY_OPTIONS: TypeOption[] = [ { value: 'NZD', label: 'NZD' }, ] +/** Creem 商品只支持 USD 和 EUR。 */ +export const CREEM_CURRENCY_OPTIONS: TypeOption[] = [ + { value: 'USD', label: 'USD' }, + { value: 'EUR', label: 'EUR' }, +] + // 与后端当前集成的 stripe-go v85.0.0 的 stripe.APIVersion 保持一致。 export const STRIPE_SDK_API_VERSION = '2026-03-25.dahlia' @@ -110,6 +117,7 @@ export const WEBHOOK_PATHS: Record = { wxpay: '/api/v1/payment/webhook/wxpay', stripe: '/api/v1/payment/webhook/stripe', airwallex: '/api/v1/payment/webhook/airwallex', + creem: '/api/v1/payment/webhook/creem', } export const RETURN_PATH = '/payment/result' @@ -121,6 +129,7 @@ export const PROVIDER_CALLBACK_PATHS: Record = { wxpay: { notifyUrl: WEBHOOK_PATHS.wxpay }, // stripe: 不需要回调 URL 配置,Webhook 单独配置。 // airwallex: 不需要回调 URL 配置,Webhook 在空中云汇后台配置。 + // creem: 不需要回调 URL 配置,Webhook 在 Creem 后台配置。 } /** Per-provider config fields (excludes notifyUrl/returnUrl which are handled separately). */ @@ -161,6 +170,13 @@ export const PROVIDER_CONFIG_FIELDS: Record = { { key: 'currency', label: '', sensitive: false, defaultValue: 'CNY', hintKey: 'admin.settings.payment.field_paymentCurrencyHint', options: PAYMENT_CURRENCY_OPTIONS }, { key: 'accountId', label: '', sensitive: false, optional: true, clearable: true, hintKey: 'admin.settings.payment.field_accountIdHint' }, ], + creem: [ + { key: 'apiKey', label: '', sensitive: true }, + { key: 'webhookSecret', label: '', sensitive: true }, + { key: 'productId', label: '', sensitive: false, hintKey: 'admin.settings.payment.field_creemProductIdHint' }, + { key: 'currency', label: '', sensitive: false, defaultValue: 'USD', hintKey: 'admin.settings.payment.field_creemCurrencyHint', options: CREEM_CURRENCY_OPTIONS }, + { key: 'apiBase', label: '', sensitive: false, optional: true, clearable: true, hintKey: 'admin.settings.payment.field_creemApiBaseHint' }, + ], } // --- Helpers --- diff --git a/frontend/src/i18n/locales/en/admin/settings.ts b/frontend/src/i18n/locales/en/admin/settings.ts index c056f3925bbc..0fb4276bba17 100644 --- a/frontend/src/i18n/locales/en/admin/settings.ts +++ b/frontend/src/i18n/locales/en/admin/settings.ts @@ -811,6 +811,7 @@ export default { providerWxpay: 'WeChat Pay (Direct)', providerStripe: 'Stripe', providerAirwallex: 'Airwallex', + providerCreem: 'Creem', typeDisabled: 'type disabled', enableTypesFirst: 'Enable at least one payment type above first', easypayRedirect: 'Redirect', @@ -853,6 +854,10 @@ export default { field_airwallexApiBaseHint: 'Must match the API key environment: use https://api-demo.airwallex.com/api/v1 for sandbox/demo keys, and https://api.airwallex.com/api/v1 for production keys. Mixed environments return credentials_invalid / Access Denied.', field_paymentCurrencyHint: 'Default is CNY. Stripe and Airwallex can choose HKD, USD, or another listed currency supported by the account; WeChat Pay, Alipay, and EasyPay remain CNY.', field_accountIdHint: 'Leave this empty unless you use multiple accounts, an organization-level key, or connected-account payments. A single-account scoped API key uses the selected account by default.', + field_productId: 'Creem Product ID', + field_creemProductIdHint: 'Create a one-time product in the Creem dashboard with any price, tax mode set to Inclusive, and the same currency as below. Each top-up overrides the unit price with the actual amount. Enter the product ID (starts with prod_).', + field_creemCurrencyHint: 'Creem supports USD and EUR only, and it must match the product currency.', + field_creemApiBaseHint: 'Optional: creem_test_ keys automatically use https://test-api.creem.io, live keys use https://api.creem.io.', field_cid: 'Channel ID', field_cidAlipay: 'Alipay Channel ID', field_cidWxpay: 'WeChat Channel ID', @@ -868,6 +873,9 @@ export default { airwallexWebhookHint: 'Configure the following URL as a Webhook endpoint in Airwallex. Select at least Payment Intent -> Succeeded (payment_intent.succeeded), preferably also Payment Intent -> Cancelled (payment_intent.cancelled). Use the account default or latest stable API version.', airwallexGuideSummary: 'When creating an Airwallex scoped API key, select Read and Write for Payment Acceptance under account-level permissions.', airwallexGuideNote: 'Do not grant unrelated permissions such as Spend, Payouts, Transfers, Funds Splits, or POS Terminals unless you explicitly need them. For webhooks, select at least payment_intent.succeeded, preferably also payment_intent.cancelled, and use the account default or latest stable API version.', + creemWebhookHint: 'In the Creem dashboard (Developers → Webhooks), add the following URL as a webhook endpoint, subscribe to at least checkout.completed, and paste the generated signing secret into Webhook Secret above.', + creemGuideSummary: 'Creem is a Merchant of Record and handles sales tax/VAT; users are redirected to the Creem hosted checkout.', + creemGuideNote: 'Creem only supports full refunds; partial refunds are rejected. The product must use inclusive tax, otherwise the buyer pays more than the order amount and the order is not credited automatically.', limitsTitle: 'Limits', limitSingleMin: 'Min per order', limitSingleMax: 'Max per order', diff --git a/frontend/src/i18n/locales/en/misc.ts b/frontend/src/i18n/locales/en/misc.ts index 0888751a0039..0fec4417c1a6 100644 --- a/frontend/src/i18n/locales/en/misc.ts +++ b/frontend/src/i18n/locales/en/misc.ts @@ -311,6 +311,7 @@ export default { wxpay: 'WeChat Pay', stripe: 'Stripe', airwallex: 'Airwallex', + creem: 'Creem', card: 'Card', link: 'Link', alipay_direct: 'Alipay (Direct)', diff --git a/frontend/src/i18n/locales/zh/admin/settings.ts b/frontend/src/i18n/locales/zh/admin/settings.ts index 27b282b88dc8..2c03dfe9fe5d 100644 --- a/frontend/src/i18n/locales/zh/admin/settings.ts +++ b/frontend/src/i18n/locales/zh/admin/settings.ts @@ -806,6 +806,7 @@ export default { providerWxpay: '微信官方', providerStripe: 'Stripe', providerAirwallex: 'Airwallex', + providerCreem: 'Creem', typeDisabled: '类型已禁用', enableTypesFirst: '请先在上方启用至少一种服务商', easypayRedirect: '跳转', @@ -848,6 +849,10 @@ export default { field_airwallexApiBaseHint: '必须和 API Key 所属环境一致:沙箱/测试密钥使用 https://api-demo.airwallex.com/api/v1,生产密钥使用 https://api.airwallex.com/api/v1。环境混用会返回 credentials_invalid / Access Denied。', field_paymentCurrencyHint: '默认 CNY。Stripe 和 Airwallex 可按账户支持从下拉项选择 HKD、USD 等币种;微信、支付宝、易支付仍按 CNY。', field_accountIdHint: '不涉及多账户、组织级密钥或连接账户收款时可以不填;单账户 Scoped API Key 会默认使用所选账户。', + field_productId: 'Creem 商品 ID', + field_creemProductIdHint: '在 Creem 后台新建一个一次性(One-time)商品,价格随便填,税费模式选「含税价(Inclusive)」,币种与下方一致;每笔充值会按实际金额覆盖商品单价。填商品 ID(prod_ 开头)。', + field_creemCurrencyHint: 'Creem 只支持 USD 和 EUR,必须和上面商品的币种一致。', + field_creemApiBaseHint: '可不填:creem_test_ 开头的测试密钥自动使用 https://test-api.creem.io,正式密钥使用 https://api.creem.io。', field_cid: '支付渠道 ID', field_cidAlipay: '支付宝渠道 ID', field_cidWxpay: '微信渠道 ID', @@ -863,6 +868,9 @@ export default { airwallexWebhookHint: '请在 Airwallex 后台将以下地址配置为 Webhook 端点;事件至少选择 Payment Intent -> Succeeded(payment_intent.succeeded),建议同时选择 Payment Intent -> Cancelled(payment_intent.cancelled);API version 选择账户默认或最新稳定版本。', airwallexGuideSummary: '创建 Airwallex Scoped API 密钥时,建议只在账户级权限中为 Payment Acceptance 勾选读取和写入。', airwallexGuideNote: '不需要勾选 Spend、Payouts、Transfers、Funds Splits、POS 终端等与在线收款无关的权限。Webhook 事件至少选择 payment_intent.succeeded,建议同时选择 payment_intent.cancelled;API version 选择账户默认或最新稳定版本。', + creemWebhookHint: '请在 Creem 后台 Developers → Webhooks 中将以下地址配置为 Webhook 端点,事件至少勾选 checkout.completed,并把生成的签名密钥填到上面的 Webhook 密钥。', + creemGuideSummary: 'Creem 是 Merchant of Record,会代收各国税费;用户跳转到 Creem 托管收银台付款。', + creemGuideNote: 'Creem 退款只支持全额退款,部分退款会被拒绝。商品必须是含税价,否则买家实付会高于订单金额,订单不会自动入账。', limitsTitle: '限额配置', limitSingleMin: '单笔最低', limitSingleMax: '单笔最高', diff --git a/frontend/src/i18n/locales/zh/misc.ts b/frontend/src/i18n/locales/zh/misc.ts index 2c6152473154..1d3286ae95a1 100644 --- a/frontend/src/i18n/locales/zh/misc.ts +++ b/frontend/src/i18n/locales/zh/misc.ts @@ -335,6 +335,7 @@ export default { wxpay: '微信支付', stripe: 'Stripe', airwallex: 'Airwallex', + creem: 'Creem', card: '银行卡', link: 'Link', alipay_direct: '支付宝(直连)', diff --git a/frontend/src/style.css b/frontend/src/style.css index 63e26e4eb71e..d29df9030166 100644 --- a/frontend/src/style.css +++ b/frontend/src/style.css @@ -133,6 +133,13 @@ @apply dark:hover:bg-[#62d9ad]; } + .btn-creem { + @apply bg-[#18181B] text-white shadow-md shadow-gray-900/20; + @apply hover:bg-[#27272A] hover:shadow-lg hover:shadow-gray-900/25; + @apply dark:bg-white dark:text-gray-950 dark:shadow-white/10; + @apply dark:hover:bg-gray-200; + } + .btn-alipay { @apply bg-[#00AEEF] text-white shadow-md shadow-[#00AEEF]/25; @apply hover:bg-[#009dd6] hover:shadow-lg hover:shadow-[#00AEEF]/30; diff --git a/frontend/src/types/payment.ts b/frontend/src/types/payment.ts index 6a4103098812..ae6094023514 100644 --- a/frontend/src/types/payment.ts +++ b/frontend/src/types/payment.ts @@ -19,7 +19,7 @@ export type OrderStatus = | 'REFUNDED' | 'REFUND_FAILED' -export type PaymentType = 'alipay' | 'wxpay' | 'alipay_direct' | 'wxpay_direct' | 'stripe' | 'easypay' | 'airwallex' +export type PaymentType = 'alipay' | 'wxpay' | 'alipay_direct' | 'wxpay_direct' | 'stripe' | 'easypay' | 'airwallex' | 'creem' export type OrderType = 'balance' | 'subscription' diff --git a/frontend/src/views/admin/SettingsView.vue b/frontend/src/views/admin/SettingsView.vue index 36bbfd4b4a7e..c3d90d9ea7f7 100644 --- a/frontend/src/views/admin/SettingsView.vue +++ b/frontend/src/views/admin/SettingsView.vue @@ -12528,6 +12528,7 @@ const allPaymentTypes = computed(() => [ { value: "wxpay", label: t("payment.methods.wxpay") }, { value: "stripe", label: t("payment.methods.stripe") }, { value: "airwallex", label: t("payment.methods.airwallex") }, + { value: "creem", label: t("payment.methods.creem") }, ]); function isPaymentTypeEnabled(type: string): boolean { @@ -12585,6 +12586,7 @@ const providerKeyOptions = computed(() => [ { value: "wxpay", label: t("admin.settings.payment.providerWxpay") }, { value: "stripe", label: t("admin.settings.payment.providerStripe") }, { value: "airwallex", label: t("admin.settings.payment.providerAirwallex") }, + { value: "creem", label: t("admin.settings.payment.providerCreem") }, ]); const enabledProviderKeyOptions = computed(() => { diff --git a/frontend/src/views/admin/orders/AdminOrdersView.vue b/frontend/src/views/admin/orders/AdminOrdersView.vue index c467f086af0e..df1124032ce8 100644 --- a/frontend/src/views/admin/orders/AdminOrdersView.vue +++ b/frontend/src/views/admin/orders/AdminOrdersView.vue @@ -207,6 +207,7 @@ const paymentTypeFilterOptions = computed(() => [ { value: 'wxpay', label: t('payment.methods.wxpay') }, { value: 'stripe', label: t('payment.methods.stripe') }, { value: 'airwallex', label: t('payment.methods.airwallex') }, + { value: 'creem', label: t('payment.methods.creem') }, ]) const orderTypeFilterOptions = computed(() => [ diff --git a/frontend/src/views/user/PaymentView.vue b/frontend/src/views/user/PaymentView.vue index 4e64af2efad6..a1a9347cad42 100644 --- a/frontend/src/views/user/PaymentView.vue +++ b/frontend/src/views/user/PaymentView.vue @@ -777,6 +777,7 @@ const paymentButtonClass = computed(() => { if (isBuiltInWxpayMethod(m)) return 'btn-wxpay' if (m === 'stripe') return 'btn-stripe' if (m === 'airwallex') return 'btn-airwallex' + if (m === 'creem') return 'btn-creem' return 'btn-primary' })