Skip to content

Commit

Permalink
[SDP-1234] DELETE disbursement in draft or ready status (#487)
Browse files Browse the repository at this point in the history
* SDP-1234 DELETE disbursement in draft or ready mode

* SDP-1234 Address PR comments
  • Loading branch information
marwen-abid authored Dec 6, 2024
1 parent f965c80 commit 3c93d5b
Show file tree
Hide file tree
Showing 9 changed files with 331 additions and 6 deletions.
2 changes: 2 additions & 0 deletions internal/data/dibursements_state_machine.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ const (
CompletedDisbursementStatus DisbursementStatus = "COMPLETED"
)

var NotStartedDisbursementStatuses = []DisbursementStatus{DraftDisbursementStatus, ReadyDisbursementStatus}

// TransitionTo transitions the disbursement status to the target state
func (status DisbursementStatus) TransitionTo(targetState DisbursementStatus) error {
return DisbursementStateMachineWithInitialState(status).TransitionTo(targetState.State())
Expand Down
6 changes: 3 additions & 3 deletions internal/data/disbursement_instructions.go
Original file line number Diff line number Diff line change
Expand Up @@ -123,9 +123,9 @@ func (di DisbursementInstructionModel) ProcessAll(ctx context.Context, opts Disb
}
}

// Step 4: Delete all pre-existing payments tied to this disbursement for each receiver in one call
if err = di.paymentModel.DeleteAllForDisbursement(ctx, dbTx, opts.Disbursement.ID); err != nil {
return fmt.Errorf("deleting payments: %w", err)
// Step 4: Delete all pre-existing draft payments tied to this disbursement for each receiver in one call
if err = di.paymentModel.DeleteAllDraftForDisbursement(ctx, dbTx, opts.Disbursement.ID); err != nil {
return fmt.Errorf("deleting draft payments: %w", err)
}

// Step 5: Create payments for all receivers
Expand Down
22 changes: 22 additions & 0 deletions internal/data/disbursements.go
Original file line number Diff line number Diff line change
Expand Up @@ -457,3 +457,25 @@ func (d *DisbursementModel) CompleteDisbursements(ctx context.Context, sqlExec d

return nil
}

// Delete deletes a disbursement by ID
func (d *DisbursementModel) Delete(ctx context.Context, sqlExec db.SQLExecuter, disbursementID string) error {
disbursementQuery := `DELETE FROM disbursements WHERE id = $1 AND status = ANY($2)`
result, err := sqlExec.ExecContext(ctx, disbursementQuery, disbursementID, pq.Array(NotStartedDisbursementStatuses))
if err != nil {
if strings.Contains(err.Error(), "violates foreign key constraint") {
return fmt.Errorf("deleting disbursement %s because it has associated payments: %w", disbursementID, err)
}
return fmt.Errorf("deleting disbursement %s: %w", disbursementID, err)
}

rowsAffected, err := result.RowsAffected()
if err != nil {
return fmt.Errorf("getting number of rows affected: %w", err)
}
if rowsAffected == 0 {
return ErrRecordNotFound
}

return nil
}
106 changes: 106 additions & 0 deletions internal/data/disbursements_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,11 @@ package data

import (
"context"
"fmt"
"testing"
"time"

"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"

Expand Down Expand Up @@ -662,3 +664,107 @@ func Test_DisbursementModel_CompleteDisbursements(t *testing.T) {
assert.Equal(t, CompletedDisbursementStatus, disbursement2.Status)
})
}

func Test_DisbursementModel_Delete(t *testing.T) {
dbt := dbtest.Open(t)
defer dbt.Close()

dbConnectionPool, outerErr := db.OpenDBConnectionPool(dbt.DSN)
require.NoError(t, outerErr)
defer dbConnectionPool.Close()

models, outerErr := NewModels(dbConnectionPool)
require.NoError(t, outerErr)

disbursementModel := &DisbursementModel{dbConnectionPool: dbConnectionPool}
ctx := context.Background()

wallet := CreateWalletFixture(t, ctx, dbConnectionPool, "wallet1", "https://www.wallet.com", "www.wallet.com", "wallet1://")
asset := CreateAssetFixture(t, ctx, dbConnectionPool, "USDC", "GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN")

t.Run("successfully deletes draft disbursement", func(t *testing.T) {
disbursement := CreateDraftDisbursementFixture(t, ctx, dbConnectionPool, models.Disbursements, Disbursement{
Name: uuid.NewString(),
Asset: asset,
Wallet: wallet,
})

err := disbursementModel.Delete(ctx, dbConnectionPool, disbursement.ID)
require.NoError(t, err)

_, err = models.Disbursements.Get(ctx, dbConnectionPool, disbursement.ID)
require.Error(t, err)
assert.Equal(t, ErrRecordNotFound, err)
})

t.Run("successfully deletes ready disbursement", func(t *testing.T) {
disbursement := CreateDraftDisbursementFixture(t, ctx, dbConnectionPool, models.Disbursements, Disbursement{
Name: uuid.NewString(),
Status: ReadyDisbursementStatus,
Asset: asset,
Wallet: wallet,
})

err := disbursementModel.Delete(ctx, dbConnectionPool, disbursement.ID)
require.NoError(t, err)

_, err = models.Disbursements.Get(ctx, dbConnectionPool, disbursement.ID)
require.Error(t, err)
assert.Equal(t, ErrRecordNotFound, err)
})

t.Run("returns error when disbursement not found", func(t *testing.T) {
err := disbursementModel.Delete(ctx, dbConnectionPool, "non-existent-id")
require.Error(t, err)
assert.EqualError(t, err, ErrRecordNotFound.Error())
})

t.Run("returns error when disbursement is not in draft status", func(t *testing.T) {
disbursement := CreateDraftDisbursementFixture(t, ctx, dbConnectionPool, models.Disbursements, Disbursement{
Name: uuid.NewString(),
Status: StartedDisbursementStatus,
Asset: asset,
Wallet: wallet,
})

err := disbursementModel.Delete(ctx, dbConnectionPool, disbursement.ID)
require.Error(t, err)
assert.EqualError(t, err, ErrRecordNotFound.Error())

// Verify disbursement still exists
_, err = models.Disbursements.Get(ctx, dbConnectionPool, disbursement.ID)
require.NoError(t, err)
})

t.Run("returns error when disbursement has associated payments", func(t *testing.T) {
disbursement := CreateDraftDisbursementFixture(t, ctx, dbConnectionPool, models.Disbursements, Disbursement{
Name: uuid.NewString(),
Asset: asset,
Wallet: wallet,
})

// Create a receiver and receiver wallet
receiver := CreateReceiverFixture(t, ctx, dbConnectionPool, &Receiver{})
receiverWallet := CreateReceiverWalletFixture(t, ctx, dbConnectionPool, receiver.ID, wallet.ID, DraftReceiversWalletStatus)

// Create an associated payment
CreatePaymentFixture(t, ctx, dbConnectionPool, models.Payment, &Payment{
Amount: "1",
StellarTransactionID: "stellar-transaction-id",
StellarOperationID: "operation-id",
Status: SuccessPaymentStatus,
Disbursement: disbursement,
Asset: *asset,
ReceiverWallet: receiverWallet,
})

// Attempt to delete the disbursement
err := disbursementModel.Delete(ctx, dbConnectionPool, disbursement.ID)
require.Error(t, err)
assert.ErrorContains(t, err, fmt.Sprintf("deleting disbursement %s because it has associated payments", disbursement.ID))

// Verify disbursement still exists
_, err = models.Disbursements.Get(ctx, dbConnectionPool, disbursement.ID)
require.NoError(t, err)
})
}
7 changes: 4 additions & 3 deletions internal/data/payments.go
Original file line number Diff line number Diff line change
Expand Up @@ -302,14 +302,15 @@ func (p *PaymentModel) GetAll(ctx context.Context, queryParams *QueryParams, sql
return payments, nil
}

// DeleteAllForDisbursement deletes all payments for a given disbursement.
func (p *PaymentModel) DeleteAllForDisbursement(ctx context.Context, sqlExec db.SQLExecuter, disbursementID string) error {
// DeleteAllDraftForDisbursement deletes all payments for a given disbursement.
func (p *PaymentModel) DeleteAllDraftForDisbursement(ctx context.Context, sqlExec db.SQLExecuter, disbursementID string) error {
query := `
DELETE FROM payments
WHERE disbursement_id = $1
AND status = $2
`

result, err := sqlExec.ExecContext(ctx, query, disbursementID)
result, err := sqlExec.ExecContext(ctx, query, disbursementID, DraftPaymentStatus)
if err != nil {
return fmt.Errorf("error deleting payments for disbursement: %w", err)
}
Expand Down
48 changes: 48 additions & 0 deletions internal/serve/httphandler/disbursement_handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import (
"github.com/stellar/go/support/log"
"github.com/stellar/go/support/render/httpjson"

"github.com/stellar/stellar-disbursement-platform-backend/db"
"github.com/stellar/stellar-disbursement-platform-backend/internal/data"
"github.com/stellar/stellar-disbursement-platform-backend/internal/monitor"
"github.com/stellar/stellar-disbursement-platform-backend/internal/serve/httperror"
Expand Down Expand Up @@ -186,6 +187,53 @@ func (d DisbursementHandler) PostDisbursement(w http.ResponseWriter, r *http.Req
httpjson.RenderStatus(w, http.StatusCreated, newDisbursement, httpjson.JSON)
}

// DeleteDisbursement deletes a draft or ready disbursement and its associated payments
func (d DisbursementHandler) DeleteDisbursement(w http.ResponseWriter, r *http.Request) {
disbursementID := chi.URLParam(r, "id")
ctx := r.Context()

ErrDisbursementStarted := errors.New("can't delete disbursement that has started")

disbursement, err := db.RunInTransactionWithResult(ctx, d.Models.DBConnectionPool, nil, func(tx db.DBTransaction) (*data.Disbursement, error) {
// Check if disbursement exists and is in draft or ready status
disbursement, err := d.Models.Disbursements.Get(ctx, tx, disbursementID)
if err != nil {
return nil, fmt.Errorf("getting disbursement: %w", err)
}

if !slices.Contains(data.NotStartedDisbursementStatuses, disbursement.Status) {
return nil, ErrDisbursementStarted
}

// Delete associated payments
err = d.Models.Payment.DeleteAllDraftForDisbursement(ctx, tx, disbursementID)
if err != nil {
return nil, fmt.Errorf("deleting payments: %w", err)
}

// Delete disbursement
err = d.Models.Disbursements.Delete(ctx, tx, disbursementID)
if err != nil {
return nil, fmt.Errorf("deleting draft or ready disbursement: %w", err)
}

return disbursement, nil
})
if err != nil {
switch {
case errors.Is(err, data.ErrRecordNotFound):
httperror.NotFound("Disbursement not found", err, nil).Render(w)
case errors.Is(err, ErrDisbursementStarted):
httperror.BadRequest("Cannot delete a disbursement that has started", err, nil).Render(w)
default:
httperror.InternalError(ctx, "Cannot delete disbursement", err, nil).Render(w)
}
return
}

httpjson.RenderStatus(w, http.StatusOK, disbursement, httpjson.JSON)
}

// GetDisbursements returns a paginated list of disbursements
func (d DisbursementHandler) GetDisbursements(w http.ResponseWriter, r *http.Request) {
validator := validators.NewDisbursementQueryValidator()
Expand Down
Loading

0 comments on commit 3c93d5b

Please sign in to comment.