diff --git a/backend/internal/app/billing.go b/backend/internal/app/billing.go index a631bceb5..86fb04f3b 100644 --- a/backend/internal/app/billing.go +++ b/backend/internal/app/billing.go @@ -118,10 +118,10 @@ func provideBalanceAdjuster(client *dbent.Client) billing.BalanceAdjuster { return billingpostgres.NewBalanceStore(client) } -// provideBillingPlans combines subscription-plan storage with payment-instance -// usage checks. -func provideBillingPlans(client *dbent.Client, orders *paymentpostgres.InstanceStore) *billing.Plans { - return billing.NewPlans(billingpostgres.NewPlanStore(client), orders) +// provideBillingPlans combines subscription-plan storage with payment-instance usage checks +// and the routing group lookup that confirms declared group selections. +func provideBillingPlans(client *dbent.Client, orders *paymentpostgres.InstanceStore, groups *routingpostgres.GroupStore) *billing.Plans { + return billing.NewPlans(billingpostgres.NewPlanStore(client), orders, planGroupValidator{lookup: groups}) } // provideSubscriptionExpiry constructs one-minute reminders using notification diff --git a/backend/internal/app/billing_http_scenario_test.go b/backend/internal/app/billing_http_scenario_test.go index d0facb7a5..47a4c009f 100644 --- a/backend/internal/app/billing_http_scenario_test.go +++ b/backend/internal/app/billing_http_scenario_test.go @@ -52,7 +52,7 @@ func TestBillingPlanHTTPPreservesEntJSON(t *testing.T) { } for name, original := range cases { t.Run(name, func(t *testing.T) { - handler := billinghttp.NewPlanHandler(billing.NewPlans(billingPlanHTTPFixture{plans: []*billing.SubscriptionPlan{billingpostgres.PlanFromEntity(original)}}, nil)) + handler := billinghttp.NewPlanHandler(billing.NewPlans(billingPlanHTTPFixture{plans: []*billing.SubscriptionPlan{billingpostgres.PlanFromEntity(original)}}, nil, nil)) router := gin.New() router.GET("/plans", handler.ListPlans) response := httptest.NewRecorder() diff --git a/backend/internal/app/routing_consumers.go b/backend/internal/app/routing_consumers.go index e55393011..3f96efbdd 100644 --- a/backend/internal/app/routing_consumers.go +++ b/backend/internal/app/routing_consumers.go @@ -13,8 +13,9 @@ import ( // routing_consumers.go adapts routing groups for API-Key policy, identity // administration, and localized subscription display. These consumers read // different views from the same routing repository. Key conversion uses -// apikey's group copying rules, identity selects administrator fields, and -// subscription display resolves group text in the request's language. +// apikey's group copying rules, identity selects administrator fields, +// subscription display resolves group text in the request's language, and plan +// writes check that declared group identifiers still resolve. // keyGroups adapts routing group reads for API-Key policy consumers. type keyGroups struct { @@ -31,6 +32,12 @@ type billingGroups struct { Repository routing.GroupRepository // shared routing repository } +// planGroupValidator checks plan group selections against stored groups through the routing +// repository that owns group existence. +type planGroupValidator struct { + lookup routing.GroupExistenceLookup // group existence reader +} + // GetByID returns the routing record through API-Key group conversion. func (p keyGroups) GetByID(ctx context.Context, id int64) (*routing.Group, error) { v, e := p.Repository.GetByID(ctx, id) @@ -86,6 +93,12 @@ func identityAdminGroup(v *routing.Group) *identity.AdminGroup { return &identity.AdminGroup{ID: v.ID, Name: v.Name, Status: v.Status, IsExclusive: v.IsExclusive, RPMLimit: v.RPMLimit} } +// ValidatePlanGroupIDs reports a missing-resource error when any supplied identifier does not +// resolve to a stored, undeleted group, so a plan cannot keep covering a deleted group. +func (v planGroupValidator) ValidatePlanGroupIDs(ctx context.Context, ids []int64) error { + return routing.ValidateGroupIDs(ctx, v.lookup, ids) +} + // GetByIDLite returns the group ID and localized name for subscription display. // Missing repository or group produces nil, read errors are retained, and // GroupDisplay uses the request context's language. diff --git a/backend/internal/app/wire_gen.go b/backend/internal/app/wire_gen.go index ec389fed8..227f339ae 100644 --- a/backend/internal/app/wire_gen.go +++ b/backend/internal/app/wire_gen.go @@ -142,7 +142,7 @@ func initializeApplication(ctx context.Context, cfg *config.Config, info BuildIn refreshTokenCache := rediscache4.NewRefreshTokenCache(redisClient) runtimeSettings := provideIdentitySettings(store) instanceStore := postgres4.NewInstanceStore(client) - plans := provideBillingPlans(client, instanceStore) + plans := provideBillingPlans(client, instanceStore, groupStore) grantSettings := provideGrantSettings(store, cfg, plans) promotionRuntimeSettings := providePromotionSettings(store) appIdentityAuthSettings := provideIdentityAuthSettings(runtimeSettings, grantSettings, displaySettings, oAuthSettings, promotionRuntimeSettings) diff --git a/backend/internal/billing/plan_localization_test.go b/backend/internal/billing/plan_localization_test.go index d50f71818..a809c86a6 100644 --- a/backend/internal/billing/plan_localization_test.go +++ b/backend/internal/billing/plan_localization_test.go @@ -18,7 +18,7 @@ func TestPlanTextRequiresLocalization(t *testing.T) { for _, request := range []UpdatePlanRequest{ {Name: &text}, {Description: &text}, {Features: &text}, {ProductName: &text}, } { - _, err := NewPlans(nil, nil).UpdatePlan(context.Background(), 1, request) + _, err := NewPlans(nil, nil, nil).UpdatePlan(context.Background(), 1, request) require.Equal(t, "LOCALIZATION_REQUIRED", apperror.Reason(err)) } } diff --git a/backend/internal/billing/plans.go b/backend/internal/billing/plans.go index ff576b59a..590d2a16f 100644 --- a/backend/internal/billing/plans.go +++ b/backend/internal/billing/plans.go @@ -23,6 +23,21 @@ import ( // checks the payment-owned in-progress order count before requesting // a delete; those separate calls provide a guard but establish no // transaction or reservation for the plan here. +// +// A declared group selection must name groups that still exist. Deleting a +// group leaves the identifiers that plans recorded, so a plan can otherwise keep +// covering a group that no subscriber can reach, and the display carries the +// identifier with no name. PlanGroupValidator answers that question for the +// service, which checks the selection it is about to store. + +// PlanGroupValidator reports whether plan group identifiers name stored groups. +type PlanGroupValidator interface { + // ValidatePlanGroupIDs returns a missing-resource error when any supplied + // identifier does not resolve to a stored group. An empty selection needs no + // lookup; a failure means the service cannot confirm the selection and the + // plan write does not proceed. + ValidatePlanGroupIDs(context.Context, []int64) error +} // PlanRepository reads and persists configured plan terms and localized content. type PlanRepository interface { @@ -74,14 +89,35 @@ type Plans struct { // constructor retains the supplied adapter as provided, so callers // bind a usable count source before requesting deletion. orders PlanOrders + // groups confirms that a group selection names stored groups before it + // is written. A plan write that declares groups fails when this adapter + // is absent, because the service cannot confirm the selection. + groups PlanGroupValidator +} + +// NewPlans constructs plan operations using the supplied storage, +// order-count, and group-selection adapters. Bind usable storage before +// repository calls, a usable count adapter before DeletePlan, and a usable +// group adapter before writing a plan that declares group identifiers; the +// constructor retains all three dependencies as supplied. +func NewPlans(store PlanRepository, orders PlanOrders, groups PlanGroupValidator) *Plans { + return &Plans{store: store, orders: orders, groups: groups} } -// NewPlans constructs plan operations using the supplied storage and -// order-count adapters. Bind usable storage before repository calls -// and a usable count adapter before DeletePlan; the constructor -// retains both dependencies as supplied. -func NewPlans(store PlanRepository, orders PlanOrders) *Plans { - return &Plans{store: store, orders: orders} +// validateGroupIDs confirms that a nonempty group selection names stored groups. +// An empty selection passes without a lookup and leaves the plan unrestricted. +// Missing adapter, lookup failure, and missing group results all stop the plan +// write, keeping a selection that points at a deleted group out of storage. An +// absent adapter reports a server-side failure, because serving plans without +// one would accept selections the deployment cannot confirm. +func (s *Plans) validateGroupIDs(ctx context.Context, groupIDs []int64) error { + if len(groupIDs) == 0 { + return nil + } + if s.groups == nil { + return apperror.InternalServer("PLAN_GROUP_VALIDATOR_UNAVAILABLE", "plan group validation is unavailable") + } + return s.groups.ValidatePlanGroupIDs(ctx, groupIDs) } // ListPlans returns the configured repository's plan list unchanged. @@ -122,9 +158,10 @@ func (s *Plans) GetPlan(ctx context.Context, id int64) (*SubscriptionPlan, error // validates its revision and translated values. // // Legacy GroupID is merged into the positive distinct GroupIDs list, -// and only selected group rates survive normalization. Required -// terms, currency syntax, and quota signs are checked before storage. -// The request passed onward clears the legacy group field and +// and only selected group rates survive normalization. The selection +// must name stored groups, so a deleted group cannot enter a new plan. +// Required terms, currency syntax, and quota signs are checked before +// storage. The request passed onward clears the legacy group field and // contains the same normalized selection used for its rate validation. func (s *Plans) CreatePlan(ctx context.Context, req CreatePlanRequest) (*SubscriptionPlan, error) { if req.Localization != nil { @@ -146,6 +183,11 @@ func (s *Plans) CreatePlan(ctx context.Context, req CreatePlanRequest) (*Subscri if err := ValidatePlanQuotas(req.DailyLimitUSD, req.WeeklyLimitUSD, req.MonthlyLimitUSD); err != nil { return nil, err } + // The group lookup runs after local term checks so a rejected price or quota does not + // reach storage adapters. + if err := s.validateGroupIDs(ctx, groupIDs); err != nil { + return nil, err + } // Storage receives one normalized group selection and its validated // rate entries. Clear the legacy leading group in that outgoing @@ -163,11 +205,13 @@ func (s *Plans) CreatePlan(ctx context.Context, req CreatePlanRequest) (*Subscri // fields before patch validation. // // Ordinary omitted pointers and the nullable fields' presence bits -// remain available for the repository to interpret. This service -// checks requested required-field and quota comparisons, while the -// repository normalizes currency and group changes and compares -// localized content revisions. Errors from either phase are returned -// in the phase where they occur. +// remain available for the repository to interpret. A submitted group +// selection must name stored groups before it replaces the stored one; +// an update that leaves group_ids out keeps the recorded selection and +// needs no group lookup. This service checks requested required-field and +// quota comparisons, while the repository normalizes currency and group +// changes and compares localized content revisions. Errors from either +// phase are returned in the phase where they occur. func (s *Plans) UpdatePlan(ctx context.Context, id int64, req UpdatePlanRequest) (*SubscriptionPlan, error) { // Text replacement needs the versioned content update so repository // revision checks and translation handling use the submitted source @@ -182,6 +226,17 @@ func (s *Plans) UpdatePlan(ctx context.Context, id int64, req UpdatePlanRequest) if err := ValidatePlanPatch(req); err != nil { return nil, err } + if req.GroupIDs != nil { + // Storage merges a supplied legacy identifier ahead of the list, so the check + // covers the selection that will actually be stored. + groupID := int64(0) + if req.GroupID != nil { + groupID = *req.GroupID + } + if err := s.validateGroupIDs(ctx, NormalizePlanGroupIDs(groupID, *req.GroupIDs)); err != nil { + return nil, err + } + } return s.store.UpdatePlan(ctx, id, req) } diff --git a/backend/internal/billing/plans_test.go b/backend/internal/billing/plans_test.go new file mode 100644 index 000000000..3cf904c64 --- /dev/null +++ b/backend/internal/billing/plans_test.go @@ -0,0 +1,206 @@ +package billing + +import ( + "context" + "errors" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/TokenFlux/TokenRouter/internal/pkg/apperror" +) + +// plans_test.go checks the group selection a plan write declares against the adapter +// that reports stored groups. A deleted group leaves its identifier in plans that already +// record it, so the service confirms the submitted selection before storage receives it. + +// planWriteGroupValidator records requested identifiers and returns the configured result, +// letting a scenario inspect which selection a plan write submitted. +type planWriteGroupValidator struct { + // err is returned from every validation call; nil accepts the selection. + err error + // calls holds each validated selection in call order. + calls [][]int64 +} + +// planWriteStore records plan writes that reach storage. +type planWriteStore struct { + // created holds creation requests accepted by the store. + created []CreatePlanRequest + // updated holds update requests accepted by the store. + updated []UpdatePlanRequest +} + +// ValidatePlanGroupIDs records the requested identifiers and returns the configured result. +func (v *planWriteGroupValidator) ValidatePlanGroupIDs(_ context.Context, ids []int64) error { + v.calls = append(v.calls, append([]int64(nil), ids...)) + return v.err +} + +// ListPlans returns no stored plans for write scenarios. +func (s *planWriteStore) ListPlans(context.Context) ([]*SubscriptionPlan, error) { + return nil, nil +} + +// ListPlansForSale returns no offered plans for write scenarios. +func (s *planWriteStore) ListPlansForSale(context.Context) ([]*SubscriptionPlan, error) { + return nil, nil +} + +// CreatePlan records the request and returns the plan it describes. +func (s *planWriteStore) CreatePlan(_ context.Context, req CreatePlanRequest) (*SubscriptionPlan, error) { + s.created = append(s.created, req) + return &SubscriptionPlan{Name: req.Name, GroupIDs: req.GroupIDs}, nil +} + +// UpdatePlan records the request and returns the replaced plan value. +func (s *planWriteStore) UpdatePlan(_ context.Context, id int64, req UpdatePlanRequest) (*SubscriptionPlan, error) { + s.updated = append(s.updated, req) + return &SubscriptionPlan{ID: id}, nil +} + +// DeletePlan accepts the requested deletion for write scenarios. +func (s *planWriteStore) DeletePlan(context.Context, int64) error { + return nil +} + +// GetPlan reports a stored plan for the requested identity. +func (s *planWriteStore) GetPlan(_ context.Context, id int64) (*SubscriptionPlan, error) { + return &SubscriptionPlan{ID: id}, nil +} + +// newPlanWriteRequest returns creation terms that pass local validation, so a scenario can +// focus on the declared group selection. +func newPlanWriteRequest(groupID int64, groupIDs []int64) CreatePlanRequest { + return CreatePlanRequest{ + Name: "Max", + Price: 20, + ValidityDays: 30, + ValidityUnit: "day", + GroupID: groupID, + GroupIDs: groupIDs, + } +} + +// TestPlansCreatePlanRejectsDeletedGroup supplies a selection whose group no longer exists. +// The write must report the group failure and leave storage untouched. +func TestPlansCreatePlanRejectsDeletedGroup(t *testing.T) { + store := &planWriteStore{} + groups := &planWriteGroupValidator{err: apperror.NotFound("GROUP_NOT_FOUND", "group not found")} + service := NewPlans(store, nil, groups) + + _, err := service.CreatePlan(context.Background(), newPlanWriteRequest(0, []int64{15, 21})) + + require.Equal(t, "GROUP_NOT_FOUND", apperror.Reason(err)) + require.Empty(t, store.created, "a rejected selection must not reach storage") +} + +// TestPlansCreatePlanValidatesMergedLegacyGroup supplies a legacy identifier beside a list +// that repeats it. The validator must receive the distinct selection storage will record, and +// an accepted selection must reach the store. +func TestPlansCreatePlanValidatesMergedLegacyGroup(t *testing.T) { + store := &planWriteStore{} + groups := &planWriteGroupValidator{} + service := NewPlans(store, nil, groups) + + _, err := service.CreatePlan(context.Background(), newPlanWriteRequest(7, []int64{9, 7})) + + require.NoError(t, err) + require.Equal(t, [][]int64{{7, 9}}, groups.calls) + require.Len(t, store.created, 1) +} + +// TestPlansCreatePlanSkipsLookupWithoutGroups supplies no group selection, which leaves the +// plan unrestricted. The validator must not run, and creation must still reach storage. +func TestPlansCreatePlanSkipsLookupWithoutGroups(t *testing.T) { + store := &planWriteStore{} + groups := &planWriteGroupValidator{} + service := NewPlans(store, nil, groups) + + _, err := service.CreatePlan(context.Background(), newPlanWriteRequest(0, nil)) + + require.NoError(t, err) + require.Empty(t, groups.calls) + require.Len(t, store.created, 1) +} + +// TestPlansCreatePlanRejectsMissingValidator supplies a group selection without a configured +// validator. The service cannot confirm the selection, so the write fails as a server-side +// failure before storage. +func TestPlansCreatePlanRejectsMissingValidator(t *testing.T) { + store := &planWriteStore{} + service := NewPlans(store, nil, nil) + + _, err := service.CreatePlan(context.Background(), newPlanWriteRequest(0, []int64{21})) + + require.Equal(t, "PLAN_GROUP_VALIDATOR_UNAVAILABLE", apperror.Reason(err)) + require.Empty(t, store.created) +} + +// TestPlansUpdatePlanRejectsDeletedGroup supplies a replacement selection that names a +// deleted group. The update must report the group failure and leave storage untouched. +func TestPlansUpdatePlanRejectsDeletedGroup(t *testing.T) { + store := &planWriteStore{} + groups := &planWriteGroupValidator{err: apperror.NotFound("GROUP_NOT_FOUND", "group not found")} + service := NewPlans(store, nil, groups) + groupIDs := []int64{15, 21} + + _, err := service.UpdatePlan(context.Background(), 7, UpdatePlanRequest{GroupIDs: &groupIDs}) + + require.Equal(t, "GROUP_NOT_FOUND", apperror.Reason(err)) + require.Empty(t, store.updated) +} + +// TestPlansUpdatePlanValidatesLegacyGroupWithList supplies a legacy identifier beside a +// submitted list. The validator must confirm the merge that storage performs, so the request +// reaching storage keeps the legacy identifier and list that produce the same selection. +func TestPlansUpdatePlanValidatesLegacyGroupWithList(t *testing.T) { + store := &planWriteStore{} + groups := &planWriteGroupValidator{} + service := NewPlans(store, nil, groups) + groupID := int64(7) + groupIDs := []int64{21} + + _, err := service.UpdatePlan(context.Background(), 7, UpdatePlanRequest{GroupID: &groupID, GroupIDs: &groupIDs}) + + require.NoError(t, err) + require.Equal(t, [][]int64{{7, 21}}, groups.calls) + require.Len(t, store.updated, 1) + // Storage repeats this merge, so the confirmed selection and the stored selection agree + // only while the submitted parts stay unchanged. + require.Equal(t, groupID, *store.updated[0].GroupID) + require.Equal(t, groupIDs, *store.updated[0].GroupIDs) +} + +// TestPlansUpdatePlanSkipsLookupWhenGroupsOmitted supplies a price-only change to a plan whose +// recorded selection may name a deleted group. The update carries no group selection, so it +// must reach storage without a lookup that could block an unrelated edit. +func TestPlansUpdatePlanSkipsLookupWhenGroupsOmitted(t *testing.T) { + store := &planWriteStore{} + groups := &planWriteGroupValidator{} + service := NewPlans(store, nil, groups) + price := 25.0 + + _, err := service.UpdatePlan(context.Background(), 7, UpdatePlanRequest{Price: &price}) + + require.NoError(t, err) + require.Empty(t, groups.calls) + require.Len(t, store.updated, 1) + require.Nil(t, store.updated[0].GroupIDs, "an omitted selection reaches storage as omitted") +} + +// TestPlansUpdatePlanReportsLookupFailure supplies a selection whose validation fails with a +// storage error. The update must propagate that failure so callers can retry instead of +// treating the selection as confirmed. +func TestPlansUpdatePlanReportsLookupFailure(t *testing.T) { + store := &planWriteStore{} + lookupErr := errors.New("group lookup unavailable") + groups := &planWriteGroupValidator{err: lookupErr} + service := NewPlans(store, nil, groups) + groupIDs := []int64{21} + + _, err := service.UpdatePlan(context.Background(), 7, UpdatePlanRequest{GroupIDs: &groupIDs}) + + require.ErrorIs(t, err, lookupErr) + require.Empty(t, store.updated) +} diff --git a/backend/internal/billing/postgres/plans_test.go b/backend/internal/billing/postgres/plans_test.go index e1ab676c9..6c13e726e 100644 --- a/backend/internal/billing/postgres/plans_test.go +++ b/backend/internal/billing/postgres/plans_test.go @@ -12,6 +12,10 @@ import ( sqlitetest "github.com/TokenFlux/TokenRouter/internal/testutil/sqlite" ) +// plans_test.go checks plan group normalization and transactional mapping writes. +// The SQLite fixture enforces group foreign keys so a failed mapping insert +// exercises rollback of the plan row saved earlier in the same transaction. + // TestNormalizePlanGroupIDs_DeduplicatesAndPreservesLegacyGroupID supplies // legacy ID three alongside duplicate and nonpositive list entries. The // normalized sequence must keep the legacy ID first and retain accepted list @@ -51,10 +55,10 @@ func TestNormalizePlanGroupRateMultipliers_RejectsNonPositiveRate(t *testing.T) require.Equal(t, "PLAN_GROUP_RATE_INVALID", apperror.Reason(err)) } -// TestCreatePlan_RollsBackWhenGroupMappingSyncFails creates a -// foreign-key-enforced mapping table and requests a missing group ID. Creation -// must fail and leave no plan row with the requested name, checking the owned -// transaction covers mapping synchronization. +// TestCreatePlan_RollsBackWhenGroupMappingSyncFails calls the plan store with a +// normalized selection whose group is missing. The mapping insert must fail +// with a foreign-key error and roll back the plan row saved earlier in the +// transaction. func TestCreatePlan_RollsBackWhenGroupMappingSyncFails(t *testing.T) { ctx := context.Background() client := sqlitetest.NewClient(t) @@ -70,8 +74,10 @@ func TestCreatePlan_RollsBackWhenGroupMappingSyncFails(t *testing.T) { `) require.NoError(t, err) - svc := billing.NewPlans(NewPlanStore(client), nil) - _, err = svc.CreatePlan(ctx, billing.CreatePlanRequest{ + // Call the store directly so group-existence validation cannot reject the + // request before the transaction saves the plan. + store := NewPlanStore(client) + _, err = store.CreatePlan(ctx, billing.CreatePlanRequest{ Name: "rollback-plan", Description: "mapping sync should fail", Price: 1, @@ -81,7 +87,7 @@ func TestCreatePlan_RollsBackWhenGroupMappingSyncFails(t *testing.T) { ForSale: true, }) - require.Error(t, err) + require.ErrorContains(t, err, "FOREIGN KEY constraint failed") count, countErr := client.SubscriptionPlan.Query(). Where(subscriptionplan.NameEQ("rollback-plan")). Count(ctx) diff --git a/backend/internal/billing/subscription_service.go b/backend/internal/billing/subscription_service.go index 8d7c35af3..46e0c3b07 100644 --- a/backend/internal/billing/subscription_service.go +++ b/backend/internal/billing/subscription_service.go @@ -65,8 +65,10 @@ var ( // with plan coverage identities. type SubscriptionGroupReader interface { // GetByIDLite resolves lightweight group display data for a supplied - // identity. Enrichment tolerates an error or nil row by retaining an ID-only - // placeholder; the reader owns its persistence and returned-value policy. + // identity. Enrichment keeps an ID-only placeholder for a nil row and for a + // failure other than a missing-resource error, and drops the identity when the + // error reports that no such group exists. The reader owns its persistence and + // returned-value policy. GetByIDLite(context.Context, int64) (*SubscriptionPlanGroup, error) } @@ -312,12 +314,14 @@ func NewSubscriptionService(groups SubscriptionGroupReader, repo UserSubscriptio // Empty group IDs also skip lookup, leaving any existing displayed entries // retained. // -// For a new display list, every supplied group identity remains represented -// even when the reader is absent, returns an error or yields nil; only its -// name stays empty. Loaded Plan pointers are mutated directly and can be -// shared across grants. Callers keep those plan objects stable during -// enrichment and separately decide whether an existing display list still -// matches changed group IDs. +// A group that storage reports as missing leaves the display list, because a plan +// can outlive a group it once named and the subscriber has no remaining access +// through that identity. A nil row, an absent reader, or any other read failure +// keeps the identity with an empty name instead: those outcomes leave the group's +// existence undetermined, and dropping it would hide access that still works. +// Loaded Plan pointers are mutated directly and can be shared across grants. +// Callers keep those plan objects stable during enrichment and separately decide +// whether an existing display list still matches changed group IDs. func (s *SubscriptionService) EnrichSubscriptionPlanGroups(ctx context.Context, subscriptions []UserSubscription) { if s == nil { return @@ -331,18 +335,21 @@ func (s *SubscriptionService) EnrichSubscriptionPlanGroups(ctx context.Context, if len(plan.GroupIDs) == 0 || len(plan.ApplicableGroups) > 0 { continue } - // Preserve every group identity while resolving optional names. Missing - // reader data becomes an ID-only entry instead of removing plan coverage - // from the display. + // Resolve names while distinguishing a deleted group from an unreadable one, so + // only the confirmed absence removes an entry from the display. plan.ApplicableGroups = make([]SubscriptionPlanGroup, 0, len(plan.GroupIDs)) for _, groupID := range plan.GroupIDs { - group := &SubscriptionPlanGroup{ID: groupID} + group := SubscriptionPlanGroup{ID: groupID} if s.groupRepo != nil { - if resolved, err := s.groupRepo.GetByIDLite(ctx, groupID); err == nil && resolved != nil { + resolved, err := s.groupRepo.GetByIDLite(ctx, groupID) + if apperror.IsNotFound(err) { + continue + } + if err == nil && resolved != nil { group.Name = resolved.Name } } - plan.ApplicableGroups = append(plan.ApplicableGroups, *group) + plan.ApplicableGroups = append(plan.ApplicableGroups, group) } } } diff --git a/backend/internal/billing/subscription_service_external_test.go b/backend/internal/billing/subscription_service_external_test.go new file mode 100644 index 000000000..df6f3ef96 --- /dev/null +++ b/backend/internal/billing/subscription_service_external_test.go @@ -0,0 +1,94 @@ +package billing_test + +import ( + "context" + "errors" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/TokenFlux/TokenRouter/internal/billing" + "github.com/TokenFlux/TokenRouter/internal/pkg/apperror" +) + +// subscription_service_external_test.go checks how plan group labels are resolved for +// subscriber-facing views. A stored plan can name a group that was deleted after the plan was +// saved, and reading a group can also fail temporarily, so the display distinguishes a +// confirmed absence from an undetermined read. + +// groupDisplayReader answers group label reads from configured per-identity results so a +// scenario can mix resolved, missing, and failing identities in one enrichment call. +type groupDisplayReader struct { + // names holds labels for identities that resolve successfully. + names map[int64]string + // errs holds a failure for an identity; a missing entry with no name reports absence. + errs map[int64]error +} + +// emptyGroupDisplayReader reports success without a row for every identity. +type emptyGroupDisplayReader struct{} + +// GetByIDLite returns the configured label or failure for id, reporting a not-found error +// for an identity that has neither. +func (r groupDisplayReader) GetByIDLite(_ context.Context, id int64) (*billing.SubscriptionPlanGroup, error) { + if err, ok := r.errs[id]; ok { + return nil, err + } + if name, ok := r.names[id]; ok { + return &billing.SubscriptionPlanGroup{ID: id, Name: name}, nil + } + return nil, apperror.NotFound("GROUP_NOT_FOUND", "group not found") +} + +// TestEnrichSubscriptionPlanGroupsDropsDeletedGroup gives a restricted plan that names a +// resolved group and a deleted one. The deleted identity must leave the display list so no +// subscriber sees a bare identifier for a group they cannot reach. +func TestEnrichSubscriptionPlanGroupsDropsDeletedGroup(t *testing.T) { + reader := groupDisplayReader{names: map[int64]string{21: "Claude(kiro)"}} + service := billing.NewSubscriptionService(reader, nil, nil) + items := []billing.UserSubscription{{Plan: &billing.SubscriptionPlan{ + ID: 7, + GroupIDs: []int64{15, 21}, + }}} + + service.EnrichSubscriptionPlanGroups(context.Background(), items) + + require.Equal(t, []billing.SubscriptionPlanGroup{{ID: 21, Name: "Claude(kiro)"}}, items[0].Plan.ApplicableGroups) + require.True(t, items[0].Plan.GroupsRestricted, "a nonempty stored selection still marks the plan restricted") +} + +// TestEnrichSubscriptionPlanGroupsKeepsUnreadableGroup gives a restricted plan whose group +// read fails with a storage error. The identity must keep its place with an empty label, +// because a failed read leaves the group's existence undetermined. +func TestEnrichSubscriptionPlanGroupsKeepsUnreadableGroup(t *testing.T) { + reader := groupDisplayReader{errs: map[int64]error{15: errors.New("connection refused")}} + service := billing.NewSubscriptionService(reader, nil, nil) + items := []billing.UserSubscription{{Plan: &billing.SubscriptionPlan{ + ID: 7, + GroupIDs: []int64{15}, + }}} + + service.EnrichSubscriptionPlanGroups(context.Background(), items) + + require.Equal(t, []billing.SubscriptionPlanGroup{{ID: 15}}, items[0].Plan.ApplicableGroups) +} + +// TestEnrichSubscriptionPlanGroupsKeepsAbsentRow gives a reader that reports success with no +// row. An empty result does not confirm deletion, so the identity stays in the display with +// an empty label. +func TestEnrichSubscriptionPlanGroupsKeepsAbsentRow(t *testing.T) { + service := billing.NewSubscriptionService(emptyGroupDisplayReader{}, nil, nil) + items := []billing.UserSubscription{{Plan: &billing.SubscriptionPlan{ + ID: 7, + GroupIDs: []int64{15}, + }}} + + service.EnrichSubscriptionPlanGroups(context.Background(), items) + + require.Equal(t, []billing.SubscriptionPlanGroup{{ID: 15}}, items[0].Plan.ApplicableGroups) +} + +// GetByIDLite returns no row and no error, matching a reader that cannot supply the group. +func (emptyGroupDisplayReader) GetByIDLite(context.Context, int64) (*billing.SubscriptionPlanGroup, error) { + return nil, nil +} diff --git a/backend/internal/payment/testkit/configuration.go b/backend/internal/payment/testkit/configuration.go index 5a2101a92..5eb243aff 100644 --- a/backend/internal/payment/testkit/configuration.go +++ b/backend/internal/payment/testkit/configuration.go @@ -28,6 +28,6 @@ func Configuration(client *dbent.Client, settings payment.ConfigurationSettings, if client != nil { store = paymentpostgres.NewInstanceStore(client) } - plans := billing.NewPlans(billingpostgres.NewPlanStore(client), paymentpostgres.NewInstanceStore(client)) + plans := billing.NewPlans(billingpostgres.NewPlanStore(client), paymentpostgres.NewInstanceStore(client), nil) return payment.NewConfigService(store, settings, key, plans, payment.ConfigurationRuntime{CreateProvider: paymentadapter.CreateProvider, LookupEnv: os.LookupEnv, Warn: slog.Warn}) } diff --git a/docs/domains/payments_and_entitlements.md b/docs/domains/payments_and_entitlements.md index c0e5406fc..80264c783 100644 --- a/docs/domains/payments_and_entitlements.md +++ b/docs/domains/payments_and_entitlements.md @@ -139,6 +139,8 @@ Webhook 路由不使用用户 JWT,所以提供商验签、订单绑定和金 套餐的分组为空,表示这个套餐不限制分组;非空时,表示可以消费的分组集合。用户的订阅列表用 `groups_restricted` 和带名称的 `applicable_groups` 展示这个区别。只有 API Key 明确选择了这个订阅时,Key 可选的分组才收窄为用户已有权限和这个集合的交集;自动结算和只用余额的 Key,可选分组保持用户原有的权限范围。指定订阅的付款主体按 Key 的归属确定,团队 Key 使用 Team Owner 的订阅。 +套餐保存时,服务端核对提交的分组是否仍然存在,指向已删除分组的 `group_ids` 返回 `GROUP_NOT_FOUND`,写入不会落库。删除分组只清理分组授权和提供商关联,套餐里记录的分组 ID 保留下来,所以订阅列表读取时会把确认已删除的分组从 `applicable_groups` 中去掉,查询失败时仍保留只有 ID 的条目。管理端编辑套餐时也会丢弃已删除的分组和它们的倍率覆盖,保存后套餐记录随之修正。只提交价格、额度等字段而不带 `group_ids` 的更新保留原分组选择,不做分组查询,因此已经记录了旧 ID 的套餐仍可修改其他字段。 + ### 周期额度窗口 diff --git a/docs/interfaces/http_api.md b/docs/interfaces/http_api.md index 6360e6abd..39d530eed 100644 --- a/docs/interfaces/http_api.md +++ b/docs/interfaces/http_api.md @@ -108,6 +108,7 @@ Markdown 正文需要 JWT,并且菜单可见;管理员页面只对管理员 - `POST /api/v1/admin/payment/orders/{id}/force-expire`:必填 JSON 字段 `reason`(1 到 500 个字符)。只有当前为 `PENDING` 的订单可以被改成 `EXPIRED`,整个过程不调用上游,成功时 `data.message=force_expired`。订单不存在返回 `NOT_FOUND`,状态被并发修改返回 `ORDER_STATUS_CHANGED`(409)。之后迟到的付款仍可以通过正常的 webhook 恢复。 - 退款查单:同时接受 `REFUND_PENDING`,以及带有有效准备记录的 `REFUNDING`。页面上是同一个查单按钮;接口只查询已经发起过的渠道退款,渠道退款只发起那一次。恢复记录不完整、互相矛盾或渠道无法确认时,返回要求人工核实的错误,调用方不能据此认为退款没发生。 - `POST /api/v1/admin/payment/providers/test`:接受 `provider_key`、`config` 和可选的 `instance_id`,目前只支持 `easypay`。带实例 ID 时,服务端按更新规则合并请求里没回传的敏感字段,再用随机订单号做一次只读查单。接口不保存草稿,也不创建订单;成功时只返回 `data.reachable=true`,上游的 body、URL 细节和凭据都不会出现在响应里。 +- `POST /api/v1/admin/payment/plans` 和 `PUT /api/v1/admin/payment/plans/{id}`:请求带上的 `group_ids` 必须指向现存分组,包含已删除或不存在的分组时返回 `GROUP_NOT_FOUND`(404),套餐写入不会执行。更新不带 `group_ids` 时保留原有分组选择,不做分组查询。 普通取消在无法确认上游支付状态时,返回 `PAYMENT_STATUS_UNAVAILABLE`(503)。某个 provider instance 还有强制过期、尚未恢复的订单时,删除接口返回 `FORCED_EXPIRED_ORDERS`(409);管理员应当停用并保留这个实例,以便接收迟到的回调。 diff --git a/frontend/src/i18n/locales/en/misc.ts b/frontend/src/i18n/locales/en/misc.ts index b554f9dfb..200db6848 100644 --- a/frontend/src/i18n/locales/en/misc.ts +++ b/frontend/src/i18n/locales/en/misc.ts @@ -902,6 +902,8 @@ export default { groupId: 'Group ID', planGroups: 'Plan Groups', planGroupsGlobalHint: 'Leave empty to make the plan available to all groups', + planGroupsRemoved: + '{count} deleted group(s) were removed from this plan. Saving applies the change.', subscriptionRateMultiplier: 'Subscription Rate', subscriptionRateMultiplierRequired: 'Subscription rate for selected groups must be greater than 0', diff --git a/frontend/src/i18n/locales/zh/misc.ts b/frontend/src/i18n/locales/zh/misc.ts index 3ae78fb03..83dcac071 100644 --- a/frontend/src/i18n/locales/zh/misc.ts +++ b/frontend/src/i18n/locales/zh/misc.ts @@ -864,6 +864,7 @@ export default { groupId: '分组 ID', planGroups: '适用分组', planGroupsGlobalHint: '不选择分组时,该套餐适用于所有分组', + planGroupsRemoved: '已从套餐中移除 {count} 个已删除的分组,保存后生效。', subscriptionRateMultiplier: '订阅倍率', subscriptionRateMultiplierRequired: '所选分组的订阅倍率必须大于 0', features: '功能特性', diff --git a/frontend/src/views/admin/orders/PlanEditDialog.vue b/frontend/src/views/admin/orders/PlanEditDialog.vue index 03859ed5e..cb3f418d0 100644 --- a/frontend/src/views/admin/orders/PlanEditDialog.vue +++ b/frontend/src/views/admin/orders/PlanEditDialog.vue @@ -196,6 +196,13 @@ {{ t('common.loading', 'Loading...') }} +
+ {{ t('payment.admin.planGroupsRemoved', { count: removedGroupIDs.length }) }} +