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

vocdoni / saas-backend / 22721750301

05 Mar 2026 02:09PM UTC coverage: 59.808% (-1.8%) from 61.65%
22721750301

push

github

emmdim
feat(client): add API client and CSV member import workflow

Introduce a new authenticated SaaS API client in `cmd/client` with token-based login,
and endpoint helpers for member/census operations.
Add import workflow orchestration to parse CSV rows, upsert organization members.

117 of 731 new or added lines in 5 files covered. (16.01%)

261 existing lines in 6 files now uncovered.

7348 of 12286 relevant lines covered (59.81%)

37.28 hits per line

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

56.25
/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

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

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

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

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

36
const MaxOrgsPerUser = 15
37

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

56
// DBInterface defines the database methods required by the Subscriptions service
57
type DBInterface interface {
58
        Plan(id uint64) (*db.Plan, error)
59
        UserByEmail(email string) (*db.User, error)
60
        Organization(address common.Address) (*db.Organization, error)
61
        OrganizationWithParent(address common.Address) (*db.Organization, *db.Organization, error)
62
        CountCensusParticipants(censusID string) (int64, error)
63
        CountOrgMembers(orgAddress common.Address) (int64, error)
64
        CountProcesses(orgAddress common.Address, draft db.DraftFilter) (int64, error)
65
        OrganizationMemberGroup(groupID string, orgAddress common.Address) (*db.OrganizationMemberGroup, error)
66
}
67

68
// Subscriptions is the service that manages the organization permissions based on
69
// the subscription plans.
70
type Subscriptions struct {
71
        db DBInterface
72
}
73

74
// New creates a new Subscriptions service with the given configuration.
75
func New(conf *Config) *Subscriptions {
1✔
76
        if conf == nil {
1✔
77
                return nil
×
UNCOV
78
        }
×
79
        return &Subscriptions{
1✔
80
                db: conf.DB,
1✔
81
        }
1✔
82
}
83

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

91
        // check WEIGHTED
92
        if process.Process.EnvelopeType.CostFromWeight && !plan.VotingTypes.Weighted {
3✔
93
                return false, fmt.Errorf("weighted elections are not allowed")
×
UNCOV
94
        }
×
95

96
        // check VOTE OVERWRITE
97
        if process.Process.VoteOptions.MaxVoteOverwrites > 0 && !plan.Features.Overwrite {
3✔
98
                return false, fmt.Errorf("vote overwrites are not allowed")
×
UNCOV
99
        }
×
100

101
        // check PROCESS DURATION
102
        duration := plan.Organization.MaxDuration * 24 * 60 * 60
3✔
103
        if process.Process.Duration > uint32(duration) {
3✔
104
                return false, fmt.Errorf("duration is greater than the allowed")
×
UNCOV
105
        }
×
106

107
        // TODO:future check if the election voting type is supported by the plan
108
        // TODO:future check if the streamURL is used and allowed by the plan
109

110
        return true, nil
3✔
111
}
112

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

124
        // Check if the organization has a subscription
125
        if org.Subscription.PlanID == 0 {
8✔
126
                return false, errors.ErrOrganizationHasNoSubscription
1✔
127
        }
1✔
128

129
        plan, err := p.db.Plan(org.Subscription.PlanID)
6✔
130
        if err != nil {
6✔
131
                return false, errors.ErrPlanNotFound.WithErr(err)
×
UNCOV
132
        }
×
133

