• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

vocdoni / saas-backend / 21511936295

30 Jan 2026 10:02AM UTC coverage: 63.31% (+0.05%) from 63.261%
21511936295

Pull #410

github

altergui
fix(subscriptions): implement annual counters and limits

Add annual usage limits design

Add usage snapshot model and collection

Add usage snapshot indexes migration

Add usage snapshot storage helpers

Add annual period calculation

Create usage snapshots on subscription updates

Use annual period usage for limits

Expose period usage in subscription response

Update docs for period usage
Pull Request #410: fix(subscriptions): implement annual counters

132 of 195 new or added lines in 7 files covered. (67.69%)

50 existing lines in 3 files now uncovered.

7042 of 11123 relevant lines covered (63.31%)

40.14 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

58.5
/subscriptions/subscriptions.go
1
// Package subscriptions provides functionality for managing organization subscriptions
2
// and enforcing permissions based on subscription plans.
3
package subscriptions
4

5
import (
6
        "fmt"
7
        "time"
8

9
        "github.com/ethereum/go-ethereum/common"
10
        "github.com/vocdoni/saas-backend/db"
11
        "github.com/vocdoni/saas-backend/errors"
12
        "go.vocdoni.io/proto/build/go/models"
13
)
14

15
// Config holds the configuration for the subscriptions service.
16
// It includes a reference to the MongoDB storage used by the service.
17
type Config struct {
18
        DB *db.MongoStorage
19
}
20

21
// DBPermission represents the permissions that an organization can have based on its subscription.
22
type DBPermission int
23

24
const (
25
        // InviteUser represents the permission to invite new users to an organization.
26
        InviteUser DBPermission = iota
27
        // DeleteUser represents the permission to remove users from an organization.
28
        DeleteUser
29
        // CreateSubOrg represents the permission to create sub-organizations.
30
        CreateSubOrg
31
        // CreateDraft represents the permission to create draft processes.
32
        CreateDraft
33
)
34

35
// String returns the string representation of the DBPermission.
36
func (p DBPermission) String() string {
×
37
        switch p {
×
38
        case InviteUser:
×
39
                return "InviteUser"
×
40
        case DeleteUser:
×
41
                return "DeleteUser"
×
42
        case CreateSubOrg:
×
43
                return "CreateSubOrg"
×
44
        case CreateDraft:
×
45
                return "CreateDraft"
×
46
        default:
×
47
                return "Unknown"
×
48
        }
49
}
50

51
// DBInterface defines the database methods required by the Subscriptions service
52
type DBInterface interface {
53
        Plan(id uint64) (*db.Plan, error)
54
        UserByEmail(email string) (*db.User, error)
55
        Organization(address common.Address) (*db.Organization, error)
56
        OrganizationWithParent(address common.Address) (*db.Organization, *db.Organization, error)
57
        CountProcesses(orgAddress common.Address, draft db.DraftFilter) (int64, error)
58
        OrganizationMemberGroup(groupID string, orgAddress common.Address) (*db.OrganizationMemberGroup, error)
59
        GetUsageSnapshot(orgAddress common.Address, periodStart time.Time) (*db.UsageSnapshot, error)
60
        UpsertUsageSnapshot(snapshot *db.UsageSnapshot) error
61
}
62

63
// Subscriptions is the service that manages the organization permissions based on
64
// the subscription plans.
65
type Subscriptions struct {
66
        db DBInterface
67
}
68

69
// New creates a new Subscriptions service with the given configuration.
70
func New(conf *Config) *Subscriptions {
1✔
71
        if conf == nil {
1✔
UNCOV
72
                return nil
×
73
        }
×
74
        return &Subscriptions{
1✔
75
                db: conf.DB,
1✔
76
        }
1✔
77
}
78

