diff --git a/backend/internal/service/payment_refund.go b/backend/internal/service/payment_refund.go index fbaeff99..6883056c 100644 --- a/backend/internal/service/payment_refund.go +++ b/backend/internal/service/payment_refund.go @@ -43,6 +43,37 @@ func (s *PaymentService) getOrderProviderInstance(ctx context.Context, o *dbent. 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)) @@ -157,7 +188,7 @@ func (s *PaymentService) validateRefundRequest(ctx context.Context, oid, uid int return nil, infraerrors.BadRequest("INVALID_STATUS", "only completed orders can request refund") } // Check provider instance allows user refund - inst, err := s.getOrderProviderInstance(ctx, o) + 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") } @@ -177,7 +208,7 @@ func (s *PaymentService) PrepareRefund(ctx context.Context, oid int64, amt float 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) + 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") @@ -314,7 +345,14 @@ func (s *PaymentService) gwRefund(ctx context.Context, p *RefundPlan) error { // 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) { - return s.getOrderProvider(ctx, o) + 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) { diff --git a/backend/internal/service/payment_refund_test.go b/backend/internal/service/payment_refund_test.go new file mode 100644 index 00000000..95104618 --- /dev/null +++ b/backend/internal/service/payment_refund_test.go @@ -0,0 +1,117 @@ +//go:build unit + +package service + +import ( + "context" + "testing" + "time" + + "github.com/Wei-Shaw/sub2api/internal/payment" + infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" + "github.com/stretchr/testify/require" +) + +func TestValidateRefundRequestRejectsLegacyGuessedProviderInstance(t *testing.T) { + ctx := context.Background() + client := newPaymentConfigServiceTestClient(t) + + user, err := client.User.Create(). + SetEmail("refund-legacy@example.com"). + SetPasswordHash("hash"). + SetUsername("refund-legacy-user"). + Save(ctx) + require.NoError(t, err) + + _, err = client.PaymentProviderInstance.Create(). + SetProviderKey(payment.TypeAlipay). + SetName("alipay-refund-instance"). + SetConfig("{}"). + SetSupportedTypes("alipay"). + SetEnabled(true). + SetAllowUserRefund(true). + SetRefundEnabled(true). + Save(ctx) + require.NoError(t, err) + + order, err := client.PaymentOrder.Create(). + SetUserID(user.ID). + SetUserEmail(user.Email). + SetUserName(user.Username). + SetAmount(88). + SetPayAmount(88). + SetFeeRate(0). + SetRechargeCode("REFUND-LEGACY-ORDER"). + SetOutTradeNo("sub2_refund_legacy_order"). + SetPaymentType(payment.TypeAlipay). + SetPaymentTradeNo("trade-legacy-refund"). + SetOrderType(payment.OrderTypeBalance). + SetStatus(OrderStatusCompleted). + SetExpiresAt(time.Now().Add(time.Hour)). + SetPaidAt(time.Now()). + SetClientIP("127.0.0.1"). + SetSrcHost("api.example.com"). + Save(ctx) + require.NoError(t, err) + + svc := &PaymentService{ + entClient: client, + } + + _, err = svc.validateRefundRequest(ctx, order.ID, user.ID) + require.Error(t, err) + require.Equal(t, "USER_REFUND_DISABLED", infraerrors.Reason(err)) +} + +func TestPrepareRefundRejectsLegacyGuessedProviderInstance(t *testing.T) { + ctx := context.Background() + client := newPaymentConfigServiceTestClient(t) + + user, err := client.User.Create(). + SetEmail("refund-legacy-admin@example.com"). + SetPasswordHash("hash"). + SetUsername("refund-legacy-admin-user"). + Save(ctx) + require.NoError(t, err) + + _, err = client.PaymentProviderInstance.Create(). + SetProviderKey(payment.TypeAlipay). + SetName("alipay-refund-admin-instance"). + SetConfig("{}"). + SetSupportedTypes("alipay"). + SetEnabled(true). + SetAllowUserRefund(true). + SetRefundEnabled(true). + Save(ctx) + require.NoError(t, err) + + order, err := client.PaymentOrder.Create(). + SetUserID(user.ID). + SetUserEmail(user.Email). + SetUserName(user.Username). + SetAmount(188). + SetPayAmount(188). + SetFeeRate(0). + SetRechargeCode("REFUND-LEGACY-ADMIN-ORDER"). + SetOutTradeNo("sub2_refund_legacy_admin_order"). + SetPaymentType(payment.TypeAlipay). + SetPaymentTradeNo("trade-legacy-admin-refund"). + SetOrderType(payment.OrderTypeBalance). + SetStatus(OrderStatusCompleted). + SetExpiresAt(time.Now().Add(time.Hour)). + SetPaidAt(time.Now()). + SetClientIP("127.0.0.1"). + SetSrcHost("api.example.com"). + Save(ctx) + require.NoError(t, err) + + svc := &PaymentService{ + entClient: client, + } + + plan, result, err := svc.PrepareRefund(ctx, order.ID, 0, "", false, false) + require.Nil(t, plan) + require.Nil(t, result) + require.Error(t, err) + require.Equal(t, "REFUND_DISABLED", infraerrors.Reason(err)) +}