Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[SDP-907] data/db: Automatic cancellation of Ready Payments after a certain time period #78

Merged
merged 9 commits into from
Oct 30, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions cmd/serve.go
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ func (s *ServerService) GetSchedulerJobRegistrars(ctx context.Context, serveOpts
}),
scheduler.WithAPAuthEnforcementJob(apAPIService, serveOpts.MonitorService, serveOpts.CrashTrackerClient.Clone()),
scheduler.WithPatchAnchorPlatformTransactionsCompletionJobOption(apAPIService, models),
scheduler.WithReadyPaymentsCancellationJobOption(models),
}, nil
}

Expand Down
27 changes: 20 additions & 7 deletions internal/data/organizations.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,9 @@ type Organization struct {
TimezoneUTCOffset string `json:"timezone_utc_offset" db:"timezone_utc_offset"`
// SMSResendInterval is the time period that SDP will wait to resend the invitation SMS to the receivers that aren't registered.
// If it's nil means resending the invitation SMS is deactivated.
SMSResendInterval *int64 `json:"sms_resend_interval" db:"sms_resend_interval"`
SMSResendInterval *int64 `json:"sms_resend_interval" db:"sms_resend_interval"`
// PaymentCancellationPeriodDays is the number of days for a ready payment to be automatically cancelled.
PaymentCancellationPeriodDays *int64 `json:"payment_cancellation_period_days" db:"payment_cancellation_period_days"`
SMSRegistrationMessageTemplate string `json:"sms_registration_message_template" db:"sms_registration_message_template"`
// OTPMessageTemplate is the message template to send the OTP code to the receivers validates their identity when registering their wallets.
// The message may have the template values {{.OTP}} and {{.OrganizationName}}, it will be parsed and the values injected when executing the template.
Expand All @@ -47,11 +49,12 @@ type Organization struct {
}

type OrganizationUpdate struct {
Name string
Logo []byte
TimezoneUTCOffset string
IsApprovalRequired *bool
SMSResendInterval *int64
Name string
Logo []byte
TimezoneUTCOffset string
IsApprovalRequired *bool
SMSResendInterval *int64
PaymentCancellationPeriodDays *int64

// Using pointers to accept empty strings
SMSRegistrationMessageTemplate *string
Expand Down Expand Up @@ -112,7 +115,8 @@ func (ou *OrganizationUpdate) areAllFieldsEmpty() bool {
ou.IsApprovalRequired == nil &&
ou.SMSRegistrationMessageTemplate == nil &&
ou.OTPMessageTemplate == nil &&
ou.SMSResendInterval == nil)
ou.SMSResendInterval == nil &&
ou.PaymentCancellationPeriodDays == nil)
}

type OrganizationModel struct {
Expand Down Expand Up @@ -209,6 +213,15 @@ func (om *OrganizationModel) Update(ctx context.Context, ou *OrganizationUpdate)
}
}

if ou.PaymentCancellationPeriodDays != nil {
if *ou.PaymentCancellationPeriodDays > 0 {
fields = append(fields, "payment_cancellation_period_days = ?")
args = append(args, *ou.PaymentCancellationPeriodDays)
} else {
fields = append(fields, "payment_cancellation_period_days = NULL")
}
}

query = om.dbConnectionPool.Rebind(fmt.Sprintf(query, strings.Join(fields, ", ")))

_, err := om.dbConnectionPool.ExecContext(ctx, query, args...)
Expand Down
25 changes: 25 additions & 0 deletions internal/data/organizations_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -417,4 +417,29 @@ func Test_Organizations_Update(t *testing.T) {
require.NoError(t, err)
assert.Nil(t, o.SMSResendInterval)
})

t.Run("updates the organization's PaymentCancellationPeriod", func(t *testing.T) {
resetOrganizationInfo(t, ctx)

o, err := organizationModel.Get(ctx)
require.NoError(t, err)
assert.Nil(t, o.PaymentCancellationPeriodDays)

var PaymentCancellationPeriod int64 = 2
err = organizationModel.Update(ctx, &OrganizationUpdate{PaymentCancellationPeriodDays: &PaymentCancellationPeriod})
require.NoError(t, err)

o, err = organizationModel.Get(ctx)
require.NoError(t, err)
assert.Equal(t, PaymentCancellationPeriod, *o.PaymentCancellationPeriodDays)

// Set it as null
PaymentCancellationPeriod = 0
err = organizationModel.Update(ctx, &OrganizationUpdate{PaymentCancellationPeriodDays: &PaymentCancellationPeriod})
require.NoError(t, err)

o, err = organizationModel.Get(ctx)
require.NoError(t, err)
assert.Nil(t, o.PaymentCancellationPeriodDays)
})
}
34 changes: 34 additions & 0 deletions internal/data/payments.go
Original file line number Diff line number Diff line change
Expand Up @@ -640,3 +640,37 @@ func newPaymentQuery(baseQuery string, queryParams *QueryParams, paginated bool,
query, params := qb.Build()
return sqlExec.Rebind(query), params
}

