kra-new/internal/data/payment/payment_order.go

645 lines
26 KiB
Go

package payment
import (
"context"
"encoding/json"
"errors"
bizpayment "kra/internal/biz/payment"
"kra/internal/paymentkit"
"strings"
"time"
"kra/pkg/database/pagination"
"github.com/google/uuid"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
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) bizpayment.PaymentOrderRepo {
return &paymentOrderRepo{data: data}
}
func newPaymentOrderPO(order *bizpayment.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, bizpayment.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, bizpayment.PaymentStatusInitialized),
ProviderStatus: order.ProviderStatus,
FulfillmentStatus: defaultString(order.FulfillmentStatus, bizpayment.FulfillmentStatusPending),
RefundStatus: defaultString(order.RefundStatus, bizpayment.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) *bizpayment.PaymentOrder {
if po == nil {
return nil
}
return &bizpayment.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, bizpayment.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 *bizpayment.PaymentOrder) (*bizpayment.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(&current).Error
if err == nil {
return toBizPaymentOrder(&current), false, nil
}
if !errors.Is(err, gorm.ErrRecordNotFound) {
return nil, false, err
}
err = db.Transaction(func(tx *gorm.DB) error {
if createErr := tx.Create(po).Error; createErr != nil {
return createErr
}
return appendPaymentEvent(ctx, tx, po, &bizpayment.PaymentEvent{
Type: bizpayment.PaymentEventOrderCreated, Source: "client", Status: po.PaymentStatus,
Amount: po.Amount, Currency: po.Currency,
})
})
if err == nil {
return toBizPaymentOrder(po), true, nil
}
if lookupErr := db.Where("provider = ? AND trade_no = ?", po.Provider, po.TradeNo).First(&current).Error; lookupErr == nil {
return toBizPaymentOrder(&current), false, nil
}
return nil, false, err
}
func (r *paymentOrderRepo) FindPaymentOrder(ctx context.Context, provider, tradeNo string) (*bizpayment.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, bizpayment.ErrPaymentOrderNotFound
}
return nil, err
}
return toBizPaymentOrder(&po), nil
}
func (r *paymentOrderRepo) ListPaymentOrders(ctx context.Context, page, pageSize int, filter bizpayment.PaymentOrderFilter) ([]*bizpayment.PaymentOrder, int64, error) {
db := r.filteredPaymentOrders(ctx, filter)
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([]*bizpayment.PaymentOrder, 0, len(rows))
for i := range rows {
items = append(items, toBizPaymentOrder(&rows[i]))
}
return items, total, nil
}
func (r *paymentOrderRepo) filteredPaymentOrders(ctx context.Context, filter bizpayment.PaymentOrderFilter) *gorm.DB {
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.ProviderTradeNo); value != "" {
db = db.Where("provider_trade_no LIKE ?", "%"+value+"%")
}
if value := strings.TrimSpace(filter.Keyword); value != "" {
like := "%" + value + "%"
db = db.Where("trade_no LIKE ? OR provider_trade_no LIKE ? OR business_id LIKE ? OR subject LIKE ?", like, like, like, like)
}
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)
}
if filter.CreatedFrom != nil {
db = db.Where("created_at >= ?", filter.CreatedFrom.UTC())
}
if filter.CreatedTo != nil {
db = db.Where("created_at < ?", filter.CreatedTo.UTC())
}
if filter.PaidFrom != nil {
db = db.Where("paid_at >= ?", filter.PaidFrom.UTC())
}
if filter.PaidTo != nil {
db = db.Where("paid_at < ?", filter.PaidTo.UTC())
}
if filter.IssueOnly {
db = db.Where("payment_status = ? OR fulfillment_status = ? OR refund_status = ? OR last_error <> ''",
bizpayment.PaymentStatusFailed, bizpayment.FulfillmentStatusFailed, bizpayment.RefundStatusFailed)
}
return db
}
func (r *paymentOrderRepo) SummarizePaymentOrders(ctx context.Context, filter bizpayment.PaymentOrderFilter) (*bizpayment.PaymentOrderSummary, error) {
type countRow struct {
OrderCount int64
PendingCount int64
PaidCount int64
RefundedCount int64
IssueCount int64
}
var counts countRow
if err := r.filteredPaymentOrders(ctx, filter).Select(`
COUNT(*) AS order_count,
COALESCE(SUM(CASE WHEN payment_status IN ? THEN 1 ELSE 0 END), 0) AS pending_count,
COALESCE(SUM(CASE WHEN payment_status IN ? THEN 1 ELSE 0 END), 0) AS paid_count,
COALESCE(SUM(CASE WHEN refunded_amount > 0 THEN 1 ELSE 0 END), 0) AS refunded_count,
COALESCE(SUM(CASE WHEN payment_status = ? OR fulfillment_status = ? OR refund_status = ? OR last_error <> '' THEN 1 ELSE 0 END), 0) AS issue_count`,
[]string{bizpayment.PaymentStatusInitialized, bizpayment.PaymentStatusPending},
[]string{bizpayment.PaymentStatusPaid, bizpayment.PaymentStatusPartiallyRefunded, bizpayment.PaymentStatusRefunded},
bizpayment.PaymentStatusFailed, bizpayment.FulfillmentStatusFailed, bizpayment.RefundStatusFailed,
).Scan(&counts).Error; err != nil {
return nil, err
}
type currencyRow struct {
Currency string
OrderAmount int64
PaidAmount int64
RefundedAmount int64
SettlementAmount int64
}
var rows []currencyRow
if err := r.filteredPaymentOrders(ctx, filter).
Select(`currency,
COALESCE(SUM(amount), 0) AS order_amount,
COALESCE(SUM(paid_amount), 0) AS paid_amount,
COALESCE(SUM(refunded_amount), 0) AS refunded_amount,
COALESCE(SUM(CASE WHEN settlement_amount > 0 THEN settlement_amount ELSE paid_amount END), 0) AS settlement_amount`).
Group("currency").Order("currency ASC").Scan(&rows).Error; err != nil {
return nil, err
}
summary := &bizpayment.PaymentOrderSummary{
OrderCount: counts.OrderCount, PendingCount: counts.PendingCount, PaidCount: counts.PaidCount,
RefundedCount: counts.RefundedCount, IssueCount: counts.IssueCount,
Currencies: make([]bizpayment.PaymentCurrencySummary, 0, len(rows)),
}
for _, row := range rows {
summary.Currencies = append(summary.Currencies, bizpayment.PaymentCurrencySummary{
Currency: row.Currency, OrderAmount: row.OrderAmount, PaidAmount: row.PaidAmount,
RefundedAmount: row.RefundedAmount, SettlementAmount: row.SettlementAmount,
NetAmount: row.SettlementAmount - row.RefundedAmount,
})
}
return summary, nil
}
func (r *paymentOrderRepo) RecordPaymentCreate(ctx context.Context, provider, tradeNo string, update *bizpayment.PaymentProviderUpdate) (*bizpayment.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 == bizpayment.PaymentStatusPaid {
// Provider create responses are never sufficient proof of payment.
status = bizpayment.PaymentStatusPending
}
if po.PaymentStatus != bizpayment.PaymentStatusPaid && po.PaymentStatus != bizpayment.PaymentStatusPartiallyRefunded && po.PaymentStatus != bizpayment.PaymentStatusRefunded && status != "" {
po.PaymentStatus = status
}
po.Version++
return savePaymentOrderWithEvent(ctx, tx, po, &bizpayment.PaymentEvent{
Type: bizpayment.PaymentEventProviderCreated, Source: update.EventSource, Status: po.PaymentStatus,
ProviderStatus: po.ProviderStatus, Message: update.EventMessage, EventID: update.EventID,
PayloadHash: update.PayloadHash, Amount: po.Amount, Currency: po.Currency,
})
})
}
func (r *paymentOrderRepo) ApplyPaymentResult(ctx context.Context, provider, tradeNo string, update *bizpayment.PaymentProviderUpdate) (*bizpayment.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 bizpayment.ErrPaymentOrderConflict
}
if update.PayerPaidAmount < 0 || update.CashPaidAmount < 0 || update.PointPaidAmount < 0 || update.DiscountAmount < 0 || update.ProviderDiscountAmount < 0 || update.MerchantDiscountAmount < 0 || update.SettlementAmount < 0 {
return bizpayment.ErrPaymentOrderConflict
}
if update.Currency != "" && !strings.EqualFold(update.Currency, po.Currency) {
return bizpayment.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 bizpayment.PaymentStatusPaid:
if po.PaymentStatus != bizpayment.PaymentStatusPartiallyRefunded && po.PaymentStatus != bizpayment.PaymentStatusRefunded {
po.PaymentStatus = bizpayment.PaymentStatusPaid
}
po.PaidAmount = po.Amount
if po.PaidAt == nil {
now := time.Now().UTC()
po.PaidAt = &now
}
case bizpayment.PaymentStatusPending:
if po.PaymentStatus != bizpayment.PaymentStatusPaid && po.PaymentStatus != bizpayment.PaymentStatusPartiallyRefunded && po.PaymentStatus != bizpayment.PaymentStatusRefunded {
po.PaymentStatus = bizpayment.PaymentStatusPending
}
case bizpayment.PaymentStatusFailed:
if po.PaymentStatus != bizpayment.PaymentStatusPaid && po.PaymentStatus != bizpayment.PaymentStatusPartiallyRefunded && po.PaymentStatus != bizpayment.PaymentStatusRefunded {
po.PaymentStatus = bizpayment.PaymentStatusFailed
}
}
po.Version++
eventType := update.EventType
if eventType == "" {
eventType = bizpayment.PaymentEventPaymentSynchronized
}
return savePaymentOrderWithEvent(ctx, tx, po, &bizpayment.PaymentEvent{
Type: eventType, Source: update.EventSource, Status: po.PaymentStatus,
ProviderStatus: po.ProviderStatus, Message: update.EventMessage, EventID: update.EventID,
PayloadHash: update.PayloadHash, Amount: po.PaidAmount, Currency: po.Currency,
})
})
}
func (r *paymentOrderRepo) BeginPaymentFulfillment(ctx context.Context, provider, tradeNo string, lease time.Duration) (*bizpayment.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 == bizpayment.FulfillmentStatusSucceeded {
duplicate = true
return nil
}
if po.PaymentStatus != bizpayment.PaymentStatusPaid {
return bizpayment.ErrPaymentOrderState
}
now := time.Now().UTC()
if po.FulfillmentStatus == bizpayment.FulfillmentStatusProcessing && po.FulfillmentLeaseUntil != nil && po.FulfillmentLeaseUntil.After(now) {
return bizpayment.ErrPaymentOrderBusy
}
if lease <= 0 {
lease = 10 * time.Minute
}
token = uuid.NewString()
until := now.Add(lease)
po.FulfillmentStatus = bizpayment.FulfillmentStatusProcessing
po.FulfillmentToken = token
po.FulfillmentLeaseUntil = &until
po.LastError = ""
po.Version++
return savePaymentOrderWithEvent(ctx, tx, po, &bizpayment.PaymentEvent{
Type: bizpayment.PaymentEventFulfillmentStarted, Status: po.FulfillmentStatus,
})
})
return order, token, duplicate, err
}
func (r *paymentOrderRepo) CompletePaymentFulfillment(ctx context.Context, provider, tradeNo, token string, success bool, message string) (*bizpayment.PaymentOrder, error) {
return r.withLockedOrder(ctx, provider, tradeNo, func(tx *gorm.DB, po *paymentOrderPO) error {
if po.FulfillmentStatus != bizpayment.FulfillmentStatusProcessing || po.FulfillmentToken != token {
return bizpayment.ErrPaymentOrderBusy
}
po.FulfillmentToken = ""
po.FulfillmentLeaseUntil = nil
po.LastError = trimTo(message, 512)
if success {
po.FulfillmentStatus = bizpayment.FulfillmentStatusSucceeded
now := time.Now().UTC()
po.FulfilledAt = &now
} else {
po.FulfillmentStatus = bizpayment.FulfillmentStatusFailed
}
po.Version++
return savePaymentOrderWithEvent(ctx, tx, po, &bizpayment.PaymentEvent{
Type: bizpayment.PaymentEventFulfillmentFinished, Status: po.FulfillmentStatus, Message: message,
})
})
}
func (r *paymentOrderRepo) BeginPaymentRefund(ctx context.Context, provider, tradeNo string, amount int64, lease time.Duration) (*bizpayment.PaymentOrder, string, error) {
var token string
order, err := r.withLockedOrder(ctx, provider, tradeNo, func(tx *gorm.DB, po *paymentOrderPO) error {
if po.PaymentStatus != bizpayment.PaymentStatusPaid && po.PaymentStatus != bizpayment.PaymentStatusPartiallyRefunded {
return bizpayment.ErrPaymentOrderState
}
if amount <= 0 {
return bizpayment.ErrPaymentOrderConflict
}
now := time.Now().UTC()
if po.RefundStatus == bizpayment.RefundStatusProcessing && po.RefundLeaseUntil != nil && po.RefundLeaseUntil.After(now) {
return bizpayment.ErrPaymentOrderBusy
}
if po.RefundStatus == bizpayment.RefundStatusProcessing && po.RefundRequestedAmount != amount {
return bizpayment.ErrPaymentOrderConflict
}
if po.RefundStatus == bizpayment.RefundStatusPending {
return bizpayment.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 == bizpayment.RefundStatusProcessing && po.RefundLeaseUntil != nil && !po.RefundLeaseUntil.After(now) {
reserved = 0
} else {
reserved = po.RefundRequestedAmount
}
if amount > po.Amount-po.RefundedAmount-reserved {
return bizpayment.ErrPaymentOrderConflict
}
if lease <= 0 {
lease = 10 * time.Minute
}
token = uuid.NewString()
if po.RefundNo == "" {
po.RefundNo = uuid.NewString()
}
until := now.Add(lease)
po.RefundStatus = bizpayment.RefundStatusProcessing
po.RefundRequestedAmount = amount
po.RefundToken = token
po.RefundLeaseUntil = &until
po.LastError = ""
po.Version++
return savePaymentOrderWithEvent(ctx, tx, po, &bizpayment.PaymentEvent{
Type: bizpayment.PaymentEventRefundStarted, Status: po.RefundStatus, Amount: amount, Currency: po.Currency,
})
})
return order, token, err
}
func (r *paymentOrderRepo) CompletePaymentRefundRequest(ctx context.Context, provider, tradeNo, token string, accepted bool, message string) (*bizpayment.PaymentOrder, error) {
return r.withLockedOrder(ctx, provider, tradeNo, func(tx *gorm.DB, po *paymentOrderPO) error {
if po.RefundStatus != bizpayment.RefundStatusProcessing || po.RefundToken != token {
return bizpayment.ErrPaymentOrderBusy
}
po.RefundToken = ""
po.RefundLeaseUntil = nil
po.LastError = trimTo(message, 512)
if accepted {
po.RefundStatus = bizpayment.RefundStatusPending
} else {
po.RefundStatus = bizpayment.RefundStatusFailed
po.RefundRequestedAmount = 0
po.RefundNo = ""
}
po.Version++
return savePaymentOrderWithEvent(ctx, tx, po, &bizpayment.PaymentEvent{
Type: bizpayment.PaymentEventRefundSubmitted, Status: po.RefundStatus, Message: message,
Amount: po.RefundRequestedAmount, Currency: po.Currency,
})
})
}
func (r *paymentOrderRepo) ConfirmPaymentRefund(ctx context.Context, provider, tradeNo, refundNo string, amount int64, success bool, message string) (*bizpayment.PaymentOrder, error) {
return r.withLockedOrder(ctx, provider, tradeNo, func(tx *gorm.DB, po *paymentOrderPO) error {
if po.RefundStatus != bizpayment.RefundStatusPending || po.RefundNo == "" || po.RefundNo != refundNo || po.RefundRequestedAmount != amount {
return bizpayment.ErrPaymentOrderState
}
po.LastError = trimTo(message, 512)
po.RefundRequestedAmount = 0
if !success {
po.RefundStatus = bizpayment.RefundStatusFailed
po.RefundNo = ""
po.Version++
return savePaymentOrderWithEvent(ctx, tx, po, &bizpayment.PaymentEvent{
Type: bizpayment.PaymentEventRefundConfirmed, Status: po.RefundStatus, Message: message,
Amount: amount, Currency: po.Currency,
})
}
po.RefundedAmount += amount
if po.RefundedAmount >= po.Amount {
po.PaymentStatus = bizpayment.PaymentStatusRefunded
po.RefundStatus = bizpayment.RefundStatusSucceeded
} else {
po.PaymentStatus = bizpayment.PaymentStatusPartiallyRefunded
po.RefundStatus = bizpayment.RefundStatusPartial
}
po.RefundNo = ""
now := time.Now().UTC()
po.RefundedAt = &now
po.Version++
return savePaymentOrderWithEvent(ctx, tx, po, &bizpayment.PaymentEvent{
Type: bizpayment.PaymentEventRefundConfirmed, Status: po.RefundStatus, Message: message,
Amount: amount, Currency: po.Currency,
})
})
}
func (r *paymentOrderRepo) withLockedOrder(ctx context.Context, provider, tradeNo string, fn func(*gorm.DB, *paymentOrderPO) error) (*bizpayment.PaymentOrder, error) {
var result *bizpayment.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 bizpayment.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 *bizpayment.PaymentProviderUpdate) error {
if update.ProviderTradeNo != "" {
if po.ProviderTradeNo != nil && *po.ProviderTradeNo != update.ProviderTradeNo {
return bizpayment.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 bizpayment.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 bizpayment.ErrPaymentProviderConflict
}
po.QueryID = update.QueryID
}
return nil
}
func normalizeOrderPaymentStatus(value string) string {
switch paymentkit.NormalizeStatus(value, "") {
case "success":
return bizpayment.PaymentStatusPaid
case "pending":
return bizpayment.PaymentStatusPending
case "failed":
return bizpayment.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)
runes := []rune(value)
if len(runes) > max {
return string(runes[:max])
}
return value
}