Skip to content

Commit

Permalink
feat: automatic payments cancellation
Browse files Browse the repository at this point in the history
  • Loading branch information
ceciliaromao committed Oct 24, 2023
1 parent a26a557 commit b35bb73
Show file tree
Hide file tree
Showing 16 changed files with 760 additions and 17 deletions.
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
24 changes: 18 additions & 6 deletions internal/data/organizations.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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...)
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.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)
})
}
24 changes: 24 additions & 0 deletions internal/data/payments.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
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())
}
119 changes: 119 additions & 0 deletions internal/data/payments_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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)
})
}
Original file line number Diff line number Diff line change
@@ -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;
45 changes: 45 additions & 0 deletions internal/db/migrations/2023-10-20-update-payments-status-type.sql
Original file line number Diff line number Diff line change
@@ -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;
Loading

0 comments on commit b35bb73

Please sign in to comment.