// CancelPaymentsWithinPeriodDays cancels automatically payments that are in "READY" status after a certain time period in days.
func (p *PaymentModel) CancelPaymentsWithinPeriodDays(ctx context.Context, sqlExec db.SQLExecuter, periodInDays int64) error {
query := `
UPDATE
payments
SET
status = 'CANCELED'::payment_status,
status_history = array_append(status_history, create_payment_status_history(NOW(), 'CANCELED', NULL))
WHERE
status = 'READY'::payment_status
AND (
SELECT (value->>'timestamp')::timestamp
FROM unnest(status_history) AS value
WHERE value->>'status' = 'READY'
ORDER BY (value->>'timestamp')::timestamp DESC
LIMIT 1
) <= $1
ceciliaromao marked this conversation as resolved.
Show resolved Hide resolved
`

result, err := sqlExec.ExecContext(ctx, query, time.Now().AddDate(0, 0, -int(periodInDays)))
if err != nil {
return fmt.Errorf("error canceling payments: %w", err)
}
numRowsAffected, err := result.RowsAffected()
if err != nil {
return fmt.Errorf("error getting number of rows affected: %w", err)
}
if numRowsAffected == 0 {
log.Debug("No payments were canceled")
}

return nil
}
20 changes: 11 additions & 9 deletions internal/data/payments_state_machine.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,19 +8,20 @@ import (
type PaymentStatus string

const (
DraftPaymentStatus PaymentStatus = "DRAFT"
ReadyPaymentStatus PaymentStatus = "READY"
PendingPaymentStatus PaymentStatus = "PENDING"
PausedPaymentStatus PaymentStatus = "PAUSED"
SuccessPaymentStatus PaymentStatus = "SUCCESS"
FailedPaymentStatus PaymentStatus = "FAILED"
DraftPaymentStatus PaymentStatus = "DRAFT"
ReadyPaymentStatus PaymentStatus = "READY"
PendingPaymentStatus PaymentStatus = "PENDING"
PausedPaymentStatus PaymentStatus = "PAUSED"
SuccessPaymentStatus PaymentStatus = "SUCCESS"
FailedPaymentStatus PaymentStatus = "FAILED"
CanceledPaymentStatus PaymentStatus = "CANCELED"
)

// Validate validates the payment status
func (status PaymentStatus) Validate() error {
switch PaymentStatus(strings.ToUpper(string(status))) {
case DraftPaymentStatus, ReadyPaymentStatus, PendingPaymentStatus,
PausedPaymentStatus, SuccessPaymentStatus, FailedPaymentStatus:
case DraftPaymentStatus, ReadyPaymentStatus, PendingPaymentStatus, PausedPaymentStatus,
SuccessPaymentStatus, FailedPaymentStatus, CanceledPaymentStatus:
return nil
default:
return fmt.Errorf("invalid payment status: %s", status)
Expand All @@ -38,6 +39,7 @@ func PaymentStateMachineWithInitialState(initialState PaymentStatus) *StateMachi
{From: DraftPaymentStatus.State(), To: ReadyPaymentStatus.State()}, // disbursement started
{From: ReadyPaymentStatus.State(), To: PendingPaymentStatus.State()}, // payment gets submitted if user is ready
{From: ReadyPaymentStatus.State(), To: PausedPaymentStatus.State()}, // payment paused (when disbursement paused)
{From: ReadyPaymentStatus.State(), To: CanceledPaymentStatus.State()}, // automatic cancellation of ready payments
{From: PausedPaymentStatus.State(), To: ReadyPaymentStatus.State()}, // payment resumed (when disbursement resumed)
{From: PendingPaymentStatus.State(), To: FailedPaymentStatus.State()}, // payment fails
{From: FailedPaymentStatus.State(), To: PendingPaymentStatus.State()}, // payment retried
Expand All @@ -49,7 +51,7 @@ func PaymentStateMachineWithInitialState(initialState PaymentStatus) *StateMachi

// PaymentStatuses returns a list of all possible payment statuses
func PaymentStatuses() []PaymentStatus {
return []PaymentStatus{DraftPaymentStatus, ReadyPaymentStatus, PendingPaymentStatus, PausedPaymentStatus, SuccessPaymentStatus, FailedPaymentStatus}
return []PaymentStatus{DraftPaymentStatus, ReadyPaymentStatus, PendingPaymentStatus, PausedPaymentStatus, SuccessPaymentStatus, FailedPaymentStatus, CanceledPaymentStatus}
}

// SourceStatuses returns a list of states that the payment status can transition from given the target state
Expand Down
7 changes: 6 additions & 1 deletion internal/data/payments_state_machine_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,11 @@ func Test_PaymentStatus_SourceStatuses(t *testing.T) {
targetStatus: FailedPaymentStatus,
expectedSourceStatuses: []PaymentStatus{PendingPaymentStatus},
},
{
name: "Canceled",
targetStatus: CanceledPaymentStatus,
expectedSourceStatuses: []PaymentStatus{ReadyPaymentStatus},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
Expand All @@ -51,6 +56,6 @@ func Test_PaymentStatus_SourceStatuses(t *testing.T) {
}

func Test_PaymentStatus_PaymentStatuses(t *testing.T) {
expectedStatuses := []PaymentStatus{DraftPaymentStatus, ReadyPaymentStatus, PendingPaymentStatus, PausedPaymentStatus, SuccessPaymentStatus, FailedPaymentStatus}
expectedStatuses := []PaymentStatus{DraftPaymentStatus, ReadyPaymentStatus, PendingPaymentStatus, PausedPaymentStatus, SuccessPaymentStatus, FailedPaymentStatus, CanceledPaymentStatus}
require.Equal(t, expectedStatuses, PaymentStatuses())
}
Loading
Loading