79
// hasElectionMetadataPermissions checks if the organization has permission to create an election with the given metadata.
80
func hasElectionMetadataPermissions(process *models.NewProcessTx, plan *db.Plan) (bool, error) {
4✔
81
        // check ANONYMOUS
4✔
82
        if process.Process.EnvelopeType.Anonymous && !plan.Features.Anonymous {
4✔
UNCOV
83
                return false, fmt.Errorf("anonymous elections are not allowed")
×
84
        }
×
85

86
        // check WEIGHTED
87
        if process.Process.EnvelopeType.CostFromWeight && !plan.VotingTypes.Weighted {
4✔
UNCOV
88
                return false, fmt.Errorf("weighted elections are not allowed")
×
89
        }
×
90

91
        // check VOTE OVERWRITE
92
        if process.Process.VoteOptions.MaxVoteOverwrites > 0 && !plan.Features.Overwrite {
4✔
UNCOV
93
                return false, fmt.Errorf("vote overwrites are not allowed")
×
94
        }
×
95

96
        // check PROCESS DURATION
97
        duration := plan.Organization.MaxDuration * 24 * 60 * 60
4✔
98
        if process.Process.Duration > uint32(duration) {
4✔
UNCOV
99
                return false, fmt.Errorf("duration is greater than the allowed")
×
100
        }
×
101

102
        // TODO:future check if the election voting type is supported by the plan
103
        // TODO:future check if the streamURL is used and allowed by the plan
104

105
        return true, nil
4✔
106
}
107

108
// HasTxPermission checks if the organization has permission to perform the given transaction.
109
func (p *Subscriptions) HasTxPermission(
110
        tx *models.Tx,
111
        txType models.TxType,
112
        org *db.Organization,
113
        user *db.User,
114
) (bool, error) {
9✔
115
        if org == nil {
10✔
116
                return false, errors.ErrInvalidData.With("organization is nil")
1✔
117
        }
1✔
118

119
        // Check if the organization has a subscription
120
        if org.Subscription.PlanID == 0 {
9✔
121
                return false, errors.ErrOrganizationHasNoSubscription
1✔
122
        }
1✔
123

124
        plan, err := p.db.Plan(org.Subscription.PlanID)
7✔
125
        if err != nil {
7✔
UNCOV
126
                return false, errors.ErrPlanNotFound.WithErr(err)
×
127
        }
×
128

129
        switch txType {
7✔
130
        // check UPDATE ACCOUNT INFO
131
        case models.TxType_SET_ACCOUNT_INFO_URI:
1✔
132
                // check if the user has the admin role for the organization
1✔
133
                if !user.HasRoleFor(org.Address, db.AdminRole) {
1✔
UNCOV
134
                        return false, errors.ErrUserHasNoAdminRole
×
135
                }
×
136
        // check CREATE PROCESS
137
        case models.TxType_NEW_PROCESS, models.TxType_SET_PROCESS_CENSUS:
4✔
138
                // check if the user has the admin role for the organization
4✔
139
                if !user.HasRoleFor(org.Address, db.AdminRole) {
4✔
UNCOV
140
                        return false, errors.ErrUserHasNoAdminRole
×
141
                }
×
142
                newProcess := tx.GetNewProcess()
4✔
143
                if newProcess.Process.MaxCensusSize > uint64(plan.Organization.MaxCensus) {
4✔
144
                        return false, errors.ErrProcessCensusSizeExceedsPlanLimit.Withf("plan max census: %d", plan.Organization.MaxCensus)
×
145
                }
×
146
                usage, ok, err := p.periodUsage(org)
4✔
147
                if err != nil {
4✔
NEW
148
                        return false, errors.ErrGenericInternalServerError.WithErr(err)
×
NEW
149
                }
×
150

151
                usedProcesses := org.Counters.Processes
4✔
152
                if ok {
5✔
153
                        usedProcesses = usage.Processes
1✔
154
                }
1✔
155

156
                if usedProcesses >= plan.Organization.MaxProcesses {
4✔
157
                        // allow processes with less than TestMaxCensusSize for user testing
×
158
                        if newProcess.Process.MaxCensusSize > uint64(db.TestMaxCensusSize) {
×
159
                                return false, errors.ErrMaxProcessesReached
×
160
                        }
×
161
                }
162
                return hasElectionMetadataPermissions(newProcess, plan)
4✔
163

164
        case models.TxType_SET_PROCESS_STATUS,
165
                models.TxType_CREATE_ACCOUNT:
2✔
166
                // check if the user has the admin role for the organization
2✔
167
                if !user.HasRoleFor(org.Address, db.AdminRole) && !user.HasRoleFor(org.Address, db.ManagerRole) {
2✔
UNCOV
168
                        return false, errors.ErrUserHasNoAdminRole
×
169
                }
×
170
        default:
×
171
                return false, fmt.Errorf("unsupported txtype")
×
172
        }
173
        return true, nil
3✔
174
}
175