134
        switch txType {
6✔
135
        // check UPDATE ACCOUNT INFO
136
        case models.TxType_SET_ACCOUNT_INFO_URI:
1✔
137
                // check if the user has the admin role for the organization
1✔
138
                if !user.HasRoleFor(org.Address, db.AdminRole) {
1✔
139
                        return false, errors.ErrUserHasNoAdminRole
×
UNCOV
140
                }
×
141
        // check CREATE PROCESS
142
        case models.TxType_NEW_PROCESS, models.TxType_SET_PROCESS_CENSUS:
3✔
143
                // check if the user has the admin role for the organization
3✔
144
                if !user.HasRoleFor(org.Address, db.AdminRole) {
3✔
145
                        return false, errors.ErrUserHasNoAdminRole
×
UNCOV
146
                }
×
147
                newProcess := tx.GetNewProcess()
3✔
148
                if newProcess.Process.MaxCensusSize > uint64(plan.Organization.MaxCensus) {
3✔
149
                        return false, errors.ErrProcessCensusSizeExceedsPlanLimit.Withf("plan max census: %d", plan.Organization.MaxCensus)
×
UNCOV
150
                }
×
151
                if org.Counters.Processes >= plan.Organization.MaxProcesses {
3✔
152
                        // allow processes with less than TestMaxCensusSize for user testing
×
153
                        if newProcess.Process.MaxCensusSize > uint64(db.TestMaxCensusSize) {
×
154
                                return false, errors.ErrMaxProcessesReached
×
UNCOV
155
                        }
×
156
                }
157
                return hasElectionMetadataPermissions(newProcess, plan)
3✔
158

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

171
// HasDBPermission checks if the user has permission to perform the given action in the organization stored in the DB
172
func (p *Subscriptions) HasDBPermission(userEmail string, orgAddress common.Address, permission DBPermission) (bool, error) {
87✔
173
        user, err := p.db.UserByEmail(userEmail)
87✔
174
        if err != nil {
88✔
175
                return false, fmt.Errorf("could not get user: %v", err)
1✔
176
        }
1✔
177
        switch permission {
86✔
178
        case InviteUser, DeleteUser, CreateSubOrg:
30✔
179
                if !user.HasRoleFor(orgAddress, db.AdminRole) {
34✔
180
                        return false, errors.ErrUserHasNoAdminRole
4✔
181
                }
4✔
182
                return true, nil
26✔
183
        case CreateOrg:
56✔
184
                // Check if the user can create more organizations based on the MaxOrgsPerUser limit
56✔
185
                if len(user.Organizations) >= MaxOrgsPerUser {
56✔
186
                        return false, errors.ErrMaxOrganizationsReached.Withf(
×
187
                                "user is part of %d organizations, max allowed is %d",
×
188
                                len(user.Organizations),
×
189
                                MaxOrgsPerUser,
×
190
                        )
×
UNCOV
191
                }
×
192
                return true, nil
56✔
193
        default:
×
UNCOV
194
                return false, fmt.Errorf("permission not found")
×
195
        }
196
}
197

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

208
                if org.Subscription.PlanID == 0 {
8✔
209
                        return errors.ErrOrganizationHasNoSubscription.With("can't create draft process")
×
UNCOV
210
                }
×
211

212
                plan, err := p.db.Plan(org.Subscription.PlanID)
8✔
213
                if err != nil {
8✔
214
                        return errors.ErrGenericInternalServerError.WithErr(err)
×
UNCOV
215
                }
×
216

217
                count, err := p.db.CountProcesses(orgAddress, db.DraftOnly)
8✔
218
                if err != nil {
8✔
219
                        return errors.ErrGenericInternalServerError.WithErr(err)
×
UNCOV
220
                }
×
221

222
                if count >= int64(plan.Organization.MaxDrafts) {
10✔
223
                        return errors.ErrMaxDraftsReached.Withf("(%d)", plan.Organization.MaxDrafts)
2✔
224
                }
2✔
225
                return nil
6✔
226
        default:
×
UNCOV
227
                return fmt.Errorf("permission not found")
×
228
        }
229
}
230

