优化结构
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,32 @@
|
||||
package payment
|
||||
|
||||
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...)
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
package payment
|
||||
|
||||
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 PaymentOrderFilter struct {
|
||||
Provider string
|
||||
TradeNo string
|
||||
BusinessType string
|
||||
BusinessID string
|
||||
PaymentStatus string
|
||||
RefundStatus string
|
||||
}
|
||||
|
||||
type PaymentOrderRepo interface {
|
||||
CreatePaymentOrder(context.Context, *PaymentOrder) (order *PaymentOrder, created bool, err error)
|
||||
FindPaymentOrder(context.Context, string, string) (*PaymentOrder, error)
|
||||
ListPaymentOrders(context.Context, int, int, PaymentOrderFilter) ([]*PaymentOrder, int64, 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
|
||||
}
|
||||
@@ -0,0 +1,391 @@
|
||||
package payment
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"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) Create(_ context.Context, req *PaymentRequest) (*PaymentResult, error) {
|
||||
copyReq := *req
|
||||
r.createdReq = ©Req
|
||||
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 = ©Order
|
||||
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) ListPaymentOrders(context.Context, int, int, PaymentOrderFilter) ([]*PaymentOrder, int64, error) {
|
||||
if r.order == nil {
|
||||
return []*PaymentOrder{}, 0, nil
|
||||
}
|
||||
return []*PaymentOrder{r.order}, 1, 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 = ©Req
|
||||
}
|
||||
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 TestPaymentFulfillRetriesFailedOrder(t *testing.T) {
|
||||
orders := &paymentOrderRepoStub{order: &PaymentOrder{Provider: PaymentAlipay, TradeNo: "order-1", BusinessType: "game_item", BusinessID: "item-1", Subject: "item", Amount: 100, Currency: "CNY", PaymentStatus: PaymentStatusPaid, FulfillmentStatus: FulfillmentStatusFailed, RefundStatus: RefundStatusNone, ConfirmationID: "11111111-1111-1111-1111-111111111111"}}
|
||||
uc := NewPaymentUsecase(&paymentRepoStub{}, orders, slog.New(slog.NewTextHandler(io.Discard, nil)))
|
||||
handler := &paymentFulfillmentStub{}
|
||||
registry := NewPaymentFulfillmentRegistry()
|
||||
if err := registry.Register(handler); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
uc.SetFulfillmentRegistry(registry)
|
||||
result, err := uc.Fulfill(context.Background(), PaymentAlipay, "order-1")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.Status != "fulfilled" || handler.calls != 1 || orders.order.FulfillmentStatus != FulfillmentStatusSucceeded {
|
||||
t.Fatalf("fulfillment retry failed: result=%+v calls=%d status=%s", result, handler.calls, orders.order.FulfillmentStatus)
|
||||
}
|
||||
}
|
||||
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPaymentConfirmationBindsAppleProductID(t *testing.T) {
|
||||
intent := &PaymentIntent{
|
||||
Provider: PaymentApple, TradeNo: "123e4567-e89b-12d3-a456-426614174000",
|
||||
Amount: 100, Currency: "USD", BusinessType: "item", BusinessID: "I1",
|
||||
Extra: json.RawMessage(`{"product_id":"com.example.coin100"}`),
|
||||
}
|
||||
result := &PaymentResult{
|
||||
Provider: PaymentApple, TradeNo: intent.TradeNo, ProviderTradeNo: "APPLE-TX-1",
|
||||
Status: "success", Amount: 100, Currency: "USD",
|
||||
Payload: json.RawMessage(`{"productId":"com.example.coin100"}`),
|
||||
}
|
||||
if err := validatePaymentConfirmation(intent, PaymentApple, intent.TradeNo, result); err != nil {
|
||||
t.Fatalf("matching productId rejected: %v", err)
|
||||
}
|
||||
result.Payload = json.RawMessage(`{"productId":"com.example.coin200"}`)
|
||||
if err := validatePaymentConfirmation(intent, PaymentApple, intent.TradeNo, result); err == nil {
|
||||
t.Fatal("mismatched Apple productId was accepted")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package payment
|
||||
|
||||
import "github.com/google/wire"
|
||||
|
||||
// ProviderSet wires the payment domain usecase independently from other
|
||||
// business domains. Persistence and transport layers consume the payment
|
||||
// interfaces without depending on the system package.
|
||||
var ProviderSet = wire.NewSet(NewPaymentUsecase)
|
||||
Reference in New Issue
Block a user