176
// HasDBPermission checks if the user has permission to perform the given action in the organization stored in the DB
177
func (p *Subscriptions) HasDBPermission(userEmail string, orgAddress common.Address, permission DBPermission) (bool, error) {
31✔
178
        user, err := p.db.UserByEmail(userEmail)
31✔
179
        if err != nil {
32✔
180
                return false, fmt.Errorf("could not get user: %v", err)
1✔
181
        }
1✔
182
        switch permission {
30✔
183
        case InviteUser, DeleteUser, CreateSubOrg:
30✔
184
                if !user.HasRoleFor(orgAddress, db.AdminRole) {
34✔
185
                        return false, errors.ErrUserHasNoAdminRole
4✔
186
                }
4✔
187
                return true, nil
26✔
UNCOV
188
        default:
×
189
                return false, fmt.Errorf("permission not found")
×
190
        }
191
}
192

193
// OrgHasPermission checks if the org has permission to perform the given action
194
func (p *Subscriptions) OrgHasPermission(orgAddress common.Address, permission DBPermission) error {
8✔
195
        switch permission {
8✔
196
        case CreateDraft:
8✔
197
                // Check if the organization has a subscription
8✔
198
                org, err := p.db.Organization(orgAddress)
8✔
199
                if err != nil {
8✔
UNCOV
200
                        return errors.ErrOrganizationNotFound.WithErr(err)
×
201
                }
×
202

203
                if org.Subscription.PlanID == 0 {
8✔
UNCOV
204
                        return errors.ErrOrganizationHasNoSubscription.With("can't create draft process")
×
205
                }
×
206

207
                plan, err := p.db.Plan(org.Subscription.PlanID)
8✔
208
                if err != nil {
8✔
UNCOV
209
                        return errors.ErrGenericInternalServerError.WithErr(err)
×
210
                }
×
211

212
                count, err := p.db.CountProcesses(orgAddress, db.DraftOnly)
8✔
213
                if err != nil {
8✔
UNCOV
214
                        return errors.ErrGenericInternalServerError.WithErr(err)
×
215
                }
×
216

217
                if count >= int64(plan.Organization.MaxDrafts) {
10✔
218
                        return errors.ErrMaxDraftsReached.Withf("(%d)", plan.Organization.MaxDrafts)
2✔
219
                }
2✔
220
                return nil
6✔
UNCOV
221
        default:
×
222
                return fmt.Errorf("permission not found")
×
223
        }
224
}
225