231
func (p *Subscriptions) OrgCanAddNMembers(orgAddress common.Address, memberNumber int) error {
32✔
232
        // Check if the organization has a subscription
32✔
233
        org, err := p.db.Organization(orgAddress)
32✔
234
        if err != nil {
32✔
235
                return errors.ErrOrganizationNotFound.WithErr(err)
×
UNCOV
236
        }
×
237

238
        if org.Subscription.PlanID == 0 {
32✔
239
                return errors.ErrOrganizationHasNoSubscription.With("can't create draft process")
×
UNCOV
240
        }
×
241

242
        plan, err := p.db.Plan(org.Subscription.PlanID)
32✔
243
        if err != nil {
32✔
244
                return errors.ErrGenericInternalServerError.WithErr(err)
×
UNCOV
245
        }
×
246

247
        count, err := p.db.CountOrgMembers(orgAddress)
32✔
248
        if err != nil {
32✔
249
                return errors.ErrGenericInternalServerError.WithErr(err)
×
UNCOV
250
        }
×
251

252
        if int(count)+memberNumber > plan.Organization.MaxCensus {
34✔
253
                return errors.ErrExceedsOrganizationMembersLimit.Withf("(%d)", plan.Organization.MaxCensus)
2✔
254
        }
2✔
255
        return nil
30✔
256
}
257

258
func (p *Subscriptions) OrgCanPublishGroupCensus(census *db.Census, groupID string) error {
26✔
259
        org, err := p.db.Organization(census.OrgAddress)
26✔
260
        if err != nil {
26✔
261
                return errors.ErrOrganizationNotFound.WithErr(err)
×
UNCOV
262
        }
×
263

264
        if org.Subscription.PlanID == 0 {
26✔
265
                return errors.ErrOrganizationHasNoSubscription
×
UNCOV
266
        }
×
267

268
        plan, err := p.db.Plan(org.Subscription.PlanID)
26✔
269
        if err != nil {
26✔
270
                return errors.ErrPlanNotFound.WithErr(err)
×
UNCOV
271
        }
×
272

273
        group, err := p.db.OrganizationMemberGroup(groupID, org.Address)
26✔
274
        if err != nil {
26✔
275
                return errors.ErrGroupNotFound.WithErr(err)
×
UNCOV
276
        }
×
277

278
        remainingEmails := plan.Features.TwoFaEmail - org.Counters.SentEmails
26✔
279
        if census.TwoFaFields.Contains(db.OrgMemberTwoFaFieldEmail) && len(group.MemberIDs) > remainingEmails {
27✔
280
                return errors.ErrProcessCensusSizeExceedsEmailAllowance.Withf("remaining emails: %d", remainingEmails)
1✔
281
        }
1✔
282
        remainingSMS := plan.Features.TwoFaSms - org.Counters.SentSMS
25✔
283
        if census.TwoFaFields.Contains(db.OrgMemberTwoFaFieldPhone) && len(group.MemberIDs) > remainingSMS {
28✔
284
                return errors.ErrProcessCensusSizeExceedsSMSAllowance.Withf("remaining sms: %d", remainingSMS)
3✔
285
        }
3✔
286

287
        return nil
22✔
288
}
289

290
func (p *Subscriptions) OrgCanAddCensusParticipants(orgAddress common.Address, censusID string, participantsCount int) error {
5✔
291
        // Check if the organization has a subscription
5✔
292
        org, err := p.db.Organization(orgAddress)
5✔
293
        if err != nil {
5✔
UNCOV
294
                return errors.ErrOrganizationNotFound.WithErr(err)
×
UNCOV
295
        }
×
296

297
        if org.Subscription.PlanID == 0 {
5✔
UNCOV
298
                return errors.ErrOrganizationHasNoSubscription.With("can't create draft process")
×
UNCOV
299
        }
×
300

301
        plan, err := p.db.Plan(org.Subscription.PlanID)
5✔
302
        if err != nil {
5✔
UNCOV
303
                return errors.ErrPlanNotFound.WithErr(err)
×
UNCOV
304
        }
×
305

306
        count, err := p.db.CountCensusParticipants(censusID)
5✔
307
        if err != nil {
5✔
UNCOV
308
                return errors.ErrGenericInternalServerError.WithErr(err)
×
UNCOV
309
        }
×
310

311
        if int(count)+participantsCount > plan.Organization.MaxCensus {
5✔
UNCOV
312
                return errors.ErrProcessCensusSizeExceedsPlanLimit.Withf("(%d)", plan.Organization.MaxCensus)
×
UNCOV
313
        }
×
314
        return nil
5✔
315
}
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