/
status.go
353 lines (307 loc) · 12.4 KB
/
status.go
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
package attendeesrv
import (
"context"
"errors"
"fmt"
aulogging "github.com/StephanHCB/go-autumn-logging"
"github.com/eurofurence/reg-attendee-service/internal/api/v1/status"
"github.com/eurofurence/reg-attendee-service/internal/entity"
"github.com/eurofurence/reg-attendee-service/internal/repository/config"
"github.com/eurofurence/reg-attendee-service/internal/repository/database"
"github.com/eurofurence/reg-attendee-service/internal/repository/mailservice"
"github.com/eurofurence/reg-attendee-service/internal/repository/paymentservice"
"github.com/eurofurence/reg-attendee-service/internal/web/util/ctxvalues"
"gorm.io/gorm"
"strings"
"time"
)
func (s *AttendeeServiceImplData) GetFullStatusHistory(ctx context.Context, attendee *entity.Attendee) ([]entity.StatusChange, error) {
// controller checks permissions
result := make([]entity.StatusChange, 0)
if attendee.ID == 0 {
return result, errors.New("invalid attendee missing id, please read full dataset from the database - this is an implementation error")
}
fromDb, err := database.GetRepository().GetStatusChangesByAttendeeId(ctx, attendee.ID)
if err != nil {
return result, err
}
// first status entry comes from registration time, not stored in db for performance reasons during initial reg
result = append(result, entity.StatusChange{
Model: gorm.Model{
CreatedAt: attendee.CreatedAt,
},
AttendeeId: attendee.ID,
Status: status.New,
Comments: "registration",
})
for _, change := range fromDb {
result = append(result, change)
}
return result, nil
}
func (s *AttendeeServiceImplData) UpdateDuesAndDoStatusChangeIfNeeded(ctx context.Context, attendee *entity.Attendee, oldStatus status.Status, newStatus status.Status, statusComment string, overrideDuesComment string, suppressMinorUpdateEmail bool) error {
// controller checks value validity
// controller checks permission via StatusChangeAllowed
// controller checks precondition via StatusChangePossible
// attendee has been loaded from db in all cases
updatedTransactionHistory, adminInfo, err := s.UpdateDuesTransactions(ctx, attendee, newStatus, overrideDuesComment)
if err != nil {
return err
}
var duesInformationChanged bool
newStatus, duesInformationChanged, err = s.UpdateAttendeeCacheAndCalculateResultingStatus(ctx, attendee, updatedTransactionHistory, newStatus)
if err != nil {
return err
}
if newStatus != oldStatus {
change := entity.StatusChange{
AttendeeId: attendee.ID,
Status: newStatus,
Comments: statusComment,
}
err = database.GetRepository().AddStatusChange(ctx, &change)
if err != nil {
return err
}
if newStatus == status.Deleted {
err = database.GetRepository().SoftDeleteAttendeeById(ctx, attendee.ID)
if err != nil {
return err
}
} else if oldStatus == status.Deleted {
err = database.GetRepository().UndeleteAttendeeById(ctx, attendee.ID)
if err != nil {
return err
}
}
if newStatus != status.Deleted && newStatus != status.CheckedIn {
suppress := suppressMinorUpdateEmail && isPaymentPhaseStatus(oldStatus) && isPaymentPhaseStatus(newStatus)
err = s.sendStatusChangeNotificationEmail(ctx, attendee, adminInfo, newStatus, statusComment, suppress)
if err != nil {
return err
}
}
} else if duesInformationChanged && (newStatus == status.Approved || newStatus == status.PartiallyPaid) {
err = s.sendStatusChangeNotificationEmail(ctx, attendee, adminInfo, newStatus, statusComment, suppressMinorUpdateEmail)
if err != nil {
return err
}
}
return nil
}
func isPaymentPhaseStatus(st status.Status) bool {
return st == status.Approved || st == status.PartiallyPaid || st == status.Paid
}
func formatCurr(value int64) string {
// TODO: format currency according to provided format from config
return fmt.Sprintf("%s %0.2f", config.Currency(), float64(value)/100.0)
}
func formatDate(value string) string {
// TODO: format due according to provided format from config
parsed, err := time.Parse(config.IsoDateFormat, value)
if err != nil {
return value
}
return parsed.Format(config.HumanDateFormat)
}
func (s *AttendeeServiceImplData) ResendStatusMail(ctx context.Context, attendee *entity.Attendee, currentStatus status.Status, currentStatusComment string) error {
adminInfo, err := database.GetRepository().GetAdminInfoByAttendeeId(ctx, attendee.ID)
if err != nil {
return err
}
if currentStatus != status.Deleted && currentStatus != status.CheckedIn && currentStatus != status.New {
err = s.sendStatusChangeNotificationEmail(ctx, attendee, adminInfo, currentStatus, currentStatusComment, false)
if err != nil {
return err
}
}
return nil
}
func (s *AttendeeServiceImplData) sendStatusChangeNotificationEmail(ctx context.Context, attendee *entity.Attendee, adminInfo *entity.AdminInfo, newStatus status.Status, statusComment string, suppress bool) error {
checkSummedId := s.badgeId(attendee.ID)
cancelReason := ""
if newStatus == status.Cancelled {
cancelReason = statusComment
}
remainingDues := attendee.CacheTotalDues - attendee.CachePaymentBalance
dueDate := formatDate(attendee.CacheDueDate)
if remainingDues <= 0 {
dueDate = ""
}
mailDto := mailservice.MailSendDto{
CommonID: "change-status-" + string(newStatus),
Lang: removeWrappingCommasWithDefault(attendee.RegistrationLanguage, "en-US"),
Variables: map[string]string{
"badge_number": fmt.Sprintf("%d", attendee.ID),
"badge_number_with_checksum": *checkSummedId,
"nickname": attendee.Nickname,
"email": attendee.Email,
"reason": cancelReason,
"remaining_dues": formatCurr(remainingDues),
"total_dues": formatCurr(attendee.CacheTotalDues),
"pending_payments": formatCurr(attendee.CacheOpenBalance),
"due_date": dueDate,
"regsys_url": config.RegsysPublicUrl(),
// --- unused values ---
// room group variables, just set so all the templates work for now
"room_group_name": "TODO room group name",
"room_group_owner": "TODO room group owner nickname",
"room_group_owner_email": "TODO room group owner email",
"room_group_member": "TODO room group member nickname",
"room_group_member_email": "TODO room group member email",
// other stuff that is no longer used
"confirm_link": "TODO confirmation link",
"new_email": "TODO email change new email",
},
To: []string{attendee.Email},
}
if s.considerGuest(ctx, adminInfo) {
if newStatus == status.Approved || newStatus == status.PartiallyPaid || newStatus == status.Paid {
mailDto.CommonID = "guest"
}
} else if suppress {
aulogging.Logger.Ctx(ctx).Info().Printf("sending mail %s to %s suppressed", mailDto.CommonID, attendee.Email)
return nil
}
err := mailservice.Get().SendEmail(ctx, mailDto)
if err != nil {
return err
}
return nil
}
func removeWrappingCommasWithDefault(v string, defaultValue string) string {
v = strings.TrimPrefix(v, ",")
v = strings.TrimSuffix(v, ",")
if v == "" {
return defaultValue
}
return v
}
func (s *AttendeeServiceImplData) StatusChangeAllowed(ctx context.Context, attendee *entity.Attendee, oldStatus status.Status, newStatus status.Status) error {
if ctxvalues.HasApiToken(ctx) || ctxvalues.IsAuthorizedAsGroup(ctx, config.OidcAdminGroup()) {
// api or admin
return nil
}
subject := ctxvalues.Subject(ctx)
if subject == "" {
// anon
return errors.New("all status changes require a logged in user")
}
if subject == attendee.Identity {
// self cancellation
if newStatus == status.Cancelled {
if oldStatus == status.New || oldStatus == status.Approved || oldStatus == status.Waiting {
aulogging.Logger.Ctx(ctx).Info().Printf("self cancellation for attendee %d by %s", attendee.ID, subject)
return nil
}
}
aulogging.Logger.Ctx(ctx).Warn().Printf("forbidden self status change attempt %s -> %s for attendee %d by %s", oldStatus, newStatus, attendee.ID, subject)
return errors.New("you are not allowed to make this status transition - the attempt has been logged")
}
// others
if oldStatus == status.Paid && newStatus == status.CheckedIn {
allowed, err := s.subjectHasAdminPermissionEntry(ctx, subject, "regdesk")
if err != nil {
return err
}
if allowed {
aulogging.Logger.Ctx(ctx).Info().Printf("regdesk check in for attendee %d by %s", attendee.ID, subject)
return nil
}
}
aulogging.Logger.Ctx(ctx).Warn().Printf("forbidden status change attempt %s -> %s for attendee %d by %s", oldStatus, newStatus, attendee.ID, subject)
return errors.New("you are not allowed to make this status transition - the attempt has been logged")
}
func (s *AttendeeServiceImplData) StatusChangePossible(ctx context.Context, attendee *entity.Attendee, oldStatus status.Status, newStatus status.Status) error {
if oldStatus == newStatus {
return SameStatusError
}
transactionHistory, err := paymentservice.Get().GetTransactions(ctx, attendee.ID)
if err != nil && !errors.Is(err, paymentservice.NoSuchDebitor404Error) {
return err
}
switch newStatus {
case status.New:
return s.checkZeroOrNegativePaymentBalance(ctx, attendee, transactionHistory)
case status.Waiting:
return s.checkZeroOrNegativePaymentBalance(ctx, attendee, transactionHistory)
case status.Approved:
if oldStatus == status.New || oldStatus == status.Waiting || oldStatus == status.Cancelled || oldStatus == status.Deleted {
if err := s.matchesBanAndNoSkip(ctx, attendee); err != nil {
return err
}
}
return nil // explicitly allow "approved" for people with a payment balance (auto-skips ahead to partially paid or paid)
case status.PartiallyPaid:
if oldStatus == status.New || oldStatus == status.Waiting || oldStatus == status.Cancelled || oldStatus == status.Deleted {
return GoToApprovedFirst
}
return s.checkPositivePaymentBalanceButNotFullPayment(ctx, attendee, transactionHistory)
case status.Paid:
if oldStatus == status.New || oldStatus == status.Waiting || oldStatus == status.Cancelled || oldStatus == status.Deleted {
return GoToApprovedFirst
}
return s.checkPaidInFullWithGraceAmount(ctx, attendee, transactionHistory)
case status.CheckedIn:
if oldStatus == status.New || oldStatus == status.Waiting || oldStatus == status.Cancelled || oldStatus == status.Deleted {
return GoToApprovedFirst
}
return s.checkPaidInFull(ctx, attendee, transactionHistory)
case status.Cancelled:
return nil
case status.Deleted:
return s.checkNoPaymentsExist(ctx, attendee, transactionHistory)
default:
return UnknownStatusError
}
}
var graceAmountCents int64 = 100 // TODO read from config
func (s *AttendeeServiceImplData) checkNoPaymentsExist(ctx context.Context, attendee *entity.Attendee, transactionHistory []paymentservice.Transaction) error {
for _, tx := range transactionHistory {
if tx.Status == paymentservice.Valid && tx.TransactionType == paymentservice.Payment && tx.Amount.GrossCent != 0 {
return CannotDeleteError
}
}
return nil
}
func (s *AttendeeServiceImplData) checkZeroOrNegativePaymentBalance(ctx context.Context, attendee *entity.Attendee, transactionHistory []paymentservice.Transaction) error {
_, paid, _, _ := s.balances(transactionHistory)
if paid <= 0 {
return nil
} else {
return HasPaymentBalanceError
}
}
func (s *AttendeeServiceImplData) checkPositivePaymentBalanceButNotFullPayment(ctx context.Context, attendee *entity.Attendee, transactionHistory []paymentservice.Transaction) error {
dues, paid, _, _ := s.balances(transactionHistory)
if paid >= 0 && paid < dues {
return nil
} else {
return InsufficientPaymentError
}
}
func (s *AttendeeServiceImplData) checkPaidInFullWithGraceAmount(ctx context.Context, attendee *entity.Attendee, transactionHistory []paymentservice.Transaction) error {
dues, paid, _, _ := s.balances(transactionHistory)
// intentionally do not check paid >= 0, there may be negative dues (previous year refunds)
if paid >= dues-graceAmountCents {
return nil
} else {
return InsufficientPaymentError
}
}
func (s *AttendeeServiceImplData) checkPaidInFull(ctx context.Context, attendee *entity.Attendee, transactionHistory []paymentservice.Transaction) error {
dues, paid, _, _ := s.balances(transactionHistory)
if paid >= dues {
return nil
} else {
return InsufficientPaymentError
}
}
func (s *AttendeeServiceImplData) IsOwnerFor(ctx context.Context) ([]*entity.Attendee, error) {
identity := ctxvalues.Subject(ctx)
if identity != "" {
return database.GetRepository().FindByIdentity(ctx, identity)
} else {
return make([]*entity.Attendee, 0), nil
}
}