package payment import ( "context" "encoding/json" "errors" "strings" "time" "github.com/google/uuid" "gorm.io/gorm" "gorm.io/gorm/clause" "kra/app/system/internal/biz" "kra/pkg/database/pagination" ) type paymentOrderPO struct { ID uint64 `gorm:"primaryKey"` CreatedAt time.Time UpdatedAt time.Time TradeNo string `gorm:"size:128;not null;uniqueIndex:idx_pay_orders_provider_trade"` Provider string `gorm:"size:64;not null;uniqueIndex:idx_pay_orders_provider_trade;uniqueIndex:idx_pay_orders_provider_provider_trade;index"` ProviderTradeNo *string `gorm:"size:128;uniqueIndex:idx_pay_orders_provider_provider_trade"` QueryID string `gorm:"size:128"` BusinessType string `gorm:"size:64;not null;index:idx_pay_orders_business"` BusinessID string `gorm:"size:128;not null;index:idx_pay_orders_business"` Subject string `gorm:"size:256;not null"` PaymentMode string `gorm:"size:32;not null;default:external;index"` OriginalAmount int64 `gorm:"not null;default:0"` Amount int64 `gorm:"not null"` PaidAmount int64 `gorm:"not null;default:0"` PayerPaidAmount int64 `gorm:"not null;default:0"` CashPaidAmount int64 `gorm:"not null;default:0"` PointPaidAmount int64 `gorm:"not null;default:0"` DiscountAmount int64 `gorm:"not null;default:0"` ProviderDiscountAmount int64 `gorm:"not null;default:0"` MerchantDiscountAmount int64 `gorm:"not null;default:0"` SettlementAmount int64 `gorm:"not null;default:0"` Currency string `gorm:"size:16;not null"` PayerCurrency string `gorm:"size:16"` AmountBreakdownKnown bool `gorm:"not null;default:false"` PaymentStatus string `gorm:"size:32;not null;index"` ProviderStatus string `gorm:"size:64"` FulfillmentStatus string `gorm:"size:32;not null;index"` RefundStatus string `gorm:"size:32;not null;index"` RefundedAmount int64 `gorm:"not null;default:0"` RefundRequestedAmount int64 `gorm:"not null;default:0"` RefundNo string `gorm:"size:128;index"` ConfirmationID string `gorm:"size:36;not null;uniqueIndex"` RequestFingerprint string `gorm:"size:64;not null;index"` CreatePayload string `gorm:"type:text"` Extra string `gorm:"type:text"` LastEventID string `gorm:"size:128"` LastPayloadHash string `gorm:"size:64"` LastError string `gorm:"size:512"` FulfillmentToken string `gorm:"size:36"` RefundToken string `gorm:"size:36"` Version uint64 `gorm:"not null;default:1"` PaidAt *time.Time FulfilledAt *time.Time RefundedAt *time.Time FulfillmentLeaseUntil *time.Time RefundLeaseUntil *time.Time } func (paymentOrderPO) TableName() string { return "pay_orders" } type paymentOrderRepo struct{ data Provider } func NewPaymentOrderRepo(data Provider) biz.PaymentOrderRepo { return &paymentOrderRepo{data: data} } func newPaymentOrderPO(order *biz.PaymentOrder) (*paymentOrderPO, error) { if order == nil { return nil, errors.New("支付订单为空") } extra := string(order.Extra) if extra == "" { extra = "{}" } createPayload := string(order.CreatePayload) return &paymentOrderPO{ ID: order.ID, TradeNo: order.TradeNo, Provider: order.Provider, ProviderTradeNo: optionalString(order.ProviderTradeNo), QueryID: order.QueryID, BusinessType: order.BusinessType, BusinessID: order.BusinessID, Subject: order.Subject, PaymentMode: defaultString(order.PaymentMode, biz.PaymentModeExternal), OriginalAmount: order.OriginalAmount, Amount: order.Amount, PaidAmount: order.PaidAmount, PayerPaidAmount: order.PayerPaidAmount, CashPaidAmount: order.CashPaidAmount, PointPaidAmount: order.PointPaidAmount, DiscountAmount: order.DiscountAmount, ProviderDiscountAmount: order.ProviderDiscountAmount, MerchantDiscountAmount: order.MerchantDiscountAmount, SettlementAmount: order.SettlementAmount, Currency: order.Currency, PayerCurrency: order.PayerCurrency, AmountBreakdownKnown: order.AmountBreakdownKnown, PaymentStatus: defaultString(order.PaymentStatus, biz.PaymentStatusInitialized), ProviderStatus: order.ProviderStatus, FulfillmentStatus: defaultString(order.FulfillmentStatus, biz.FulfillmentStatusPending), RefundStatus: defaultString(order.RefundStatus, biz.RefundStatusNone), RefundedAmount: order.RefundedAmount, RefundRequestedAmount: order.RefundRequestedAmount, RefundNo: order.RefundNo, ConfirmationID: order.ConfirmationID, RequestFingerprint: order.RequestFingerprint, CreatePayload: createPayload, Extra: extra, LastEventID: order.LastEventID, LastPayloadHash: order.LastPayloadHash, LastError: order.LastError, FulfillmentToken: order.FulfillmentToken, RefundToken: order.RefundToken, Version: defaultVersion(order.Version), PaidAt: order.PaidAt, FulfilledAt: order.FulfilledAt, RefundedAt: order.RefundedAt, FulfillmentLeaseUntil: order.FulfillmentLeaseUntil, RefundLeaseUntil: order.RefundLeaseUntil, }, nil } func toBizPaymentOrder(po *paymentOrderPO) *biz.PaymentOrder { if po == nil { return nil } return &biz.PaymentOrder{ ID: po.ID, TradeNo: po.TradeNo, Provider: po.Provider, ProviderTradeNo: dereferenceString(po.ProviderTradeNo), QueryID: po.QueryID, BusinessType: po.BusinessType, BusinessID: po.BusinessID, Subject: po.Subject, PaymentMode: defaultString(po.PaymentMode, biz.PaymentModeExternal), OriginalAmount: po.OriginalAmount, Amount: po.Amount, PaidAmount: po.PaidAmount, PayerPaidAmount: po.PayerPaidAmount, CashPaidAmount: po.CashPaidAmount, PointPaidAmount: po.PointPaidAmount, DiscountAmount: po.DiscountAmount, ProviderDiscountAmount: po.ProviderDiscountAmount, MerchantDiscountAmount: po.MerchantDiscountAmount, SettlementAmount: po.SettlementAmount, Currency: po.Currency, PayerCurrency: po.PayerCurrency, AmountBreakdownKnown: po.AmountBreakdownKnown, PaymentStatus: po.PaymentStatus, ProviderStatus: po.ProviderStatus, FulfillmentStatus: po.FulfillmentStatus, RefundStatus: po.RefundStatus, RefundedAmount: po.RefundedAmount, RefundRequestedAmount: po.RefundRequestedAmount, RefundNo: po.RefundNo, ConfirmationID: po.ConfirmationID, RequestFingerprint: po.RequestFingerprint, CreatePayload: json.RawMessage(po.CreatePayload), Extra: json.RawMessage(po.Extra), LastEventID: po.LastEventID, LastPayloadHash: po.LastPayloadHash, LastError: po.LastError, FulfillmentToken: po.FulfillmentToken, RefundToken: po.RefundToken, Version: po.Version, CreatedAt: po.CreatedAt, UpdatedAt: po.UpdatedAt, PaidAt: po.PaidAt, FulfilledAt: po.FulfilledAt, RefundedAt: po.RefundedAt, FulfillmentLeaseUntil: po.FulfillmentLeaseUntil, RefundLeaseUntil: po.RefundLeaseUntil, } } func (r *paymentOrderRepo) CreatePaymentOrder(ctx context.Context, order *biz.PaymentOrder) (*biz.PaymentOrder, bool, error) { po, err := newPaymentOrderPO(order) if err != nil { return nil, false, err } db := r.data.DB().WithContext(ctx) var current paymentOrderPO err = db.Where("provider = ? AND trade_no = ?", po.Provider, po.TradeNo).First(¤t).Error if err == nil { return toBizPaymentOrder(¤t), false, nil } if !errors.Is(err, gorm.ErrRecordNotFound) { return nil, false, err } if err = db.Create(po).Error; err == nil { return toBizPaymentOrder(po), true, nil } if lookupErr := db.Where("provider = ? AND trade_no = ?", po.Provider, po.TradeNo).First(¤t).Error; lookupErr == nil { return toBizPaymentOrder(¤t), false, nil } return nil, false, err } func (r *paymentOrderRepo) FindPaymentOrder(ctx context.Context, provider, tradeNo string) (*biz.PaymentOrder, error) { var po paymentOrderPO if err := r.data.DB().WithContext(ctx).Where("provider = ? AND trade_no = ?", provider, tradeNo).First(&po).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, biz.ErrPaymentOrderNotFound } return nil, err } return toBizPaymentOrder(&po), nil } func (r *paymentOrderRepo) ListPaymentOrders(ctx context.Context, page, pageSize int, filter biz.PaymentOrderFilter) ([]*biz.PaymentOrder, int64, error) { db := r.data.DB().WithContext(ctx).Model(&paymentOrderPO{}) if value := strings.TrimSpace(filter.Provider); value != "" { db = db.Where("provider = ?", value) } if value := strings.TrimSpace(filter.TradeNo); value != "" { db = db.Where("trade_no LIKE ?", "%"+value+"%") } if value := strings.TrimSpace(filter.BusinessType); value != "" { db = db.Where("business_type = ?", value) } if value := strings.TrimSpace(filter.BusinessID); value != "" { db = db.Where("business_id LIKE ?", "%"+value+"%") } if value := strings.TrimSpace(filter.PaymentStatus); value != "" { db = db.Where("payment_status = ?", value) } if value := strings.TrimSpace(filter.RefundStatus); value != "" { db = db.Where("refund_status = ?", value) } var total int64 if err := db.Count(&total).Error; err != nil { return nil, 0, err } var rows []paymentOrderPO if err := pagination.ApplyRequired(db.Order("id desc"), page, pageSize, 100).Find(&rows).Error; err != nil { return nil, 0, err } items := make([]*biz.PaymentOrder, 0, len(rows)) for i := range rows { items = append(items, toBizPaymentOrder(&rows[i])) } return items, total, nil } func (r *paymentOrderRepo) RecordPaymentCreate(ctx context.Context, provider, tradeNo string, update *biz.PaymentProviderUpdate) (*biz.PaymentOrder, error) { return r.withLockedOrder(ctx, provider, tradeNo, func(tx *gorm.DB, po *paymentOrderPO) error { if update == nil { return errors.New("支付下单结果为空") } if err := applyProviderIdentity(tx, po, update); err != nil { return err } if len(update.CreatePayload) > 0 { po.CreatePayload = string(update.CreatePayload) } po.ProviderStatus = trimTo(update.ProviderStatus, 64) po.LastEventID = trimTo(update.EventID, 128) po.LastPayloadHash = trimTo(update.PayloadHash, 64) status := normalizeOrderPaymentStatus(update.Status) if status == biz.PaymentStatusPaid { // Provider create responses are never sufficient proof of payment. status = biz.PaymentStatusPending } if po.PaymentStatus != biz.PaymentStatusPaid && po.PaymentStatus != biz.PaymentStatusPartiallyRefunded && po.PaymentStatus != biz.PaymentStatusRefunded && status != "" { po.PaymentStatus = status } po.Version++ return tx.Save(po).Error }) } func (r *paymentOrderRepo) ApplyPaymentResult(ctx context.Context, provider, tradeNo string, update *biz.PaymentProviderUpdate) (*biz.PaymentOrder, error) { return r.withLockedOrder(ctx, provider, tradeNo, func(tx *gorm.DB, po *paymentOrderPO) error { if update == nil { return errors.New("支付查单结果为空") } if err := applyProviderIdentity(tx, po, update); err != nil { return err } if update.Amount > 0 && update.Amount != po.Amount { return biz.ErrPaymentOrderConflict } if update.PayerPaidAmount < 0 || update.CashPaidAmount < 0 || update.PointPaidAmount < 0 || update.DiscountAmount < 0 || update.ProviderDiscountAmount < 0 || update.MerchantDiscountAmount < 0 || update.SettlementAmount < 0 { return biz.ErrPaymentOrderConflict } if update.Currency != "" && !strings.EqualFold(update.Currency, po.Currency) { return biz.ErrPaymentOrderConflict } po.ProviderStatus = trimTo(update.ProviderStatus, 64) po.LastEventID = trimTo(update.EventID, 128) po.LastPayloadHash = trimTo(update.PayloadHash, 64) if update.AmountBreakdownKnown { po.PayerPaidAmount = update.PayerPaidAmount po.CashPaidAmount = update.CashPaidAmount po.PointPaidAmount = update.PointPaidAmount po.DiscountAmount = update.DiscountAmount po.ProviderDiscountAmount = update.ProviderDiscountAmount po.MerchantDiscountAmount = update.MerchantDiscountAmount po.SettlementAmount = update.SettlementAmount po.PayerCurrency = trimTo(update.PayerCurrency, 16) po.AmountBreakdownKnown = true } switch normalizeOrderPaymentStatus(update.Status) { case biz.PaymentStatusPaid: if po.PaymentStatus != biz.PaymentStatusPartiallyRefunded && po.PaymentStatus != biz.PaymentStatusRefunded { po.PaymentStatus = biz.PaymentStatusPaid } po.PaidAmount = po.Amount if po.PaidAt == nil { now := time.Now().UTC() po.PaidAt = &now } case biz.PaymentStatusPending: if po.PaymentStatus != biz.PaymentStatusPaid && po.PaymentStatus != biz.PaymentStatusPartiallyRefunded && po.PaymentStatus != biz.PaymentStatusRefunded { po.PaymentStatus = biz.PaymentStatusPending } case biz.PaymentStatusFailed: if po.PaymentStatus != biz.PaymentStatusPaid && po.PaymentStatus != biz.PaymentStatusPartiallyRefunded && po.PaymentStatus != biz.PaymentStatusRefunded { po.PaymentStatus = biz.PaymentStatusFailed } } po.Version++ return tx.Save(po).Error }) } func (r *paymentOrderRepo) BeginPaymentFulfillment(ctx context.Context, provider, tradeNo string, lease time.Duration) (*biz.PaymentOrder, string, bool, error) { var token string var duplicate bool order, err := r.withLockedOrder(ctx, provider, tradeNo, func(tx *gorm.DB, po *paymentOrderPO) error { if po.FulfillmentStatus == biz.FulfillmentStatusSucceeded { duplicate = true return nil } if po.PaymentStatus != biz.PaymentStatusPaid { return biz.ErrPaymentOrderState } now := time.Now().UTC() if po.FulfillmentStatus == biz.FulfillmentStatusProcessing && po.FulfillmentLeaseUntil != nil && po.FulfillmentLeaseUntil.After(now) { return biz.ErrPaymentOrderBusy } if lease <= 0 { lease = 10 * time.Minute } token = uuid.NewString() until := now.Add(lease) po.FulfillmentStatus = biz.FulfillmentStatusProcessing po.FulfillmentToken = token po.FulfillmentLeaseUntil = &until po.LastError = "" po.Version++ return tx.Save(po).Error }) return order, token, duplicate, err } func (r *paymentOrderRepo) CompletePaymentFulfillment(ctx context.Context, provider, tradeNo, token string, success bool, message string) (*biz.PaymentOrder, error) { return r.withLockedOrder(ctx, provider, tradeNo, func(tx *gorm.DB, po *paymentOrderPO) error { if po.FulfillmentStatus != biz.FulfillmentStatusProcessing || po.FulfillmentToken != token { return biz.ErrPaymentOrderBusy } po.FulfillmentToken = "" po.FulfillmentLeaseUntil = nil po.LastError = trimTo(message, 512) if success { po.FulfillmentStatus = biz.FulfillmentStatusSucceeded now := time.Now().UTC() po.FulfilledAt = &now } else { po.FulfillmentStatus = biz.FulfillmentStatusFailed } po.Version++ return tx.Save(po).Error }) } func (r *paymentOrderRepo) BeginPaymentRefund(ctx context.Context, provider, tradeNo string, amount int64, lease time.Duration) (*biz.PaymentOrder, string, error) { var token string order, err := r.withLockedOrder(ctx, provider, tradeNo, func(tx *gorm.DB, po *paymentOrderPO) error { if po.PaymentStatus != biz.PaymentStatusPaid && po.PaymentStatus != biz.PaymentStatusPartiallyRefunded { return biz.ErrPaymentOrderState } if amount <= 0 { return biz.ErrPaymentOrderConflict } now := time.Now().UTC() if po.RefundStatus == biz.RefundStatusProcessing && po.RefundLeaseUntil != nil && po.RefundLeaseUntil.After(now) { return biz.ErrPaymentOrderBusy } if po.RefundStatus == biz.RefundStatusProcessing && po.RefundRequestedAmount != amount { return biz.ErrPaymentOrderConflict } if po.RefundStatus == biz.RefundStatusPending { return biz.ErrPaymentOrderBusy } // An expired processing lease means the provider outcome is unknown. // Retry the same refund amount with the same durable refund number. A // different amount must never reuse that operation identity. reserved := int64(0) if po.RefundStatus == biz.RefundStatusProcessing && po.RefundLeaseUntil != nil && !po.RefundLeaseUntil.After(now) { reserved = 0 } else { reserved = po.RefundRequestedAmount } if amount > po.Amount-po.RefundedAmount-reserved { return biz.ErrPaymentOrderConflict } if lease <= 0 { lease = 10 * time.Minute } token = uuid.NewString() if po.RefundNo == "" { po.RefundNo = uuid.NewString() } until := now.Add(lease) po.RefundStatus = biz.RefundStatusProcessing po.RefundRequestedAmount = amount po.RefundToken = token po.RefundLeaseUntil = &until po.LastError = "" po.Version++ return tx.Save(po).Error }) return order, token, err } func (r *paymentOrderRepo) CompletePaymentRefundRequest(ctx context.Context, provider, tradeNo, token string, accepted bool, message string) (*biz.PaymentOrder, error) { return r.withLockedOrder(ctx, provider, tradeNo, func(tx *gorm.DB, po *paymentOrderPO) error { if po.RefundStatus != biz.RefundStatusProcessing || po.RefundToken != token { return biz.ErrPaymentOrderBusy } po.RefundToken = "" po.RefundLeaseUntil = nil po.LastError = trimTo(message, 512) if accepted { po.RefundStatus = biz.RefundStatusPending } else { po.RefundStatus = biz.RefundStatusFailed po.RefundRequestedAmount = 0 po.RefundNo = "" } po.Version++ return tx.Save(po).Error }) } func (r *paymentOrderRepo) ConfirmPaymentRefund(ctx context.Context, provider, tradeNo, refundNo string, amount int64, success bool, message string) (*biz.PaymentOrder, error) { return r.withLockedOrder(ctx, provider, tradeNo, func(tx *gorm.DB, po *paymentOrderPO) error { if po.RefundStatus != biz.RefundStatusPending || po.RefundNo == "" || po.RefundNo != refundNo || po.RefundRequestedAmount != amount { return biz.ErrPaymentOrderState } po.LastError = trimTo(message, 512) po.RefundRequestedAmount = 0 if !success { po.RefundStatus = biz.RefundStatusFailed po.RefundNo = "" po.Version++ return tx.Save(po).Error } po.RefundedAmount += amount if po.RefundedAmount >= po.Amount { po.PaymentStatus = biz.PaymentStatusRefunded po.RefundStatus = biz.RefundStatusSucceeded } else { po.PaymentStatus = biz.PaymentStatusPartiallyRefunded po.RefundStatus = biz.RefundStatusPartial } po.RefundNo = "" now := time.Now().UTC() po.RefundedAt = &now po.Version++ return tx.Save(po).Error }) } func (r *paymentOrderRepo) withLockedOrder(ctx context.Context, provider, tradeNo string, fn func(*gorm.DB, *paymentOrderPO) error) (*biz.PaymentOrder, error) { var result *biz.PaymentOrder err := r.data.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error { var po paymentOrderPO if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("provider = ? AND trade_no = ?", provider, tradeNo).First(&po).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return biz.ErrPaymentOrderNotFound } return err } if err := fn(tx, &po); err != nil { return err } result = toBizPaymentOrder(&po) return nil }) return result, err } func applyProviderIdentity(tx *gorm.DB, po *paymentOrderPO, update *biz.PaymentProviderUpdate) error { if update.ProviderTradeNo != "" { if po.ProviderTradeNo != nil && *po.ProviderTradeNo != update.ProviderTradeNo { return biz.ErrPaymentProviderConflict } var other paymentOrderPO // This check is repeated under the order transaction so a platform // transaction cannot be attached to a different merchant order. if err := tx.Where("provider = ? AND provider_trade_no = ? AND id <> ?", po.Provider, update.ProviderTradeNo, po.ID).First(&other).Error; err == nil { return biz.ErrPaymentProviderConflict } else if !errors.Is(err, gorm.ErrRecordNotFound) { return err } value := update.ProviderTradeNo po.ProviderTradeNo = &value } if update.QueryID != "" { if po.QueryID != "" && po.QueryID != update.QueryID { return biz.ErrPaymentProviderConflict } po.QueryID = update.QueryID } return nil } func normalizeOrderPaymentStatus(value string) string { switch strings.ToLower(strings.TrimSpace(value)) { case "success", "paid", "fulfilled": return biz.PaymentStatusPaid case "pending", "created", "client_pending", "processing": return biz.PaymentStatusPending case "failed", "closed", "cancelled", "canceled": return biz.PaymentStatusFailed default: return "" } } func optionalString(value string) *string { if value == "" { return nil } return &value } func dereferenceString(value *string) string { if value == nil { return "" } return *value } func defaultString(value, fallback string) string { if value == "" { return fallback } return value } func defaultVersion(value uint64) uint64 { if value == 0 { return 1 } return value } func trimTo(value string, max int) string { value = strings.TrimSpace(value) if len(value) > max { return value[:max] } return value }