mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-15 23:22:59 +08:00
- The SDK returns signed business-failure responses (for example ACQ.SYSTEM_ERROR) with a nil error and empty fields. QueryRefund now checks the response code and returns an error, so the order stays REFUND_PENDING instead of being read as "no refund record" and failed. - A missing refund record is treated as failed only after a 10 minute grace period from when the refund became pending. Alipay notes that a query sent too soon can miss a refund that later succeeds. The pending time comes from the REFUND_PENDING audit entry.
892 lines
34 KiB
Go
892 lines
34 KiB
Go
package service
|
||
|
||
import (
|
||
"context"
|
||
"encoding/json"
|
||
"errors"
|
||
"fmt"
|
||
"log/slog"
|
||
"math"
|
||
"strconv"
|
||
"strings"
|
||
"time"
|
||
|
||
"entgo.io/ent/dialect/sql"
|
||
dbent "github.com/Wei-Shaw/sub2api/ent"
|
||
"github.com/Wei-Shaw/sub2api/ent/paymentauditlog"
|
||
"github.com/Wei-Shaw/sub2api/ent/paymentorder"
|
||
"github.com/Wei-Shaw/sub2api/ent/paymentproviderinstance"
|
||
"github.com/Wei-Shaw/sub2api/internal/payment"
|
||
"github.com/Wei-Shaw/sub2api/internal/payment/provider"
|
||
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
|
||
"github.com/Wei-Shaw/sub2api/internal/pkg/servertiming"
|
||
)
|
||
|
||
// --- Refund Flow ---
|
||
|
||
var createPaymentProviderFromInstance = provider.CreateProvider
|
||
|
||
// getOrderProviderInstance looks up the provider instance that processed this order.
|
||
// For legacy orders without provider_instance_id, it resolves only when the
|
||
// historical instance is uniquely identifiable from the stored order fields.
|
||
func (s *PaymentService) getOrderProviderInstance(ctx context.Context, o *dbent.PaymentOrder) (*dbent.PaymentProviderInstance, error) {
|
||
if s == nil || s.entClient == nil || o == nil {
|
||
return nil, nil
|
||
}
|
||
|
||
if snapshot := psOrderProviderSnapshot(o); snapshot != nil {
|
||
return s.resolveSnapshotOrderProviderInstance(ctx, o, snapshot)
|
||
}
|
||
|
||
instIDStr := strings.TrimSpace(psStringValue(o.ProviderInstanceID))
|
||
if instIDStr == "" {
|
||
return s.resolveUniqueLegacyOrderProviderInstance(ctx, o)
|
||
}
|
||
|
||
instID, err := strconv.ParseInt(instIDStr, 10, 64)
|
||
if err != nil {
|
||
return nil, nil
|
||
}
|
||
return s.entClient.PaymentProviderInstance.Get(ctx, instID)
|
||
}
|
||
|
||
// getRefundOrderProviderInstance resolves the provider instance for refund paths.
|
||
// Refunds must be pinned to an explicit historical binding, so legacy
|
||
// "best-effort" provider guessing is intentionally not allowed here.
|
||
func (s *PaymentService) getRefundOrderProviderInstance(ctx context.Context, o *dbent.PaymentOrder) (*dbent.PaymentProviderInstance, error) {
|
||
if s == nil || s.entClient == nil || o == nil {
|
||
return nil, nil
|
||
}
|
||
|
||
if snapshot := psOrderProviderSnapshot(o); snapshot != nil {
|
||
return s.resolveSnapshotOrderProviderInstance(ctx, o, snapshot)
|
||
}
|
||
|
||
instIDStr := strings.TrimSpace(psStringValue(o.ProviderInstanceID))
|
||
if instIDStr == "" {
|
||
return nil, nil
|
||
}
|
||
|
||
instID, err := strconv.ParseInt(instIDStr, 10, 64)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("order %d refund provider instance id is invalid: %s", o.ID, instIDStr)
|
||
}
|
||
inst, err := s.entClient.PaymentProviderInstance.Get(ctx, instID)
|
||
if err != nil {
|
||
if dbent.IsNotFound(err) {
|
||
return nil, fmt.Errorf("order %d refund provider instance %s is missing", o.ID, instIDStr)
|
||
}
|
||
return nil, err
|
||
}
|
||
return inst, nil
|
||
}
|
||
|
||
func (s *PaymentService) resolveUniqueLegacyOrderProviderInstance(ctx context.Context, o *dbent.PaymentOrder) (*dbent.PaymentProviderInstance, error) {
|
||
paymentType := payment.GetBasePaymentType(strings.TrimSpace(o.PaymentType))
|
||
providerKey := strings.TrimSpace(psStringValue(o.ProviderKey))
|
||
if providerKey != "" {
|
||
instances, err := s.entClient.PaymentProviderInstance.Query().
|
||
Where(paymentproviderinstance.ProviderKeyEQ(providerKey)).
|
||
All(ctx)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
matched := psFilterLegacyOrderProviderInstances(paymentType, instances)
|
||
if len(matched) == 1 {
|
||
return matched[0], nil
|
||
}
|
||
return nil, nil
|
||
}
|
||
|
||
if paymentType == "" {
|
||
return nil, nil
|
||
}
|
||
|
||
instances, err := s.entClient.PaymentProviderInstance.Query().
|
||
All(ctx)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
matched := psFilterLegacyOrderProviderInstances(paymentType, instances)
|
||
if len(matched) == 1 {
|
||
return matched[0], nil
|
||
}
|
||
return nil, nil
|
||
}
|
||
|
||
func psFilterLegacyOrderProviderInstances(orderPaymentType string, instances []*dbent.PaymentProviderInstance) []*dbent.PaymentProviderInstance {
|
||
if len(instances) == 0 {
|
||
return nil
|
||
}
|
||
if strings.TrimSpace(orderPaymentType) == "" {
|
||
return instances
|
||
}
|
||
var matched []*dbent.PaymentProviderInstance
|
||
for _, inst := range instances {
|
||
if psLegacyOrderMatchesInstance(orderPaymentType, inst) {
|
||
matched = append(matched, inst)
|
||
}
|
||
}
|
||
return matched
|
||
}
|
||
|
||
func psLegacyOrderMatchesInstance(orderPaymentType string, inst *dbent.PaymentProviderInstance) bool {
|
||
if inst == nil {
|
||
return false
|
||
}
|
||
|
||
baseType := payment.GetBasePaymentType(strings.TrimSpace(orderPaymentType))
|
||
instanceProviderKey := strings.TrimSpace(inst.ProviderKey)
|
||
if baseType == "" {
|
||
return false
|
||
}
|
||
|
||
if baseType == payment.TypeStripe {
|
||
return instanceProviderKey == payment.TypeStripe
|
||
}
|
||
if instanceProviderKey == payment.TypeStripe {
|
||
return false
|
||
}
|
||
if instanceProviderKey == baseType {
|
||
return true
|
||
}
|
||
return payment.InstanceSupportsType(inst.SupportedTypes, baseType)
|
||
}
|
||
|
||
func (s *PaymentService) RequestRefund(ctx context.Context, oid, uid int64, reason string) error {
|
||
o, err := s.validateRefundRequest(ctx, oid, uid)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
u, err := s.userRepo.GetByID(ctx, o.UserID)
|
||
if err != nil {
|
||
return fmt.Errorf("get user: %w", err)
|
||
}
|
||
if u.Balance < o.Amount {
|
||
return infraerrors.BadRequest("BALANCE_NOT_ENOUGH", "refund amount exceeds balance")
|
||
}
|
||
nr := strings.TrimSpace(reason)
|
||
now := time.Now()
|
||
by := fmt.Sprintf("%d", uid)
|
||
c, err := s.entClient.PaymentOrder.Update().Where(paymentorder.IDEQ(oid), paymentorder.UserIDEQ(uid), paymentorder.StatusEQ(OrderStatusCompleted), paymentorder.OrderTypeEQ(payment.OrderTypeBalance)).SetStatus(OrderStatusRefundRequested).SetRefundRequestedAt(now).SetRefundRequestReason(nr).SetRefundRequestedBy(by).SetRefundAmount(o.Amount).Save(ctx)
|
||
if err != nil {
|
||
return fmt.Errorf("update: %w", err)
|
||
}
|
||
if c == 0 {
|
||
return infraerrors.Conflict("CONFLICT", "order status changed")
|
||
}
|
||
s.writeAuditLog(ctx, oid, "REFUND_REQUESTED", fmt.Sprintf("user:%d", uid), map[string]any{"amount": o.Amount, "reason": nr})
|
||
return nil
|
||
}
|
||
|
||
func (s *PaymentService) validateRefundRequest(ctx context.Context, oid, uid int64) (*dbent.PaymentOrder, error) {
|
||
o, err := s.entClient.PaymentOrder.Get(ctx, oid)
|
||
if err != nil {
|
||
return nil, infraerrors.NotFound("NOT_FOUND", "order not found")
|
||
}
|
||
if o.UserID != uid {
|
||
return nil, infraerrors.Forbidden("FORBIDDEN", "no permission")
|
||
}
|
||
if o.OrderType != payment.OrderTypeBalance {
|
||
return nil, infraerrors.BadRequest("INVALID_ORDER_TYPE", "only balance orders can request refund")
|
||
}
|
||
if o.Status != OrderStatusCompleted {
|
||
return nil, infraerrors.BadRequest("INVALID_STATUS", "only completed orders can request refund")
|
||
}
|
||
// Check provider instance allows user refund
|
||
inst, err := s.getRefundOrderProviderInstance(ctx, o)
|
||
if err != nil || inst == nil {
|
||
return nil, infraerrors.Forbidden("USER_REFUND_DISABLED", "refund is not available for this order")
|
||
}
|
||
if !inst.AllowUserRefund {
|
||
return nil, infraerrors.Forbidden("USER_REFUND_DISABLED", "user refund is not enabled for this provider")
|
||
}
|
||
return o, nil
|
||
}
|
||
|
||
func (s *PaymentService) PrepareRefund(ctx context.Context, oid int64, amt float64, reason string, force, deduct bool) (*RefundPlan, *RefundResult, error) {
|
||
o, err := s.entClient.PaymentOrder.Get(ctx, oid)
|
||
if err != nil {
|
||
return nil, nil, infraerrors.NotFound("NOT_FOUND", "order not found")
|
||
}
|
||
// REFUND_PENDING 只能通过 QueryAndFinalizeRefund 收敛:重新发起会重复扣减并向网关重复退款。
|
||
ok := []string{OrderStatusCompleted, OrderStatusRefundRequested, OrderStatusRefundFailed}
|
||
if !psSliceContains(ok, o.Status) {
|
||
return nil, nil, infraerrors.BadRequest("INVALID_STATUS", "order status does not allow refund")
|
||
}
|
||
// Check provider instance allows admin refund
|
||
inst, instErr := s.getRefundOrderProviderInstance(ctx, o)
|
||
if instErr != nil {
|
||
slog.Warn("refund: provider instance lookup failed", "orderID", oid, "error", instErr)
|
||
return nil, nil, infraerrors.InternalServer("PROVIDER_LOOKUP_FAILED", "failed to look up payment provider for this order")
|
||
}
|
||
if inst == nil {
|
||
// Legacy order without provider_instance_id — block refund
|
||
return nil, nil, infraerrors.Forbidden("REFUND_DISABLED", "refund is not available for this order")
|
||
}
|
||
if !inst.RefundEnabled {
|
||
return nil, nil, infraerrors.Forbidden("REFUND_DISABLED", "refund is not enabled for this provider")
|
||
}
|
||
if math.IsNaN(amt) || math.IsInf(amt, 0) {
|
||
return nil, nil, infraerrors.BadRequest("INVALID_AMOUNT", "invalid refund amount")
|
||
}
|
||
if amt <= 0 {
|
||
amt = o.Amount
|
||
}
|
||
orderCurrency := PaymentOrderCurrency(o)
|
||
if amt-o.Amount > paymentAmountToleranceForCurrency(orderCurrency) {
|
||
return nil, nil, infraerrors.BadRequest("REFUND_AMOUNT_EXCEEDED", "refund amount exceeds recharge")
|
||
}
|
||
ga := calculateGatewayRefundAmount(o.Amount, o.PayAmount, amt, orderCurrency)
|
||
rr := strings.TrimSpace(reason)
|
||
if rr == "" && o.RefundRequestReason != nil {
|
||
rr = *o.RefundRequestReason
|
||
}
|
||
if rr == "" {
|
||
rr = fmt.Sprintf("refund order:%d", o.ID)
|
||
}
|
||
p := &RefundPlan{OrderID: oid, Order: o, RefundAmount: amt, GatewayAmount: ga, Reason: rr, Force: force, DeductBalance: deduct, DeductionType: payment.DeductionTypeNone}
|
||
if deduct {
|
||
if er := s.prepDeduct(ctx, o, p, force); er != nil {
|
||
return nil, er, nil
|
||
}
|
||
}
|
||
return p, nil, nil
|
||
}
|
||
|
||
func (s *PaymentService) prepDeduct(ctx context.Context, o *dbent.PaymentOrder, p *RefundPlan, force bool) *RefundResult {
|
||
if o.OrderType == payment.OrderTypeSubscription {
|
||
p.DeductionType = payment.DeductionTypeSubscription
|
||
if o.SubscriptionGroupID != nil && o.SubscriptionDays != nil {
|
||
p.SubDaysToDeduct = *o.SubscriptionDays
|
||
sub, err := s.subscriptionSvc.GetActiveSubscription(ctx, o.UserID, *o.SubscriptionGroupID)
|
||
if err == nil && sub != nil {
|
||
p.SubscriptionID = sub.ID
|
||
} else if !force {
|
||
return &RefundResult{Success: false, Warning: "cannot find active subscription for deduction, use force", RequireForce: true}
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
u, err := s.userRepo.GetByID(ctx, o.UserID)
|
||
if err != nil {
|
||
if !force {
|
||
return &RefundResult{Success: false, Warning: "cannot fetch user balance, use force", RequireForce: true}
|
||
}
|
||
return nil
|
||
}
|
||
p.DeductionType = payment.DeductionTypeBalance
|
||
if u.Balance < p.RefundAmount && !force {
|
||
return &RefundResult{Success: false, Warning: "user balance is insufficient for deduction, use force", RequireForce: true}
|
||
}
|
||
p.BalanceToDeduct = math.Max(0, math.Min(p.RefundAmount, u.Balance))
|
||
return nil
|
||
}
|
||
|
||
type availableBalanceDeductor interface {
|
||
DeductAvailableBalance(ctx context.Context, id int64, amount float64) (float64, error)
|
||
}
|
||
|
||
func (s *PaymentService) deductAvailableBalance(ctx context.Context, userID int64, amount float64) (float64, error) {
|
||
repo, ok := s.userRepo.(availableBalanceDeductor)
|
||
if !ok {
|
||
return 0, errors.New("user repository does not support available balance deduction")
|
||
}
|
||
return repo.DeductAvailableBalance(ctx, userID, amount)
|
||
}
|
||
|
||
// invalidateRefundBalanceCache 退款扣减或归还余额后失效计费缓存,否则网关在缓存 TTL 内仍按旧余额放行。
|
||
func (s *PaymentService) invalidateRefundBalanceCache(ctx context.Context, userID int64) {
|
||
if s.redeemService == nil {
|
||
return
|
||
}
|
||
s.redeemService.invalidateRedeemCaches(ctx, userID, &RedeemCode{Type: RedeemTypeBalance})
|
||
}
|
||
|
||
func (s *PaymentService) ExecuteRefund(ctx context.Context, p *RefundPlan) (*RefundResult, error) {
|
||
c, err := s.entClient.PaymentOrder.Update().Where(paymentorder.IDEQ(p.OrderID), paymentorder.StatusIn(OrderStatusCompleted, OrderStatusRefundRequested, OrderStatusRefundFailed)).SetStatus(OrderStatusRefunding).Save(ctx)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("lock: %w", err)
|
||
}
|
||
if c == 0 {
|
||
return nil, infraerrors.Conflict("CONFLICT", "order status changed")
|
||
}
|
||
if p.DeductionType == payment.DeductionTypeBalance && p.BalanceToDeduct > 0 {
|
||
// Skip balance deduction on retry if previous attempt already deducted
|
||
// but failed to roll back (REFUND_ROLLBACK_FAILED in audit log).
|
||
if !s.hasAuditLog(ctx, p.OrderID, "REFUND_ROLLBACK_FAILED") {
|
||
deducted, err := s.deductAvailableBalance(ctx, p.Order.UserID, p.BalanceToDeduct)
|
||
if err != nil {
|
||
s.restoreStatus(ctx, p)
|
||
return nil, fmt.Errorf("deduction: %w", err)
|
||
}
|
||
p.BalanceToDeduct = deducted
|
||
s.invalidateRefundBalanceCache(ctx, p.Order.UserID)
|
||
} else {
|
||
slog.Warn("skipping balance deduction on retry (previous rollback failed)", "orderID", p.OrderID)
|
||
p.BalanceToDeduct = 0
|
||
}
|
||
}
|
||
if p.DeductionType == payment.DeductionTypeSubscription && p.SubDaysToDeduct > 0 && p.SubscriptionID > 0 {
|
||
if !s.hasAuditLog(ctx, p.OrderID, "REFUND_ROLLBACK_FAILED") {
|
||
_, err := s.subscriptionSvc.ExtendSubscription(ctx, p.SubscriptionID, -p.SubDaysToDeduct)
|
||
if err != nil {
|
||
if errors.Is(err, ErrAdjustWouldExpire) {
|
||
// Deduction would expire the subscription — revoke it entirely
|
||
slog.Info("subscription deduction would expire, revoking", "orderID", p.OrderID, "subID", p.SubscriptionID, "days", p.SubDaysToDeduct)
|
||
if revokeErr := s.subscriptionSvc.RevokeSubscription(ctx, p.SubscriptionID); revokeErr != nil {
|
||
s.restoreStatus(ctx, p)
|
||
return nil, fmt.Errorf("revoke subscription: %w", revokeErr)
|
||
}
|
||
} else {
|
||
// Other errors (DB failure, not found) — abort refund
|
||
s.restoreStatus(ctx, p)
|
||
return nil, fmt.Errorf("deduct subscription days: %w", err)
|
||
}
|
||
}
|
||
} else {
|
||
slog.Warn("skipping subscription deduction on retry (previous rollback failed)", "orderID", p.OrderID)
|
||
p.SubDaysToDeduct = 0
|
||
}
|
||
}
|
||
resp, err := s.gwRefund(ctx, p)
|
||
if err != nil {
|
||
return s.handleGwFail(ctx, p, err)
|
||
}
|
||
return s.finishRefund(ctx, p, resp)
|
||
}
|
||
|
||
func (s *PaymentService) gwRefund(ctx context.Context, p *RefundPlan) (*payment.RefundResponse, error) {
|
||
if p.Order.PaymentTradeNo == "" {
|
||
s.writeAuditLog(ctx, p.Order.ID, "REFUND_NO_TRADE_NO", "admin", map[string]any{"detail": "skipped"})
|
||
return &payment.RefundResponse{Status: payment.ProviderStatusSuccess}, nil
|
||
}
|
||
|
||
// Use the exact provider instance that created this order, not a random one
|
||
// from the registry. Each instance has its own merchant credentials.
|
||
prov, err := s.getRefundProvider(ctx, p.Order)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("get refund provider: %w", err)
|
||
}
|
||
if err := validateProviderSnapshotMetadata(p.Order, prov.ProviderKey(), providerMerchantIdentityMetadata(prov)); err != nil {
|
||
s.writeAuditLog(ctx, p.Order.ID, "REFUND_PROVIDER_METADATA_MISMATCH", "admin", map[string]any{
|
||
"detail": err.Error(),
|
||
})
|
||
return nil, err
|
||
}
|
||
finishProviderCall := servertiming.ObserveDependency(ctx, "payment")
|
||
resp, err := prov.Refund(ctx, payment.RefundRequest{
|
||
TradeNo: p.Order.PaymentTradeNo,
|
||
OrderID: p.Order.OutTradeNo,
|
||
Amount: formatGatewayRefundAmount(p.GatewayAmount, p.Order),
|
||
Reason: p.Reason,
|
||
})
|
||
finishProviderCall()
|
||
if err != nil {
|
||
if resp != nil && strings.TrimSpace(resp.Status) == payment.ProviderStatusPending {
|
||
return resp, nil
|
||
}
|
||
return nil, err
|
||
}
|
||
if err := validateRefundProviderResponse(resp); err != nil {
|
||
return nil, err
|
||
}
|
||
return resp, nil
|
||
}
|
||
|
||
func formatGatewayRefundAmount(amount float64, order *dbent.PaymentOrder) string {
|
||
return payment.FormatAmountForCurrency(amount, PaymentOrderCurrency(order))
|
||
}
|
||
|
||
func validateRefundProviderResponse(resp *payment.RefundResponse) error {
|
||
if resp == nil {
|
||
return fmt.Errorf("payment refund response missing")
|
||
}
|
||
status := strings.TrimSpace(resp.Status)
|
||
switch status {
|
||
case payment.ProviderStatusSuccess, payment.ProviderStatusRefunded, payment.ProviderStatusPending:
|
||
return nil
|
||
case payment.ProviderStatusFailed:
|
||
return fmt.Errorf("payment refund failed: status %s", status)
|
||
default:
|
||
return fmt.Errorf("payment refund returned unknown status: %s", status)
|
||
}
|
||
}
|
||
|
||
func (s *PaymentService) finishRefund(ctx context.Context, p *RefundPlan, resp *payment.RefundResponse) (*RefundResult, error) {
|
||
if err := validateRefundProviderResponse(resp); err != nil {
|
||
return s.handleGwFail(ctx, p, err)
|
||
}
|
||
switch strings.TrimSpace(resp.Status) {
|
||
case payment.ProviderStatusSuccess, payment.ProviderStatusRefunded:
|
||
return s.markRefundOk(ctx, p)
|
||
case payment.ProviderStatusPending:
|
||
return s.markRefundPending(ctx, p, resp)
|
||
default:
|
||
return s.handleGwFail(ctx, p, fmt.Errorf("payment refund returned unknown status: %s", strings.TrimSpace(resp.Status)))
|
||
}
|
||
}
|
||
|
||
func (s *PaymentService) QueryAndFinalizeRefund(ctx context.Context, oid int64) (*RefundResult, error) {
|
||
o, err := s.entClient.PaymentOrder.Get(ctx, oid)
|
||
if err != nil {
|
||
return nil, infraerrors.NotFound("NOT_FOUND", "order not found")
|
||
}
|
||
if o.Status != OrderStatusRefundPending {
|
||
return nil, infraerrors.BadRequest("INVALID_STATUS", "only refund pending orders can be finalized")
|
||
}
|
||
|
||
prov, err := s.getRefundProvider(ctx, o)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("get refund provider: %w", err)
|
||
}
|
||
queryProvider, ok := prov.(payment.RefundQueryProvider)
|
||
if !ok {
|
||
return nil, infraerrors.BadRequest("REFUND_QUERY_UNSUPPORTED", "this payment provider does not support refund status query; please verify manually")
|
||
}
|
||
|
||
pendingDetail, err := s.latestRefundPendingDetail(ctx, oid)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
finishProviderCall := servertiming.ObserveDependency(ctx, "payment")
|
||
resp, err := queryProvider.QueryRefund(ctx, payment.RefundQueryRequest{
|
||
TradeNo: o.PaymentTradeNo,
|
||
OrderID: o.OutTradeNo,
|
||
RefundID: pendingDetail.RefundID,
|
||
Amount: formatGatewayRefundAmount(o.RefundAmount, o),
|
||
RequestedAt: pendingDetail.requestedAt,
|
||
})
|
||
finishProviderCall()
|
||
if err != nil {
|
||
return nil, fmt.Errorf("query refund: %w", err)
|
||
}
|
||
plan := s.refundFinalizePlan(o)
|
||
switch {
|
||
case pendingDetail.DeductionHeld:
|
||
pendingDetail.applyTo(plan)
|
||
case !pendingDetail.DeductionRollbackOK:
|
||
plan.BalanceToDeduct = 0
|
||
plan.SubDaysToDeduct = 0
|
||
case o.OrderType == payment.OrderTypeSubscription:
|
||
if early := s.prepDeduct(ctx, o, plan, true); early != nil {
|
||
return early, nil
|
||
}
|
||
}
|
||
if err := validateRefundProviderResponse(resp); err != nil {
|
||
return s.finalizeRefundFailed(ctx, plan, err)
|
||
}
|
||
switch strings.TrimSpace(resp.Status) {
|
||
case payment.ProviderStatusSuccess, payment.ProviderStatusRefunded:
|
||
return s.finalizePendingRefundSuccess(ctx, plan)
|
||
case payment.ProviderStatusPending:
|
||
s.writeAuditLog(ctx, oid, "REFUND_QUERY_PENDING", "admin", map[string]any{"refundID": resp.RefundID})
|
||
return &RefundResult{Success: false, Warning: "gateway refund is still pending confirmation"}, nil
|
||
default:
|
||
return s.finalizeRefundFailed(ctx, plan, fmt.Errorf("payment refund returned unknown status: %s", strings.TrimSpace(resp.Status)))
|
||
}
|
||
}
|
||
|
||
func (s *PaymentService) finalizePendingRefundSuccess(ctx context.Context, p *RefundPlan) (_ *RefundResult, err error) {
|
||
tx, err := s.entClient.Tx(ctx)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("begin refund finalization: %w", err)
|
||
}
|
||
defer func() {
|
||
if err != nil {
|
||
_ = tx.Rollback()
|
||
}
|
||
}()
|
||
txCtx := dbent.NewTxContext(ctx, tx)
|
||
|
||
claimed, err := tx.PaymentOrder.Update().
|
||
Where(paymentorder.IDEQ(p.OrderID), paymentorder.StatusEQ(OrderStatusRefundPending)).
|
||
SetStatus(OrderStatusRefunding).
|
||
Save(txCtx)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("claim pending refund: %w", err)
|
||
}
|
||
if claimed == 0 {
|
||
return nil, infraerrors.Conflict("CONFLICT", "order status changed")
|
||
}
|
||
|
||
if err := s.applyRefundFinalDeduction(txCtx, p); err != nil {
|
||
return nil, err
|
||
}
|
||
result, err := s.markRefundOkTx(txCtx, tx.Client(), p)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if err = tx.Commit(); err != nil {
|
||
return nil, fmt.Errorf("commit refund finalization: %w", err)
|
||
}
|
||
if !p.deductionHeld && p.DeductionType == payment.DeductionTypeBalance && p.BalanceToDeduct > 0 {
|
||
s.invalidateRefundBalanceCache(ctx, p.Order.UserID)
|
||
}
|
||
return result, nil
|
||
}
|
||
|
||
func (s *PaymentService) refundFinalizePlan(o *dbent.PaymentOrder) *RefundPlan {
|
||
refundAmount := o.RefundAmount
|
||
reason := strings.TrimSpace(psStringValue(o.RefundReason))
|
||
if reason == "" {
|
||
reason = fmt.Sprintf("refund order:%d", o.ID)
|
||
}
|
||
return &RefundPlan{
|
||
OrderID: o.ID,
|
||
Order: o,
|
||
RefundAmount: refundAmount,
|
||
GatewayAmount: calculateGatewayRefundAmount(o.Amount, o.PayAmount, refundAmount, PaymentOrderCurrency(o)),
|
||
Reason: reason,
|
||
Force: o.ForceRefund,
|
||
DeductBalance: true,
|
||
DeductionType: payment.DeductionTypeBalance,
|
||
BalanceToDeduct: func() float64 {
|
||
if o.OrderType == payment.OrderTypeBalance {
|
||
return refundAmount
|
||
}
|
||
return 0
|
||
}(),
|
||
}
|
||
}
|
||
|
||
func (s *PaymentService) applyRefundFinalDeduction(ctx context.Context, p *RefundPlan) error {
|
||
if p.deductionHeld {
|
||
// 进入 pending 时扣减已保留,终态成功无需再扣。
|
||
return nil
|
||
}
|
||
if p.DeductionType == payment.DeductionTypeBalance && p.BalanceToDeduct > 0 {
|
||
deducted, err := s.deductAvailableBalance(ctx, p.Order.UserID, p.BalanceToDeduct)
|
||
if err != nil {
|
||
return fmt.Errorf("deduction: %w", err)
|
||
}
|
||
p.BalanceToDeduct = deducted
|
||
}
|
||
if p.DeductionType == payment.DeductionTypeSubscription && p.SubDaysToDeduct > 0 && p.SubscriptionID > 0 {
|
||
if _, err := s.subscriptionSvc.ExtendSubscription(ctx, p.SubscriptionID, -p.SubDaysToDeduct); err != nil {
|
||
if errors.Is(err, ErrAdjustWouldExpire) {
|
||
if revokeErr := s.subscriptionSvc.RevokeSubscription(ctx, p.SubscriptionID); revokeErr != nil {
|
||
return fmt.Errorf("revoke subscription: %w", revokeErr)
|
||
}
|
||
} else {
|
||
return fmt.Errorf("deduct subscription days: %w", err)
|
||
}
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// finalizeRefundFailed 将 REFUND_PENDING 收敛为 REFUND_FAILED,并归还进入 pending 时保留的扣减。
|
||
// 认领状态、归还扣减与审计在同一事务内完成:两者分开提交时,进程在中间退出会留下
|
||
// 已失败但扣减未归还、且没有 REFUND_ROLLBACK_FAILED 标记的订单,重试退款会再扣一次。
|
||
func (s *PaymentService) finalizeRefundFailed(ctx context.Context, p *RefundPlan, gErr error) (*RefundResult, error) {
|
||
warning := "gateway refund failed: " + psErrMsg(gErr)
|
||
returnErr, err := s.finalizeRefundFailedTx(ctx, p, gErr, nil)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if returnErr == nil {
|
||
if p.deductionHeld && p.DeductionType == payment.DeductionTypeBalance && p.BalanceToDeduct > 0 {
|
||
s.invalidateRefundBalanceCache(ctx, p.Order.UserID)
|
||
}
|
||
return &RefundResult{Success: false, Warning: warning}, nil
|
||
}
|
||
|
||
// 归还失败(整个事务已回滚,订单仍为 REFUND_PENDING):仍收敛为 REFUND_FAILED,
|
||
// 并同事务写入 REFUND_ROLLBACK_FAILED,使重试退款跳过扣减,避免二次扣减。
|
||
slog.Error("[CRITICAL] refund failed but held deduction could not be returned", "orderID", p.OrderID, "error", returnErr)
|
||
if _, err := s.finalizeRefundFailedTx(ctx, p, gErr, returnErr); err != nil {
|
||
return nil, err
|
||
}
|
||
return &RefundResult{Success: false, Warning: warning + "; refund deduction rollback failed"}, nil
|
||
}
|
||
|
||
// finalizeRefundFailedTx 在单个事务内认领 REFUND_PENDING → REFUND_FAILED。rollbackErr 为 nil 时同事务
|
||
// 归还保留的扣减,归还失败则回滚整个事务并通过 returnErr 返回;rollbackErr 非 nil 时不再归还,
|
||
// 改为同事务记录 REFUND_ROLLBACK_FAILED。
|
||
func (s *PaymentService) finalizeRefundFailedTx(ctx context.Context, p *RefundPlan, gErr, rollbackErr error) (returnErr error, err error) {
|
||
tx, err := s.entClient.Tx(ctx)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("begin refund failure finalization: %w", err)
|
||
}
|
||
committed := false
|
||
defer func() {
|
||
if !committed {
|
||
_ = tx.Rollback()
|
||
}
|
||
}()
|
||
txCtx := dbent.NewTxContext(ctx, tx)
|
||
|
||
claimed, err := tx.PaymentOrder.Update().
|
||
Where(paymentorder.IDEQ(p.OrderID), paymentorder.StatusEQ(OrderStatusRefundPending)).
|
||
SetStatus(OrderStatusRefundFailed).
|
||
SetFailedAt(time.Now()).
|
||
SetFailedReason(psErrMsg(gErr)).
|
||
Save(txCtx)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("mark refund failed: %w", err)
|
||
}
|
||
if claimed == 0 {
|
||
return nil, infraerrors.Conflict("CONFLICT", "order status changed")
|
||
}
|
||
|
||
if p.deductionHeld && rollbackErr == nil {
|
||
if err := s.returnHeldRefundDeduction(txCtx, p); err != nil {
|
||
return err, nil
|
||
}
|
||
}
|
||
if rollbackErr != nil {
|
||
if err := createPaymentAuditLogTx(txCtx, tx.Client(), p.OrderID, "REFUND_ROLLBACK_FAILED", map[string]any{
|
||
"gatewayError": psErrMsg(gErr),
|
||
"rollbackError": psErrMsg(rollbackErr),
|
||
"balanceDeducted": p.BalanceToDeduct,
|
||
"subDaysDeducted": p.SubDaysToDeduct,
|
||
}); err != nil {
|
||
return nil, err
|
||
}
|
||
}
|
||
if err := createPaymentAuditLogTx(txCtx, tx.Client(), p.OrderID, "REFUND_FAILED", map[string]any{"detail": psErrMsg(gErr)}); err != nil {
|
||
return nil, err
|
||
}
|
||
if err := tx.Commit(); err != nil {
|
||
return nil, fmt.Errorf("commit refund failure finalization: %w", err)
|
||
}
|
||
committed = true
|
||
return nil, nil
|
||
}
|
||
|
||
// returnHeldRefundDeduction 归还进入 REFUND_PENDING 时保留的扣减;ctx 携带事务时随事务提交或回滚。
|
||
func (s *PaymentService) returnHeldRefundDeduction(ctx context.Context, p *RefundPlan) error {
|
||
if p.DeductionType == payment.DeductionTypeBalance && p.BalanceToDeduct > 0 {
|
||
if err := s.userRepo.UpdateBalance(ctx, p.Order.UserID, p.BalanceToDeduct); err != nil {
|
||
return fmt.Errorf("return held balance deduction: %w", err)
|
||
}
|
||
}
|
||
if p.DeductionType == payment.DeductionTypeSubscription && p.SubDaysToDeduct > 0 && p.SubscriptionID > 0 {
|
||
if _, err := s.subscriptionSvc.ExtendSubscription(ctx, p.SubscriptionID, p.SubDaysToDeduct); err != nil {
|
||
return fmt.Errorf("return held subscription deduction: %w", err)
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func createPaymentAuditLogTx(ctx context.Context, client *dbent.Client, oid int64, action string, detail map[string]any) error {
|
||
raw, err := json.Marshal(detail)
|
||
if err != nil {
|
||
return fmt.Errorf("marshal %s audit: %w", action, err)
|
||
}
|
||
if _, err := client.PaymentAuditLog.Create().
|
||
SetOrderID(strconv.FormatInt(oid, 10)).
|
||
SetAction(action).
|
||
SetDetail(string(raw)).
|
||
SetOperator("admin").
|
||
Save(ctx); err != nil {
|
||
return fmt.Errorf("write %s audit: %w", action, err)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// refundPendingAuditDetail 记录在 REFUND_PENDING 审计日志中。DeductionHeld 为 true 表示
|
||
// 进入 pending 时扣减被保留;旧记录没有该字段,扣减已在 pending 时归还(DeductionRollbackOK)。
|
||
type refundPendingAuditDetail struct {
|
||
RefundID string `json:"refundID"`
|
||
RefundAmount float64 `json:"refundAmount"`
|
||
Reason string `json:"reason"`
|
||
Force bool `json:"force"`
|
||
DeductionRollbackOK bool `json:"deductionRollbackOK,omitempty"`
|
||
DeductionHeld bool `json:"deductionHeld,omitempty"`
|
||
DeductionType string `json:"deductionType,omitempty"`
|
||
BalanceDeducted float64 `json:"balanceDeducted"`
|
||
SubDaysDeducted int `json:"subDaysDeducted"`
|
||
SubscriptionID int64 `json:"subscriptionID,omitempty"`
|
||
// requestedAt 取自审计记录的创建时间,不参与序列化。
|
||
requestedAt time.Time
|
||
}
|
||
|
||
func (d refundPendingAuditDetail) applyTo(p *RefundPlan) {
|
||
p.deductionHeld = true
|
||
p.DeductionType = d.DeductionType
|
||
if p.DeductionType == "" {
|
||
p.DeductionType = payment.DeductionTypeNone
|
||
}
|
||
p.BalanceToDeduct = d.BalanceDeducted
|
||
p.SubDaysToDeduct = d.SubDaysDeducted
|
||
p.SubscriptionID = d.SubscriptionID
|
||
}
|
||
|
||
// latestRefundPendingDetail 读取最近一次 REFUND_PENDING 审计。没有记录视为旧订单(扣减已在
|
||
// pending 时归还);读取或解析失败必须返回错误:误判为旧记录会让保留的扣减被重复扣减或不再归还。
|
||
func (s *PaymentService) latestRefundPendingDetail(ctx context.Context, oid int64) (refundPendingAuditDetail, error) {
|
||
detail := refundPendingAuditDetail{DeductionRollbackOK: true}
|
||
logEntry, err := s.entClient.PaymentAuditLog.Query().
|
||
Where(paymentauditlog.OrderIDEQ(strconv.FormatInt(oid, 10)), paymentauditlog.ActionEQ("REFUND_PENDING")).
|
||
Order(paymentauditlog.ByCreatedAt(sql.OrderDesc()), paymentauditlog.ByID(sql.OrderDesc())).
|
||
First(ctx)
|
||
if err != nil {
|
||
if dbent.IsNotFound(err) {
|
||
return detail, nil
|
||
}
|
||
return refundPendingAuditDetail{}, fmt.Errorf("load refund pending audit: %w", err)
|
||
}
|
||
if err := json.Unmarshal([]byte(logEntry.Detail), &detail); err != nil {
|
||
return refundPendingAuditDetail{}, fmt.Errorf("parse refund pending audit: %w", err)
|
||
}
|
||
detail.RefundID = strings.TrimSpace(detail.RefundID)
|
||
detail.requestedAt = logEntry.CreatedAt
|
||
return detail, nil
|
||
}
|
||
|
||
// getRefundProvider creates a provider using the order's original instance config.
|
||
// Delegates to getOrderProvider which handles instance lookup and fallback.
|
||
func (s *PaymentService) getRefundProvider(ctx context.Context, o *dbent.PaymentOrder) (payment.Provider, error) {
|
||
inst, err := s.getRefundOrderProviderInstance(ctx, o)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if inst == nil {
|
||
return nil, fmt.Errorf("refund provider instance is unavailable for order %d", o.ID)
|
||
}
|
||
return s.createProviderFromInstance(ctx, inst)
|
||
}
|
||
|
||
func (s *PaymentService) handleGwFail(ctx context.Context, p *RefundPlan, gErr error) (*RefundResult, error) {
|
||
if s.RollbackRefund(ctx, p, gErr) {
|
||
s.restoreStatus(ctx, p)
|
||
s.writeAuditLog(ctx, p.OrderID, "REFUND_GATEWAY_FAILED", "admin", map[string]any{"detail": psErrMsg(gErr)})
|
||
return &RefundResult{Success: false, Warning: "gateway failed: " + psErrMsg(gErr) + ", rolled back"}, nil
|
||
}
|
||
now := time.Now()
|
||
_, _ = s.entClient.PaymentOrder.UpdateOneID(p.OrderID).SetStatus(OrderStatusRefundFailed).SetFailedAt(now).SetFailedReason(psErrMsg(gErr)).Save(ctx)
|
||
s.writeAuditLog(ctx, p.OrderID, "REFUND_FAILED", "admin", map[string]any{"detail": psErrMsg(gErr)})
|
||
return nil, infraerrors.InternalServer("REFUND_FAILED", psErrMsg(gErr))
|
||
}
|
||
|
||
func (s *PaymentService) markRefundOk(ctx context.Context, p *RefundPlan) (*RefundResult, error) {
|
||
fs := OrderStatusRefunded
|
||
if p.RefundAmount < p.Order.Amount {
|
||
fs = OrderStatusPartiallyRefunded
|
||
}
|
||
now := time.Now()
|
||
_, err := s.entClient.PaymentOrder.UpdateOneID(p.OrderID).SetStatus(fs).SetRefundAmount(p.RefundAmount).SetRefundReason(p.Reason).SetRefundAt(now).SetForceRefund(p.Force).Save(ctx)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("mark refund: %w", err)
|
||
}
|
||
s.writeAuditLog(ctx, p.OrderID, "REFUND_SUCCESS", "admin", map[string]any{"refundAmount": p.RefundAmount, "reason": p.Reason, "balanceDeducted": p.BalanceToDeduct, "force": p.Force})
|
||
return &RefundResult{Success: true, BalanceDeducted: p.BalanceToDeduct, SubDaysDeducted: p.SubDaysToDeduct}, nil
|
||
}
|
||
|
||
func (s *PaymentService) markRefundOkTx(ctx context.Context, client *dbent.Client, p *RefundPlan) (*RefundResult, error) {
|
||
fs := OrderStatusRefunded
|
||
if p.RefundAmount < p.Order.Amount {
|
||
fs = OrderStatusPartiallyRefunded
|
||
}
|
||
now := time.Now()
|
||
_, err := client.PaymentOrder.UpdateOneID(p.OrderID).SetStatus(fs).SetRefundAmount(p.RefundAmount).SetRefundReason(p.Reason).SetRefundAt(now).SetForceRefund(p.Force).Save(ctx)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("mark refund: %w", err)
|
||
}
|
||
detail, err := json.Marshal(map[string]any{"refundAmount": p.RefundAmount, "reason": p.Reason, "balanceDeducted": p.BalanceToDeduct, "force": p.Force})
|
||
if err != nil {
|
||
return nil, fmt.Errorf("marshal refund audit: %w", err)
|
||
}
|
||
if _, err := client.PaymentAuditLog.Create().
|
||
SetOrderID(strconv.FormatInt(p.OrderID, 10)).
|
||
SetAction("REFUND_SUCCESS").
|
||
SetDetail(string(detail)).
|
||
SetOperator("admin").
|
||
Save(ctx); err != nil {
|
||
return nil, fmt.Errorf("write refund audit: %w", err)
|
||
}
|
||
return &RefundResult{Success: true, BalanceDeducted: p.BalanceToDeduct, SubDaysDeducted: p.SubDaysToDeduct}, nil
|
||
}
|
||
|
||
// markRefundPending 在网关未给出终态时保留已执行的扣减:若此时归还,用户可在 pending 期间
|
||
// 消费这部分余额,终态成功时只能扣到剩余余额。网关确认失败后由 finalizeRefundFailed 归还。
|
||
// 订单状态与审计记录同事务写入,确保终态收敛时能读到保留的扣减。
|
||
func (s *PaymentService) markRefundPending(ctx context.Context, p *RefundPlan, resp *payment.RefundResponse) (_ *RefundResult, err error) {
|
||
detail, err := json.Marshal(refundPendingAuditDetail{
|
||
RefundID: refundResponseID(resp),
|
||
RefundAmount: p.RefundAmount,
|
||
Reason: p.Reason,
|
||
Force: p.Force,
|
||
DeductionHeld: true,
|
||
DeductionType: p.DeductionType,
|
||
BalanceDeducted: p.BalanceToDeduct,
|
||
SubDaysDeducted: p.SubDaysToDeduct,
|
||
SubscriptionID: p.SubscriptionID,
|
||
})
|
||
if err != nil {
|
||
return nil, fmt.Errorf("marshal refund pending audit: %w", err)
|
||
}
|
||
|
||
tx, err := s.entClient.Tx(ctx)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("begin mark refund pending: %w", err)
|
||
}
|
||
defer func() {
|
||
if err != nil {
|
||
_ = tx.Rollback()
|
||
slog.Error("[CRITICAL] gateway refund pending but order not marked", "orderID", p.OrderID, "error", err)
|
||
}
|
||
}()
|
||
_, err = tx.PaymentOrder.UpdateOneID(p.OrderID).
|
||
SetStatus(OrderStatusRefundPending).
|
||
SetRefundAmount(p.RefundAmount).
|
||
SetRefundReason(p.Reason).
|
||
ClearRefundAt().
|
||
SetForceRefund(p.Force).
|
||
ClearFailedAt().
|
||
ClearFailedReason().
|
||
Save(ctx)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("mark refund pending: %w", err)
|
||
}
|
||
if _, err = tx.PaymentAuditLog.Create().
|
||
SetOrderID(strconv.FormatInt(p.OrderID, 10)).
|
||
SetAction("REFUND_PENDING").
|
||
SetDetail(string(detail)).
|
||
SetOperator("admin").
|
||
Save(ctx); err != nil {
|
||
return nil, fmt.Errorf("write refund pending audit: %w", err)
|
||
}
|
||
if err = tx.Commit(); err != nil {
|
||
return nil, fmt.Errorf("commit mark refund pending: %w", err)
|
||
}
|
||
return &RefundResult{Success: false, Warning: "gateway refund is pending confirmation"}, nil
|
||
}
|
||
|
||
func refundResponseID(resp *payment.RefundResponse) string {
|
||
if resp == nil {
|
||
return ""
|
||
}
|
||
return strings.TrimSpace(resp.RefundID)
|
||
}
|
||
|
||
func (s *PaymentService) RollbackRefund(ctx context.Context, p *RefundPlan, gErr error) bool {
|
||
if p.DeductionType == payment.DeductionTypeBalance && p.BalanceToDeduct > 0 {
|
||
if err := s.userRepo.UpdateBalance(ctx, p.Order.UserID, p.BalanceToDeduct); err != nil {
|
||
slog.Error("[CRITICAL] rollback failed", "orderID", p.OrderID, "amount", p.BalanceToDeduct, "error", err)
|
||
s.writeAuditLog(ctx, p.OrderID, "REFUND_ROLLBACK_FAILED", "admin", map[string]any{"gatewayError": psErrMsg(gErr), "rollbackError": psErrMsg(err), "balanceDeducted": p.BalanceToDeduct})
|
||
return false
|
||
}
|
||
s.invalidateRefundBalanceCache(ctx, p.Order.UserID)
|
||
}
|
||
if p.DeductionType == payment.DeductionTypeSubscription && p.SubDaysToDeduct > 0 && p.SubscriptionID > 0 {
|
||
if _, err := s.subscriptionSvc.ExtendSubscription(ctx, p.SubscriptionID, p.SubDaysToDeduct); err != nil {
|
||
slog.Error("[CRITICAL] subscription rollback failed", "orderID", p.OrderID, "subID", p.SubscriptionID, "days", p.SubDaysToDeduct, "error", err)
|
||
s.writeAuditLog(ctx, p.OrderID, "REFUND_ROLLBACK_FAILED", "admin", map[string]any{"gatewayError": psErrMsg(gErr), "rollbackError": psErrMsg(err), "subDaysDeducted": p.SubDaysToDeduct})
|
||
return false
|
||
}
|
||
}
|
||
return true
|
||
}
|
||
|
||
func (s *PaymentService) restoreStatus(ctx context.Context, p *RefundPlan) {
|
||
rs := OrderStatusCompleted
|
||
if p.Order.Status == OrderStatusRefundRequested {
|
||
rs = OrderStatusRefundRequested
|
||
}
|
||
_, _ = s.entClient.PaymentOrder.UpdateOneID(p.OrderID).SetStatus(rs).Save(ctx)
|
||
}
|