226
func (p *Subscriptions) OrgCanPublishGroupCensus(census *db.Census, groupID string) error {
27✔
227
        org, err := p.db.Organization(census.OrgAddress)
27✔
228
        if err != nil {
27✔
229
                return errors.ErrOrganizationNotFound.WithErr(err)
×
230
        }
×
231

232
        if org.Subscription.PlanID == 0 {
27✔
233
                return errors.ErrOrganizationHasNoSubscription
×
234
        }
×
235

236
        plan, err := p.db.Plan(org.Subscription.PlanID)
27✔
237
        if err != nil {
27✔
238
                return errors.ErrPlanNotFound.WithErr(err)
×
239
        }
×
240

241
        group, err := p.db.OrganizationMemberGroup(groupID, org.Address)
27✔
242
        if err != nil {
27✔
243
                return errors.ErrGroupNotFound.WithErr(err)
×
244
        }
×
245

246
        usage, ok, err := p.periodUsage(org)
27✔
247
        if err != nil {
27✔
NEW
248
                return errors.ErrGenericInternalServerError.WithErr(err)
×
NEW
249
        }
×
250
        sentEmails := org.Counters.SentEmails
27✔
251
        sentSMS := org.Counters.SentSMS
27✔
252
        if ok {
28✔
253
                sentEmails = usage.SentEmails
1✔
254
                sentSMS = usage.SentSMS
1✔
255
        }
1✔
256

257
        remainingEmails := plan.Organization.MaxSentEmails - sentEmails
27✔
258
        if census.TwoFaFields.Contains(db.OrgMemberTwoFaFieldEmail) && len(group.MemberIDs) > remainingEmails {
28✔
259
                return errors.ErrProcessCensusSizeExceedsEmailAllowance.Withf("remaining emails: %d", remainingEmails)
1✔
260
        }
1✔
261
        remainingSMS := plan.Organization.MaxSentSMS - sentSMS
26✔
262
        if census.TwoFaFields.Contains(db.OrgMemberTwoFaFieldPhone) && len(group.MemberIDs) > remainingSMS {
29✔
263
                return errors.ErrProcessCensusSizeExceedsSMSAllowance.Withf("remaining sms: %d", remainingSMS)
3✔
264
        }
3✔
265

266
        return nil
23✔
267
}
268

269
func (p *Subscriptions) periodUsage(org *db.Organization) (db.OrganizationCounters, bool, error) {
32✔
270
        if org == nil {
32✔
NEW
271
                return db.OrganizationCounters{}, false, errors.ErrInvalidData
×
NEW
272
        }
×
273

274
        periodStart, periodEnd, ok := db.ComputeAnnualPeriod(
32✔
275
                org.Subscription,
32✔
276
                org.Subscription.BillingPeriod,
32✔
277
                time.Now(),
32✔
278
        )
32✔
279
        if !ok {
61✔
280
                return db.OrganizationCounters{}, false, nil
29✔
281
        }
29✔
282

283
        snapshot, err := p.db.GetUsageSnapshot(org.Address, periodStart)
3✔
284
        if err != nil {
3✔
NEW
285
                if err == db.ErrNotFound {
×
NEW
286
                        snapshot = &db.UsageSnapshot{
×
NEW
287
                                OrgAddress:    org.Address,
×
NEW
288
                                PeriodStart:   periodStart,
×
NEW
289
                                PeriodEnd:     periodEnd,
×
NEW
290
                                BillingPeriod: org.Subscription.BillingPeriod,
×
NEW
291
                                Baseline: db.UsageSnapshotBaseline{
×
NEW
292
                                        Processes:  org.Counters.Processes,
×
NEW
293
                                        SentSMS:    org.Counters.SentSMS,
×
NEW
294
                                        SentEmails: org.Counters.SentEmails,
×
NEW
295
                                },
×
NEW
296
                        }
×
NEW
297
                        if err := p.db.UpsertUsageSnapshot(snapshot); err != nil {
×
NEW
298
                                return db.OrganizationCounters{}, false, err
×
NEW
299
                        }
×
NEW
300
                        return db.OrganizationCounters{}, true, nil
×
301
                }
NEW
302
                return db.OrganizationCounters{}, false, err
×
303
        }
304

305
        return db.OrganizationCounters{
3✔
306
                Processes:  clampCounter(org.Counters.Processes - snapshot.Baseline.Processes),
3✔
307
                SentSMS:    clampCounter(org.Counters.SentSMS - snapshot.Baseline.SentSMS),
3✔
308
                SentEmails: clampCounter(org.Counters.SentEmails - snapshot.Baseline.SentEmails),
3✔
309
        }, true, nil
3✔
310
}
311

312
func clampCounter(value int) int {
9✔
313
        if value < 0 {
9✔
NEW
314
                return 0
×
NEW
315
        }
×
316
        return value
9✔
317
}
318

319
func (p *Subscriptions) PeriodUsage(org *db.Organization) (db.OrganizationCounters, bool, error) {
1✔
320
        return p.periodUsage(org)
1✔
321
}
1✔
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc