feat(payment): 订阅套餐支持币种标注,区分 $ 符号对应币种

后台配置与用户端展示的套餐价格统一使用 $ 符号,而实际扣款币种随
支付渠道配置漂移(如 Stripe 配置 NZD 时按新西兰元扣款),用户易
误认为美元。本次为套餐新增可选的展示性币种标注:

- SubscriptionPlan 新增 currency 字段(ISO 4217 三字母码,默认空)
- 空值不展示任何标注,存量套餐行为完全不变
- 非空值复用 payment.NormalizePaymentCurrency 校验并规范化为大写
- 后台套餐编辑弹窗新增可选币种输入,列表价格列展示币种小字
- 用户端套餐卡片价格与划线原价旁展示币种小字
- 纯展示性质,不影响任何支付/扣款逻辑

Closes #4315

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
turingcat
2026-07-15 11:17:45 +08:00
co-authored by Claude Fable 5
parent cf6aa1a5c3
commit 2bbdd47002
19 changed files with 380 additions and 16 deletions
+2 -1
View File
@@ -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]},
},
},
}
+55 -1
View File
@@ -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
+14 -8
View File
@@ -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.
+3
View File
@@ -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").
+12 -1
View File
@@ -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(", ")
@@ -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()
+70
View File
@@ -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))
+70
View File
@@ -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) {
+44
View File
@@ -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)
}
@@ -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"`
@@ -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)
}
@@ -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")
}
@@ -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"`
@@ -28,10 +28,11 @@
<div class="flex items-baseline gap-1">
<span class="text-xs text-gray-400 dark:text-dark-500">$</span>
<span :class="['text-2xl font-extrabold tracking-tight', textClass]">{{ plan.price }}</span>
<span v-if="plan.currency" class="text-xs font-medium text-gray-400 dark:text-dark-500">{{ plan.currency }}</span>
</div>
<span class="text-[11px] text-gray-400 dark:text-dark-500">/ {{ validitySuffix }}</span>
<div v-if="plan.original_price" class="mt-0.5 flex items-center justify-end gap-1.5">
<span class="text-xs text-gray-400 line-through dark:text-dark-500">${{ plan.original_price }}</span>
<span class="text-xs text-gray-400 line-through dark:text-dark-500">${{ plan.original_price }}<template v-if="plan.currency"> {{ plan.currency }}</template></span>
<span :class="['rounded px-1 py-0.5 text-[10px] font-semibold', discountClass]">{{ discountText }}</span>
</div>
</div>
+3
View File
@@ -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)',
+3
View File
@@ -569,6 +569,9 @@ export default {
deletePlanConfirm: '确定要删除此套餐吗?',
originalPrice: '原价',
price: '价格',
currency: '币种标注',
currencyPlaceholder: '如 USD / NZD / CNY',
currencyHint: '仅用于价格展示的 ISO 三字母币种码,留空不展示,不影响实际扣款',
subscriptionCnyPayPreview: 'CNY 通道实扣预览:{amount}',
subscriptionCnyPayPreviewWithFee: '(含 {feeRate}% 手续费:{total})',
validityDays: '有效期(天)',
+2
View File
@@ -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 */
@@ -30,6 +30,7 @@
<template #cell-price="{ value, row }">
<div class="text-sm">
<span class="font-medium text-gray-900 dark:text-white">${{ (value ?? 0).toFixed(2) }}</span>
<span v-if="row.currency" class="ml-1 text-xs text-gray-400">{{ row.currency }}</span>
<span v-if="row.original_price" class="ml-1 text-xs text-gray-400 line-through">${{ row.original_price.toFixed(2) }}</span>
</div>
</template>
@@ -53,6 +53,11 @@
</div>
<div class="grid grid-cols-2 gap-4">
<div><label class="input-label">{{ t('payment.admin.sortOrder') }}</label><input v-model.number="planForm.sort_order" type="number" min="0" class="input" /></div>
<div>
<label class="input-label">{{ t('payment.admin.currency') }}</label>
<input v-model="planForm.currency" type="text" maxlength="3" class="input uppercase" :placeholder="t('payment.admin.currencyPlaceholder')" />
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">{{ t('payment.admin.currencyHint') }}</p>
</div>
</div>
<div>
<label class="input-label">{{ t('payment.admin.features') }}</label>
@@ -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,