Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
fbadb00
docs: add SePay payment gateway design spec
trinknx Aug 14, 2026
b20bb4a
docs: add SePay payment gateway implementation plan
trinknx Aug 14, 2026
246f519
feat(payment): add sepay payment type with VND-only currency
trinknx Aug 14, 2026
798b396
feat(payment): add VietQR EMV payload builder
trinknx Aug 14, 2026
c59680d
feat(payment): add SePay provider config, VietQR CreatePayment, refun…
trinknx Aug 14, 2026
0146352
feat(payment): SePay webhook verification (HMAC-SHA256 / API key)
trinknx Aug 14, 2026
ce53f9e
feat(payment): SePay QueryOrder via API v2 transactions
trinknx Aug 14, 2026
5784294
feat(payment): register SePay in provider factory
trinknx Aug 14, 2026
96fb2d9
feat(payment): SePay webhook route with required JSON success body
trinknx Aug 14, 2026
35d4e63
feat(payment): lenient sepay code resolution for webhooks and queries
trinknx Aug 14, 2026
dbb05d1
test(payment): make sepay query-reference test discriminating
trinknx Aug 14, 2026
4bd870b
feat(payment): sepay provider registration, refund block, config fiel…
trinknx Aug 14, 2026
c64e263
feat(payment): SUBSCRIPTION_USD_TO_VND_RATE for sepay subscription or…
trinknx Aug 14, 2026
a3fcbc8
feat(payment-frontend): sepay provider config, method plumbing and i18n
trinknx Aug 14, 2026
c9eed06
feat(payment-frontend): VND subscription rate display and admin input
trinknx Aug 14, 2026
4861d0c
chore(payment): sepay integration final fixes
trinknx Aug 14, 2026
4afa29d
fix(payment): sepay order snapshot currency, metadata cross-check and…
trinknx Aug 14, 2026
6ffc18c
fix(payment): align sepay identity metadata key across webhook and qu…
trinknx Aug 14, 2026
4b5408b
fix(payment): match sepay codes with stripped separators
trinknx Aug 14, 2026
df6b0f9
docs: record sandbox-validated sepay code normalization amendment
trinknx Aug 14, 2026
956ec3a
feat(payment): show sepay bank-transfer details on checkout QR
trinknx Aug 15, 2026
2f74d45
fix(payment): emit separator-free sepay transfer content
trinknx Aug 15, 2026
85a0b28
feat(payment): convert VND recharge to USD balance via configured rate
trinknx Aug 15, 2026
9896ef9
fix(payment): keep sepay transfer content lowercase prefix
trinknx Aug 15, 2026
159b513
feat(payment): serve sepay QR via vietqr.app image
trinknx Aug 15, 2026
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
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,8 @@ docs/*
!docs/PAYMENT_CN.md
!docs/ADMIN_PAYMENT_INTEGRATION_API.md
!docs/ASYNC_IMAGE_TASKS.md
!docs/superpowers/
!docs/superpowers/**
!docs/legal/
!docs/legal/*.md
.serena/
Expand Down
1 change: 1 addition & 0 deletions backend/internal/handler/admin/setting_handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -356,6 +356,7 @@ func (h *SettingHandler) GetSettings(c *gin.Context) {
PaymentBalanceDisabled: paymentCfg.BalanceDisabled,
PaymentBalanceRechargeMultiplier: paymentCfg.BalanceRechargeMultiplier,
PaymentSubscriptionUSDToCNYRate: paymentCfg.SubscriptionUSDToCNYRate,
PaymentSubscriptionUSDToVNDRate: paymentCfg.SubscriptionUSDToVNDRate,
PaymentRechargeFeeRate: paymentCfg.RechargeFeeRate,
PaymentLoadBalanceStrat: paymentCfg.LoadBalanceStrategy,
PaymentProductNamePrefix: paymentCfg.ProductNamePrefix,
Expand Down
4 changes: 4 additions & 0 deletions backend/internal/handler/admin/setting_handler_update.go
Original file line number Diff line number Diff line change
Expand Up @@ -308,6 +308,7 @@ type UpdateSettingsRequest struct {
PaymentBalanceDisabled *bool `json:"payment_balance_disabled"`
PaymentBalanceRechargeMultiplier *float64 `json:"payment_balance_recharge_multiplier"`
PaymentSubscriptionUSDToCNYRate *float64 `json:"payment_subscription_usd_to_cny_rate"`
PaymentSubscriptionUSDToVNDRate *float64 `json:"payment_subscription_usd_to_vnd_rate"`
PaymentRechargeFeeRate *float64 `json:"payment_recharge_fee_rate"`
PaymentLoadBalanceStrat *string `json:"payment_load_balance_strategy"`
PaymentProductNamePrefix *string `json:"payment_product_name_prefix"`
Expand Down Expand Up @@ -2048,6 +2049,7 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
BalanceDisabled: req.PaymentBalanceDisabled,
BalanceRechargeMultiplier: req.PaymentBalanceRechargeMultiplier,
SubscriptionUSDToCNYRate: req.PaymentSubscriptionUSDToCNYRate,
SubscriptionUSDToVNDRate: req.PaymentSubscriptionUSDToVNDRate,
RechargeFeeRate: req.PaymentRechargeFeeRate,
LoadBalanceStrategy: req.PaymentLoadBalanceStrat,
ProductNamePrefix: req.PaymentProductNamePrefix,
Expand Down Expand Up @@ -2324,6 +2326,7 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
PaymentBalanceDisabled: updatedPaymentCfg.BalanceDisabled,
PaymentBalanceRechargeMultiplier: updatedPaymentCfg.BalanceRechargeMultiplier,
PaymentSubscriptionUSDToCNYRate: updatedPaymentCfg.SubscriptionUSDToCNYRate,
PaymentSubscriptionUSDToVNDRate: updatedPaymentCfg.SubscriptionUSDToVNDRate,
PaymentRechargeFeeRate: updatedPaymentCfg.RechargeFeeRate,
PaymentLoadBalanceStrat: updatedPaymentCfg.LoadBalanceStrategy,
PaymentProductNamePrefix: updatedPaymentCfg.ProductNamePrefix,
Expand Down Expand Up @@ -2395,6 +2398,7 @@ func hasPaymentFields(req UpdateSettingsRequest) bool {
req.PaymentOrderTimeoutMin != nil || req.PaymentMaxPendingOrders != nil ||
req.PaymentEnabledTypes != nil || req.PaymentBalanceDisabled != nil ||
req.PaymentBalanceRechargeMultiplier != nil || req.PaymentSubscriptionUSDToCNYRate != nil ||
req.PaymentSubscriptionUSDToVNDRate != nil ||
req.PaymentRechargeFeeRate != nil ||
req.PaymentLoadBalanceStrat != nil || req.PaymentProductNamePrefix != nil ||
req.PaymentProductNameSuffix != nil || req.PaymentHelpImageURL != nil ||
Expand Down
1 change: 1 addition & 0 deletions backend/internal/handler/dto/settings.go
Original file line number Diff line number Diff line change
Expand Up @@ -273,6 +273,7 @@ type SystemSettings struct {
PaymentBalanceDisabled bool `json:"payment_balance_disabled"`
PaymentBalanceRechargeMultiplier float64 `json:"payment_balance_recharge_multiplier"`
PaymentSubscriptionUSDToCNYRate float64 `json:"payment_subscription_usd_to_cny_rate"`
PaymentSubscriptionUSDToVNDRate float64 `json:"payment_subscription_usd_to_vnd_rate"`
PaymentRechargeFeeRate float64 `json:"payment_recharge_fee_rate"`
PaymentLoadBalanceStrat string `json:"payment_load_balance_strategy"`
PaymentProductNamePrefix string `json:"payment_product_name_prefix"`
Expand Down
2 changes: 2 additions & 0 deletions backend/internal/handler/payment_handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,7 @@ func (h *PaymentHandler) GetCheckoutInfo(c *gin.Context) {
BalanceDisabled: cfg.BalanceDisabled,
BalanceRechargeMultiplier: cfg.BalanceRechargeMultiplier,
SubscriptionUSDToCNYRate: cfg.SubscriptionUSDToCNYRate,
SubscriptionUSDToVNDRate: cfg.SubscriptionUSDToVNDRate,
RechargeFeeRate: cfg.RechargeFeeRate,
HelpText: cfg.HelpText,
HelpImageURL: cfg.HelpImageURL,
Expand All @@ -165,6 +166,7 @@ type checkoutInfoResponse struct {
BalanceDisabled bool `json:"balance_disabled"`
BalanceRechargeMultiplier float64 `json:"balance_recharge_multiplier"`
SubscriptionUSDToCNYRate float64 `json:"subscription_usd_to_cny_rate"`
SubscriptionUSDToVNDRate float64 `json:"subscription_usd_to_vnd_rate"`
RechargeFeeRate float64 `json:"recharge_fee_rate"`
HelpText string `json:"help_text"`
HelpImageURL string `json:"help_image_url"`
Expand Down
16 changes: 16 additions & 0 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)
}

// SepayNotify handles SePay transaction webhooks.
// POST /api/v1/payment/webhook/sepay
func (h *PaymentWebhookHandler) SepayNotify(c *gin.Context) {
h.handleNotify(c, payment.TypeSePay)
}

// 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 @@ -164,6 +170,13 @@ func extractOutTradeNo(rawBody, providerKey string) string {
if err := json.Unmarshal([]byte(rawBody), &payload); err == nil {
return strings.TrimSpace(payload.Data.Object.MerchantOrderID)
}
case payment.TypeSePay:
var payload struct {
Code *string `json:"code"`
}
if err := json.Unmarshal([]byte(rawBody), &payload); err == nil && payload.Code != nil {
return strings.TrimSpace(*payload.Code)
}
}
// 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 @@ -210,6 +223,9 @@ func writeSuccessResponse(c *gin.Context, providerKey string) {
c.JSON(http.StatusOK, wxpaySuccessResponse{Code: wxpaySuccessCode, Message: wxpaySuccessMessage})
case payment.TypeStripe, payment.TypeAirwallex:
c.String(http.StatusOK, "")
case payment.TypeSePay:
// SePay requires exactly {"success":true} with HTTP 200/201.
c.JSON(http.StatusOK, gin.H{"success": true})
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: "sepay returns JSON success body",
providerKey: payment.TypeSePay,
wantCode: http.StatusOK,
wantContentType: "application/json",
wantBody: `{"success":true}`,
},
{
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: "sepay json payload with code",
providerKey: payment.TypeSePay,
rawBody: `{"code":"sub2_20260814aB3kX9mQ","transferType":"in","transferAmount":50000}`,
want: "sub2_20260814aB3kX9mQ",
},
{
name: "sepay json payload with null code",
providerKey: payment.TypeSePay,
rawBody: `{"code":null,"transferType":"in","transferAmount":50000}`,
want: "",
},
}

for _, tt := range tests {
Expand Down
3 changes: 3 additions & 0 deletions backend/internal/payment/currency.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@ import (

const DefaultPaymentCurrency = "CNY"

// CurrencyVND is the only currency SePay bank transfers support.
const CurrencyVND = "VND"

type paymentCurrencyAmountUnit struct {
apiMinorUnit int
maxFractionDigits int
Expand Down
2 changes: 2 additions & 0 deletions backend/internal/payment/provider/factory.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.TypeSePay:
return NewSePay(instanceID, config)
default:
return nil, fmt.Errorf("unknown provider key: %s", providerKey)
}
Expand Down
20 changes: 20 additions & 0 deletions backend/internal/payment/provider/factory_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package provider

import (
"testing"

"github.com/Wei-Shaw/sub2api/internal/payment"
)

func TestCreateProviderSePay(t *testing.T) {
p, err := CreateProvider(payment.TypeSePay, "7", sepayTestConfig())
if err != nil {
t.Fatal(err)
}
if p.ProviderKey() != payment.TypeSePay {
t.Fatalf("provider key = %q", p.ProviderKey())
}
if _, err := CreateProvider(payment.TypeSePay, "7", map[string]string{}); err == nil {
t.Fatal("expected config validation error from factory")
}
}
Loading
Loading