Release / update-version (push) Has been cancelled
Release / build-frontend (push) Has been cancelled
Release / release (push) Has been cancelled
Release / sync-version-file (push) Has been cancelled
CI / shell (push) Canceled after 0s
CI / test (push) Canceled after 0s
CI / frontend (push) Canceled after 0s
CI / golangci-lint (push) Canceled after 0s
Security Scan / backend-security (push) Canceled after 0s
Security Scan / frontend-security (push) Canceled after 0s
721 lines
27 KiB
Go
721 lines
27 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")
|
|
}
|
|
ok := []string{OrderStatusCompleted, OrderStatusRefundRequested, OrderStatusRefundPending, 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)
|
|
}
|
|
|
|
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, OrderStatusRefundPending, 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
|
|
} 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 := s.latestRefundPendingDetail(ctx, oid)
|
|
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),
|
|
})
|
|
finishProviderCall()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("query refund: %w", err)
|
|
}
|
|
if err := validateRefundProviderResponse(resp); err != nil {
|
|
return s.finalizeRefundFailed(ctx, o, err)
|
|
}
|
|
|
|
plan := s.refundFinalizePlan(o)
|
|
if !pendingDetail.DeductionRollbackOK {
|
|
plan.BalanceToDeduct = 0
|
|
plan.SubDaysToDeduct = 0
|
|
} else if o.OrderType == payment.OrderTypeSubscription {
|
|
if early := s.prepDeduct(ctx, o, plan, true); early != nil {
|
|
return early, nil
|
|
}
|
|
}
|
|
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, o, 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)
|
|
}
|
|
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.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
|
|
}
|
|
|
|
func (s *PaymentService) finalizeRefundFailed(ctx context.Context, o *dbent.PaymentOrder, gErr error) (*RefundResult, error) {
|
|
now := time.Now()
|
|
_, _ = s.entClient.PaymentOrder.UpdateOneID(o.ID).SetStatus(OrderStatusRefundFailed).SetFailedAt(now).SetFailedReason(psErrMsg(gErr)).Save(ctx)
|
|
s.writeAuditLog(ctx, o.ID, "REFUND_FAILED", "admin", map[string]any{"detail": psErrMsg(gErr)})
|
|
return &RefundResult{Success: false, Warning: "gateway refund failed: " + psErrMsg(gErr)}, nil
|
|
}
|
|
|
|
type refundPendingAuditDetail struct {
|
|
RefundID string `json:"refundID"`
|
|
DeductionRollbackOK bool `json:"deductionRollbackOK"`
|
|
}
|
|
|
|
func (s *PaymentService) latestRefundPendingDetail(ctx context.Context, oid int64) refundPendingAuditDetail {
|
|
logEntry, err := s.entClient.PaymentAuditLog.Query().
|
|
Where(paymentauditlog.OrderIDEQ(strconv.FormatInt(oid, 10)), paymentauditlog.ActionEQ("REFUND_PENDING")).
|
|
Order(paymentauditlog.ByCreatedAt(sql.OrderDesc())).
|
|
First(ctx)
|
|
if err != nil || logEntry == nil {
|
|
return refundPendingAuditDetail{DeductionRollbackOK: true}
|
|
}
|
|
detail := refundPendingAuditDetail{DeductionRollbackOK: true}
|
|
_ = json.Unmarshal([]byte(logEntry.Detail), &detail)
|
|
detail.RefundID = strings.TrimSpace(detail.RefundID)
|
|
return detail
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
func (s *PaymentService) markRefundPending(ctx context.Context, p *RefundPlan, resp *payment.RefundResponse) (*RefundResult, error) {
|
|
balanceDeducted := p.BalanceToDeduct
|
|
subDaysDeducted := p.SubDaysToDeduct
|
|
rollbackOK := s.RollbackRefund(ctx, p, nil)
|
|
if rollbackOK {
|
|
p.BalanceToDeduct = 0
|
|
p.SubDaysToDeduct = 0
|
|
}
|
|
|
|
_, err := s.entClient.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)
|
|
}
|
|
|
|
detail := map[string]any{
|
|
"refundID": refundResponseID(resp),
|
|
"refundAmount": p.RefundAmount,
|
|
"reason": p.Reason,
|
|
"force": p.Force,
|
|
"balanceDeducted": p.BalanceToDeduct,
|
|
"subDaysDeducted": p.SubDaysToDeduct,
|
|
"balanceRolledBack": balanceDeducted,
|
|
"subDaysRolledBack": subDaysDeducted,
|
|
"deductionRollbackOK": rollbackOK,
|
|
}
|
|
s.writeAuditLog(ctx, p.OrderID, "REFUND_PENDING", "admin", detail)
|
|
|
|
warning := "gateway refund is pending confirmation"
|
|
if !rollbackOK {
|
|
warning += "; refund deduction rollback failed"
|
|
}
|
|
return &RefundResult{Success: false, Warning: warning}, 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
|
|
}
|
|
}
|
|
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)
|
|
}
|