支付相关

This commit is contained in:
Yvan
2026-08-19 18:06:03 +08:00
parent 1ef8ecb7b2
commit e54cbd1207
56 changed files with 8453 additions and 42 deletions
+1 -1
View File
@@ -3,4 +3,4 @@ package biz
import "github.com/google/wire"
// ProviderSet is biz providers.
var ProviderSet = wire.NewSet(NewUserUsecase, NewAuthenticationUsecase, NewSystemConfigUsecase, NewAuthorityUsecase, NewAPIUsecase, NewPermissionUsecase, NewAccessControlUsecase, NewMenuUsecase, NewDepartmentUsecase, NewPositionUsecase, NewDictionaryUsecase, NewParameterUsecase, NewTokenUsecase, NewSecurityUsecase, NewVersionUsecase, NewExportUsecase, NewAuditUsecase, NewAuditRecorderUsecase, NewLogViewerUsecase, NewTaskUsecase, NewTaskApplicationUsecase, NewMediaUsecase, NewAnnouncementUsecase, NewEmailUsecase)
var ProviderSet = wire.NewSet(NewUserUsecase, NewAuthenticationUsecase, NewSystemConfigUsecase, NewAuthorityUsecase, NewAPIUsecase, NewPermissionUsecase, NewAccessControlUsecase, NewMenuUsecase, NewDepartmentUsecase, NewPositionUsecase, NewDictionaryUsecase, NewParameterUsecase, NewTokenUsecase, NewSecurityUsecase, NewVersionUsecase, NewExportUsecase, NewAuditUsecase, NewAuditRecorderUsecase, NewLogViewerUsecase, NewTaskUsecase, NewTaskApplicationUsecase, NewMediaUsecase, NewAnnouncementUsecase, NewEmailUsecase, NewPaymentUsecase)
File diff suppressed because it is too large Load Diff
+32
View File
@@ -0,0 +1,32 @@
package biz
import (
"context"
"log/slog"
)
// PaymentLogger isolates payment audit logging from the payment workflow and
// lets an application replace slog with its own structured logger.
type PaymentLogger interface {
Info(context.Context, string, ...any)
Error(context.Context, string, ...any)
}
type slogPaymentLogger struct {
logger *slog.Logger
}
func NewPaymentLogger(logger *slog.Logger) PaymentLogger {
if logger == nil {
logger = slog.Default()
}
return &slogPaymentLogger{logger: logger.With("mod", "payment")}
}
func (l *slogPaymentLogger) Info(ctx context.Context, message string, args ...any) {
l.logger.InfoContext(ctx, message, args...)
}
func (l *slogPaymentLogger) Error(ctx context.Context, message string, args ...any) {
l.logger.ErrorContext(ctx, message, args...)
}
+210
View File
@@ -0,0 +1,210 @@
package biz
import (
"context"
"encoding/json"
"errors"
"strings"
"sync"
"time"
)
const (
PaymentStatusInitialized = "initialized"
PaymentStatusPending = "pending"
PaymentStatusPaid = "paid"
PaymentStatusFailed = "failed"
PaymentStatusClosed = "closed"
PaymentStatusPartiallyRefunded = "partially_refunded"
PaymentStatusRefunded = "refunded"
FulfillmentStatusPending = "pending"
FulfillmentStatusProcessing = "processing"
FulfillmentStatusSucceeded = "succeeded"
FulfillmentStatusFailed = "failed"
RefundStatusNone = "none"
RefundStatusProcessing = "processing"
RefundStatusPending = "pending"
RefundStatusPartial = "partial"
RefundStatusSucceeded = "succeeded"
RefundStatusFailed = "failed"
)
var (
ErrPaymentOrderNotFound = errors.New("支付订单不存在")
ErrPaymentOrderConflict = errors.New("支付订单参数冲突")
ErrPaymentOrderBusy = errors.New("支付订单正在处理中")
ErrPaymentOrderState = errors.New("支付订单状态不允许当前操作")
ErrPaymentProviderConflict = errors.New("支付平台交易号冲突")
)
// PaymentOrder is one payment attempt. Business modules own their domain
// orders and link to this record through BusinessType and BusinessID.
type PaymentOrder struct {
ID uint64
TradeNo string
Provider string
ProviderTradeNo string
QueryID string
BusinessType string
BusinessID string
Subject string
PaymentMode string
OriginalAmount int64
Amount int64
PaidAmount int64
PayerPaidAmount int64
CashPaidAmount int64
PointPaidAmount int64
DiscountAmount int64
ProviderDiscountAmount int64
MerchantDiscountAmount int64
SettlementAmount int64
Currency string
PayerCurrency string
AmountBreakdownKnown bool
PaymentStatus string
ProviderStatus string
FulfillmentStatus string
RefundStatus string
RefundedAmount int64
RefundRequestedAmount int64
RefundNo string
ConfirmationID string
RequestFingerprint string
CreatePayload json.RawMessage
Extra json.RawMessage
LastEventID string
LastPayloadHash string
LastError string
FulfillmentToken string
RefundToken string
Version uint64
CreatedAt time.Time
UpdatedAt time.Time
PaidAt *time.Time
FulfilledAt *time.Time
RefundedAt *time.Time
FulfillmentLeaseUntil *time.Time
RefundLeaseUntil *time.Time
}
type PaymentProviderUpdate struct {
Status string
ProviderStatus string
ProviderTradeNo string
QueryID string
Amount int64
PayerPaidAmount int64
CashPaidAmount int64
PointPaidAmount int64
DiscountAmount int64
ProviderDiscountAmount int64
MerchantDiscountAmount int64
SettlementAmount int64
Currency string
PayerCurrency string
AmountBreakdownKnown bool
EventID string
PayloadHash string
CreatePayload json.RawMessage
}
type PaymentOrderRepo interface {
CreatePaymentOrder(context.Context, *PaymentOrder) (order *PaymentOrder, created bool, err error)
FindPaymentOrder(context.Context, string, string) (*PaymentOrder, error)
RecordPaymentCreate(context.Context, string, string, *PaymentProviderUpdate) (*PaymentOrder, error)
ApplyPaymentResult(context.Context, string, string, *PaymentProviderUpdate) (*PaymentOrder, error)
BeginPaymentFulfillment(context.Context, string, string, time.Duration) (order *PaymentOrder, token string, duplicate bool, err error)
CompletePaymentFulfillment(context.Context, string, string, string, bool, string) (*PaymentOrder, error)
BeginPaymentRefund(context.Context, string, string, int64, time.Duration) (order *PaymentOrder, token string, err error)
CompletePaymentRefundRequest(context.Context, string, string, string, bool, string) (*PaymentOrder, error)
ConfirmPaymentRefund(context.Context, string, string, string, int64, bool, string) (*PaymentOrder, error)
}
// PaymentOrderSource is implemented by each business module. It returns the
// canonical order facts; client-provided amount, currency and subject are not
// trusted once the persistent payment-order flow is enabled.
type PaymentOrderSource interface {
Type() string
PreparePayment(context.Context, string, string, string) (*PaymentIntent, error)
}
type PaymentBusinessModule interface {
PaymentOrderSource
PaymentFulfillmentHandler
PaymentRefundAuthorizer
}
type PaymentRefundAuthorizer interface {
AuthorizeRefund(context.Context, *PaymentOrder, int64) error
}
type PaymentOrderSourceRegistry struct {
mu sync.RWMutex
sources map[string]PaymentOrderSource
}
func NewPaymentOrderSourceRegistry() *PaymentOrderSourceRegistry {
return &PaymentOrderSourceRegistry{sources: map[string]PaymentOrderSource{}}
}
func (r *PaymentOrderSourceRegistry) Register(source PaymentOrderSource) error {
if source == nil || strings.TrimSpace(source.Type()) == "" {
return errors.New("支付业务订单来源无效")
}
r.mu.Lock()
defer r.mu.Unlock()
if _, exists := r.sources[source.Type()]; exists {
return errors.New("支付业务订单来源已注册: " + source.Type())
}
r.sources[source.Type()] = source
return nil
}
func (r *PaymentOrderSourceRegistry) Source(kind string) PaymentOrderSource {
if r == nil {
return nil
}
r.mu.RLock()
defer r.mu.RUnlock()
return r.sources[kind]
}
func (r *PaymentOrderSourceRegistry) Unregister(kind string) {
if r == nil {
return
}
r.mu.Lock()
defer r.mu.Unlock()
delete(r.sources, kind)
}
func (o *PaymentOrder) Intent() *PaymentIntent {
if o == nil {
return nil
}
return &PaymentIntent{
Provider: o.Provider,
TradeNo: o.TradeNo,
BusinessType: o.BusinessType,
BusinessID: o.BusinessID,
Subject: o.Subject,
Amount: o.Amount,
Currency: o.Currency,
Extra: append(json.RawMessage(nil), o.Extra...),
PaymentMode: defaultPaymentMode(o.Provider, o.PaymentMode),
OriginalAmount: o.OriginalAmount,
}
}
func defaultPaymentMode(provider, mode string) string {
if strings.TrimSpace(mode) != "" {
return mode
}
if provider == PaymentInternal {
return PaymentModeInternal
}
return PaymentModeExternal
}
+348
View File
@@ -0,0 +1,348 @@
package biz
import (
"context"
"errors"
"io"
"log/slog"
"testing"
"time"
)
type paymentRepoStub struct {
create *PaymentResult
createdReq *PaymentRequest
refund *PaymentResult
refundErr error
refundedReq *PaymentRefundRequest
callback *PaymentResult
query *PaymentResult
queryCalls int
callbackErr error
}
func (r *paymentRepoStub) ListConfigs(context.Context) ([]*PaymentConfig, error) { return nil, nil }
func (r *paymentRepoStub) SaveConfig(context.Context, *PaymentConfig) error { return nil }
func (r *paymentRepoStub) Create(_ context.Context, req *PaymentRequest) (*PaymentResult, error) {
copyReq := *req
r.createdReq = &copyReq
return r.create, nil
}
type paymentOrderRepoStub struct {
order *PaymentOrder
}
func (r *paymentOrderRepoStub) CreatePaymentOrder(_ context.Context, order *PaymentOrder) (*PaymentOrder, bool, error) {
if r.order != nil {
return r.order, false, nil
}
copyOrder := *order
r.order = &copyOrder
return r.order, true, nil
}
func (r *paymentOrderRepoStub) FindPaymentOrder(context.Context, string, string) (*PaymentOrder, error) {
if r.order == nil {
return nil, ErrPaymentOrderNotFound
}
return r.order, nil
}
func (r *paymentOrderRepoStub) RecordPaymentCreate(_ context.Context, _, _ string, update *PaymentProviderUpdate) (*PaymentOrder, error) {
r.order.PaymentStatus = PaymentStatusPending
r.order.CreatePayload = append(r.order.CreatePayload[:0], update.CreatePayload...)
return r.order, nil
}
func (r *paymentOrderRepoStub) ApplyPaymentResult(_ context.Context, _, _ string, update *PaymentProviderUpdate) (*PaymentOrder, error) {
if update.Status == "success" {
r.order.PaymentStatus = PaymentStatusPaid
r.order.PaidAmount = r.order.Amount
r.order.ProviderTradeNo = update.ProviderTradeNo
}
return r.order, nil
}
func (r *paymentOrderRepoStub) BeginPaymentFulfillment(context.Context, string, string, time.Duration) (*PaymentOrder, string, bool, error) {
if r.order.FulfillmentStatus == FulfillmentStatusSucceeded {
return r.order, "", true, nil
}
r.order.FulfillmentStatus = FulfillmentStatusProcessing
return r.order, "token", false, nil
}
func (r *paymentOrderRepoStub) CompletePaymentFulfillment(_ context.Context, _, _, _ string, success bool, _ string) (*PaymentOrder, error) {
if success {
r.order.FulfillmentStatus = FulfillmentStatusSucceeded
} else {
r.order.FulfillmentStatus = FulfillmentStatusFailed
}
return r.order, nil
}
func (r *paymentOrderRepoStub) BeginPaymentRefund(context.Context, string, string, int64, time.Duration) (*PaymentOrder, string, error) {
r.order.RefundNo = "refund-1"
r.order.RefundStatus = RefundStatusProcessing
return r.order, "refund-token", nil
}
func (r *paymentOrderRepoStub) CompletePaymentRefundRequest(context.Context, string, string, string, bool, string) (*PaymentOrder, error) {
r.order.RefundStatus = RefundStatusPending
return r.order, nil
}
func (r *paymentOrderRepoStub) ConfirmPaymentRefund(context.Context, string, string, string, int64, bool, string) (*PaymentOrder, error) {
return r.order, nil
}
type paymentSourceStub struct{ intent *PaymentIntent }
func (s *paymentSourceStub) Type() string { return s.intent.BusinessType }
func (s *paymentSourceStub) PreparePayment(context.Context, string, string, string) (*PaymentIntent, error) {
return s.intent, nil
}
func (r *paymentRepoStub) Query(context.Context, string, string) (*PaymentResult, error) {
r.queryCalls++
return r.query, nil
}
func (r *paymentRepoStub) Refund(_ context.Context, req *PaymentRefundRequest) (*PaymentResult, error) {
if req != nil {
copyReq := *req
r.refundedReq = &copyReq
}
return r.refund, r.refundErr
}
func (r *paymentRepoStub) HandleCallback(context.Context, *PaymentCallback) (*PaymentResult, error) {
return r.callback, r.callbackErr
}
type paymentFulfillmentStub struct {
calls int
failFirst bool
confirmIDs []string
}
func (h *paymentFulfillmentStub) Type() string { return "game_item" }
func (h *paymentFulfillmentStub) AuthorizeRefund(context.Context, *PaymentOrder, int64) error {
return nil
}
func (h *paymentFulfillmentStub) Fulfill(_ context.Context, confirmation *PaymentConfirmation) error {
h.calls++
h.confirmIDs = append(h.confirmIDs, confirmation.ID)
if h.failFirst && h.calls == 1 {
return errors.New("temporary fulfillment failure")
}
return nil
}
func newPaymentUsecaseForTest(repo PaymentRepo) *PaymentUsecase {
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
return NewPaymentUsecase(repo, &paymentOrderRepoStub{order: testBizPaymentOrder()}, logger)
}
func testBizPaymentOrder() *PaymentOrder {
return &PaymentOrder{Provider: PaymentAlipay, TradeNo: "order-1", BusinessType: "game_item", BusinessID: "item-1", Subject: "item", Amount: 100, Currency: "CNY", PaymentStatus: PaymentStatusPending, FulfillmentStatus: FulfillmentStatusPending, RefundStatus: RefundStatusNone, ConfirmationID: "11111111-1111-1111-1111-111111111111"}
}
func successPaymentResult() *PaymentResult {
return &PaymentResult{
Provider: PaymentAlipay,
Status: "success",
TradeNo: "order-1",
ProviderTradeNo: "ali-1",
Amount: 100,
Currency: "CNY",
}
}
func successIntent() *PaymentIntent {
return &PaymentIntent{
Provider: PaymentAlipay,
TradeNo: "order-1",
BusinessType: "game_item",
BusinessID: "item-1",
Amount: 100,
Currency: "CNY",
}
}
func TestPaymentCallbackQueriesBeforeFulfillment(t *testing.T) {
repo := &paymentRepoStub{callback: successPaymentResult(), query: successPaymentResult()}
uc := newPaymentUsecaseForTest(repo)
_, err := uc.Callback(context.Background(), &PaymentCallback{Provider: PaymentAlipay, Body: []byte("callback")})
if err == nil {
t.Fatal("callback succeeded without fulfillment handler")
}
if repo.queryCalls != 1 {
t.Fatalf("query calls = %d, want 1", repo.queryCalls)
}
}
func TestPaymentCallbackPendingDoesNotFulfill(t *testing.T) {
pending := successPaymentResult()
pending.Status = "pending"
repo := &paymentRepoStub{callback: successPaymentResult(), query: pending}
handler := &paymentFulfillmentStub{}
uc := newPaymentUsecaseForTest(repo)
registry := NewPaymentFulfillmentRegistry()
if err := registry.Register(handler); err != nil {
t.Fatal(err)
}
uc.SetFulfillmentRegistry(registry)
result, err := uc.Callback(context.Background(), &PaymentCallback{Provider: PaymentAlipay, Body: []byte("callback")})
if err != nil {
t.Fatal(err)
}
if result.Status != "pending" || handler.calls != 0 {
t.Fatalf("result=%+v handler=%d", result, handler.calls)
}
}
func TestPaymentCallbackRejectsAmountMismatch(t *testing.T) {
repo := &paymentRepoStub{callback: successPaymentResult(), query: successPaymentResult()}
handler := &paymentFulfillmentStub{}
uc := newPaymentUsecaseForTest(repo)
uc.orders.(*paymentOrderRepoStub).order.Amount = 101
registry := NewPaymentFulfillmentRegistry()
_ = registry.Register(handler)
uc.SetFulfillmentRegistry(registry)
if _, err := uc.Callback(context.Background(), &PaymentCallback{Provider: PaymentAlipay, Body: []byte("callback")}); err == nil {
t.Fatal("amount mismatch was accepted")
}
if handler.calls != 0 {
t.Fatal("handler ran after amount mismatch")
}
}
func TestPaymentCallbackRequiresFulfillmentHandler(t *testing.T) {
repo := &paymentRepoStub{callback: successPaymentResult(), query: successPaymentResult()}
uc := newPaymentUsecaseForTest(repo)
if _, err := uc.Callback(context.Background(), &PaymentCallback{Provider: PaymentAlipay, Body: []byte("callback")}); err == nil {
t.Fatal("callback succeeded without fulfillment handler")
}
}
func TestPaymentCallbackDuplicateDoesNotFulfillTwice(t *testing.T) {
repo := &paymentRepoStub{callback: successPaymentResult(), query: successPaymentResult()}
handler := &paymentFulfillmentStub{}
uc := newPaymentUsecaseForTest(repo)
registry := NewPaymentFulfillmentRegistry()
_ = registry.Register(handler)
uc.SetFulfillmentRegistry(registry)
callback := &PaymentCallback{Provider: PaymentAlipay, Body: []byte("callback")}
if _, err := uc.Callback(context.Background(), callback); err != nil {
t.Fatal(err)
}
second, err := uc.Callback(context.Background(), callback)
if err != nil {
t.Fatal(err)
}
if !second.Duplicate || handler.calls != 1 || repo.queryCalls != 2 {
t.Fatalf("duplicate=%v handler=%d query=%d", second.Duplicate, handler.calls, repo.queryCalls)
}
}
func TestPaymentCallbackFulfillmentFailureCanRetry(t *testing.T) {
repo := &paymentRepoStub{callback: successPaymentResult(), query: successPaymentResult()}
handler := &paymentFulfillmentStub{failFirst: true}
uc := newPaymentUsecaseForTest(repo)
registry := NewPaymentFulfillmentRegistry()
_ = registry.Register(handler)
uc.SetFulfillmentRegistry(registry)
callback := &PaymentCallback{Provider: PaymentAlipay, Body: []byte("callback")}
if _, err := uc.Callback(context.Background(), callback); err == nil {
t.Fatal("first fulfillment failure was accepted")
}
result, err := uc.Callback(context.Background(), callback)
if err != nil {
t.Fatal(err)
}
if result.Status != "fulfilled" || handler.calls != 2 || handler.confirmIDs[0] != handler.confirmIDs[1] {
t.Fatalf("result=%+v handler=%d ids=%v", result, handler.calls, handler.confirmIDs)
}
}
func TestPaymentQueryRejectsIncompleteSuccess(t *testing.T) {
result := successPaymentResult()
result.ProviderTradeNo = ""
uc := newPaymentUsecaseForTest(&paymentRepoStub{query: result})
if _, err := uc.Query(context.Background(), PaymentAlipay, "order-1"); err == nil {
t.Fatal("incomplete successful query was accepted")
}
}
func TestPaymentQueryRejectsUnknownStatus(t *testing.T) {
result := successPaymentResult()
result.Status = "maybe"
uc := newPaymentUsecaseForTest(&paymentRepoStub{query: result})
if _, err := uc.Query(context.Background(), PaymentAlipay, "order-1"); err == nil {
t.Fatal("unknown payment status was accepted")
}
}
func TestPaymentCallbackErrorCarriesSafeAck(t *testing.T) {
ack := DefaultPaymentCallbackAck(PaymentAlipay, false)
err := callbackResultError(&PaymentResult{FailureAck: ack}, errors.New("internal order resolver details"))
callbackErr, ok := err.(*PaymentCallbackError)
if !ok || string(callbackErr.Ack.Body) != "failure" {
t.Fatalf("callback error = %#v", err)
}
}
func TestPersistentPaymentCreateUsesBusinessCanonicalAmount(t *testing.T) {
repo := &paymentRepoStub{create: &PaymentResult{Provider: PaymentAlipay, Status: "created", TradeNo: "order-1", Payload: []byte(`{"prepay":"x"}`)}}
orders := &paymentOrderRepoStub{}
uc := NewPaymentUsecase(repo, orders, slog.New(slog.NewTextHandler(io.Discard, nil)))
sources := NewPaymentOrderSourceRegistry()
if err := sources.Register(&paymentSourceStub{intent: &PaymentIntent{Provider: PaymentAlipay, TradeNo: "order-1", BusinessType: "game_item", BusinessID: "item-1", Subject: "canonical item", Amount: 100, Currency: "CNY", Extra: []byte(`{"product_id":"canonical"}`)}}); err != nil {
t.Fatal(err)
}
uc.SetOrderSourceRegistry(sources)
result, err := uc.Create(context.Background(), &PaymentRequest{Provider: PaymentAlipay, TradeNo: "order-1", BusinessType: "game_item", BusinessID: "item-1", Subject: "forged", Amount: 1, Currency: "USD", Extra: map[string]any{"product_id": "forged"}})
if err != nil {
t.Fatal(err)
}
if repo.createdReq.Amount != 100 || repo.createdReq.Currency != "CNY" || repo.createdReq.Subject != "canonical item" || repo.createdReq.Extra["product_id"] != "canonical" || orders.order.Amount != 100 || result.OrderStatus != PaymentStatusPending {
t.Fatalf("canonical order was not used: request=%+v order=%+v result=%+v", repo.createdReq, orders.order, result)
}
}
func TestPersistentPaymentCallbackFulfillsAndPersistsDuplicate(t *testing.T) {
repo := &paymentRepoStub{callback: successPaymentResult(), query: successPaymentResult()}
orders := &paymentOrderRepoStub{order: &PaymentOrder{Provider: PaymentAlipay, TradeNo: "order-1", BusinessType: "game_item", BusinessID: "item-1", Subject: "item", Amount: 100, Currency: "CNY", PaymentStatus: PaymentStatusPending, FulfillmentStatus: FulfillmentStatusPending, RefundStatus: RefundStatusNone, ConfirmationID: "11111111-1111-1111-1111-111111111111"}}
handler := &paymentFulfillmentStub{}
uc := NewPaymentUsecase(repo, orders, slog.New(slog.NewTextHandler(io.Discard, nil)))
registry := NewPaymentFulfillmentRegistry()
if err := registry.Register(handler); err != nil {
t.Fatal(err)
}
uc.SetFulfillmentRegistry(registry)
first, err := uc.Callback(context.Background(), &PaymentCallback{Provider: PaymentAlipay, Body: []byte("callback")})
if err != nil {
t.Fatal(err)
}
second, err := uc.Callback(context.Background(), &PaymentCallback{Provider: PaymentAlipay, Body: []byte("callback")})
if err != nil {
t.Fatal(err)
}
if first.Status != "fulfilled" || second.Status != "fulfilled" || !second.Duplicate || handler.calls != 1 || orders.order.FulfillmentStatus != FulfillmentStatusSucceeded {
t.Fatalf("persistent callback flow failed: first=%+v second=%+v handler=%d order=%+v", first, second, handler.calls, orders.order)
}
}
func TestPaymentConfirmationAcceptsProviderDiscount(t *testing.T) {
intent := &PaymentIntent{Provider: PaymentAlipay, TradeNo: "T1", Amount: 1000, Currency: "CNY", BusinessType: "item", BusinessID: "I1"}
result := &PaymentResult{Provider: PaymentAlipay, TradeNo: "T1", ProviderTradeNo: "P1", Status: "success", Amount: 1000, PayerPaidAmount: 800, CashPaidAmount: 800, DiscountAmount: 200, ProviderDiscountAmount: 150, MerchantDiscountAmount: 50, SettlementAmount: 950, Currency: "CNY", AmountBreakdownKnown: true}
if err := validatePaymentConfirmation(intent, PaymentAlipay, "T1", result); err != nil {
t.Fatal(err)
}
}
func TestPaymentConfirmationRejectsOverpayment(t *testing.T) {
intent := &PaymentIntent{Provider: PaymentWechatV3, TradeNo: "T1", Amount: 1000, Currency: "CNY", BusinessType: "item", BusinessID: "I1"}
result := &PaymentResult{Provider: PaymentWechatV3, TradeNo: "T1", ProviderTradeNo: "P1", Status: "success", Amount: 1000, PayerPaidAmount: 1001, CashPaidAmount: 1001, Currency: "CNY", AmountBreakdownKnown: true}
if err := validatePaymentConfirmation(intent, PaymentWechatV3, "T1", result); err == nil {
t.Fatal("overpayment was accepted")
}
}
func TestPaymentConfirmationRejectsBrokenDiscountConservation(t *testing.T) {
intent := &PaymentIntent{Provider: PaymentWechatV2, TradeNo: "T1", Amount: 1000, Currency: "CNY", BusinessType: "item", BusinessID: "I1"}
result := &PaymentResult{Provider: PaymentWechatV2, TradeNo: "T1", ProviderTradeNo: "P1", Status: "success", Amount: 1000, PayerPaidAmount: 800, CashPaidAmount: 800, DiscountAmount: 100, Currency: "CNY", AmountBreakdownKnown: true}
if err := validatePaymentConfirmation(intent, PaymentWechatV2, "T1", result); err == nil {
t.Fatal("broken discount conservation was accepted")
}
}