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..0b1a24bc4 100644 --- a/internal/data/organizations.go +++ b/internal/data/organizations.go @@ -33,6 +33,7 @@ type Organization struct { // 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"` + PaymentCancellationPeriod *int64 `json:"payment_cancellation_period" db:"payment_cancellation_period"` 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 +48,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 + PaymentCancellationPeriod *int64 // Using pointers to accept empty strings SMSRegistrationMessageTemplate *string @@ -112,7 +114,8 @@ func (ou *OrganizationUpdate) areAllFieldsEmpty() bool { ou.IsApprovalRequired == nil && ou.SMSRegistrationMessageTemplate == nil && ou.OTPMessageTemplate == nil && - ou.SMSResendInterval == nil) + ou.SMSResendInterval == nil && + ou.PaymentCancellationPeriod == nil) } type OrganizationModel struct { @@ -209,6 +212,15 @@ func (om *OrganizationModel) Update(ctx context.Context, ou *OrganizationUpdate) } } + if ou.PaymentCancellationPeriod != nil { + if *ou.PaymentCancellationPeriod > 0 { + fields = append(fields, "payment_cancellation_period = ?") + args = append(args, *ou.PaymentCancellationPeriod) + } else { + fields = append(fields, "payment_cancellation_period = 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..7ce8cce95 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.PaymentCancellationPeriod) + + var PaymentCancellationPeriod int64 = 2 + err = organizationModel.Update(ctx, &OrganizationUpdate{PaymentCancellationPeriod: &PaymentCancellationPeriod}) + require.NoError(t, err) + + o, err = organizationModel.Get(ctx) + require.NoError(t, err) + assert.Equal(t, PaymentCancellationPeriod, *o.PaymentCancellationPeriod) + + // Set it as null + PaymentCancellationPeriod = 0 + err = organizationModel.Update(ctx, &OrganizationUpdate{PaymentCancellationPeriod: &PaymentCancellationPeriod}) + require.NoError(t, err) + + o, err = organizationModel.Get(ctx) + require.NoError(t, err) + assert.Nil(t, o.PaymentCancellationPeriod) + }) } diff --git a/internal/data/payments.go b/internal/data/payments.go index 070f69a59..c58dd6036 100644 --- a/internal/data/payments.go +++ b/internal/data/payments.go @@ -640,3 +640,27 @@ func newPaymentQuery(baseQuery string, queryParams *QueryParams, paginated bool, query, params := qb.Build() return sqlExec.Rebind(query), params } + +// CancelPayments cancels automatically payments that are in "READY" status for an informed time period in days. +func (p *PaymentModel) CancelPayments(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 updated_at <= $1 + ` + result, err := sqlExec.ExecContext(ctx, query, time.Now().AddDate(0, 0, int(-periodInDays))) + if err != nil { + return fmt.Errorf("error canceling payment: %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..763476dab 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,121 @@ 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, + UpdatedAt: 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, + UpdatedAt: time.Now(), + }) + + err := models.Payment.CancelPayments(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("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, + UpdatedAt: 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, + UpdatedAt: time.Now().AddDate(0, 0, -7), + }) + + err := models.Payment.CancelPayments(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-20-alter-organizations-table.sql b/internal/db/migrations/2023-10-20-alter-organizations-table.sql new file mode 100644 index 000000000..af2365d41 --- /dev/null +++ b/internal/db/migrations/2023-10-20-alter-organizations-table.sql @@ -0,0 +1,9 @@ +-- +migrate Up +ALTER TABLE + public.organizations +ADD + COLUMN payment_cancellation_period int; + +-- +migrate Down +ALTER TABLE + public.public.organizations DROP COLUMN payment_cancellation_period; \ No newline at end of file diff --git a/internal/db/migrations/2023-10-20-update-payments-status-type.sql b/internal/db/migrations/2023-10-20-update-payments-status-type.sql new file mode 100644 index 000000000..e931230be --- /dev/null +++ b/internal/db/migrations/2023-10-20-update-payments-status-type.sql @@ -0,0 +1,45 @@ +-- Update the payments_status type. +-- Add a new value 'CANCELED'. + +-- +migrate Up +ALTER TYPE payment_status +ADD + VALUE 'CANCELED'; + +-- +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; \ No newline at end of file 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..29d40f8fc --- /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 = "ReadyPaymentCancellation" + 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..5ab07ebd3 --- /dev/null +++ b/internal/scheduler/jobs/ready_payments_cancellation_job_test.go @@ -0,0 +1,178 @@ +package jobs + +import ( + "context" + "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/stellar/stellar-disbursement-platform-backend/internal/services" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +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() + models, err := data.NewModels(dbConnectionPool) + require.NoError(t, err) + + 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, + }) + + s := services.NewReadyPaymentsCancellationService(models) + + 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, + UpdatedAt: time.Now().AddDate(0, 0, -7), + }) + + cancelErr := s.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 to activate it.", + entries[0].Message, + ) + }) + + // Set the Payment Cancellation Period + var paymentCancellationPeriod int64 = 5 + err = models.Organizations.Update(ctx, &data.OrganizationUpdate{PaymentCancellationPeriod: &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, + UpdatedAt: 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, + UpdatedAt: time.Now(), + }) + + err := s.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.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, + UpdatedAt: 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, + UpdatedAt: time.Now().AddDate(0, 0, -7), + }) + + cancelErr := s.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.CanceledPaymentStatus, payment1DB.Status) + assert.Equal(t, data.CanceledPaymentStatus, payment2DB.Status) + }) +} + +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..ef20ed2a0 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"` + PaymentCancellationPeriod *int64 `json:"payment_cancellation_period"` 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.PaymentCancellationPeriod == 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, + PaymentCancellationPeriod: reqBody.PaymentCancellationPeriod, }) if err != nil { httperror.InternalError(ctx, "Cannot update organization", err, nil).Render(rw) diff --git a/internal/serve/httphandler/profile_handler_test.go b/internal/serve/httphandler/profile_handler_test.go index 78ae698ff..31c0a7446 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.PaymentCancellationPeriod) + + // Custom period + w := httptest.NewRecorder() + req, err := createOrganizationProfileMultipartRequest(t, url, "", "", `{"payment_cancellation_period": 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.PaymentCancellationPeriod) + + // 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.PaymentCancellationPeriod) + + // Back to default period + w = httptest.NewRecorder() + req, err = createOrganizationProfileMultipartRequest(t, url, "", "", `{"payment_cancellation_period": 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.PaymentCancellationPeriod) + }) } func Test_ProfileHandler_PatchUserProfile(t *testing.T) { 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..a85157891 --- /dev/null +++ b/internal/services/ready_payments_cancelation_service_test.go @@ -0,0 +1,156 @@ +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, + UpdatedAt: 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 to activate it.", + entries[0].Message, + ) + }) + + // Set the Payment Cancellation Period + var paymentCancellationPeriod int64 = 5 + err = models.Organizations.Update(ctx, &data.OrganizationUpdate{PaymentCancellationPeriod: &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, + UpdatedAt: 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, + UpdatedAt: 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, + UpdatedAt: 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, + UpdatedAt: 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..6f924e312 --- /dev/null +++ b/internal/services/ready_payments_cancellation_service.go @@ -0,0 +1,46 @@ +package services + +import ( + "context" + "fmt" + + "github.com/stellar/go/support/log" + "github.com/stellar/stellar-disbursement-platform-backend/internal/data" + "github.com/stellar/stellar-disbursement-platform-backend/internal/db" +) + +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.PaymentCancellationPeriod == nil { + log.Debug("automatic ready payment cancellation is deactivated. Set a valid value to the organization's payment_cancellation_period to activate it.") + return nil + } + + return db.RunInTransaction(ctx, s.sdpModels.DBConnectionPool, nil, func(dbTx db.DBTransaction) error { + if err := s.sdpModels.Payment.CancelPayments(ctx, dbTx, *organization.PaymentCancellationPeriod); err != nil { + return fmt.Errorf("canceling ready payments after %d days: %w", int(*organization.PaymentCancellationPeriod), err) + } + return nil + }) +}