major fixes
This commit is contained in:
@@ -0,0 +1,332 @@
|
||||
package billing
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
// ErrFeatureNotGrantable is returned when a permission update names a key the company
|
||||
// owner is not allowed to toggle (shell chrome, plan-metering capabilities, always-on keys).
|
||||
var ErrFeatureNotGrantable = errors.New("feature not grantable")
|
||||
|
||||
// alwaysGrantedPrefixes are never restrictable: the app shell must keep working for
|
||||
// every member, and capability.* keys meter the plan (credits, SKU caps) rather than
|
||||
// describe UI access — restricting them per member would silently break billing math.
|
||||
var alwaysGrantedPrefixes = []string{"shell.", "capability."}
|
||||
|
||||
// alwaysGrantedKeys keep a restricted member out of a dead end: they can always reach
|
||||
// the dashboard landing page and their own profile (to change password / sign out).
|
||||
var alwaysGrantedKeys = map[string]bool{
|
||||
"dashboard.overview": true,
|
||||
"settings.profile": true,
|
||||
}
|
||||
|
||||
// IsGrantableFeatureKey reports whether a company owner may toggle key per member.
|
||||
func IsGrantableFeatureKey(key string) bool {
|
||||
key = strings.TrimSpace(key)
|
||||
if !IsKnownFeatureKey(key) {
|
||||
return false
|
||||
}
|
||||
if alwaysGrantedKeys[key] {
|
||||
return false
|
||||
}
|
||||
for _, prefix := range alwaysGrantedPrefixes {
|
||||
if strings.HasPrefix(key, prefix) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// GrantableFeatureKeys returns the catalog subset an owner may restrict, in catalog order.
|
||||
func GrantableFeatureKeys() []string {
|
||||
out := make([]string, 0, len(FeatureCatalogKeys))
|
||||
for _, key := range FeatureCatalogKeys {
|
||||
if IsGrantableFeatureKey(key) {
|
||||
out = append(out, key)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// MemberPermissionEntry is one grantable key for the owner-facing permission editor.
|
||||
type MemberPermissionEntry struct {
|
||||
Key string `json:"key"`
|
||||
Section string `json:"section"`
|
||||
// Parent is the nearest grantable ancestor key ("catalog.products" for
|
||||
// "catalog.products.tab_error"), so the editor can nest children under it.
|
||||
Parent string `json:"parent,omitempty"`
|
||||
// PlanAllowed is false when the company's plan already denies the key — the
|
||||
// editor shows it greyed out rather than pretending the owner can grant it.
|
||||
PlanAllowed bool `json:"plan_allowed"`
|
||||
}
|
||||
|
||||
// MemberPermissionCatalog is the payload for GET /api/team/permission-catalog.
|
||||
type MemberPermissionCatalog struct {
|
||||
Sections []MemberPermissionSection `json:"sections"`
|
||||
}
|
||||
|
||||
// MemberPermissionSection groups grantable keys under one dashboard area.
|
||||
type MemberPermissionSection struct {
|
||||
ID string `json:"id"`
|
||||
Entries []MemberPermissionEntry `json:"entries"`
|
||||
}
|
||||
|
||||
// grantableParent returns the nearest ancestor key that is itself grantable.
|
||||
func grantableParent(key string) string {
|
||||
parts := strings.Split(key, ".")
|
||||
for i := len(parts) - 1; i > 0; i-- {
|
||||
candidate := strings.Join(parts[:i], ".")
|
||||
if IsGrantableFeatureKey(candidate) {
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// PermissionCatalogForCompany lists the grantable keys grouped by section, annotated
|
||||
// with what the company's own plan already allows.
|
||||
func (s *Service) PermissionCatalogForCompany(ctx context.Context, companyID uuid.UUID) (MemberPermissionCatalog, error) {
|
||||
caps, err := s.CapabilitiesForCompany(ctx, companyID)
|
||||
if err != nil {
|
||||
return MemberPermissionCatalog{}, err
|
||||
}
|
||||
bySection := map[string][]MemberPermissionEntry{}
|
||||
order := make([]string, 0, len(FeatureSections))
|
||||
for _, key := range GrantableFeatureKeys() {
|
||||
section, _ := SectionOfFeature(key)
|
||||
if section == "" {
|
||||
section = "other"
|
||||
}
|
||||
if _, seen := bySection[section]; !seen {
|
||||
order = append(order, section)
|
||||
}
|
||||
bySection[section] = append(bySection[section], MemberPermissionEntry{
|
||||
Key: key,
|
||||
Section: section,
|
||||
Parent: grantableParent(key),
|
||||
PlanAllowed: caps.Features != nil && caps.Features[key],
|
||||
})
|
||||
}
|
||||
out := MemberPermissionCatalog{Sections: make([]MemberPermissionSection, 0, len(order))}
|
||||
for _, section := range order {
|
||||
out.Sections = append(out.Sections, MemberPermissionSection{ID: section, Entries: bySection[section]})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// SanitizeMemberPermissions normalizes an incoming overlay to its canonical form:
|
||||
// only grantable keys survive, and only denials (false) are stored — an "allowed"
|
||||
// entry is the absence of a key. Returns ErrFeatureNotGrantable on an unknown or
|
||||
// protected key so a typo fails loudly instead of silently granting access.
|
||||
func SanitizeMemberPermissions(in map[string]bool) (map[string]bool, error) {
|
||||
out := map[string]bool{}
|
||||
for rawKey, allowed := range in {
|
||||
key := strings.TrimSpace(rawKey)
|
||||
if key == "" {
|
||||
continue
|
||||
}
|
||||
if allowed {
|
||||
// Allow is the default; storing it would only add drift.
|
||||
if !IsKnownFeatureKey(key) {
|
||||
return nil, fmt.Errorf("%w: %s", ErrUnknownFeatureKey, key)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if !IsGrantableFeatureKey(key) {
|
||||
return nil, fmt.Errorf("%w: %s", ErrFeatureNotGrantable, key)
|
||||
}
|
||||
out[key] = false
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// MemberDeniesFeature reports whether the overlay denies key, honouring parent-prefix
|
||||
// denial (denying "catalog.products" also denies "catalog.products.tab_error").
|
||||
// Mirrors effectiveFeature() in apps/web/src/lib/plan-capabilities.ts.
|
||||
func MemberDeniesFeature(perms map[string]bool, key string) bool {
|
||||
if len(perms) == 0 {
|
||||
return false
|
||||
}
|
||||
if alwaysGrantedKeys[key] {
|
||||
return false
|
||||
}
|
||||
for _, prefix := range alwaysGrantedPrefixes {
|
||||
if strings.HasPrefix(key, prefix) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
if denied, ok := perms[key]; ok && !denied {
|
||||
return true
|
||||
}
|
||||
parts := strings.Split(key, ".")
|
||||
for i := 1; i < len(parts); i++ {
|
||||
ancestor := strings.Join(parts[:i], ".")
|
||||
if denied, ok := perms[ancestor]; ok && !denied {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ApplyMemberPermissions intersects a company feature map with a member overlay.
|
||||
// Returns a new map (the input is left untouched) plus the sorted list of keys the
|
||||
// overlay turned off — the UI uses that to say "restricted by your administrator"
|
||||
// instead of "upgrade your plan".
|
||||
func ApplyMemberPermissions(features map[string]bool, perms map[string]bool) (map[string]bool, []string) {
|
||||
if len(perms) == 0 {
|
||||
return features, nil
|
||||
}
|
||||
out := make(map[string]bool, len(features))
|
||||
denied := make([]string, 0, len(perms))
|
||||
for key, allowed := range features {
|
||||
if allowed && MemberDeniesFeature(perms, key) {
|
||||
out[key] = false
|
||||
denied = append(denied, key)
|
||||
continue
|
||||
}
|
||||
out[key] = allowed
|
||||
}
|
||||
sort.Strings(denied)
|
||||
return out, denied
|
||||
}
|
||||
|
||||
// FeatureETag exposes the feature-map digest so callers outside the package (the /me
|
||||
// handler) can re-stamp it after narrowing a map with ApplyMemberPermissions.
|
||||
func FeatureETag(features map[string]bool) string { return featureETag(features) }
|
||||
|
||||
// MemberPermissions returns the stored overlay for one company member.
|
||||
//
|
||||
// The owner (companies.owner_user_id) is never restricted — they are the one who edits
|
||||
// these — so an owner always resolves to a nil overlay regardless of what is stored.
|
||||
// Reads memberships directly (rather than through the auth package) so capability
|
||||
// resolution stays a single self-contained query on the hot /me + /capabilities path.
|
||||
func (s *Service) MemberPermissions(ctx context.Context, companyID, userID uuid.UUID) (map[string]bool, error) {
|
||||
if s == nil || s.Pool == nil || companyID == uuid.Nil || userID == uuid.Nil {
|
||||
return nil, nil
|
||||
}
|
||||
var raw []byte
|
||||
var isOwner bool
|
||||
err := s.Pool.QueryRow(ctx, `
|
||||
SELECT COALESCE(m.permissions, '{}'::jsonb),
|
||||
(c.owner_user_id IS NOT NULL AND c.owner_user_id = m.user_id) AS is_owner
|
||||
FROM memberships m
|
||||
JOIN companies c ON c.id = m.company_id
|
||||
WHERE m.company_id = $1 AND m.user_id = $2`, companyID, userID).Scan(&raw, &isOwner)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
// Platform staff acting on a tenant have no membership row — unrestricted.
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
// Pre-migration (no permissions / owner_user_id column): treat as unrestricted
|
||||
// rather than locking every member out of the dashboard.
|
||||
if isUndefinedColumn(err) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
if isOwner {
|
||||
return nil, nil
|
||||
}
|
||||
return decodeMemberPermissions(raw)
|
||||
}
|
||||
|
||||
func decodeMemberPermissions(raw []byte) (map[string]bool, error) {
|
||||
if len(raw) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
var parsed map[string]bool
|
||||
if err := json.Unmarshal(raw, &parsed); err != nil {
|
||||
// A hand-edited / legacy blob must not brick the dashboard.
|
||||
return nil, nil
|
||||
}
|
||||
out := map[string]bool{}
|
||||
for key, allowed := range parsed {
|
||||
if !allowed && IsGrantableFeatureKey(key) {
|
||||
out[key] = false
|
||||
}
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// CapabilitiesForMember returns the company capabilities narrowed by the caller's
|
||||
// per-member overlay. This is what the dashboard shell (nav, route guard, feature
|
||||
// gates) reads, so restricting a member here hides the surface everywhere at once.
|
||||
func (s *Service) CapabilitiesForMember(ctx context.Context, companyID, userID uuid.UUID) (Capabilities, error) {
|
||||
caps, err := s.CapabilitiesForCompany(ctx, companyID)
|
||||
if err != nil {
|
||||
return caps, err
|
||||
}
|
||||
perms, err := s.MemberPermissions(ctx, companyID, userID)
|
||||
if err != nil {
|
||||
return caps, err
|
||||
}
|
||||
if len(perms) == 0 {
|
||||
return caps, nil
|
||||
}
|
||||
features, denied := ApplyMemberPermissions(caps.Features, perms)
|
||||
caps.Features = features
|
||||
caps.MemberRestricted = true
|
||||
caps.MemberDeniedFeatures = denied
|
||||
caps.DisabledFeatures = mergeDisabledFeatures(caps.DisabledFeatures, denied)
|
||||
// Re-stamp the ETag: two members of one company now have different maps, and a
|
||||
// shared ETag would let one member's cached response satisfy another's request.
|
||||
caps.FeatureETag = featureETag(features)
|
||||
return caps, nil
|
||||
}
|
||||
|
||||
func mergeDisabledFeatures(existing, extra []string) []string {
|
||||
seen := make(map[string]bool, len(existing)+len(extra))
|
||||
out := make([]string, 0, len(existing)+len(extra))
|
||||
for _, list := range [][]string{existing, extra} {
|
||||
for _, key := range list {
|
||||
if seen[key] {
|
||||
continue
|
||||
}
|
||||
seen[key] = true
|
||||
out = append(out, key)
|
||||
}
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// AssertFeaturesForMember fails closed on the first key denied by the plan, the global
|
||||
// gates, or the member's own overlay.
|
||||
func (s *Service) AssertFeaturesForMember(ctx context.Context, companyID, userID uuid.UUID, keys ...string) error {
|
||||
if len(keys) == 0 {
|
||||
return nil
|
||||
}
|
||||
if err := s.AssertFeatures(ctx, companyID, keys...); err != nil {
|
||||
return err
|
||||
}
|
||||
perms, err := s.MemberPermissions(ctx, companyID, userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, key := range keys {
|
||||
key = strings.TrimSpace(key)
|
||||
if key == "" {
|
||||
continue
|
||||
}
|
||||
if MemberDeniesFeature(perms, key) {
|
||||
return fmt.Errorf("%w: %s", ErrMemberFeatureDenied, key)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ErrMemberFeatureDenied separates "your administrator turned this off" from
|
||||
// ErrFeatureDisabled ("your plan does not include this") so the UI can offer the right
|
||||
// next step — ask an admin vs. upgrade.
|
||||
var ErrMemberFeatureDenied = errors.New("member_feature_denied")
|
||||
@@ -0,0 +1,181 @@
|
||||
package billing
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestIsGrantableFeatureKey(t *testing.T) {
|
||||
grantable := []string{
|
||||
"stores.hub",
|
||||
"settings.api_keys",
|
||||
"catalog.products",
|
||||
"catalog.products.tab_error",
|
||||
"marketing.campaigns.send",
|
||||
}
|
||||
for _, key := range grantable {
|
||||
if !IsGrantableFeatureKey(key) {
|
||||
t.Errorf("expected %q to be grantable", key)
|
||||
}
|
||||
}
|
||||
notGrantable := []string{
|
||||
"shell.navigation", // app chrome
|
||||
"capability.ai_credits", // plan metering, not UI access
|
||||
"dashboard.overview", // always-on landing page
|
||||
"settings.profile", // always-on: own password / sign out
|
||||
"catalog.not_a_real_key", // unknown
|
||||
"",
|
||||
}
|
||||
for _, key := range notGrantable {
|
||||
if IsGrantableFeatureKey(key) {
|
||||
t.Errorf("expected %q to NOT be grantable", key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSanitizeMemberPermissions(t *testing.T) {
|
||||
t.Run("keeps only denials of grantable keys", func(t *testing.T) {
|
||||
got, err := SanitizeMemberPermissions(map[string]bool{
|
||||
"stores.hub": false,
|
||||
"settings.api_keys": false,
|
||||
"catalog.products": true, // allow is the default — never stored
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
want := map[string]bool{"stores.hub": false, "settings.api_keys": false}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("got %v want %v", got, want)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("rejects protected keys instead of silently dropping them", func(t *testing.T) {
|
||||
if _, err := SanitizeMemberPermissions(map[string]bool{"shell.navigation": false}); !errors.Is(err, ErrFeatureNotGrantable) {
|
||||
t.Fatalf("expected ErrFeatureNotGrantable, got %v", err)
|
||||
}
|
||||
if _, err := SanitizeMemberPermissions(map[string]bool{"dashboard.overview": false}); !errors.Is(err, ErrFeatureNotGrantable) {
|
||||
t.Fatalf("expected ErrFeatureNotGrantable for always-on key, got %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("rejects unknown keys", func(t *testing.T) {
|
||||
if _, err := SanitizeMemberPermissions(map[string]bool{"nope.nope": false}); !errors.Is(err, ErrFeatureNotGrantable) {
|
||||
t.Fatalf("expected ErrFeatureNotGrantable, got %v", err)
|
||||
}
|
||||
if _, err := SanitizeMemberPermissions(map[string]bool{"nope.nope": true}); !errors.Is(err, ErrUnknownFeatureKey) {
|
||||
t.Fatalf("expected ErrUnknownFeatureKey, got %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestMemberDeniesFeature(t *testing.T) {
|
||||
perms := map[string]bool{"stores.hub": false, "catalog.products": false}
|
||||
|
||||
if !MemberDeniesFeature(perms, "stores.hub") {
|
||||
t.Error("direct denial should apply")
|
||||
}
|
||||
if !MemberDeniesFeature(perms, "catalog.products.tab_error") {
|
||||
t.Error("denying a parent must deny its descendants")
|
||||
}
|
||||
if MemberDeniesFeature(perms, "catalog.categories") {
|
||||
t.Error("sibling keys must stay allowed")
|
||||
}
|
||||
if MemberDeniesFeature(nil, "stores.hub") {
|
||||
t.Error("an empty overlay denies nothing")
|
||||
}
|
||||
|
||||
// Protected keys survive even a hand-edited overlay that names them.
|
||||
hostile := map[string]bool{"shell.navigation": false, "dashboard.overview": false, "capability.ai_credits": false}
|
||||
for key := range hostile {
|
||||
if MemberDeniesFeature(hostile, key) {
|
||||
t.Errorf("%q must never be deniable", key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyMemberPermissions(t *testing.T) {
|
||||
features := map[string]bool{
|
||||
"stores.hub": true,
|
||||
"catalog.products": true,
|
||||
"catalog.products.tab_error": true,
|
||||
"marketing.campaigns": false, // already off by plan
|
||||
"dashboard.overview": true,
|
||||
}
|
||||
perms := map[string]bool{"stores.hub": false, "catalog.products": false, "marketing.campaigns": false}
|
||||
|
||||
got, denied := ApplyMemberPermissions(features, perms)
|
||||
|
||||
for _, key := range []string{"stores.hub", "catalog.products", "catalog.products.tab_error"} {
|
||||
if got[key] {
|
||||
t.Errorf("expected %q to be denied", key)
|
||||
}
|
||||
}
|
||||
if !got["dashboard.overview"] {
|
||||
t.Error("always-on keys must survive the overlay")
|
||||
}
|
||||
// Already-off plan keys are not reported as member denials — the member did not lose them.
|
||||
want := []string{"catalog.products", "catalog.products.tab_error", "stores.hub"}
|
||||
if !reflect.DeepEqual(denied, want) {
|
||||
t.Fatalf("denied = %v, want %v", denied, want)
|
||||
}
|
||||
// The input map must not be mutated — it is shared with the company-level cache.
|
||||
if !features["stores.hub"] {
|
||||
t.Error("ApplyMemberPermissions mutated its input")
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyMemberPermissionsIsNoopWithoutOverlay(t *testing.T) {
|
||||
features := map[string]bool{"stores.hub": true}
|
||||
got, denied := ApplyMemberPermissions(features, nil)
|
||||
if len(denied) != 0 {
|
||||
t.Fatalf("expected no denials, got %v", denied)
|
||||
}
|
||||
if !reflect.DeepEqual(got, features) {
|
||||
t.Fatalf("expected the map unchanged, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGrantableFeatureKeysExcludeProtectedPrefixes(t *testing.T) {
|
||||
keys := GrantableFeatureKeys()
|
||||
if len(keys) == 0 {
|
||||
t.Fatal("expected a non-empty grantable catalog")
|
||||
}
|
||||
for _, key := range keys {
|
||||
if !IsGrantableFeatureKey(key) {
|
||||
t.Errorf("GrantableFeatureKeys returned non-grantable %q", key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGrantableParent(t *testing.T) {
|
||||
if got := grantableParent("catalog.products.tab_error"); got != "catalog.products" {
|
||||
t.Errorf("got %q, want catalog.products", got)
|
||||
}
|
||||
// dashboard.overview is always-on, so its children have no grantable parent.
|
||||
if got := grantableParent("dashboard.stats"); got != "" {
|
||||
t.Errorf("got %q, want empty", got)
|
||||
}
|
||||
if got := grantableParent("stores.hub"); got != "" {
|
||||
t.Errorf("got %q, want empty", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeMemberPermissionsIsDefensive(t *testing.T) {
|
||||
// Garbage must not brick the dashboard — it resolves to "unrestricted".
|
||||
if got, _ := decodeMemberPermissions([]byte("not json")); got != nil {
|
||||
t.Errorf("expected nil for invalid JSON, got %v", got)
|
||||
}
|
||||
if got, _ := decodeMemberPermissions([]byte(`{}`)); got != nil {
|
||||
t.Errorf("expected nil for an empty overlay, got %v", got)
|
||||
}
|
||||
// Stale / protected keys are dropped at read time too.
|
||||
got, err := decodeMemberPermissions([]byte(`{"stores.hub":false,"shell.navigation":false,"gone.key":false,"feeds.list":true}`))
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
want := map[string]bool{"stores.hub": false}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("got %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
@@ -50,6 +50,11 @@ type Capabilities struct {
|
||||
DisabledFeatures []string `json:"disabled_features"`
|
||||
FeatureETag string `json:"feature_etag"`
|
||||
Entitlements Entitlements `json:"entitlements"`
|
||||
// MemberRestricted is true when the company owner narrowed this member's access.
|
||||
// MemberDeniedFeatures lists the keys the plan allows but the member overlay denies,
|
||||
// so the UI can say "ask your administrator" instead of "upgrade your plan".
|
||||
MemberRestricted bool `json:"member_restricted,omitempty"`
|
||||
MemberDeniedFeatures []string `json:"member_denied_features,omitempty"`
|
||||
}
|
||||
|
||||
// FeatureGatesUpdate is the PUT /api/admin/feature-gates body.
|
||||
|
||||
@@ -58,6 +58,10 @@ type CreditsOverview struct {
|
||||
Sections map[string]bool `json:"sections,omitempty"`
|
||||
DisabledFeatures []string `json:"disabled_features,omitempty"`
|
||||
FeatureETag string `json:"feature_etag,omitempty"`
|
||||
// Per-member access overlay (settings > team). Set by the /me handler so the
|
||||
// dashboard can say "ask your administrator" rather than "upgrade your plan".
|
||||
MemberRestricted bool `json:"member_restricted,omitempty"`
|
||||
MemberDeniedFeatures []string `json:"member_denied_features,omitempty"`
|
||||
}
|
||||
|
||||
func (s *Service) CreditsOverview(ctx context.Context, companyID uuid.UUID, lowThreshold int) (CreditsOverview, error) {
|
||||
|
||||
Reference in New Issue
Block a user