feat: add per-provider allow_user_refund control and align wildcard matching
allow_user_refund: - Add allow_user_refund field to PaymentProviderInstance ent schema - Migration 103: ALTER TABLE payment_provider_instances ADD COLUMN - Cascade logic: disabling refund_enabled auto-disables allow_user_refund - User refund validation: check provider instance allows user refund - Admin refund validation: check provider instance allows admin refund - Subscription refund: deduct days on refund, rollback on failure - New endpoint: GET /payment/orders/refund-eligible-providers - Frontend: ToggleSwitch in ProviderCard/Dialog, cascade in SettingsView Wildcard matching: - Change findPricingForModel from "longest prefix wins" to "config order priority (first match wins)", aligning with channel service behavior
This commit is contained in:
@@ -17,6 +17,19 @@ import (
|
||||
|
||||
// --- Refund Flow ---
|
||||
|
||||
// getOrderProviderInstance looks up the provider instance that processed this order.
|
||||
// Returns nil, nil for legacy orders without provider_instance_id.
|
||||
func (s *PaymentService) getOrderProviderInstance(ctx context.Context, o *dbent.PaymentOrder) (*dbent.PaymentProviderInstance, error) {
|
||||
if o.ProviderInstanceID == nil || *o.ProviderInstanceID == "" {
|
||||
return nil, nil
|
||||
}
|
||||
instID, err := strconv.ParseInt(*o.ProviderInstanceID, 10, 64)
|
||||
if err != nil {
|
||||
return nil, nil
|
||||
}
|
||||
return s.entClient.PaymentProviderInstance.Get(ctx, instID)
|
||||
}
|
||||
|
||||
func (s *PaymentService) RequestRefund(ctx context.Context, oid, uid int64, reason string) error {
|
||||
o, err := s.validateRefundRequest(ctx, oid, uid)
|
||||
if err != nil {
|
||||
@@ -57,6 +70,14 @@ func (s *PaymentService) validateRefundRequest(ctx context.Context, oid, uid int
|
||||
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.getOrderProviderInstance(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
|
||||
}
|
||||
|
||||
@@ -69,6 +90,18 @@ func (s *PaymentService) PrepareRefund(ctx context.Context, oid int64, amt float
|
||||
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.getOrderProviderInstance(ctx, o)
|
||||
if instErr != nil {
|
||||
slog.Warn("refund: provider instance not found", "orderID", oid, "error", instErr)
|
||||
}
|
||||
if inst != nil && !inst.RefundEnabled {
|
||||
return nil, nil, infraerrors.Forbidden("REFUND_DISABLED", "refund is not enabled for this provider")
|
||||
}
|
||||
if inst == nil && instErr == nil {
|
||||
// Legacy order without provider_instance_id — block refund
|
||||
return nil, nil, infraerrors.Forbidden("REFUND_DISABLED", "refund is not available for this order")
|
||||
}
|
||||
if math.IsNaN(amt) || math.IsInf(amt, 0) {
|
||||
return nil, nil, infraerrors.BadRequest("INVALID_AMOUNT", "invalid refund amount")
|
||||
}
|
||||
@@ -102,6 +135,15 @@ func (s *PaymentService) PrepareRefund(ctx context.Context, oid int64, amt float
|
||||
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)
|
||||
@@ -137,6 +179,21 @@ func (s *PaymentService) ExecuteRefund(ctx context.Context, p *RefundPlan) (*Ref
|
||||
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 {
|
||||
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 {
|
||||
slog.Warn("skipping subscription deduction on retry (previous rollback failed)", "orderID", p.OrderID)
|
||||
p.SubDaysToDeduct = 0
|
||||
}
|
||||
}
|
||||
if err := s.gwRefund(ctx, p); err != nil {
|
||||
return s.handleGwFail(ctx, p, err)
|
||||
}
|
||||
@@ -204,6 +261,13 @@ func (s *PaymentService) RollbackRefund(ctx context.Context, p *RefundPlan, gErr
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user