Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 23 additions & 2 deletions backend/internal/handler/payment_webhook_handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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")
Expand Down
19 changes: 19 additions & 0 deletions backend/internal/handler/payment_webhook_handler_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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 {
Expand Down
16 changes: 16 additions & 0 deletions backend/internal/payment/currency.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
Loading
Loading