feat(payment): add per-provider allow_user_refund control

- Add allow_user_refund field to payment_provider_instances (migration 103)
- Backend: validateRefundRequest checks allow_user_refund for user requests
- Backend: PrepareRefund checks refund_enabled for admin refunds
- Legacy orders (no provider_instance_id) default to blocking refund
- Cascade: disabling refund_enabled auto-disables allow_user_refund
- Frontend: ProviderCard/Dialog show allow_user_refund toggle when refund_enabled
- Frontend: UserOrdersView checks eligible providers before showing refund button
- New API: GET /payment/orders/refund-eligible-providers
This commit is contained in:
erio
2026-04-12 21:38:34 +08:00
parent 80734ebab4
commit db264c4a12
24 changed files with 374 additions and 52 deletions
+1
View File
@@ -616,6 +616,7 @@ var (
{Name: "sort_order", Type: field.TypeInt, Default: 0},
{Name: "limits", Type: field.TypeString, Default: "", SchemaType: map[string]string{"postgres": "text"}},
{Name: "refund_enabled", Type: field.TypeBool, Default: false},
{Name: "allow_user_refund", Type: field.TypeBool, Default: false},
{Name: "created_at", Type: field.TypeTime, SchemaType: map[string]string{"postgres": "timestamptz"}},
{Name: "updated_at", Type: field.TypeTime, SchemaType: map[string]string{"postgres": "timestamptz"}},
}
+74 -20
View File
@@ -15642,25 +15642,26 @@ func (m *PaymentOrderMutation) ResetEdge(name string) error {
// PaymentProviderInstanceMutation represents an operation that mutates the PaymentProviderInstance nodes in the graph.
type PaymentProviderInstanceMutation struct {
config
op Op
typ string
id *int64
provider_key *string
name *string
_config *string
supported_types *string
enabled *bool
payment_mode *string
sort_order *int
addsort_order *int
limits *string
refund_enabled *bool
created_at *time.Time
updated_at *time.Time
clearedFields map[string]struct{}
done bool
oldValue func(context.Context) (*PaymentProviderInstance, error)
predicates []predicate.PaymentProviderInstance
op Op
typ string
id *int64
provider_key *string
name *string
_config *string
supported_types *string
enabled *bool
payment_mode *string
sort_order *int
addsort_order *int
limits *string
refund_enabled *bool
allow_user_refund *bool
created_at *time.Time
updated_at *time.Time
clearedFields map[string]struct{}
done bool
oldValue func(context.Context) (*PaymentProviderInstance, error)
predicates []predicate.PaymentProviderInstance
}
var _ ent.Mutation = (*PaymentProviderInstanceMutation)(nil)
@@ -16105,6 +16106,42 @@ func (m *PaymentProviderInstanceMutation) ResetRefundEnabled() {
m.refund_enabled = nil
}
// SetAllowUserRefund sets the "allow_user_refund" field.
func (m *PaymentProviderInstanceMutation) SetAllowUserRefund(b bool) {
m.allow_user_refund = &b
}
// AllowUserRefund returns the value of the "allow_user_refund" field in the mutation.
func (m *PaymentProviderInstanceMutation) AllowUserRefund() (r bool, exists bool) {
v := m.allow_user_refund
if v == nil {
return
}
return *v, true
}
// OldAllowUserRefund returns the old "allow_user_refund" field's value of the PaymentProviderInstance entity.
// If the PaymentProviderInstance object wasn't provided to the builder, the object is fetched from the database.
// An error is returned if the mutation operation is not UpdateOne, or the database query fails.
func (m *PaymentProviderInstanceMutation) OldAllowUserRefund(ctx context.Context) (v bool, err error) {
if !m.op.Is(OpUpdateOne) {
return v, errors.New("OldAllowUserRefund is only allowed on UpdateOne operations")
}
if m.id == nil || m.oldValue == nil {
return v, errors.New("OldAllowUserRefund requires an ID field in the mutation")
}
oldValue, err := m.oldValue(ctx)
if err != nil {
return v, fmt.Errorf("querying old value for OldAllowUserRefund: %w", err)
}
return oldValue.AllowUserRefund, nil
}
// ResetAllowUserRefund resets all changes to the "allow_user_refund" field.
func (m *PaymentProviderInstanceMutation) ResetAllowUserRefund() {
m.allow_user_refund = nil
}
// SetCreatedAt sets the "created_at" field.
func (m *PaymentProviderInstanceMutation) SetCreatedAt(t time.Time) {
m.created_at = &t
@@ -16211,7 +16248,7 @@ func (m *PaymentProviderInstanceMutation) Type() string {
// order to get all numeric fields that were incremented/decremented, call
// AddedFields().
func (m *PaymentProviderInstanceMutation) Fields() []string {
fields := make([]string, 0, 11)
fields := make([]string, 0, 12)
if m.provider_key != nil {
fields = append(fields, paymentproviderinstance.FieldProviderKey)
}
@@ -16239,6 +16276,9 @@ func (m *PaymentProviderInstanceMutation) Fields() []string {
if m.refund_enabled != nil {
fields = append(fields, paymentproviderinstance.FieldRefundEnabled)
}
if m.allow_user_refund != nil {
fields = append(fields, paymentproviderinstance.FieldAllowUserRefund)
}
if m.created_at != nil {
fields = append(fields, paymentproviderinstance.FieldCreatedAt)
}
@@ -16271,6 +16311,8 @@ func (m *PaymentProviderInstanceMutation) Field(name string) (ent.Value, bool) {
return m.Limits()
case paymentproviderinstance.FieldRefundEnabled:
return m.RefundEnabled()
case paymentproviderinstance.FieldAllowUserRefund:
return m.AllowUserRefund()
case paymentproviderinstance.FieldCreatedAt:
return m.CreatedAt()
case paymentproviderinstance.FieldUpdatedAt:
@@ -16302,6 +16344,8 @@ func (m *PaymentProviderInstanceMutation) OldField(ctx context.Context, name str
return m.OldLimits(ctx)
case paymentproviderinstance.FieldRefundEnabled:
return m.OldRefundEnabled(ctx)
case paymentproviderinstance.FieldAllowUserRefund:
return m.OldAllowUserRefund(ctx)
case paymentproviderinstance.FieldCreatedAt:
return m.OldCreatedAt(ctx)
case paymentproviderinstance.FieldUpdatedAt:
@@ -16378,6 +16422,13 @@ func (m *PaymentProviderInstanceMutation) SetField(name string, value ent.Value)
}
m.SetRefundEnabled(v)
return nil
case paymentproviderinstance.FieldAllowUserRefund:
v, ok := value.(bool)
if !ok {
return fmt.Errorf("unexpected type %T for field %s", value, name)
}
m.SetAllowUserRefund(v)
return nil
case paymentproviderinstance.FieldCreatedAt:
v, ok := value.(time.Time)
if !ok {
@@ -16483,6 +16534,9 @@ func (m *PaymentProviderInstanceMutation) ResetField(name string) error {
case paymentproviderinstance.FieldRefundEnabled:
m.ResetRefundEnabled()
return nil
case paymentproviderinstance.FieldAllowUserRefund:
m.ResetAllowUserRefund()
return nil
case paymentproviderinstance.FieldCreatedAt:
m.ResetCreatedAt()
return nil
+12 -1
View File
@@ -35,6 +35,8 @@ type PaymentProviderInstance struct {
Limits string `json:"limits,omitempty"`
// RefundEnabled holds the value of the "refund_enabled" field.
RefundEnabled bool `json:"refund_enabled,omitempty"`
// AllowUserRefund holds the value of the "allow_user_refund" field.
AllowUserRefund bool `json:"allow_user_refund,omitempty"`
// CreatedAt holds the value of the "created_at" field.
CreatedAt time.Time `json:"created_at,omitempty"`
// UpdatedAt holds the value of the "updated_at" field.
@@ -47,7 +49,7 @@ func (*PaymentProviderInstance) scanValues(columns []string) ([]any, error) {
values := make([]any, len(columns))
for i := range columns {
switch columns[i] {
case paymentproviderinstance.FieldEnabled, paymentproviderinstance.FieldRefundEnabled:
case paymentproviderinstance.FieldEnabled, paymentproviderinstance.FieldRefundEnabled, paymentproviderinstance.FieldAllowUserRefund:
values[i] = new(sql.NullBool)
case paymentproviderinstance.FieldID, paymentproviderinstance.FieldSortOrder:
values[i] = new(sql.NullInt64)
@@ -130,6 +132,12 @@ func (_m *PaymentProviderInstance) assignValues(columns []string, values []any)
} else if value.Valid {
_m.RefundEnabled = value.Bool
}
case paymentproviderinstance.FieldAllowUserRefund:
if value, ok := values[i].(*sql.NullBool); !ok {
return fmt.Errorf("unexpected type %T for field allow_user_refund", values[i])
} else if value.Valid {
_m.AllowUserRefund = value.Bool
}
case paymentproviderinstance.FieldCreatedAt:
if value, ok := values[i].(*sql.NullTime); !ok {
return fmt.Errorf("unexpected type %T for field created_at", values[i])
@@ -205,6 +213,9 @@ func (_m *PaymentProviderInstance) String() string {
builder.WriteString("refund_enabled=")
builder.WriteString(fmt.Sprintf("%v", _m.RefundEnabled))
builder.WriteString(", ")
builder.WriteString("allow_user_refund=")
builder.WriteString(fmt.Sprintf("%v", _m.AllowUserRefund))
builder.WriteString(", ")
builder.WriteString("created_at=")
builder.WriteString(_m.CreatedAt.Format(time.ANSIC))
builder.WriteString(", ")
@@ -31,6 +31,8 @@ const (
FieldLimits = "limits"
// FieldRefundEnabled holds the string denoting the refund_enabled field in the database.
FieldRefundEnabled = "refund_enabled"
// FieldAllowUserRefund holds the string denoting the allow_user_refund field in the database.
FieldAllowUserRefund = "allow_user_refund"
// FieldCreatedAt holds the string denoting the created_at field in the database.
FieldCreatedAt = "created_at"
// FieldUpdatedAt holds the string denoting the updated_at field in the database.
@@ -51,6 +53,7 @@ var Columns = []string{
FieldSortOrder,
FieldLimits,
FieldRefundEnabled,
FieldAllowUserRefund,
FieldCreatedAt,
FieldUpdatedAt,
}
@@ -88,6 +91,8 @@ var (
DefaultLimits string
// DefaultRefundEnabled holds the default value on creation for the "refund_enabled" field.
DefaultRefundEnabled bool
// DefaultAllowUserRefund holds the default value on creation for the "allow_user_refund" field.
DefaultAllowUserRefund bool
// DefaultCreatedAt holds the default value on creation for the "created_at" field.
DefaultCreatedAt func() time.Time
// DefaultUpdatedAt holds the default value on creation for the "updated_at" field.
@@ -149,6 +154,11 @@ func ByRefundEnabled(opts ...sql.OrderTermOption) OrderOption {
return sql.OrderByField(FieldRefundEnabled, opts...).ToFunc()
}
// ByAllowUserRefund orders the results by the allow_user_refund field.
func ByAllowUserRefund(opts ...sql.OrderTermOption) OrderOption {
return sql.OrderByField(FieldAllowUserRefund, opts...).ToFunc()
}
// ByCreatedAt orders the results by the created_at field.
func ByCreatedAt(opts ...sql.OrderTermOption) OrderOption {
return sql.OrderByField(FieldCreatedAt, opts...).ToFunc()
@@ -99,6 +99,11 @@ func RefundEnabled(v bool) predicate.PaymentProviderInstance {
return predicate.PaymentProviderInstance(sql.FieldEQ(FieldRefundEnabled, v))
}
// AllowUserRefund applies equality check predicate on the "allow_user_refund" field. It's identical to AllowUserRefundEQ.
func AllowUserRefund(v bool) predicate.PaymentProviderInstance {
return predicate.PaymentProviderInstance(sql.FieldEQ(FieldAllowUserRefund, v))
}
// CreatedAt applies equality check predicate on the "created_at" field. It's identical to CreatedAtEQ.
func CreatedAt(v time.Time) predicate.PaymentProviderInstance {
return predicate.PaymentProviderInstance(sql.FieldEQ(FieldCreatedAt, v))
@@ -559,6 +564,16 @@ func RefundEnabledNEQ(v bool) predicate.PaymentProviderInstance {
return predicate.PaymentProviderInstance(sql.FieldNEQ(FieldRefundEnabled, v))
}
// AllowUserRefundEQ applies the EQ predicate on the "allow_user_refund" field.
func AllowUserRefundEQ(v bool) predicate.PaymentProviderInstance {
return predicate.PaymentProviderInstance(sql.FieldEQ(FieldAllowUserRefund, v))
}
// AllowUserRefundNEQ applies the NEQ predicate on the "allow_user_refund" field.
func AllowUserRefundNEQ(v bool) predicate.PaymentProviderInstance {
return predicate.PaymentProviderInstance(sql.FieldNEQ(FieldAllowUserRefund, v))
}
// CreatedAtEQ applies the EQ predicate on the "created_at" field.
func CreatedAtEQ(v time.Time) predicate.PaymentProviderInstance {
return predicate.PaymentProviderInstance(sql.FieldEQ(FieldCreatedAt, v))
@@ -132,6 +132,20 @@ func (_c *PaymentProviderInstanceCreate) SetNillableRefundEnabled(v *bool) *Paym
return _c
}
// SetAllowUserRefund sets the "allow_user_refund" field.
func (_c *PaymentProviderInstanceCreate) SetAllowUserRefund(v bool) *PaymentProviderInstanceCreate {
_c.mutation.SetAllowUserRefund(v)
return _c
}
// SetNillableAllowUserRefund sets the "allow_user_refund" field if the given value is not nil.
func (_c *PaymentProviderInstanceCreate) SetNillableAllowUserRefund(v *bool) *PaymentProviderInstanceCreate {
if v != nil {
_c.SetAllowUserRefund(*v)
}
return _c
}
// SetCreatedAt sets the "created_at" field.
func (_c *PaymentProviderInstanceCreate) SetCreatedAt(v time.Time) *PaymentProviderInstanceCreate {
_c.mutation.SetCreatedAt(v)
@@ -223,6 +237,10 @@ func (_c *PaymentProviderInstanceCreate) defaults() {
v := paymentproviderinstance.DefaultRefundEnabled
_c.mutation.SetRefundEnabled(v)
}
if _, ok := _c.mutation.AllowUserRefund(); !ok {
v := paymentproviderinstance.DefaultAllowUserRefund
_c.mutation.SetAllowUserRefund(v)
}
if _, ok := _c.mutation.CreatedAt(); !ok {
v := paymentproviderinstance.DefaultCreatedAt()
_c.mutation.SetCreatedAt(v)
@@ -282,6 +300,9 @@ func (_c *PaymentProviderInstanceCreate) check() error {
if _, ok := _c.mutation.RefundEnabled(); !ok {
return &ValidationError{Name: "refund_enabled", err: errors.New(`ent: missing required field "PaymentProviderInstance.refund_enabled"`)}
}
if _, ok := _c.mutation.AllowUserRefund(); !ok {
return &ValidationError{Name: "allow_user_refund", err: errors.New(`ent: missing required field "PaymentProviderInstance.allow_user_refund"`)}
}
if _, ok := _c.mutation.CreatedAt(); !ok {
return &ValidationError{Name: "created_at", err: errors.New(`ent: missing required field "PaymentProviderInstance.created_at"`)}
}
@@ -351,6 +372,10 @@ func (_c *PaymentProviderInstanceCreate) createSpec() (*PaymentProviderInstance,
_spec.SetField(paymentproviderinstance.FieldRefundEnabled, field.TypeBool, value)
_node.RefundEnabled = value
}
if value, ok := _c.mutation.AllowUserRefund(); ok {
_spec.SetField(paymentproviderinstance.FieldAllowUserRefund, field.TypeBool, value)
_node.AllowUserRefund = value
}
if value, ok := _c.mutation.CreatedAt(); ok {
_spec.SetField(paymentproviderinstance.FieldCreatedAt, field.TypeTime, value)
_node.CreatedAt = value
@@ -525,6 +550,18 @@ func (u *PaymentProviderInstanceUpsert) UpdateRefundEnabled() *PaymentProviderIn
return u
}
// SetAllowUserRefund sets the "allow_user_refund" field.
func (u *PaymentProviderInstanceUpsert) SetAllowUserRefund(v bool) *PaymentProviderInstanceUpsert {
u.Set(paymentproviderinstance.FieldAllowUserRefund, v)
return u
}
// UpdateAllowUserRefund sets the "allow_user_refund" field to the value that was provided on create.
func (u *PaymentProviderInstanceUpsert) UpdateAllowUserRefund() *PaymentProviderInstanceUpsert {
u.SetExcluded(paymentproviderinstance.FieldAllowUserRefund)
return u
}
// SetUpdatedAt sets the "updated_at" field.
func (u *PaymentProviderInstanceUpsert) SetUpdatedAt(v time.Time) *PaymentProviderInstanceUpsert {
u.Set(paymentproviderinstance.FieldUpdatedAt, v)
@@ -715,6 +752,20 @@ func (u *PaymentProviderInstanceUpsertOne) UpdateRefundEnabled() *PaymentProvide
})
}
// SetAllowUserRefund sets the "allow_user_refund" field.
func (u *PaymentProviderInstanceUpsertOne) SetAllowUserRefund(v bool) *PaymentProviderInstanceUpsertOne {
return u.Update(func(s *PaymentProviderInstanceUpsert) {
s.SetAllowUserRefund(v)
})
}
// UpdateAllowUserRefund sets the "allow_user_refund" field to the value that was provided on create.
func (u *PaymentProviderInstanceUpsertOne) UpdateAllowUserRefund() *PaymentProviderInstanceUpsertOne {
return u.Update(func(s *PaymentProviderInstanceUpsert) {
s.UpdateAllowUserRefund()
})
}
// SetUpdatedAt sets the "updated_at" field.
func (u *PaymentProviderInstanceUpsertOne) SetUpdatedAt(v time.Time) *PaymentProviderInstanceUpsertOne {
return u.Update(func(s *PaymentProviderInstanceUpsert) {
@@ -1073,6 +1124,20 @@ func (u *PaymentProviderInstanceUpsertBulk) UpdateRefundEnabled() *PaymentProvid
})
}
// SetAllowUserRefund sets the "allow_user_refund" field.
func (u *PaymentProviderInstanceUpsertBulk) SetAllowUserRefund(v bool) *PaymentProviderInstanceUpsertBulk {
return u.Update(func(s *PaymentProviderInstanceUpsert) {
s.SetAllowUserRefund(v)
})
}
// UpdateAllowUserRefund sets the "allow_user_refund" field to the value that was provided on create.
func (u *PaymentProviderInstanceUpsertBulk) UpdateAllowUserRefund() *PaymentProviderInstanceUpsertBulk {
return u.Update(func(s *PaymentProviderInstanceUpsert) {
s.UpdateAllowUserRefund()
})
}
// SetUpdatedAt sets the "updated_at" field.
func (u *PaymentProviderInstanceUpsertBulk) SetUpdatedAt(v time.Time) *PaymentProviderInstanceUpsertBulk {
return u.Update(func(s *PaymentProviderInstanceUpsert) {
@@ -161,6 +161,20 @@ func (_u *PaymentProviderInstanceUpdate) SetNillableRefundEnabled(v *bool) *Paym
return _u
}
// SetAllowUserRefund sets the "allow_user_refund" field.
func (_u *PaymentProviderInstanceUpdate) SetAllowUserRefund(v bool) *PaymentProviderInstanceUpdate {
_u.mutation.SetAllowUserRefund(v)
return _u
}
// SetNillableAllowUserRefund sets the "allow_user_refund" field if the given value is not nil.
func (_u *PaymentProviderInstanceUpdate) SetNillableAllowUserRefund(v *bool) *PaymentProviderInstanceUpdate {
if v != nil {
_u.SetAllowUserRefund(*v)
}
return _u
}
// SetUpdatedAt sets the "updated_at" field.
func (_u *PaymentProviderInstanceUpdate) SetUpdatedAt(v time.Time) *PaymentProviderInstanceUpdate {
_u.mutation.SetUpdatedAt(v)
@@ -275,6 +289,9 @@ func (_u *PaymentProviderInstanceUpdate) sqlSave(ctx context.Context) (_node int
if value, ok := _u.mutation.RefundEnabled(); ok {
_spec.SetField(paymentproviderinstance.FieldRefundEnabled, field.TypeBool, value)
}
if value, ok := _u.mutation.AllowUserRefund(); ok {
_spec.SetField(paymentproviderinstance.FieldAllowUserRefund, field.TypeBool, value)
}
if value, ok := _u.mutation.UpdatedAt(); ok {
_spec.SetField(paymentproviderinstance.FieldUpdatedAt, field.TypeTime, value)
}
@@ -431,6 +448,20 @@ func (_u *PaymentProviderInstanceUpdateOne) SetNillableRefundEnabled(v *bool) *P
return _u
}
// SetAllowUserRefund sets the "allow_user_refund" field.
func (_u *PaymentProviderInstanceUpdateOne) SetAllowUserRefund(v bool) *PaymentProviderInstanceUpdateOne {
_u.mutation.SetAllowUserRefund(v)
return _u
}
// SetNillableAllowUserRefund sets the "allow_user_refund" field if the given value is not nil.
func (_u *PaymentProviderInstanceUpdateOne) SetNillableAllowUserRefund(v *bool) *PaymentProviderInstanceUpdateOne {
if v != nil {
_u.SetAllowUserRefund(*v)
}
return _u
}
// SetUpdatedAt sets the "updated_at" field.
func (_u *PaymentProviderInstanceUpdateOne) SetUpdatedAt(v time.Time) *PaymentProviderInstanceUpdateOne {
_u.mutation.SetUpdatedAt(v)
@@ -575,6 +606,9 @@ func (_u *PaymentProviderInstanceUpdateOne) sqlSave(ctx context.Context) (_node
if value, ok := _u.mutation.RefundEnabled(); ok {
_spec.SetField(paymentproviderinstance.FieldRefundEnabled, field.TypeBool, value)
}
if value, ok := _u.mutation.AllowUserRefund(); ok {
_spec.SetField(paymentproviderinstance.FieldAllowUserRefund, field.TypeBool, value)
}
if value, ok := _u.mutation.UpdatedAt(); ok {
_spec.SetField(paymentproviderinstance.FieldUpdatedAt, field.TypeTime, value)
}
+6 -2
View File
@@ -668,12 +668,16 @@ func init() {
paymentproviderinstanceDescRefundEnabled := paymentproviderinstanceFields[8].Descriptor()
// paymentproviderinstance.DefaultRefundEnabled holds the default value on creation for the refund_enabled field.
paymentproviderinstance.DefaultRefundEnabled = paymentproviderinstanceDescRefundEnabled.Default.(bool)
// paymentproviderinstanceDescAllowUserRefund is the schema descriptor for allow_user_refund field.
paymentproviderinstanceDescAllowUserRefund := paymentproviderinstanceFields[9].Descriptor()
// paymentproviderinstance.DefaultAllowUserRefund holds the default value on creation for the allow_user_refund field.
paymentproviderinstance.DefaultAllowUserRefund = paymentproviderinstanceDescAllowUserRefund.Default.(bool)
// paymentproviderinstanceDescCreatedAt is the schema descriptor for created_at field.
paymentproviderinstanceDescCreatedAt := paymentproviderinstanceFields[9].Descriptor()
paymentproviderinstanceDescCreatedAt := paymentproviderinstanceFields[10].Descriptor()
// paymentproviderinstance.DefaultCreatedAt holds the default value on creation for the created_at field.
paymentproviderinstance.DefaultCreatedAt = paymentproviderinstanceDescCreatedAt.Default.(func() time.Time)
// paymentproviderinstanceDescUpdatedAt is the schema descriptor for updated_at field.
paymentproviderinstanceDescUpdatedAt := paymentproviderinstanceFields[10].Descriptor()
paymentproviderinstanceDescUpdatedAt := paymentproviderinstanceFields[11].Descriptor()
// paymentproviderinstance.DefaultUpdatedAt holds the default value on creation for the updated_at field.
paymentproviderinstance.DefaultUpdatedAt = paymentproviderinstanceDescUpdatedAt.Default.(func() time.Time)
// paymentproviderinstance.UpdateDefaultUpdatedAt holds the default value on update for the updated_at field.
@@ -53,6 +53,8 @@ func (PaymentProviderInstance) Fields() []ent.Field {
Default(""),
field.Bool("refund_enabled").
Default(false),
field.Bool("allow_user_refund").
Default(false),
field.Time("created_at").
Immutable().
Default(time.Now).
@@ -335,6 +335,16 @@ func (h *PaymentHandler) RequestRefund(c *gin.Context) {
response.Success(c, gin.H{"message": "refund requested"})
}
// GetRefundEligibleProviders returns provider instance IDs that allow user refund.
func (h *PaymentHandler) GetRefundEligibleProviders(c *gin.Context) {
ids, err := h.configService.GetUserRefundEligibleInstanceIDs(c.Request.Context())
if err != nil {
response.ErrorFrom(c, err)
return
}
response.Success(c, gin.H{"provider_instance_ids": ids})
}
// VerifyOrderRequest is the request body for verifying a payment order.
type VerifyOrderRequest struct {
OutTradeNo string `json:"out_trade_no" binding:"required"`
@@ -39,6 +39,7 @@ func RegisterPaymentRoutes(
orders.GET("/:id", paymentHandler.GetOrder)
orders.POST("/:id/cancel", paymentHandler.CancelOrder)
orders.POST("/:id/refund-request", paymentHandler.RequestRefund)
orders.GET("/refund-eligible-providers", paymentHandler.GetRefundEligibleProviders)
}
}
@@ -29,7 +29,8 @@ type ProviderInstanceResponse struct {
SupportedTypes []string `json:"supported_types"`
Limits string `json:"limits"`
Enabled bool `json:"enabled"`
RefundEnabled bool `json:"refund_enabled"`
RefundEnabled bool `json:"refund_enabled"`
AllowUserRefund bool `json:"allow_user_refund"`
SortOrder int `json:"sort_order"`
PaymentMode string `json:"payment_mode"`
}
@@ -46,8 +47,8 @@ func (s *PaymentConfigService) ListProviderInstancesWithConfig(ctx context.Conte
resp := ProviderInstanceResponse{
ID: int64(inst.ID), ProviderKey: inst.ProviderKey, Name: inst.Name,
SupportedTypes: splitTypes(inst.SupportedTypes), Limits: inst.Limits,
Enabled: inst.Enabled, RefundEnabled: inst.RefundEnabled, SortOrder: inst.SortOrder,
PaymentMode: inst.PaymentMode,
Enabled: inst.Enabled, RefundEnabled: inst.RefundEnabled, AllowUserRefund: inst.AllowUserRefund,
SortOrder: inst.SortOrder, PaymentMode: inst.PaymentMode,
}
resp.Config, err = s.decryptAndMaskConfig(inst.Config)
if err != nil {
@@ -110,10 +111,12 @@ func (s *PaymentConfigService) CreateProviderInstance(ctx context.Context, req C
if err != nil {
return nil, err
}
allowUserRefund := req.AllowUserRefund && req.RefundEnabled
return s.entClient.PaymentProviderInstance.Create().
SetProviderKey(req.ProviderKey).SetName(req.Name).SetConfig(enc).
SetSupportedTypes(typesStr).SetEnabled(req.Enabled).SetPaymentMode(req.PaymentMode).
SetSortOrder(req.SortOrder).SetLimits(req.Limits).SetRefundEnabled(req.RefundEnabled).
SetAllowUserRefund(allowUserRefund).
Save(ctx)
}
@@ -221,6 +224,21 @@ func (s *PaymentConfigService) UpdateProviderInstance(ctx context.Context, id in
}
if req.RefundEnabled != nil {
u.SetRefundEnabled(*req.RefundEnabled)
// Cascade: turning off refund_enabled also disables allow_user_refund
if !*req.RefundEnabled {
u.SetAllowUserRefund(false)
}
}
if req.AllowUserRefund != nil {
// Only allow enabling when refund_enabled is true
if *req.AllowUserRefund {
inst, err := s.entClient.PaymentProviderInstance.Get(ctx, id)
if err == nil && inst.RefundEnabled {
u.SetAllowUserRefund(true)
}
} else {
u.SetAllowUserRefund(false)
}
}
if req.PaymentMode != nil {
u.SetPaymentMode(*req.PaymentMode)
@@ -228,6 +246,23 @@ func (s *PaymentConfigService) UpdateProviderInstance(ctx context.Context, id in
return u.Save(ctx)
}
// GetUserRefundEligibleInstanceIDs returns provider instance IDs that allow user refund.
func (s *PaymentConfigService) GetUserRefundEligibleInstanceIDs(ctx context.Context) ([]string, error) {
instances, err := s.entClient.PaymentProviderInstance.Query().
Where(
paymentproviderinstance.AllowUserRefundEQ(true),
paymentproviderinstance.RefundEnabledEQ(true),
).Select(paymentproviderinstance.FieldID).All(ctx)
if err != nil {
return nil, err
}
ids := make([]string, 0, len(instances))
for _, inst := range instances {
ids = append(ids, strconv.FormatInt(int64(inst.ID), 10))
}
return ids, nil
}
func (s *PaymentConfigService) mergeConfig(ctx context.Context, id int64, newConfig map[string]string) (map[string]string, error) {
inst, err := s.entClient.PaymentProviderInstance.Get(ctx, id)
if err != nil {
@@ -105,26 +105,28 @@ type MethodLimitsResponse struct {
}
type CreateProviderInstanceRequest struct {
ProviderKey string `json:"provider_key"`
Name string `json:"name"`
Config map[string]string `json:"config"`
SupportedTypes []string `json:"supported_types"`
Enabled bool `json:"enabled"`
PaymentMode string `json:"payment_mode"`
SortOrder int `json:"sort_order"`
Limits string `json:"limits"`
RefundEnabled bool `json:"refund_enabled"`
ProviderKey string `json:"provider_key"`
Name string `json:"name"`
Config map[string]string `json:"config"`
SupportedTypes []string `json:"supported_types"`
Enabled bool `json:"enabled"`
PaymentMode string `json:"payment_mode"`
SortOrder int `json:"sort_order"`
Limits string `json:"limits"`
RefundEnabled bool `json:"refund_enabled"`
AllowUserRefund bool `json:"allow_user_refund"`
}
type UpdateProviderInstanceRequest struct {
Name *string `json:"name"`
Config map[string]string `json:"config"`
SupportedTypes []string `json:"supported_types"`
Enabled *bool `json:"enabled"`
PaymentMode *string `json:"payment_mode"`
SortOrder *int `json:"sort_order"`
Limits *string `json:"limits"`
RefundEnabled *bool `json:"refund_enabled"`
Name *string `json:"name"`
Config map[string]string `json:"config"`
SupportedTypes []string `json:"supported_types"`
Enabled *bool `json:"enabled"`
PaymentMode *string `json:"payment_mode"`
SortOrder *int `json:"sort_order"`
Limits *string `json:"limits"`
RefundEnabled *bool `json:"refund_enabled"`
AllowUserRefund *bool `json:"allow_user_refund"`
}
type CreatePlanRequest struct {
GroupID int64 `json:"group_id"`
@@ -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")
}
@@ -0,0 +1 @@
ALTER TABLE payment_provider_instances ADD COLUMN IF NOT EXISTS allow_user_refund BOOLEAN NOT NULL DEFAULT false;
+4
View File
@@ -75,5 +75,9 @@ export const paymentAPI = {
/** Request a refund for a completed order */
requestRefund(id: number, data: { reason: string }) {
return apiClient.post(`/payment/orders/${id}/refund-request`, data)
},
getRefundEligibleProviders() {
return apiClient.get<{ provider_instance_ids: string[] }>('/payment/orders/refund-eligible-providers')
}
}
@@ -32,7 +32,8 @@
<!-- Toggles + Payment mode + Supported types (single row) -->
<div class="flex flex-wrap items-center gap-x-5 gap-y-2">
<ToggleSwitch :label="t('common.enabled')" :checked="form.enabled" @toggle="form.enabled = !form.enabled" />
<ToggleSwitch :label="t('admin.settings.payment.refundEnabled')" :checked="form.refund_enabled" @toggle="form.refund_enabled = !form.refund_enabled" />
<ToggleSwitch :label="t('admin.settings.payment.refundEnabled')" :checked="form.refund_enabled" @toggle="form.refund_enabled = !form.refund_enabled; if (!form.refund_enabled) form.allow_user_refund = false" />
<ToggleSwitch v-if="form.refund_enabled" :label="t('admin.settings.payment.allowUserRefund')" :checked="form.allow_user_refund" @toggle="form.allow_user_refund = !form.allow_user_refund" />
<div v-if="form.provider_key === 'easypay'" class="flex items-center gap-2">
<span class="text-xs font-medium text-gray-500 dark:text-gray-400">{{ t('admin.settings.payment.paymentMode') }}</span>
<div class="flex gap-1.5">
@@ -243,6 +244,7 @@ const emit = defineEmits<{
enabled: boolean
payment_mode: string
refund_enabled: boolean
allow_user_refund: boolean
config: Record<string, string>
limits: string
}]
@@ -258,6 +260,7 @@ const form = reactive({
enabled: true,
payment_mode: PAYMENT_MODE_QRCODE,
refund_enabled: false,
allow_user_refund: false,
})
const config = reactive<Record<string, string>>({})
const limits = reactive<Record<string, Record<string, number>>>({})
@@ -433,6 +436,7 @@ function handleSave() {
enabled: form.enabled,
payment_mode: form.provider_key === 'easypay' ? form.payment_mode : '',
refund_enabled: form.refund_enabled,
allow_user_refund: form.refund_enabled ? form.allow_user_refund : false,
config: filteredConfig,
limits: serializeLimits(),
})
@@ -452,6 +456,7 @@ function reset(defaultKey: string) {
form.enabled = true
form.payment_mode = defaultKey === 'easypay' ? PAYMENT_MODE_QRCODE : ''
form.refund_enabled = false
form.allow_user_refund = false
clearConfig()
applyDefaults()
}
@@ -463,6 +468,7 @@ function loadProvider(provider: ProviderInstance) {
form.enabled = provider.enabled
form.payment_mode = provider.payment_mode || (provider.provider_key === 'easypay' ? PAYMENT_MODE_QRCODE : '')
form.refund_enabled = provider.refund_enabled
form.allow_user_refund = provider.allow_user_refund
clearConfig()
// Pre-fill config from API response (non-sensitive in cleartext, sensitive masked as ••••••••)
if (provider.config) {
@@ -115,7 +115,7 @@ const emit = defineEmits<{
create: []
edit: [provider: ProviderInstance]
delete: [provider: ProviderInstance]
toggleField: [provider: ProviderInstance, field: 'enabled' | 'refund_enabled']
toggleField: [provider: ProviderInstance, field: 'enabled' | 'refund_enabled' | 'allow_user_refund']
toggleType: [provider: ProviderInstance, type: string]
reorder: [providers: { id: number; sort_order: number }[]]
}>()
@@ -46,6 +46,7 @@
<div class="flex items-center gap-4">
<ToggleSwitch :label="t('common.enabled')" :checked="provider.enabled" @toggle="emit('toggleField', 'enabled')" />
<ToggleSwitch :label="t('admin.settings.payment.refundEnabled')" :checked="provider.refund_enabled" @toggle="emit('toggleField', 'refund_enabled')" />
<ToggleSwitch v-if="provider.refund_enabled" :label="t('admin.settings.payment.allowUserRefund')" :checked="provider.allow_user_refund" @toggle="emit('toggleField', 'allow_user_refund')" />
<div class="flex items-center gap-2 border-l border-gray-200 pl-3 dark:border-dark-600">
<button type="button" @click="emit('edit')" class="flex flex-col items-center gap-0.5 rounded-lg p-1.5 text-gray-500 transition-colors hover:bg-blue-50 hover:text-blue-600 dark:hover:bg-blue-900/20 dark:hover:text-blue-400">
<Icon name="edit" size="sm" />
@@ -84,7 +85,7 @@ const props = defineProps<{
}>()
const emit = defineEmits<{
toggleField: [field: 'enabled' | 'refund_enabled']
toggleField: [field: 'enabled' | 'refund_enabled' | 'allow_user_refund']
toggleType: [type: string]
edit: []
delete: []
+2
View File
@@ -4586,6 +4586,7 @@ export default {
supportedTypes: 'Supported Payment Types',
supportedTypesHint: 'Comma-separated, e.g. alipay,wxpay',
refundEnabled: 'Allow Refund',
allowUserRefund: 'Allow User Refund',
},
balanceNotify: {
title: 'Balance Low Notification',
@@ -5331,6 +5332,7 @@ export default {
refundSuccess: 'Refund successful',
refundInfo: 'Refund Info',
refundEnabled: 'Refund Enabled',
allowUserRefund: 'Allow User Refund',
alreadyRefunded: 'Already Refunded',
deductBalance: 'Deduct Balance',
deductBalanceHint: 'Subtract recharged amount from user balance',
+1
View File
@@ -4758,6 +4758,7 @@ export default {
supportedTypes: '支持的支付方式',
supportedTypesHint: '逗号分隔,如 alipay,wxpay',
refundEnabled: '允许退款',
allowUserRefund: '允许用户退款',
},
balanceNotify: {
title: '余额不足提醒',
+2
View File
@@ -89,6 +89,7 @@ export interface PaymentOrder {
refund_requested_by?: number
refund_request_reason?: string
plan_id?: number
provider_instance_id?: string
}
// ==================== Plans & Channels ====================
@@ -138,6 +139,7 @@ export interface ProviderInstance {
enabled: boolean
payment_mode: string
refund_enabled: boolean
allow_user_refund: boolean
limits: string
sort_order: number
}
+18 -4
View File
@@ -4063,12 +4063,26 @@ async function handleSaveProvider(payload: Partial<ProviderInstance>) {
}
}
async function handleToggleField(provider: ProviderInstance, field: 'enabled' | 'refund_enabled') {
const newValue = field === 'enabled' ? !provider.enabled : !provider.refund_enabled
async function handleToggleField(provider: ProviderInstance, field: 'enabled' | 'refund_enabled' | 'allow_user_refund') {
let newValue: boolean
if (field === 'enabled') newValue = !provider.enabled
else if (field === 'refund_enabled') newValue = !provider.refund_enabled
else newValue = !provider.allow_user_refund
const payload: Record<string, boolean> = { [field]: newValue }
// Cascade: turning off refund_enabled also turns off allow_user_refund
if (field === 'refund_enabled' && !newValue) {
payload.allow_user_refund = false
}
try {
await adminAPI.payment.updateProvider(provider.id, { [field]: newValue })
await adminAPI.payment.updateProvider(provider.id, payload)
if (field === 'enabled') provider.enabled = newValue
else provider.refund_enabled = newValue
else if (field === 'refund_enabled') {
provider.refund_enabled = newValue
if (!newValue) provider.allow_user_refund = false
} else {
provider.allow_user_refund = newValue
}
} catch (err: unknown) { appStore.showError(extractApiErrorMessage(err, t('common.error'), paymentErrorMap.value)) }
}
+16 -2
View File
@@ -22,7 +22,7 @@
<Icon name="x" size="sm" />
<span>{{ t('payment.orders.cancel') }}</span>
</button>
<button v-if="row.status === 'COMPLETED'" @click="openRefundDialog(row)" class="inline-flex items-center gap-1 rounded-md px-2 py-1 text-xs font-medium text-purple-600 hover:bg-purple-50 dark:text-purple-400 dark:hover:bg-purple-900/20">
<button v-if="canRequestRefund(row)" @click="openRefundDialog(row)" class="inline-flex items-center gap-1 rounded-md px-2 py-1 text-xs font-medium text-purple-600 hover:bg-purple-50 dark:text-purple-400 dark:hover:bg-purple-900/20">
<Icon name="dollar" size="sm" />
<span>{{ t('payment.orders.requestRefund') }}</span>
</button>
@@ -102,6 +102,7 @@ const appStore = useAppStore()
const loading = ref(false)
const actionLoading = ref(false)
const orders = ref<PaymentOrder[]>([])
const refundEligibleProviders = ref<Set<string>>(new Set())
const currentFilter = ref('')
const cancelTargetId = ref<number | null>(null)
const refundTarget = ref<PaymentOrder | null>(null)
@@ -171,5 +172,18 @@ async function confirmRefund() {
}
}
onMounted(() => fetchOrders())
function canRequestRefund(order: PaymentOrder): boolean {
if (order.status !== 'COMPLETED') return false
if (!order.provider_instance_id) return false
return refundEligibleProviders.value.has(order.provider_instance_id)
}
async function loadRefundEligibility() {
try {
const res = await paymentAPI.getRefundEligibleProviders()
refundEligibleProviders.value = new Set(res.data.provider_instance_ids || [])
} catch { /* ignore — default to hiding refund button */ }
}
onMounted(() => { fetchOrders(); loadRefundEligibility() })
</script>