diff --git a/cmd/serve.go b/cmd/serve.go index 729e2a116..c185437cd 100644 --- a/cmd/serve.go +++ b/cmd/serve.go @@ -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 } diff --git a/internal/data/organizations.go b/internal/data/organizations.go index 20cdcb956..d0796fd4f 100644 --- a/internal/data/organizations.go +++ b/internal/data/organizations.go @@ -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. @@ -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 @@ -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 { @@ -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...) diff --git a/internal/data/organizations_test.go b/internal/data/organizations_test.go index 688f9a611..5d40d554f 100644 --- a/internal/data/organizations_test.go +++ b/internal/data/organizations_test.go @@ -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) + }) } diff --git a/internal/data/payments.go b/internal/data/payments.go index 070f69a59..d4468ccfc 100644 --- a/internal/data/payments.go +++ b/internal/data/payments.go @@ -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 + ` + + 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 +} diff --git a/internal/data/payments_state_machine.go b/internal/data/payments_state_machine.go index 356bcb19f..f0230ef04 100644 --- a/internal/data/payments_state_machine.go +++ b/internal/data/payments_state_machine.go @@ -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) @@ -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 @@ -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 diff --git a/internal/data/payments_state_machine_test.go b/internal/data/payments_state_machine_test.go index 5e0e05067..aeec4d7c3 100644 --- a/internal/data/payments_state_machine_test.go +++ b/internal/data/payments_state_machine_test.go @@ -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) { @@ -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()) } diff --git a/internal/data/payments_test.go b/internal/data/payments_test.go index 9ef564cf3..fab47932b 100644 --- a/internal/data/payments_test.go +++ b/internal/data/payments_test.go @@ -6,6 +6,7 @@ import ( "time" "github.com/lib/pq" + "github.com/stellar/go/support/log" "github.com/stellar/stellar-disbursement-platform-backend/internal/db" "github.com/stellar/stellar-disbursement-platform-backend/internal/db/dbtest" "github.com/stellar/stellar-disbursement-platform-backend/internal/utils" @@ -1256,3 +1257,267 @@ func Test_PaymentModelGetAllReadyToPatchCompletionAnchorTransactions(t *testing. assert.Equal(t, receiverWallet.AnchorPlatformTransactionID, payments[0].ReceiverWallet.AnchorPlatformTransactionID) }) } + +func Test_PaymentModelCancelPayment(t *testing.T) { + dbt := dbtest.Open(t) + defer dbt.Close() + + dbConnectionPool, err := db.OpenDBConnectionPool(dbt.DSN) + require.NoError(t, err) + defer dbConnectionPool.Close() + + ctx := context.Background() + + models, err := NewModels(dbConnectionPool) + require.NoError(t, err) + + DeleteAllPaymentsFixtures(t, ctx, dbConnectionPool) + DeleteAllDisbursementFixtures(t, ctx, dbConnectionPool) + DeleteAllCountryFixtures(t, ctx, dbConnectionPool) + DeleteAllAssetFixtures(t, ctx, dbConnectionPool) + DeleteAllReceiverWalletsFixtures(t, ctx, dbConnectionPool) + DeleteAllReceiversFixtures(t, ctx, dbConnectionPool) + DeleteAllWalletFixtures(t, ctx, dbConnectionPool) + + country := CreateCountryFixture(t, ctx, dbConnectionPool, "BRA", "Brazil") + wallet := CreateWalletFixture(t, ctx, dbConnectionPool, "Wallet", "https://www.wallet.com", "www.wallet.com", "wallet://") + asset := CreateAssetFixture(t, ctx, dbConnectionPool, "USDC", "GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVV") + + receiver := CreateReceiverFixture(t, ctx, dbConnectionPool, &Receiver{}) + receiverWallet := CreateReceiverWalletFixture(t, ctx, dbConnectionPool, receiver.ID, wallet.ID, ReadyReceiversWalletStatus) + + disbursement := CreateDisbursementFixture(t, ctx, dbConnectionPool, models.Disbursements, &Disbursement{ + Country: country, + Wallet: wallet, + Asset: asset, + Status: ReadyDisbursementStatus, + VerificationField: VerificationFieldDateOfBirth, + }) + + t.Run("no ready payment for more than 5 days won't cancel any", func(t *testing.T) { + getEntries := log.DefaultLogger.StartTest(log.DebugLevel) + payment1 := CreatePaymentFixture(t, ctx, dbConnectionPool, models.Payment, &Payment{ + Amount: "1", + StellarTransactionID: "stellar-transaction-id-1", + StellarOperationID: "operation-id-1", + Status: DraftPaymentStatus, + Disbursement: disbursement, + ReceiverWallet: receiverWallet, + Asset: *asset, + StatusHistory: []PaymentStatusHistoryEntry{ + { + Status: DraftPaymentStatus, + StatusMessage: "", + Timestamp: time.Now().AddDate(0, 0, -6), + }, + }, + }) + + payment2 := CreatePaymentFixture(t, ctx, dbConnectionPool, models.Payment, &Payment{ + Amount: "1", + StellarTransactionID: "stellar-transaction-id-1", + StellarOperationID: "operation-id-1", + Status: ReadyPaymentStatus, + Disbursement: disbursement, + ReceiverWallet: receiverWallet, + Asset: *asset, + StatusHistory: []PaymentStatusHistoryEntry{ + { + Status: ReadyPaymentStatus, + StatusMessage: "", + Timestamp: time.Now(), + }, + }, + }) + + err := models.Payment.CancelPaymentsWithinPeriodDays(ctx, dbConnectionPool, 5) + require.NoError(t, err) + + entries := getEntries() + require.Len(t, entries, 1) + assert.Equal( + t, + "No payments were canceled", + entries[0].Message, + ) + + payment1DB, err := models.Payment.Get(ctx, payment1.ID, dbConnectionPool) + require.NoError(t, err) + + payment2DB, err := models.Payment.Get(ctx, payment2.ID, dbConnectionPool) + require.NoError(t, err) + + assert.Equal(t, DraftPaymentStatus, payment1DB.Status) + assert.Equal(t, ReadyPaymentStatus, payment2DB.Status) + }) + + t.Run("successfully cancel payments when it has multiple ready status history entries", func(t *testing.T) { + payment1 := CreatePaymentFixture(t, ctx, dbConnectionPool, models.Payment, &Payment{ + Amount: "1", + StellarTransactionID: "stellar-transaction-id-1", + StellarOperationID: "operation-id-1", + Status: ReadyPaymentStatus, + Disbursement: disbursement, + ReceiverWallet: receiverWallet, + Asset: *asset, + StatusHistory: []PaymentStatusHistoryEntry{ + { + Status: DraftPaymentStatus, + StatusMessage: "", + Timestamp: time.Now().AddDate(0, 0, -7), + }, + { + Status: ReadyPaymentStatus, + StatusMessage: "", + Timestamp: time.Now().AddDate(0, 0, -7), + }, + { + Status: PendingPaymentStatus, + StatusMessage: "", + Timestamp: time.Now().AddDate(0, 0, -5), + }, + { + Status: FailedPaymentStatus, + StatusMessage: "", + Timestamp: time.Now().AddDate(0, 0, -5), + }, + { + Status: ReadyPaymentStatus, + StatusMessage: "", + Timestamp: time.Now(), + }, + }, + }) + + payment2 := CreatePaymentFixture(t, ctx, dbConnectionPool, models.Payment, &Payment{ + Amount: "1", + StellarTransactionID: "stellar-transaction-id-1", + StellarOperationID: "operation-id-1", + Status: ReadyPaymentStatus, + Disbursement: disbursement, + ReceiverWallet: receiverWallet, + Asset: *asset, + StatusHistory: []PaymentStatusHistoryEntry{ + { + Status: DraftPaymentStatus, + StatusMessage: "", + Timestamp: time.Now().AddDate(0, 0, -7), + }, + { + Status: ReadyPaymentStatus, + StatusMessage: "", + Timestamp: time.Now().AddDate(0, 0, -7), + }, + { + Status: PendingPaymentStatus, + StatusMessage: "", + Timestamp: time.Now().AddDate(0, 0, -5), + }, + { + Status: FailedPaymentStatus, + StatusMessage: "", + Timestamp: time.Now().AddDate(0, 0, -3), + }, + { + Status: ReadyPaymentStatus, + StatusMessage: "", + Timestamp: time.Now().AddDate(0, 0, -3), + }, + }, + }) + + payment3 := CreatePaymentFixture(t, ctx, dbConnectionPool, models.Payment, &Payment{ + Amount: "1", + StellarTransactionID: "stellar-transaction-id-1", + StellarOperationID: "operation-id-1", + Status: SuccessPaymentStatus, + Disbursement: disbursement, + ReceiverWallet: receiverWallet, + Asset: *asset, + StatusHistory: []PaymentStatusHistoryEntry{ + { + Status: DraftPaymentStatus, + StatusMessage: "", + Timestamp: time.Now().AddDate(0, 0, -7), + }, + { + Status: ReadyPaymentStatus, + StatusMessage: "", + Timestamp: time.Now().AddDate(0, 0, -7), + }, + { + Status: PendingPaymentStatus, + StatusMessage: "", + Timestamp: time.Now().AddDate(0, 0, -7), + }, + { + Status: SuccessPaymentStatus, + StatusMessage: "", + Timestamp: time.Now().AddDate(0, 0, -7), + }, + }, + }) + + err := models.Payment.CancelPaymentsWithinPeriodDays(ctx, dbConnectionPool, 5) + require.NoError(t, err) + + payment1DB, err := models.Payment.Get(ctx, payment1.ID, dbConnectionPool) + require.NoError(t, err) + payment2DB, err := models.Payment.Get(ctx, payment2.ID, dbConnectionPool) + require.NoError(t, err) + payment3DB, err := models.Payment.Get(ctx, payment3.ID, dbConnectionPool) + require.NoError(t, err) + + assert.Equal(t, ReadyPaymentStatus, payment1DB.Status) + assert.Equal(t, ReadyPaymentStatus, payment2DB.Status) + assert.Equal(t, SuccessPaymentStatus, payment3DB.Status) + }) + + t.Run("cancels ready payments for more than 5 days", func(t *testing.T) { + payment1 := CreatePaymentFixture(t, ctx, dbConnectionPool, models.Payment, &Payment{ + Amount: "1", + StellarTransactionID: "stellar-transaction-id-1", + StellarOperationID: "operation-id-1", + Status: ReadyPaymentStatus, + Disbursement: disbursement, + ReceiverWallet: receiverWallet, + Asset: *asset, + StatusHistory: []PaymentStatusHistoryEntry{ + { + Status: ReadyPaymentStatus, + StatusMessage: "", + Timestamp: time.Now().AddDate(0, 0, -5), + }, + }, + }) + + payment2 := CreatePaymentFixture(t, ctx, dbConnectionPool, models.Payment, &Payment{ + Amount: "1", + StellarTransactionID: "stellar-transaction-id-1", + StellarOperationID: "operation-id-1", + Status: ReadyPaymentStatus, + Disbursement: disbursement, + ReceiverWallet: receiverWallet, + Asset: *asset, + StatusHistory: []PaymentStatusHistoryEntry{ + { + Status: ReadyPaymentStatus, + StatusMessage: "", + Timestamp: time.Now().AddDate(0, 0, -7), + }, + }, + }) + + err := models.Payment.CancelPaymentsWithinPeriodDays(ctx, dbConnectionPool, 5) + require.NoError(t, err) + + payment1DB, err := models.Payment.Get(ctx, payment1.ID, dbConnectionPool) + require.NoError(t, err) + + payment2DB, err := models.Payment.Get(ctx, payment2.ID, dbConnectionPool) + require.NoError(t, err) + + assert.Equal(t, CanceledPaymentStatus, payment1DB.Status) + assert.Equal(t, CanceledPaymentStatus, payment2DB.Status) + }) +} diff --git a/internal/db/migrations/2023-10-25.0-update-payments-status-type-and-organizations-table.sql b/internal/db/migrations/2023-10-25.0-update-payments-status-type-and-organizations-table.sql new file mode 100644 index 000000000..85bb196d0 --- /dev/null +++ b/internal/db/migrations/2023-10-25.0-update-payments-status-type-and-organizations-table.sql @@ -0,0 +1,50 @@ +-- +migrate Up +ALTER TYPE payment_status +ADD + VALUE 'CANCELED'; + +ALTER TABLE + public.organizations +ADD + COLUMN payment_cancellation_period_days INTEGER; + +-- +migrate Down +UPDATE + payments +SET + status = 'FAILED'::payment_status +WHERE + status = 'CANCELED'::payment_status; + +ALTER TYPE payment_status RENAME TO old_payment_status; + +CREATE TYPE payment_status AS ENUM ( + 'DRAFT', + 'READY', + 'PENDING', + 'PAUSED', + 'SUCCESS', + 'FAILED' +); + +-- +migrate StatementBegin +CREATE OR REPLACE FUNCTION create_payment_status_history(time_stamp TIMESTAMP WITH TIME ZONE, pay_status payment_status, status_message VARCHAR) +RETURNS jsonb AS $$ + BEGIN + RETURN json_build_object( + 'timestamp', time_stamp, + 'status', pay_status, + 'status_message', status_message + ); + END; +$$ LANGUAGE plpgsql; +-- +migrate StatementEnd + +ALTER TABLE payments RENAME COLUMN status TO status_old; +ALTER TABLE payments ADD COLUMN status payment_status DEFAULT payment_status('DRAFT'); +UPDATE payments SET status = status_old::text::payment_status; +ALTER TABLE payments DROP COLUMN status_old; +DROP TYPE old_payment_status CASCADE; + +ALTER TABLE + public.organizations DROP COLUMN payment_cancellation_period_days; diff --git a/internal/scheduler/jobs/ready_payments_cancellation_job.go b/internal/scheduler/jobs/ready_payments_cancellation_job.go new file mode 100644 index 000000000..8fbf4bf9f --- /dev/null +++ b/internal/scheduler/jobs/ready_payments_cancellation_job.go @@ -0,0 +1,44 @@ +package jobs + +import ( + "context" + "fmt" + "time" + + "github.com/stellar/go/support/log" + "github.com/stellar/stellar-disbursement-platform-backend/internal/data" + "github.com/stellar/stellar-disbursement-platform-backend/internal/services" +) + +const ( + ReadyPaymentsCancellationJobName = "ready_payments_cancellation" + ReadyPaymentsCancellationJobInterval = 5 +) + +type ReadyPaymentsCancellationJob struct { + service services.ReadyPaymentsCancellationServiceInterface +} + +func (j ReadyPaymentsCancellationJob) GetName() string { + return ReadyPaymentsCancellationJobName +} + +func (j ReadyPaymentsCancellationJob) GetInterval() time.Duration { + return time.Minute * ReadyPaymentsCancellationJobInterval +} + +func (j ReadyPaymentsCancellationJob) Execute(ctx context.Context) error { + if err := j.service.CancelReadyPayments(ctx); err != nil { + err = fmt.Errorf("error cancelling ready payments: %w", err) + log.Ctx(ctx).Error(err) + return err + } + return nil +} + +func NewReadyPaymentsCancellationJob(models *data.Models) *ReadyPaymentsCancellationJob { + s := services.NewReadyPaymentsCancellationService(models) + return &ReadyPaymentsCancellationJob{ + service: s, + } +} diff --git a/internal/scheduler/jobs/ready_payments_cancellation_job_test.go b/internal/scheduler/jobs/ready_payments_cancellation_job_test.go new file mode 100644 index 000000000..6a9419b2c --- /dev/null +++ b/internal/scheduler/jobs/ready_payments_cancellation_job_test.go @@ -0,0 +1,82 @@ +package jobs + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/stellar/go/support/log" + "github.com/stellar/stellar-disbursement-platform-backend/internal/data" + "github.com/stellar/stellar-disbursement-platform-backend/internal/db" + "github.com/stellar/stellar-disbursement-platform-backend/internal/db/dbtest" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +type mockReadyPaymentsCancellation struct { + mock.Mock +} + +func (s *mockReadyPaymentsCancellation) CancelReadyPayments(ctx context.Context) error { + args := s.Called(ctx) + return args.Error(0) +} + +func Test_ReadyPaymentsCancellationJob(t *testing.T) { + j := ReadyPaymentsCancellationJob{} + + assert.Equal(t, ReadyPaymentsCancellationJobName, j.GetName()) + assert.Equal(t, ReadyPaymentsCancellationJobInterval*time.Minute, j.GetInterval()) +} + +func Test_ReadyPaymentsCancellationJob_Execute(t *testing.T) { + dbt := dbtest.Open(t) + defer dbt.Close() + + dbConnectionPool, err := db.OpenDBConnectionPool(dbt.DSN) + require.NoError(t, err) + defer dbConnectionPool.Close() + + ctx := context.Background() + + mockService := mockReadyPaymentsCancellation{} + j := &ReadyPaymentsCancellationJob{ + service: &mockService, + } + + t.Run("returns error when cancellation service fails", func(t *testing.T) { + getEntries := log.DefaultLogger.StartTest(log.ErrorLevel) + mockService.On("CancelReadyPayments", ctx).Return(errors.New("Unexpected error")).Once() + + err := j.Execute(ctx) + assert.EqualError(t, err, "error cancelling ready payments: Unexpected error") + + entries := getEntries() + require.Len(t, entries, 1) + assert.Equal(t, entries[0].Message, "error cancelling ready payments: Unexpected error") + }) + + t.Run("executes successfully", func(t *testing.T) { + mockService.On("CancelReadyPayments", ctx).Return(nil).Once() + + err := j.Execute(ctx) + assert.NoError(t, err) + }) +} + +func Test_NewReadyPaymentsCancellationJob(t *testing.T) { + dbt := dbtest.Open(t) + defer dbt.Close() + + dbConnectionPool, err := db.OpenDBConnectionPool(dbt.DSN) + require.NoError(t, err) + defer dbConnectionPool.Close() + + models, err := data.NewModels(dbConnectionPool) + require.NoError(t, err) + + j := NewReadyPaymentsCancellationJob(models) + assert.NotNil(t, j) +} diff --git a/internal/scheduler/scheduler.go b/internal/scheduler/scheduler.go index 84e2b3a07..e95b5aeaa 100644 --- a/internal/scheduler/scheduler.go +++ b/internal/scheduler/scheduler.go @@ -186,3 +186,11 @@ func WithPatchAnchorPlatformTransactionsCompletionJobOption(apAPISvc anchorplatf s.addJob(j) } } + +func WithReadyPaymentsCancellationJobOption(models *data.Models) SchedulerJobRegisterOption { + return func(s *Scheduler) { + j := jobs.NewReadyPaymentsCancellationJob(models) + log.Infof("registering %s job to scheduler", j.GetName()) + s.addJob(j) + } +} diff --git a/internal/serve/httphandler/profile_handler.go b/internal/serve/httphandler/profile_handler.go index 8f072907c..827de445d 100644 --- a/internal/serve/httphandler/profile_handler.go +++ b/internal/serve/httphandler/profile_handler.go @@ -49,6 +49,7 @@ type PatchOrganizationProfileRequest struct { TimezoneUTCOffset string `json:"timezone_utc_offset"` IsApprovalRequired *bool `json:"is_approval_required"` SMSResendInterval *int64 `json:"sms_resend_interval"` + PaymentCancellationPeriodDays *int64 `json:"payment_cancellation_period_days"` SMSRegistrationMessageTemplate *string `json:"sms_registration_message_template"` OTPMessageTemplate *string `json:"otp_message_template"` } @@ -59,7 +60,8 @@ func (r *PatchOrganizationProfileRequest) AreAllFieldsEmpty() bool { r.IsApprovalRequired == nil && r.SMSRegistrationMessageTemplate == nil && r.OTPMessageTemplate == nil && - r.SMSResendInterval == nil) + r.SMSResendInterval == nil && + r.PaymentCancellationPeriodDays == nil) } type PatchUserProfileRequest struct { @@ -159,6 +161,7 @@ func (h ProfileHandler) PatchOrganizationProfile(rw http.ResponseWriter, req *ht SMSRegistrationMessageTemplate: reqBody.SMSRegistrationMessageTemplate, OTPMessageTemplate: reqBody.OTPMessageTemplate, SMSResendInterval: reqBody.SMSResendInterval, + PaymentCancellationPeriodDays: reqBody.PaymentCancellationPeriodDays, }) if err != nil { httperror.InternalError(ctx, "Cannot update organization", err, nil).Render(rw) @@ -358,12 +361,13 @@ func (h ProfileHandler) GetOrganizationInfo(rw http.ResponseWriter, req *http.Re } resp := map[string]interface{}{ - "name": org.Name, - "logo_url": lu.String(), - "distribution_account_public_key": h.DistributionPublicKey, - "timezone_utc_offset": org.TimezoneUTCOffset, - "is_approval_required": org.IsApprovalRequired, - "sms_resend_interval": 0, + "name": org.Name, + "logo_url": lu.String(), + "distribution_account_public_key": h.DistributionPublicKey, + "timezone_utc_offset": org.TimezoneUTCOffset, + "is_approval_required": org.IsApprovalRequired, + "sms_resend_interval": 0, + "payment_cancellation_period_days": 0, } if org.SMSRegistrationMessageTemplate != data.DefaultSMSRegistrationMessageTemplate { @@ -378,6 +382,10 @@ func (h ProfileHandler) GetOrganizationInfo(rw http.ResponseWriter, req *http.Re resp["sms_resend_interval"] = *org.SMSResendInterval } + if org.PaymentCancellationPeriodDays != nil { + resp["payment_cancellation_period_days"] = *org.PaymentCancellationPeriodDays + } + httpjson.RenderStatus(rw, http.StatusOK, resp, httpjson.JSON) } diff --git a/internal/serve/httphandler/profile_handler_test.go b/internal/serve/httphandler/profile_handler_test.go index 78ae698ff..7d74d4313 100644 --- a/internal/serve/httphandler/profile_handler_test.go +++ b/internal/serve/httphandler/profile_handler_test.go @@ -664,6 +664,72 @@ func Test_ProfileHandler_PatchOrganizationProfile(t *testing.T) { require.NoError(t, err) assert.Nil(t, org.SMSResendInterval) }) + + t.Run("updates organization's Payment Cancellation Period", func(t *testing.T) { + resetOrganizationInfo(t, ctx, dbConnectionPool) + ctx = context.WithValue(ctx, middleware.TokenContextKey, "token") + + org, err := models.Organizations.Get(ctx) + require.NoError(t, err) + assert.Nil(t, org.PaymentCancellationPeriodDays) + + // Custom period + w := httptest.NewRecorder() + req, err := createOrganizationProfileMultipartRequest(t, url, "", "", `{"payment_cancellation_period_days": 2}`, new(bytes.Buffer)) + require.NoError(t, err) + req = req.WithContext(ctx) + http.HandlerFunc(handler.PatchOrganizationProfile).ServeHTTP(w, req) + + resp := w.Result() + respBody, err := io.ReadAll(resp.Body) + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusOK, resp.StatusCode) + assert.JSONEq(t, `{"message": "updated successfully"}`, string(respBody)) + + org, err = models.Organizations.Get(ctx) + require.NoError(t, err) + assert.Equal(t, int64(2), *org.PaymentCancellationPeriodDays) + + // Don't update the period + w = httptest.NewRecorder() + req, err = createOrganizationProfileMultipartRequest(t, url, "", "", `{"organization_name": "MyOrg"}`, new(bytes.Buffer)) + require.NoError(t, err) + req = req.WithContext(ctx) + http.HandlerFunc(handler.PatchOrganizationProfile).ServeHTTP(w, req) + + resp = w.Result() + respBody, err = io.ReadAll(resp.Body) + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusOK, resp.StatusCode) + assert.JSONEq(t, `{"message": "updated successfully"}`, string(respBody)) + + org, err = models.Organizations.Get(ctx) + require.NoError(t, err) + assert.Equal(t, int64(2), *org.PaymentCancellationPeriodDays) + + // Back to default period + w = httptest.NewRecorder() + req, err = createOrganizationProfileMultipartRequest(t, url, "", "", `{"payment_cancellation_period_days": 0}`, new(bytes.Buffer)) + require.NoError(t, err) + req = req.WithContext(ctx) + http.HandlerFunc(handler.PatchOrganizationProfile).ServeHTTP(w, req) + + resp = w.Result() + respBody, err = io.ReadAll(resp.Body) + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusOK, resp.StatusCode) + assert.JSONEq(t, `{"message": "updated successfully"}`, string(respBody)) + + org, err = models.Organizations.Get(ctx) + require.NoError(t, err) + assert.Nil(t, org.PaymentCancellationPeriodDays) + }) } func Test_ProfileHandler_PatchUserProfile(t *testing.T) { @@ -1311,7 +1377,8 @@ func Test_ProfileHandler_GetOrganizationInfo(t *testing.T) { "distribution_account_public_key": %q, "timezone_utc_offset": "+00:00", "is_approval_required": false, - "sms_resend_interval": 0 + "sms_resend_interval": 0, + "payment_cancellation_period_days": 0 } `, distributionAccountPK) @@ -1346,7 +1413,8 @@ func Test_ProfileHandler_GetOrganizationInfo(t *testing.T) { "timezone_utc_offset": "+00:00", "is_approval_required":false, "sms_registration_message_template": "My custom receiver wallet registration invite. MyOrg 👋", - "sms_resend_interval": 0 + "sms_resend_interval": 0, + "payment_cancellation_period_days": 0 } `, distributionAccountPK) @@ -1378,7 +1446,8 @@ func Test_ProfileHandler_GetOrganizationInfo(t *testing.T) { "is_approval_required":false, "sms_registration_message_template": "My custom receiver wallet registration invite. MyOrg 👋", "otp_message_template": "Here's your OTP Code to complete your registration. MyOrg 👋", - "sms_resend_interval": 0 + "sms_resend_interval": 0, + "payment_cancellation_period_days": 0 } `, distributionAccountPK) @@ -1414,7 +1483,45 @@ func Test_ProfileHandler_GetOrganizationInfo(t *testing.T) { "distribution_account_public_key": %q, "timezone_utc_offset": "+00:00", "is_approval_required":false, - "sms_resend_interval": 2 + "sms_resend_interval": 2, + "payment_cancellation_period_days": 0 + } + `, distributionAccountPK) + + assert.Equal(t, http.StatusOK, resp.StatusCode) + assert.JSONEq(t, wantsBody, string(respBody)) + }) + + t.Run("returns the custom payment_cancellation_period_days", func(t *testing.T) { + resetOrganizationInfo(t, ctx, dbConnectionPool) + + ctx = context.WithValue(ctx, middleware.TokenContextKey, "mytoken") + + var paymentCancellationPeriodDays int64 = 5 + err := models.Organizations.Update(ctx, &data.OrganizationUpdate{ + PaymentCancellationPeriodDays: &paymentCancellationPeriodDays, + }) + require.NoError(t, err) + + w := httptest.NewRecorder() + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + require.NoError(t, err) + http.HandlerFunc(handler.GetOrganizationInfo).ServeHTTP(w, req) + + resp := w.Result() + respBody, err := io.ReadAll(resp.Body) + require.NoError(t, err) + defer resp.Body.Close() + + wantsBody := fmt.Sprintf(` + { + "logo_url": "http://localhost:8000/organization/logo?token=mytoken", + "name": "MyCustomAid", + "distribution_account_public_key": %q, + "timezone_utc_offset": "+00:00", + "is_approval_required":false, + "sms_resend_interval": 0, + "payment_cancellation_period_days": 5 } `, distributionAccountPK) diff --git a/internal/services/ready_payments_cancelation_service_test.go b/internal/services/ready_payments_cancelation_service_test.go new file mode 100644 index 000000000..9b6d8fb0e --- /dev/null +++ b/internal/services/ready_payments_cancelation_service_test.go @@ -0,0 +1,186 @@ +package services + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/stellar/go/support/log" + "github.com/stellar/stellar-disbursement-platform-backend/internal/data" + "github.com/stellar/stellar-disbursement-platform-backend/internal/db" + "github.com/stellar/stellar-disbursement-platform-backend/internal/db/dbtest" +) + +func Test_ReadyPaymentsCancellationService_CancelReadyPaymentsService(t *testing.T) { + dbt := dbtest.Open(t) + defer dbt.Close() + + dbConnectionPool, err := db.OpenDBConnectionPool(dbt.DSN) + require.NoError(t, err) + defer dbConnectionPool.Close() + + models, err := data.NewModels(dbConnectionPool) + require.NoError(t, err) + + service := NewReadyPaymentsCancellationService(models) + ctx := context.Background() + + data.DeleteAllPaymentsFixtures(t, ctx, dbConnectionPool) + data.DeleteAllDisbursementFixtures(t, ctx, dbConnectionPool) + data.DeleteAllCountryFixtures(t, ctx, dbConnectionPool) + data.DeleteAllAssetFixtures(t, ctx, dbConnectionPool) + data.DeleteAllReceiverWalletsFixtures(t, ctx, dbConnectionPool) + data.DeleteAllReceiversFixtures(t, ctx, dbConnectionPool) + data.DeleteAllWalletFixtures(t, ctx, dbConnectionPool) + + country := data.CreateCountryFixture(t, ctx, dbConnectionPool, "BRA", "Brazil") + wallet := data.CreateWalletFixture(t, ctx, dbConnectionPool, "Wallet", "https://www.wallet.com", "www.wallet.com", "wallet://") + asset := data.CreateAssetFixture(t, ctx, dbConnectionPool, "USDC", "GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVV") + + receiver := data.CreateReceiverFixture(t, ctx, dbConnectionPool, &data.Receiver{}) + receiverWallet := data.CreateReceiverWalletFixture(t, ctx, dbConnectionPool, receiver.ID, wallet.ID, data.RegisteredReceiversWalletStatus) + + disbursement := data.CreateDisbursementFixture(t, ctx, dbConnectionPool, models.Disbursements, &data.Disbursement{ + Country: country, + Wallet: wallet, + Asset: asset, + Status: data.ReadyDisbursementStatus, + VerificationField: data.VerificationFieldDateOfBirth, + }) + + t.Run("automatic payment cancellation is deactivated", func(t *testing.T) { + getEntries := log.DefaultLogger.StartTest(log.DebugLevel) + + _ = data.CreatePaymentFixture(t, ctx, dbConnectionPool, models.Payment, &data.Payment{ + Amount: "1", + StellarTransactionID: "stellar-transaction-id-1", + StellarOperationID: "operation-id-1", + Status: data.ReadyPaymentStatus, + Disbursement: disbursement, + ReceiverWallet: receiverWallet, + Asset: *asset, + StatusHistory: []data.PaymentStatusHistoryEntry{ + { + Status: data.ReadyPaymentStatus, + StatusMessage: "", + Timestamp: time.Now().AddDate(0, 0, -7), + }, + }, + }) + + cancelErr := service.CancelReadyPayments(ctx) + require.NoError(t, cancelErr) + + entries := getEntries() + require.Len(t, entries, 1) + assert.Equal( + t, + "automatic ready payment cancellation is deactivated. Set a valid value to the organization's payment_cancellation_period_days to activate it.", + entries[0].Message, + ) + }) + + // Set the Payment Cancellation Period + var paymentCancellationPeriod int64 = 5 + err = models.Organizations.Update(ctx, &data.OrganizationUpdate{PaymentCancellationPeriodDays: &paymentCancellationPeriod}) + require.NoError(t, err) + + t.Run("no ready payment for more than 5 days won't cancel any", func(t *testing.T) { + payment1 := data.CreatePaymentFixture(t, ctx, dbConnectionPool, models.Payment, &data.Payment{ + Amount: "1", + StellarTransactionID: "stellar-transaction-id-1", + StellarOperationID: "operation-id-1", + Status: data.DraftPaymentStatus, + Disbursement: disbursement, + ReceiverWallet: receiverWallet, + Asset: *asset, + StatusHistory: []data.PaymentStatusHistoryEntry{ + { + Status: data.DraftPaymentStatus, + StatusMessage: "", + Timestamp: time.Now().AddDate(0, 0, -6), + }, + }, + }) + + payment2 := data.CreatePaymentFixture(t, ctx, dbConnectionPool, models.Payment, &data.Payment{ + Amount: "1", + StellarTransactionID: "stellar-transaction-id-1", + StellarOperationID: "operation-id-1", + Status: data.ReadyPaymentStatus, + Disbursement: disbursement, + ReceiverWallet: receiverWallet, + Asset: *asset, + StatusHistory: []data.PaymentStatusHistoryEntry{ + { + Status: data.ReadyPaymentStatus, + StatusMessage: "", + Timestamp: time.Now(), + }, + }, + }) + + cancelErr := service.CancelReadyPayments(ctx) + require.NoError(t, cancelErr) + + payment1DB, err := models.Payment.Get(ctx, payment1.ID, dbConnectionPool) + require.NoError(t, err) + + payment2DB, err := models.Payment.Get(ctx, payment2.ID, dbConnectionPool) + require.NoError(t, err) + + assert.Equal(t, data.DraftPaymentStatus, payment1DB.Status) + assert.Equal(t, data.ReadyPaymentStatus, payment2DB.Status) + }) + + t.Run("cancels ready payments for more than 5 days", func(t *testing.T) { + payment1 := data.CreatePaymentFixture(t, ctx, dbConnectionPool, models.Payment, &data.Payment{ + Amount: "1", + StellarTransactionID: "stellar-transaction-id-1", + StellarOperationID: "operation-id-1", + Status: data.ReadyPaymentStatus, + Disbursement: disbursement, + ReceiverWallet: receiverWallet, + Asset: *asset, + StatusHistory: []data.PaymentStatusHistoryEntry{ + { + Status: data.ReadyPaymentStatus, + StatusMessage: "", + Timestamp: time.Now().AddDate(0, 0, -5), + }, + }, + }) + + payment2 := data.CreatePaymentFixture(t, ctx, dbConnectionPool, models.Payment, &data.Payment{ + Amount: "1", + StellarTransactionID: "stellar-transaction-id-1", + StellarOperationID: "operation-id-1", + Status: data.ReadyPaymentStatus, + Disbursement: disbursement, + ReceiverWallet: receiverWallet, + Asset: *asset, + StatusHistory: []data.PaymentStatusHistoryEntry{ + { + Status: data.ReadyPaymentStatus, + StatusMessage: "", + Timestamp: time.Now().AddDate(0, 0, -7), + }, + }, + }) + + err := service.CancelReadyPayments(ctx) + require.NoError(t, err) + + payment1DB, err := models.Payment.Get(ctx, payment1.ID, dbConnectionPool) + require.NoError(t, err) + + payment2DB, err := models.Payment.Get(ctx, payment2.ID, dbConnectionPool) + require.NoError(t, err) + + assert.Equal(t, data.CanceledPaymentStatus, payment1DB.Status) + assert.Equal(t, data.CanceledPaymentStatus, payment2DB.Status) + }) +} diff --git a/internal/services/ready_payments_cancellation_service.go b/internal/services/ready_payments_cancellation_service.go new file mode 100644 index 000000000..d9837fc1a --- /dev/null +++ b/internal/services/ready_payments_cancellation_service.go @@ -0,0 +1,43 @@ +package services + +import ( + "context" + "fmt" + + "github.com/stellar/go/support/log" + "github.com/stellar/stellar-disbursement-platform-backend/internal/data" +) + +type ReadyPaymentsCancellationServiceInterface interface { + CancelReadyPayments(ctx context.Context) error +} + +var _ ReadyPaymentsCancellationServiceInterface = (*ReadyPaymentsCancellationService)(nil) + +type ReadyPaymentsCancellationService struct { + sdpModels *data.Models +} + +func NewReadyPaymentsCancellationService(models *data.Models) *ReadyPaymentsCancellationService { + return &ReadyPaymentsCancellationService{ + sdpModels: models, + } +} + +// CancelReadyPayments cancels SDP's ready-to-pay payments that are older than the specified period. +func (s ReadyPaymentsCancellationService) CancelReadyPayments(ctx context.Context) error { + organization, err := s.sdpModels.Organizations.Get(ctx) + if err != nil { + return fmt.Errorf("error getting organization: %w", err) + } + + if organization.PaymentCancellationPeriodDays == nil { + log.Debug("automatic ready payment cancellation is deactivated. Set a valid value to the organization's payment_cancellation_period_days to activate it.") + return nil + } + + if err := s.sdpModels.Payment.CancelPaymentsWithinPeriodDays(ctx, s.sdpModels.DBConnectionPool, *organization.PaymentCancellationPeriodDays); err != nil { + return fmt.Errorf("canceling ready payments after %d days: %w", int(*organization.PaymentCancellationPeriodDays), err) + } + return nil +}