From 2bbdd47002a95c6eb54a5f90fdb0abb212220ff8 Mon Sep 17 00:00:00 2001 From: turingcat Date: Wed, 15 Jul 2026 11:17:45 +0800 Subject: [PATCH] =?UTF-8?q?feat(payment):=20=E8=AE=A2=E9=98=85=E5=A5=97?= =?UTF-8?q?=E9=A4=90=E6=94=AF=E6=8C=81=E5=B8=81=E7=A7=8D=E6=A0=87=E6=B3=A8?= =?UTF-8?q?=EF=BC=8C=E5=8C=BA=E5=88=86=20$=20=E7=AC=A6=E5=8F=B7=E5=AF=B9?= =?UTF-8?q?=E5=BA=94=E5=B8=81=E7=A7=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 后台配置与用户端展示的套餐价格统一使用 $ 符号,而实际扣款币种随 支付渠道配置漂移(如 Stripe 配置 NZD 时按新西兰元扣款),用户易 误认为美元。本次为套餐新增可选的展示性币种标注: - SubscriptionPlan 新增 currency 字段(ISO 4217 三字母码,默认空) - 空值不展示任何标注,存量套餐行为完全不变 - 非空值复用 payment.NormalizePaymentCurrency 校验并规范化为大写 - 后台套餐编辑弹窗新增可选币种输入,列表价格列展示币种小字 - 用户端套餐卡片价格与划线原价旁展示币种小字 - 纯展示性质,不影响任何支付/扣款逻辑 Closes #4315 Co-Authored-By: Claude Fable 5 --- backend/ent/migrate/schema.go | 3 +- backend/ent/mutation.go | 56 ++++++++++++++- backend/ent/runtime/runtime.go | 22 +++--- backend/ent/schema/subscription_plan.go | 3 + backend/ent/subscriptionplan.go | 13 +++- .../ent/subscriptionplan/subscriptionplan.go | 12 ++++ backend/ent/subscriptionplan/where.go | 70 +++++++++++++++++++ backend/ent/subscriptionplan_create.go | 70 +++++++++++++++++++ backend/ent/subscriptionplan_update.go | 44 ++++++++++++ backend/internal/handler/payment_handler.go | 4 ++ .../internal/service/payment_config_plans.go | 27 ++++++- .../payment_config_plans_validation_test.go | 46 ++++++++++++ .../service/payment_config_service.go | 2 + .../payment/SubscriptionPlanCard.vue | 3 +- frontend/src/i18n/locales/en/misc.ts | 3 + frontend/src/i18n/locales/zh/misc.ts | 3 + frontend/src/types/payment.ts | 2 + .../admin/orders/AdminPaymentPlansView.vue | 1 + .../src/views/admin/orders/PlanEditDialog.vue | 12 +++- 19 files changed, 380 insertions(+), 16 deletions(-) diff --git a/backend/ent/migrate/schema.go b/backend/ent/migrate/schema.go index 52229f9151..994780cd42 100644 --- a/backend/ent/migrate/schema.go +++ b/backend/ent/migrate/schema.go @@ -1447,6 +1447,7 @@ var ( {Name: "description", Type: field.TypeString, Default: "", SchemaType: map[string]string{"postgres": "text"}}, {Name: "price", Type: field.TypeFloat64, SchemaType: map[string]string{"postgres": "decimal(20,2)"}}, {Name: "original_price", Type: field.TypeFloat64, Nullable: true, SchemaType: map[string]string{"postgres": "decimal(20,2)"}}, + {Name: "currency", Type: field.TypeString, Size: 3, Default: ""}, {Name: "validity_days", Type: field.TypeInt, Default: 30}, {Name: "validity_unit", Type: field.TypeString, Size: 10, Default: "day"}, {Name: "features", Type: field.TypeString, Default: "", SchemaType: map[string]string{"postgres": "text"}}, @@ -1470,7 +1471,7 @@ var ( { Name: "subscriptionplan_for_sale", Unique: false, - Columns: []*schema.Column{SubscriptionPlansColumns[10]}, + Columns: []*schema.Column{SubscriptionPlansColumns[11]}, }, }, } diff --git a/backend/ent/mutation.go b/backend/ent/mutation.go index fb35531878..46b97462eb 100644 --- a/backend/ent/mutation.go +++ b/backend/ent/mutation.go @@ -38152,6 +38152,7 @@ type SubscriptionPlanMutation struct { addprice *float64 original_price *float64 addoriginal_price *float64 + currency *string validity_days *int addvalidity_days *int validity_unit *string @@ -38520,6 +38521,42 @@ func (m *SubscriptionPlanMutation) ResetOriginalPrice() { delete(m.clearedFields, subscriptionplan.FieldOriginalPrice) } +// SetCurrency sets the "currency" field. +func (m *SubscriptionPlanMutation) SetCurrency(s string) { + m.currency = &s +} + +// Currency returns the value of the "currency" field in the mutation. +func (m *SubscriptionPlanMutation) Currency() (r string, exists bool) { + v := m.currency + if v == nil { + return + } + return *v, true +} + +// OldCurrency returns the old "currency" field's value of the SubscriptionPlan entity. +// If the SubscriptionPlan 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 *SubscriptionPlanMutation) OldCurrency(ctx context.Context) (v string, err error) { + if !m.op.Is(OpUpdateOne) { + return v, errors.New("OldCurrency is only allowed on UpdateOne operations") + } + if m.id == nil || m.oldValue == nil { + return v, errors.New("OldCurrency requires an ID field in the mutation") + } + oldValue, err := m.oldValue(ctx) + if err != nil { + return v, fmt.Errorf("querying old value for OldCurrency: %w", err) + } + return oldValue.Currency, nil +} + +// ResetCurrency resets all changes to the "currency" field. +func (m *SubscriptionPlanMutation) ResetCurrency() { + m.currency = nil +} + // SetValidityDays sets the "validity_days" field. func (m *SubscriptionPlanMutation) SetValidityDays(i int) { m.validity_days = &i @@ -38882,7 +38919,7 @@ func (m *SubscriptionPlanMutation) Type() string { // order to get all numeric fields that were incremented/decremented, call // AddedFields(). func (m *SubscriptionPlanMutation) Fields() []string { - fields := make([]string, 0, 13) + fields := make([]string, 0, 14) if m.group_id != nil { fields = append(fields, subscriptionplan.FieldGroupID) } @@ -38898,6 +38935,9 @@ func (m *SubscriptionPlanMutation) Fields() []string { if m.original_price != nil { fields = append(fields, subscriptionplan.FieldOriginalPrice) } + if m.currency != nil { + fields = append(fields, subscriptionplan.FieldCurrency) + } if m.validity_days != nil { fields = append(fields, subscriptionplan.FieldValidityDays) } @@ -38940,6 +38980,8 @@ func (m *SubscriptionPlanMutation) Field(name string) (ent.Value, bool) { return m.Price() case subscriptionplan.FieldOriginalPrice: return m.OriginalPrice() + case subscriptionplan.FieldCurrency: + return m.Currency() case subscriptionplan.FieldValidityDays: return m.ValidityDays() case subscriptionplan.FieldValidityUnit: @@ -38975,6 +39017,8 @@ func (m *SubscriptionPlanMutation) OldField(ctx context.Context, name string) (e return m.OldPrice(ctx) case subscriptionplan.FieldOriginalPrice: return m.OldOriginalPrice(ctx) + case subscriptionplan.FieldCurrency: + return m.OldCurrency(ctx) case subscriptionplan.FieldValidityDays: return m.OldValidityDays(ctx) case subscriptionplan.FieldValidityUnit: @@ -39035,6 +39079,13 @@ func (m *SubscriptionPlanMutation) SetField(name string, value ent.Value) error } m.SetOriginalPrice(v) return nil + case subscriptionplan.FieldCurrency: + v, ok := value.(string) + if !ok { + return fmt.Errorf("unexpected type %T for field %s", value, name) + } + m.SetCurrency(v) + return nil case subscriptionplan.FieldValidityDays: v, ok := value.(int) if !ok { @@ -39227,6 +39278,9 @@ func (m *SubscriptionPlanMutation) ResetField(name string) error { case subscriptionplan.FieldOriginalPrice: m.ResetOriginalPrice() return nil + case subscriptionplan.FieldCurrency: + m.ResetCurrency() + return nil case subscriptionplan.FieldValidityDays: m.ResetValidityDays() return nil diff --git a/backend/ent/runtime/runtime.go b/backend/ent/runtime/runtime.go index 867f1cbdbd..f13e14c519 100644 --- a/backend/ent/runtime/runtime.go +++ b/backend/ent/runtime/runtime.go @@ -1718,40 +1718,46 @@ func init() { subscriptionplanDescDescription := subscriptionplanFields[2].Descriptor() // subscriptionplan.DefaultDescription holds the default value on creation for the description field. subscriptionplan.DefaultDescription = subscriptionplanDescDescription.Default.(string) + // subscriptionplanDescCurrency is the schema descriptor for currency field. + subscriptionplanDescCurrency := subscriptionplanFields[5].Descriptor() + // subscriptionplan.DefaultCurrency holds the default value on creation for the currency field. + subscriptionplan.DefaultCurrency = subscriptionplanDescCurrency.Default.(string) + // subscriptionplan.CurrencyValidator is a validator for the "currency" field. It is called by the builders before save. + subscriptionplan.CurrencyValidator = subscriptionplanDescCurrency.Validators[0].(func(string) error) // subscriptionplanDescValidityDays is the schema descriptor for validity_days field. - subscriptionplanDescValidityDays := subscriptionplanFields[5].Descriptor() + subscriptionplanDescValidityDays := subscriptionplanFields[6].Descriptor() // subscriptionplan.DefaultValidityDays holds the default value on creation for the validity_days field. subscriptionplan.DefaultValidityDays = subscriptionplanDescValidityDays.Default.(int) // subscriptionplanDescValidityUnit is the schema descriptor for validity_unit field. - subscriptionplanDescValidityUnit := subscriptionplanFields[6].Descriptor() + subscriptionplanDescValidityUnit := subscriptionplanFields[7].Descriptor() // subscriptionplan.DefaultValidityUnit holds the default value on creation for the validity_unit field. subscriptionplan.DefaultValidityUnit = subscriptionplanDescValidityUnit.Default.(string) // subscriptionplan.ValidityUnitValidator is a validator for the "validity_unit" field. It is called by the builders before save. subscriptionplan.ValidityUnitValidator = subscriptionplanDescValidityUnit.Validators[0].(func(string) error) // subscriptionplanDescFeatures is the schema descriptor for features field. - subscriptionplanDescFeatures := subscriptionplanFields[7].Descriptor() + subscriptionplanDescFeatures := subscriptionplanFields[8].Descriptor() // subscriptionplan.DefaultFeatures holds the default value on creation for the features field. subscriptionplan.DefaultFeatures = subscriptionplanDescFeatures.Default.(string) // subscriptionplanDescProductName is the schema descriptor for product_name field. - subscriptionplanDescProductName := subscriptionplanFields[8].Descriptor() + subscriptionplanDescProductName := subscriptionplanFields[9].Descriptor() // subscriptionplan.DefaultProductName holds the default value on creation for the product_name field. subscriptionplan.DefaultProductName = subscriptionplanDescProductName.Default.(string) // subscriptionplan.ProductNameValidator is a validator for the "product_name" field. It is called by the builders before save. subscriptionplan.ProductNameValidator = subscriptionplanDescProductName.Validators[0].(func(string) error) // subscriptionplanDescForSale is the schema descriptor for for_sale field. - subscriptionplanDescForSale := subscriptionplanFields[9].Descriptor() + subscriptionplanDescForSale := subscriptionplanFields[10].Descriptor() // subscriptionplan.DefaultForSale holds the default value on creation for the for_sale field. subscriptionplan.DefaultForSale = subscriptionplanDescForSale.Default.(bool) // subscriptionplanDescSortOrder is the schema descriptor for sort_order field. - subscriptionplanDescSortOrder := subscriptionplanFields[10].Descriptor() + subscriptionplanDescSortOrder := subscriptionplanFields[11].Descriptor() // subscriptionplan.DefaultSortOrder holds the default value on creation for the sort_order field. subscriptionplan.DefaultSortOrder = subscriptionplanDescSortOrder.Default.(int) // subscriptionplanDescCreatedAt is the schema descriptor for created_at field. - subscriptionplanDescCreatedAt := subscriptionplanFields[11].Descriptor() + subscriptionplanDescCreatedAt := subscriptionplanFields[12].Descriptor() // subscriptionplan.DefaultCreatedAt holds the default value on creation for the created_at field. subscriptionplan.DefaultCreatedAt = subscriptionplanDescCreatedAt.Default.(func() time.Time) // subscriptionplanDescUpdatedAt is the schema descriptor for updated_at field. - subscriptionplanDescUpdatedAt := subscriptionplanFields[12].Descriptor() + subscriptionplanDescUpdatedAt := subscriptionplanFields[13].Descriptor() // subscriptionplan.DefaultUpdatedAt holds the default value on creation for the updated_at field. subscriptionplan.DefaultUpdatedAt = subscriptionplanDescUpdatedAt.Default.(func() time.Time) // subscriptionplan.UpdateDefaultUpdatedAt holds the default value on update for the updated_at field. diff --git a/backend/ent/schema/subscription_plan.go b/backend/ent/schema/subscription_plan.go index 3e30490bb2..973f84b534 100644 --- a/backend/ent/schema/subscription_plan.go +++ b/backend/ent/schema/subscription_plan.go @@ -43,6 +43,9 @@ func (SubscriptionPlan) Fields() []ent.Field { SchemaType(map[string]string{dialect.Postgres: "decimal(20,2)"}). Optional(). Nillable(), + field.String("currency"). + MaxLen(3). + Default(""), field.Int("validity_days"). Default(30), field.String("validity_unit"). diff --git a/backend/ent/subscriptionplan.go b/backend/ent/subscriptionplan.go index fa4d7ae3b5..3158dc271a 100644 --- a/backend/ent/subscriptionplan.go +++ b/backend/ent/subscriptionplan.go @@ -27,6 +27,8 @@ type SubscriptionPlan struct { Price float64 `json:"price,omitempty"` // OriginalPrice holds the value of the "original_price" field. OriginalPrice *float64 `json:"original_price,omitempty"` + // Currency holds the value of the "currency" field. + Currency string `json:"currency,omitempty"` // ValidityDays holds the value of the "validity_days" field. ValidityDays int `json:"validity_days,omitempty"` // ValidityUnit holds the value of the "validity_unit" field. @@ -57,7 +59,7 @@ func (*SubscriptionPlan) scanValues(columns []string) ([]any, error) { values[i] = new(sql.NullFloat64) case subscriptionplan.FieldID, subscriptionplan.FieldGroupID, subscriptionplan.FieldValidityDays, subscriptionplan.FieldSortOrder: values[i] = new(sql.NullInt64) - case subscriptionplan.FieldName, subscriptionplan.FieldDescription, subscriptionplan.FieldValidityUnit, subscriptionplan.FieldFeatures, subscriptionplan.FieldProductName: + case subscriptionplan.FieldName, subscriptionplan.FieldDescription, subscriptionplan.FieldCurrency, subscriptionplan.FieldValidityUnit, subscriptionplan.FieldFeatures, subscriptionplan.FieldProductName: values[i] = new(sql.NullString) case subscriptionplan.FieldCreatedAt, subscriptionplan.FieldUpdatedAt: values[i] = new(sql.NullTime) @@ -113,6 +115,12 @@ func (_m *SubscriptionPlan) assignValues(columns []string, values []any) error { _m.OriginalPrice = new(float64) *_m.OriginalPrice = value.Float64 } + case subscriptionplan.FieldCurrency: + if value, ok := values[i].(*sql.NullString); !ok { + return fmt.Errorf("unexpected type %T for field currency", values[i]) + } else if value.Valid { + _m.Currency = value.String + } case subscriptionplan.FieldValidityDays: if value, ok := values[i].(*sql.NullInt64); !ok { return fmt.Errorf("unexpected type %T for field validity_days", values[i]) @@ -214,6 +222,9 @@ func (_m *SubscriptionPlan) String() string { builder.WriteString(fmt.Sprintf("%v", *v)) } builder.WriteString(", ") + builder.WriteString("currency=") + builder.WriteString(_m.Currency) + builder.WriteString(", ") builder.WriteString("validity_days=") builder.WriteString(fmt.Sprintf("%v", _m.ValidityDays)) builder.WriteString(", ") diff --git a/backend/ent/subscriptionplan/subscriptionplan.go b/backend/ent/subscriptionplan/subscriptionplan.go index fa125aa714..e004d6b370 100644 --- a/backend/ent/subscriptionplan/subscriptionplan.go +++ b/backend/ent/subscriptionplan/subscriptionplan.go @@ -23,6 +23,8 @@ const ( FieldPrice = "price" // FieldOriginalPrice holds the string denoting the original_price field in the database. FieldOriginalPrice = "original_price" + // FieldCurrency holds the string denoting the currency field in the database. + FieldCurrency = "currency" // FieldValidityDays holds the string denoting the validity_days field in the database. FieldValidityDays = "validity_days" // FieldValidityUnit holds the string denoting the validity_unit field in the database. @@ -51,6 +53,7 @@ var Columns = []string{ FieldDescription, FieldPrice, FieldOriginalPrice, + FieldCurrency, FieldValidityDays, FieldValidityUnit, FieldFeatures, @@ -76,6 +79,10 @@ var ( NameValidator func(string) error // DefaultDescription holds the default value on creation for the "description" field. DefaultDescription string + // DefaultCurrency holds the default value on creation for the "currency" field. + DefaultCurrency string + // CurrencyValidator is a validator for the "currency" field. It is called by the builders before save. + CurrencyValidator func(string) error // DefaultValidityDays holds the default value on creation for the "validity_days" field. DefaultValidityDays int // DefaultValidityUnit holds the default value on creation for the "validity_unit" field. @@ -133,6 +140,11 @@ func ByOriginalPrice(opts ...sql.OrderTermOption) OrderOption { return sql.OrderByField(FieldOriginalPrice, opts...).ToFunc() } +// ByCurrency orders the results by the currency field. +func ByCurrency(opts ...sql.OrderTermOption) OrderOption { + return sql.OrderByField(FieldCurrency, opts...).ToFunc() +} + // ByValidityDays orders the results by the validity_days field. func ByValidityDays(opts ...sql.OrderTermOption) OrderOption { return sql.OrderByField(FieldValidityDays, opts...).ToFunc() diff --git a/backend/ent/subscriptionplan/where.go b/backend/ent/subscriptionplan/where.go index 319cfdb509..46f9713f63 100644 --- a/backend/ent/subscriptionplan/where.go +++ b/backend/ent/subscriptionplan/where.go @@ -79,6 +79,11 @@ func OriginalPrice(v float64) predicate.SubscriptionPlan { return predicate.SubscriptionPlan(sql.FieldEQ(FieldOriginalPrice, v)) } +// Currency applies equality check predicate on the "currency" field. It's identical to CurrencyEQ. +func Currency(v string) predicate.SubscriptionPlan { + return predicate.SubscriptionPlan(sql.FieldEQ(FieldCurrency, v)) +} + // ValidityDays applies equality check predicate on the "validity_days" field. It's identical to ValidityDaysEQ. func ValidityDays(v int) predicate.SubscriptionPlan { return predicate.SubscriptionPlan(sql.FieldEQ(FieldValidityDays, v)) @@ -379,6 +384,71 @@ func OriginalPriceNotNil() predicate.SubscriptionPlan { return predicate.SubscriptionPlan(sql.FieldNotNull(FieldOriginalPrice)) } +// CurrencyEQ applies the EQ predicate on the "currency" field. +func CurrencyEQ(v string) predicate.SubscriptionPlan { + return predicate.SubscriptionPlan(sql.FieldEQ(FieldCurrency, v)) +} + +// CurrencyNEQ applies the NEQ predicate on the "currency" field. +func CurrencyNEQ(v string) predicate.SubscriptionPlan { + return predicate.SubscriptionPlan(sql.FieldNEQ(FieldCurrency, v)) +} + +// CurrencyIn applies the In predicate on the "currency" field. +func CurrencyIn(vs ...string) predicate.SubscriptionPlan { + return predicate.SubscriptionPlan(sql.FieldIn(FieldCurrency, vs...)) +} + +// CurrencyNotIn applies the NotIn predicate on the "currency" field. +func CurrencyNotIn(vs ...string) predicate.SubscriptionPlan { + return predicate.SubscriptionPlan(sql.FieldNotIn(FieldCurrency, vs...)) +} + +// CurrencyGT applies the GT predicate on the "currency" field. +func CurrencyGT(v string) predicate.SubscriptionPlan { + return predicate.SubscriptionPlan(sql.FieldGT(FieldCurrency, v)) +} + +// CurrencyGTE applies the GTE predicate on the "currency" field. +func CurrencyGTE(v string) predicate.SubscriptionPlan { + return predicate.SubscriptionPlan(sql.FieldGTE(FieldCurrency, v)) +} + +// CurrencyLT applies the LT predicate on the "currency" field. +func CurrencyLT(v string) predicate.SubscriptionPlan { + return predicate.SubscriptionPlan(sql.FieldLT(FieldCurrency, v)) +} + +// CurrencyLTE applies the LTE predicate on the "currency" field. +func CurrencyLTE(v string) predicate.SubscriptionPlan { + return predicate.SubscriptionPlan(sql.FieldLTE(FieldCurrency, v)) +} + +// CurrencyContains applies the Contains predicate on the "currency" field. +func CurrencyContains(v string) predicate.SubscriptionPlan { + return predicate.SubscriptionPlan(sql.FieldContains(FieldCurrency, v)) +} + +// CurrencyHasPrefix applies the HasPrefix predicate on the "currency" field. +func CurrencyHasPrefix(v string) predicate.SubscriptionPlan { + return predicate.SubscriptionPlan(sql.FieldHasPrefix(FieldCurrency, v)) +} + +// CurrencyHasSuffix applies the HasSuffix predicate on the "currency" field. +func CurrencyHasSuffix(v string) predicate.SubscriptionPlan { + return predicate.SubscriptionPlan(sql.FieldHasSuffix(FieldCurrency, v)) +} + +// CurrencyEqualFold applies the EqualFold predicate on the "currency" field. +func CurrencyEqualFold(v string) predicate.SubscriptionPlan { + return predicate.SubscriptionPlan(sql.FieldEqualFold(FieldCurrency, v)) +} + +// CurrencyContainsFold applies the ContainsFold predicate on the "currency" field. +func CurrencyContainsFold(v string) predicate.SubscriptionPlan { + return predicate.SubscriptionPlan(sql.FieldContainsFold(FieldCurrency, v)) +} + // ValidityDaysEQ applies the EQ predicate on the "validity_days" field. func ValidityDaysEQ(v int) predicate.SubscriptionPlan { return predicate.SubscriptionPlan(sql.FieldEQ(FieldValidityDays, v)) diff --git a/backend/ent/subscriptionplan_create.go b/backend/ent/subscriptionplan_create.go index 9109db3a01..ff7f5b4596 100644 --- a/backend/ent/subscriptionplan_create.go +++ b/backend/ent/subscriptionplan_create.go @@ -68,6 +68,20 @@ func (_c *SubscriptionPlanCreate) SetNillableOriginalPrice(v *float64) *Subscrip return _c } +// SetCurrency sets the "currency" field. +func (_c *SubscriptionPlanCreate) SetCurrency(v string) *SubscriptionPlanCreate { + _c.mutation.SetCurrency(v) + return _c +} + +// SetNillableCurrency sets the "currency" field if the given value is not nil. +func (_c *SubscriptionPlanCreate) SetNillableCurrency(v *string) *SubscriptionPlanCreate { + if v != nil { + _c.SetCurrency(*v) + } + return _c +} + // SetValidityDays sets the "validity_days" field. func (_c *SubscriptionPlanCreate) SetValidityDays(v int) *SubscriptionPlanCreate { _c.mutation.SetValidityDays(v) @@ -219,6 +233,10 @@ func (_c *SubscriptionPlanCreate) defaults() { v := subscriptionplan.DefaultDescription _c.mutation.SetDescription(v) } + if _, ok := _c.mutation.Currency(); !ok { + v := subscriptionplan.DefaultCurrency + _c.mutation.SetCurrency(v) + } if _, ok := _c.mutation.ValidityDays(); !ok { v := subscriptionplan.DefaultValidityDays _c.mutation.SetValidityDays(v) @@ -272,6 +290,14 @@ func (_c *SubscriptionPlanCreate) check() error { if _, ok := _c.mutation.Price(); !ok { return &ValidationError{Name: "price", err: errors.New(`ent: missing required field "SubscriptionPlan.price"`)} } + if _, ok := _c.mutation.Currency(); !ok { + return &ValidationError{Name: "currency", err: errors.New(`ent: missing required field "SubscriptionPlan.currency"`)} + } + if v, ok := _c.mutation.Currency(); ok { + if err := subscriptionplan.CurrencyValidator(v); err != nil { + return &ValidationError{Name: "currency", err: fmt.Errorf(`ent: validator failed for field "SubscriptionPlan.currency": %w`, err)} + } + } if _, ok := _c.mutation.ValidityDays(); !ok { return &ValidationError{Name: "validity_days", err: errors.New(`ent: missing required field "SubscriptionPlan.validity_days"`)} } @@ -353,6 +379,10 @@ func (_c *SubscriptionPlanCreate) createSpec() (*SubscriptionPlan, *sqlgraph.Cre _spec.SetField(subscriptionplan.FieldOriginalPrice, field.TypeFloat64, value) _node.OriginalPrice = &value } + if value, ok := _c.mutation.Currency(); ok { + _spec.SetField(subscriptionplan.FieldCurrency, field.TypeString, value) + _node.Currency = value + } if value, ok := _c.mutation.ValidityDays(); ok { _spec.SetField(subscriptionplan.FieldValidityDays, field.TypeInt, value) _node.ValidityDays = value @@ -521,6 +551,18 @@ func (u *SubscriptionPlanUpsert) ClearOriginalPrice() *SubscriptionPlanUpsert { return u } +// SetCurrency sets the "currency" field. +func (u *SubscriptionPlanUpsert) SetCurrency(v string) *SubscriptionPlanUpsert { + u.Set(subscriptionplan.FieldCurrency, v) + return u +} + +// UpdateCurrency sets the "currency" field to the value that was provided on create. +func (u *SubscriptionPlanUpsert) UpdateCurrency() *SubscriptionPlanUpsert { + u.SetExcluded(subscriptionplan.FieldCurrency) + return u +} + // SetValidityDays sets the "validity_days" field. func (u *SubscriptionPlanUpsert) SetValidityDays(v int) *SubscriptionPlanUpsert { u.Set(subscriptionplan.FieldValidityDays, v) @@ -760,6 +802,20 @@ func (u *SubscriptionPlanUpsertOne) ClearOriginalPrice() *SubscriptionPlanUpsert }) } +// SetCurrency sets the "currency" field. +func (u *SubscriptionPlanUpsertOne) SetCurrency(v string) *SubscriptionPlanUpsertOne { + return u.Update(func(s *SubscriptionPlanUpsert) { + s.SetCurrency(v) + }) +} + +// UpdateCurrency sets the "currency" field to the value that was provided on create. +func (u *SubscriptionPlanUpsertOne) UpdateCurrency() *SubscriptionPlanUpsertOne { + return u.Update(func(s *SubscriptionPlanUpsert) { + s.UpdateCurrency() + }) +} + // SetValidityDays sets the "validity_days" field. func (u *SubscriptionPlanUpsertOne) SetValidityDays(v int) *SubscriptionPlanUpsertOne { return u.Update(func(s *SubscriptionPlanUpsert) { @@ -1181,6 +1237,20 @@ func (u *SubscriptionPlanUpsertBulk) ClearOriginalPrice() *SubscriptionPlanUpser }) } +// SetCurrency sets the "currency" field. +func (u *SubscriptionPlanUpsertBulk) SetCurrency(v string) *SubscriptionPlanUpsertBulk { + return u.Update(func(s *SubscriptionPlanUpsert) { + s.SetCurrency(v) + }) +} + +// UpdateCurrency sets the "currency" field to the value that was provided on create. +func (u *SubscriptionPlanUpsertBulk) UpdateCurrency() *SubscriptionPlanUpsertBulk { + return u.Update(func(s *SubscriptionPlanUpsert) { + s.UpdateCurrency() + }) +} + // SetValidityDays sets the "validity_days" field. func (u *SubscriptionPlanUpsertBulk) SetValidityDays(v int) *SubscriptionPlanUpsertBulk { return u.Update(func(s *SubscriptionPlanUpsert) { diff --git a/backend/ent/subscriptionplan_update.go b/backend/ent/subscriptionplan_update.go index c9225d0f7e..c56b8c3dfb 100644 --- a/backend/ent/subscriptionplan_update.go +++ b/backend/ent/subscriptionplan_update.go @@ -125,6 +125,20 @@ func (_u *SubscriptionPlanUpdate) ClearOriginalPrice() *SubscriptionPlanUpdate { return _u } +// SetCurrency sets the "currency" field. +func (_u *SubscriptionPlanUpdate) SetCurrency(v string) *SubscriptionPlanUpdate { + _u.mutation.SetCurrency(v) + return _u +} + +// SetNillableCurrency sets the "currency" field if the given value is not nil. +func (_u *SubscriptionPlanUpdate) SetNillableCurrency(v *string) *SubscriptionPlanUpdate { + if v != nil { + _u.SetCurrency(*v) + } + return _u +} + // SetValidityDays sets the "validity_days" field. func (_u *SubscriptionPlanUpdate) SetValidityDays(v int) *SubscriptionPlanUpdate { _u.mutation.ResetValidityDays() @@ -277,6 +291,11 @@ func (_u *SubscriptionPlanUpdate) check() error { return &ValidationError{Name: "name", err: fmt.Errorf(`ent: validator failed for field "SubscriptionPlan.name": %w`, err)} } } + if v, ok := _u.mutation.Currency(); ok { + if err := subscriptionplan.CurrencyValidator(v); err != nil { + return &ValidationError{Name: "currency", err: fmt.Errorf(`ent: validator failed for field "SubscriptionPlan.currency": %w`, err)} + } + } if v, ok := _u.mutation.ValidityUnit(); ok { if err := subscriptionplan.ValidityUnitValidator(v); err != nil { return &ValidationError{Name: "validity_unit", err: fmt.Errorf(`ent: validator failed for field "SubscriptionPlan.validity_unit": %w`, err)} @@ -329,6 +348,9 @@ func (_u *SubscriptionPlanUpdate) sqlSave(ctx context.Context) (_node int, err e if _u.mutation.OriginalPriceCleared() { _spec.ClearField(subscriptionplan.FieldOriginalPrice, field.TypeFloat64) } + if value, ok := _u.mutation.Currency(); ok { + _spec.SetField(subscriptionplan.FieldCurrency, field.TypeString, value) + } if value, ok := _u.mutation.ValidityDays(); ok { _spec.SetField(subscriptionplan.FieldValidityDays, field.TypeInt, value) } @@ -473,6 +495,20 @@ func (_u *SubscriptionPlanUpdateOne) ClearOriginalPrice() *SubscriptionPlanUpdat return _u } +// SetCurrency sets the "currency" field. +func (_u *SubscriptionPlanUpdateOne) SetCurrency(v string) *SubscriptionPlanUpdateOne { + _u.mutation.SetCurrency(v) + return _u +} + +// SetNillableCurrency sets the "currency" field if the given value is not nil. +func (_u *SubscriptionPlanUpdateOne) SetNillableCurrency(v *string) *SubscriptionPlanUpdateOne { + if v != nil { + _u.SetCurrency(*v) + } + return _u +} + // SetValidityDays sets the "validity_days" field. func (_u *SubscriptionPlanUpdateOne) SetValidityDays(v int) *SubscriptionPlanUpdateOne { _u.mutation.ResetValidityDays() @@ -638,6 +674,11 @@ func (_u *SubscriptionPlanUpdateOne) check() error { return &ValidationError{Name: "name", err: fmt.Errorf(`ent: validator failed for field "SubscriptionPlan.name": %w`, err)} } } + if v, ok := _u.mutation.Currency(); ok { + if err := subscriptionplan.CurrencyValidator(v); err != nil { + return &ValidationError{Name: "currency", err: fmt.Errorf(`ent: validator failed for field "SubscriptionPlan.currency": %w`, err)} + } + } if v, ok := _u.mutation.ValidityUnit(); ok { if err := subscriptionplan.ValidityUnitValidator(v); err != nil { return &ValidationError{Name: "validity_unit", err: fmt.Errorf(`ent: validator failed for field "SubscriptionPlan.validity_unit": %w`, err)} @@ -707,6 +748,9 @@ func (_u *SubscriptionPlanUpdateOne) sqlSave(ctx context.Context) (_node *Subscr if _u.mutation.OriginalPriceCleared() { _spec.ClearField(subscriptionplan.FieldOriginalPrice, field.TypeFloat64) } + if value, ok := _u.mutation.Currency(); ok { + _spec.SetField(subscriptionplan.FieldCurrency, field.TypeString, value) + } if value, ok := _u.mutation.ValidityDays(); ok { _spec.SetField(subscriptionplan.FieldValidityDays, field.TypeInt, value) } diff --git a/backend/internal/handler/payment_handler.go b/backend/internal/handler/payment_handler.go index 1ad054da75..672f8c3758 100644 --- a/backend/internal/handler/payment_handler.go +++ b/backend/internal/handler/payment_handler.go @@ -64,6 +64,7 @@ func (h *PaymentHandler) GetPlans(c *gin.Context) { Description string `json:"description"` Price float64 `json:"price"` OriginalPrice *float64 `json:"original_price,omitempty"` + Currency string `json:"currency,omitempty"` ValidityDays int `json:"validity_days"` ValidityUnit string `json:"validity_unit"` Features string `json:"features"` @@ -81,6 +82,7 @@ func (h *PaymentHandler) GetPlans(c *gin.Context) { RateMultiplier: gi.RateMultiplier, PeakRateEnabled: gi.PeakRateEnabled, PeakStart: gi.PeakStart, PeakEnd: gi.PeakEnd, PeakRateMultiplier: gi.PeakRateMultiplier, Name: p.Name, Description: p.Description, Price: p.Price, OriginalPrice: p.OriginalPrice, + Currency: p.Currency, ValidityDays: p.ValidityDays, ValidityUnit: p.ValidityUnit, Features: p.Features, ProductName: p.ProductName, ForSale: p.ForSale, SortOrder: p.SortOrder, }) @@ -124,6 +126,7 @@ func (h *PaymentHandler) GetCheckoutInfo(c *gin.Context) { WeeklyLimitUSD: gi.WeeklyLimitUSD, MonthlyLimitUSD: gi.MonthlyLimitUSD, ModelScopes: gi.ModelScopes, Name: p.Name, Description: p.Description, Price: p.Price, OriginalPrice: p.OriginalPrice, + Currency: p.Currency, ValidityDays: p.ValidityDays, ValidityUnit: p.ValidityUnit, Features: parseFeatures(p.Features), ProductName: p.ProductName, }) @@ -178,6 +181,7 @@ type checkoutPlan struct { Description string `json:"description"` Price float64 `json:"price"` OriginalPrice *float64 `json:"original_price,omitempty"` + Currency string `json:"currency,omitempty"` ValidityDays int `json:"validity_days"` ValidityUnit string `json:"validity_unit"` Features []string `json:"features"` diff --git a/backend/internal/service/payment_config_plans.go b/backend/internal/service/payment_config_plans.go index 0191539457..095730c1e8 100644 --- a/backend/internal/service/payment_config_plans.go +++ b/backend/internal/service/payment_config_plans.go @@ -8,9 +8,23 @@ import ( dbent "github.com/Wei-Shaw/sub2api/ent" "github.com/Wei-Shaw/sub2api/ent/group" "github.com/Wei-Shaw/sub2api/ent/subscriptionplan" + "github.com/Wei-Shaw/sub2api/internal/payment" infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" ) +// normalizePlanCurrency validates and normalizes the display-only currency label. +// Empty means "no label" and is kept as-is so existing plans stay unchanged. +func normalizePlanCurrency(raw string) (string, error) { + if strings.TrimSpace(raw) == "" { + return "", nil + } + currency, err := payment.NormalizePaymentCurrency(raw) + if err != nil { + return "", infraerrors.BadRequest("PLAN_CURRENCY_INVALID", "currency must be a 3-letter ISO currency code") + } + return currency, nil +} + // validatePlanRequired checks that all required fields for a plan are provided. func validatePlanRequired(name string, groupID int64, price float64, validityDays int, validityUnit string, originalPrice *float64) error { if strings.TrimSpace(name) == "" { @@ -122,9 +136,13 @@ func (s *PaymentConfigService) CreatePlan(ctx context.Context, req CreatePlanReq if err := validatePlanRequired(req.Name, req.GroupID, req.Price, req.ValidityDays, req.ValidityUnit, req.OriginalPrice); err != nil { return nil, err } + currency, err := normalizePlanCurrency(req.Currency) + if err != nil { + return nil, err + } b := s.entClient.SubscriptionPlan.Create(). SetGroupID(req.GroupID).SetName(req.Name).SetDescription(req.Description). - SetPrice(req.Price).SetValidityDays(req.ValidityDays).SetValidityUnit(req.ValidityUnit). + SetPrice(req.Price).SetCurrency(currency).SetValidityDays(req.ValidityDays).SetValidityUnit(req.ValidityUnit). SetFeatures(req.Features).SetProductName(req.ProductName). SetForSale(req.ForSale).SetSortOrder(req.SortOrder) if req.OriginalPrice != nil { @@ -156,6 +174,13 @@ func (s *PaymentConfigService) UpdatePlan(ctx context.Context, id int64, req Upd if req.OriginalPrice != nil { u.SetOriginalPrice(*req.OriginalPrice) } + if req.Currency != nil { + currency, err := normalizePlanCurrency(*req.Currency) + if err != nil { + return nil, err + } + u.SetCurrency(currency) + } if req.ValidityDays != nil { u.SetValidityDays(*req.ValidityDays) } diff --git a/backend/internal/service/payment_config_plans_validation_test.go b/backend/internal/service/payment_config_plans_validation_test.go index bcbe901f2a..130eaa610d 100644 --- a/backend/internal/service/payment_config_plans_validation_test.go +++ b/backend/internal/service/payment_config_plans_validation_test.go @@ -191,3 +191,49 @@ func TestValidatePlanPatch_AllNil(t *testing.T) { err := validatePlanPatch(UpdatePlanRequest{}) require.NoError(t, err) } + +// --- normalizePlanCurrency tests --- +// Empty must stay empty (not coerced to the default payment currency), +// so existing plans keep rendering without any currency label. + +func TestNormalizePlanCurrency_EmptyKeepsEmpty(t *testing.T) { + currency, err := normalizePlanCurrency("") + require.NoError(t, err) + require.Equal(t, "", currency) +} + +func TestNormalizePlanCurrency_WhitespaceKeepsEmpty(t *testing.T) { + currency, err := normalizePlanCurrency(" ") + require.NoError(t, err) + require.Equal(t, "", currency) +} + +func TestNormalizePlanCurrency_LowercaseNormalized(t *testing.T) { + currency, err := normalizePlanCurrency("nzd") + require.NoError(t, err) + require.Equal(t, "NZD", currency) +} + +func TestNormalizePlanCurrency_ValidUppercase(t *testing.T) { + currency, err := normalizePlanCurrency("USD") + require.NoError(t, err) + require.Equal(t, "USD", currency) +} + +func TestNormalizePlanCurrency_TooShort(t *testing.T) { + _, err := normalizePlanCurrency("NZ") + require.Error(t, err) + require.Contains(t, err.Error(), "currency") +} + +func TestNormalizePlanCurrency_TooLong(t *testing.T) { + _, err := normalizePlanCurrency("NZDD") + require.Error(t, err) + require.Contains(t, err.Error(), "currency") +} + +func TestNormalizePlanCurrency_NonLetter(t *testing.T) { + _, err := normalizePlanCurrency("N2D") + require.Error(t, err) + require.Contains(t, err.Error(), "currency") +} diff --git a/backend/internal/service/payment_config_service.go b/backend/internal/service/payment_config_service.go index 52a7ddc67e..edeff8b56b 100644 --- a/backend/internal/service/payment_config_service.go +++ b/backend/internal/service/payment_config_service.go @@ -162,6 +162,7 @@ type CreatePlanRequest struct { Description string `json:"description"` Price float64 `json:"price"` OriginalPrice *float64 `json:"original_price"` + Currency string `json:"currency"` ValidityDays int `json:"validity_days"` ValidityUnit string `json:"validity_unit"` Features string `json:"features"` @@ -176,6 +177,7 @@ type UpdatePlanRequest struct { Description *string `json:"description"` Price *float64 `json:"price"` OriginalPrice *float64 `json:"original_price"` + Currency *string `json:"currency"` ValidityDays *int `json:"validity_days"` ValidityUnit *string `json:"validity_unit"` Features *string `json:"features"` diff --git a/frontend/src/components/payment/SubscriptionPlanCard.vue b/frontend/src/components/payment/SubscriptionPlanCard.vue index 5f8161677b..d37645642a 100644 --- a/frontend/src/components/payment/SubscriptionPlanCard.vue +++ b/frontend/src/components/payment/SubscriptionPlanCard.vue @@ -28,10 +28,11 @@
$ {{ plan.price }} + {{ plan.currency }}
/ {{ validitySuffix }}
- ${{ plan.original_price }} + ${{ plan.original_price }} {{ discountText }}
diff --git a/frontend/src/i18n/locales/en/misc.ts b/frontend/src/i18n/locales/en/misc.ts index d109a2b71b..d0f35b36db 100644 --- a/frontend/src/i18n/locales/en/misc.ts +++ b/frontend/src/i18n/locales/en/misc.ts @@ -545,6 +545,9 @@ export default { deletePlanConfirm: 'Are you sure you want to delete this plan?', originalPrice: 'Original Price', price: 'Price', + currency: 'Currency Label', + currencyPlaceholder: 'e.g. USD / NZD / CNY', + currencyHint: 'Display-only 3-letter ISO currency code shown next to the price; leave empty to hide, does not affect billing', subscriptionCnyPayPreview: 'CNY channel charge preview: {amount}', subscriptionCnyPayPreviewWithFee: '({feeRate}% fee included: {total})', validityDays: 'Validity (days)', diff --git a/frontend/src/i18n/locales/zh/misc.ts b/frontend/src/i18n/locales/zh/misc.ts index a670f73a17..f9b975974d 100644 --- a/frontend/src/i18n/locales/zh/misc.ts +++ b/frontend/src/i18n/locales/zh/misc.ts @@ -569,6 +569,9 @@ export default { deletePlanConfirm: '确定要删除此套餐吗?', originalPrice: '原价', price: '价格', + currency: '币种标注', + currencyPlaceholder: '如 USD / NZD / CNY', + currencyHint: '仅用于价格展示的 ISO 三字母币种码,留空不展示,不影响实际扣款', subscriptionCnyPayPreview: 'CNY 通道实扣预览:{amount}', subscriptionCnyPayPreviewWithFee: '(含 {feeRate}% 手续费:{total})', validityDays: '有效期(天)', diff --git a/frontend/src/types/payment.ts b/frontend/src/types/payment.ts index 303ad9961f..a3eeaf78c3 100644 --- a/frontend/src/types/payment.ts +++ b/frontend/src/types/payment.ts @@ -124,6 +124,8 @@ export interface SubscriptionPlan { description: string price: number original_price?: number + /** Display-only ISO 4217 currency label (e.g. "NZD"); empty means no label */ + currency?: string validity_days: number validity_unit: string /** Stored as JSON string in backend; API layer should parse before use */ diff --git a/frontend/src/views/admin/orders/AdminPaymentPlansView.vue b/frontend/src/views/admin/orders/AdminPaymentPlansView.vue index f99c33cd2f..eb4f39a312 100644 --- a/frontend/src/views/admin/orders/AdminPaymentPlansView.vue +++ b/frontend/src/views/admin/orders/AdminPaymentPlansView.vue @@ -30,6 +30,7 @@ diff --git a/frontend/src/views/admin/orders/PlanEditDialog.vue b/frontend/src/views/admin/orders/PlanEditDialog.vue index 92b6574734..0ddc4cab6a 100644 --- a/frontend/src/views/admin/orders/PlanEditDialog.vue +++ b/frontend/src/views/admin/orders/PlanEditDialog.vue @@ -53,6 +53,11 @@
+
+ + +

{{ t('payment.admin.currencyHint') }}

+
@@ -117,7 +122,7 @@ const { t } = useI18n() const appStore = useAppStore() const saving = ref(false) -const planForm = reactive({ name: '', group_id: null as number | null, description: '', price: 0, original_price: 0, validity_days: 30, validity_unit: 'days', sort_order: 0, for_sale: true }) +const planForm = reactive({ name: '', group_id: null as number | null, description: '', price: 0, original_price: 0, currency: '', validity_days: 30, validity_unit: 'days', sort_order: 0, for_sale: true }) const planFeaturesText = ref('') const validityUnitOptions = computed(() => [ @@ -170,10 +175,10 @@ const subscriptionCnyPreview = computed(() => { watch(() => props.show, (visible) => { if (!visible) return if (props.plan) { - Object.assign(planForm, { name: props.plan.name, group_id: props.plan.group_id, description: props.plan.description, price: props.plan.price, original_price: props.plan.original_price || 0, validity_days: props.plan.validity_days, validity_unit: props.plan.validity_unit || 'days', sort_order: props.plan.sort_order || 0, for_sale: props.plan.for_sale }) + Object.assign(planForm, { name: props.plan.name, group_id: props.plan.group_id, description: props.plan.description, price: props.plan.price, original_price: props.plan.original_price || 0, currency: props.plan.currency || '', validity_days: props.plan.validity_days, validity_unit: props.plan.validity_unit || 'days', sort_order: props.plan.sort_order || 0, for_sale: props.plan.for_sale }) planFeaturesText.value = (props.plan.features || []).join('\n') } else { - Object.assign(planForm, { name: '', group_id: null, description: '', price: 0, original_price: 0, validity_days: 30, validity_unit: 'days', sort_order: 0, for_sale: true }) + Object.assign(planForm, { name: '', group_id: null, description: '', price: 0, original_price: 0, currency: '', validity_days: 30, validity_unit: 'days', sort_order: 0, for_sale: true }) planFeaturesText.value = '' } }) @@ -187,6 +192,7 @@ function buildPlanPayload() { description: planForm.description, price: planForm.price, original_price: planForm.original_price || 0, + currency: planForm.currency.trim().toUpperCase(), validity_days: planForm.validity_days, validity_unit: planForm.validity_unit, sort_order: planForm.sort_order,