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

vocdoni / saas-backend / 21511535617

30 Jan 2026 09:33AM UTC coverage: 63.154% (-0.1%) from 63.261%
21511535617

Pull #410

github

altergui
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%)

217 existing lines in 7 files now uncovered.

7233 of 11453 relevant lines covered (63.15%)

39.95 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✔
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✔
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✔
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✔
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✔
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✔
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✔
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✔
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✔
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✔
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✔
200
                        return errors.ErrOrganizationNotFound.WithErr(err)
×
201
                }
×
202

203
                if org.Subscription.PlanID == 0 {
8✔
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✔
209
                        return errors.ErrGenericInternalServerError.WithErr(err)
×
210
                }
×
211

212
                count, err := p.db.CountProcesses(orgAddress, db.DraftOnly)
8✔
213
                if err != nil {
8✔
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✔
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