Skip to content

Commit

Permalink
[SDP-1025]: New endpoint to cancel individual payment in ready status (
Browse files Browse the repository at this point in the history
…#130)

What
Implement a new endpoint PATCH /payments/{id}/status that allows the user to change the status of an individual payment

Why
UNHCR asked for the ability to cancel individual payments.
  • Loading branch information
ceciliaromao authored Dec 22, 2023
1 parent c26c2f5 commit e308c77
Show file tree
Hide file tree
Showing 8 changed files with 543 additions and 1 deletion.
10 changes: 10 additions & 0 deletions internal/data/payments_state_machine.go
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,16 @@ func (status PaymentStatus) SourceStatuses() []PaymentStatus {
return fromStates
}

// ToPaymentStatus converts a string to a PaymentStatus
func ToPaymentStatus(s string) (PaymentStatus, error) {
err := PaymentStatus(s).Validate()
if err != nil {
return "", err
}

return PaymentStatus(strings.ToUpper(s)), nil
}

func (status PaymentStatus) State() State {
return State(status)
}
153 changes: 153 additions & 0 deletions internal/data/payments_state_machine_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package data

import (
"fmt"
"testing"

"github.com/stretchr/testify/require"
Expand Down Expand Up @@ -59,3 +60,155 @@ func Test_PaymentStatus_PaymentStatuses(t *testing.T) {
expectedStatuses := []PaymentStatus{DraftPaymentStatus, ReadyPaymentStatus, PendingPaymentStatus, PausedPaymentStatus, SuccessPaymentStatus, FailedPaymentStatus, CanceledPaymentStatus}
require.Equal(t, expectedStatuses, PaymentStatuses())
}

func Test_PaymentStatus_ToPaymentStatus(t *testing.T) {
tests := []struct {
name string
actual string
want PaymentStatus
err error
}{
{
name: "valid entry",
actual: "CANCELED",
want: CanceledPaymentStatus,
err: nil,
},
{
name: "valid lower case",
actual: "canceled",
want: CanceledPaymentStatus,
err: nil,
},
{
name: "valid weird case",
actual: "CancEled",
want: CanceledPaymentStatus,
err: nil,
},
{
name: "invalid entry",
actual: "NOT_VALID",
want: CanceledPaymentStatus,
err: fmt.Errorf("invalid payment status: NOT_VALID"),
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := ToPaymentStatus(tt.actual)

if tt.err != nil {
require.EqualError(t, err, tt.err.Error())
return
} else {
require.NoError(t, err)
require.Equal(t, tt.want, got)
}
})
}
}

func Test_PaymentStatus_TransitionTo(t *testing.T) {
tests := []struct {
name string
actual PaymentStatus
target PaymentStatus
err error
}{
{
name: "disbursement started transition - success",
actual: DraftPaymentStatus,
target: ReadyPaymentStatus,
err: nil,
},
{
name: "disbursement started transition - success",
actual: DraftPaymentStatus,
target: ReadyPaymentStatus,
err: nil,
},
{
name: "payment gets submitted if user is ready",
actual: ReadyPaymentStatus,
target: PendingPaymentStatus,
err: nil,
},
{
name: "user pauses payment transition",
actual: ReadyPaymentStatus,
target: PausedPaymentStatus,
err: nil,
},
{
name: "user cancels payment transition",
actual: ReadyPaymentStatus,
target: CanceledPaymentStatus,
err: nil,
},
{
name: "user resumes payment transition",
actual: PausedPaymentStatus,
target: ReadyPaymentStatus,
err: nil,
},
{
name: "payment fails transition",
actual: PendingPaymentStatus,
target: FailedPaymentStatus,
err: nil,
},
{
name: "payment is retried transition",
actual: FailedPaymentStatus,
target: PendingPaymentStatus,
err: nil,
},
{
name: "payment succeeds transition",
actual: PendingPaymentStatus,
target: SuccessPaymentStatus,
err: nil,
},
{
name: "invalid cancellation 1",
actual: DraftPaymentStatus,
target: CanceledPaymentStatus,
err: fmt.Errorf("cannot transition from DRAFT to CANCELED"),
},
{
name: "invalid cancellation 2",
actual: PendingPaymentStatus,
target: CanceledPaymentStatus,
err: fmt.Errorf("cannot transition from PENDING to CANCELED"),
},
{
name: "invalid cancellation 3",
actual: PausedPaymentStatus,
target: CanceledPaymentStatus,
err: fmt.Errorf("cannot transition from PAUSED to CANCELED"),
},
{
name: "invalid cancellation 4",
actual: FailedPaymentStatus,
target: CanceledPaymentStatus,
err: fmt.Errorf("cannot transition from FAILED to CANCELED"),
},
{
name: "invalid cancellation 5",
actual: SuccessPaymentStatus,
target: CanceledPaymentStatus,
err: fmt.Errorf("cannot transition from SUCCESS to CANCELED"),
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := tt.actual.TransitionTo(tt.target)
if tt.err != nil {
require.EqualError(t, err, tt.err.Error())
return
} else {
require.NoError(t, err)
}
})
}
}
57 changes: 57 additions & 0 deletions internal/serve/httphandler/payments_handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package httphandler

import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
Expand All @@ -16,6 +17,7 @@ import (
"github.com/stellar/stellar-disbursement-platform-backend/internal/serve/httpresponse"
"github.com/stellar/stellar-disbursement-platform-backend/internal/serve/middleware"
"github.com/stellar/stellar-disbursement-platform-backend/internal/serve/validators"
"github.com/stellar/stellar-disbursement-platform-backend/internal/services"
"github.com/stellar/stellar-disbursement-platform-backend/internal/utils"
"github.com/stellar/stellar-disbursement-platform-backend/stellar-auth/pkg/auth"
)
Expand Down Expand Up @@ -153,3 +155,58 @@ func (p PaymentsHandler) getPaymentsWithCount(ctx context.Context, queryParams *
return utils.NewResultWithTotal(totalPayments, payments), nil
})
}

type PatchPaymentStatusRequest struct {
Status string `json:"status"`
}

type UpdatePaymentStatusResponseBody struct {
Message string `json:"message"`
}

func (p PaymentsHandler) PatchPaymentStatus(w http.ResponseWriter, r *http.Request) {
var patchRequest PatchPaymentStatusRequest
err := json.NewDecoder(r.Body).Decode(&patchRequest)
if err != nil {
httperror.BadRequest("invalid request body", err, nil).Render(w)
return
}

// validate request
toStatus, err := data.ToPaymentStatus(patchRequest.Status)
if err != nil {
httperror.BadRequest("invalid status", err, nil).Render(w)
return
}

paymentManagementService := services.NewPaymentManagementService(p.Models, p.DBConnectionPool)
response := UpdatePaymentStatusResponseBody{}

ctx := r.Context()
paymentID := chi.URLParam(r, "id")

switch toStatus {
case data.CanceledPaymentStatus:
err = paymentManagementService.CancelPayment(ctx, paymentID)
response.Message = "Payment canceled"
default:
err = services.ErrPaymentStatusCantBeChanged
}

if err != nil {
switch {
case errors.Is(err, services.ErrPaymentNotFound):
httperror.NotFound(services.ErrPaymentNotFound.Error(), err, nil).Render(w)
case errors.Is(err, services.ErrPaymentNotReadyToCancel):
httperror.BadRequest(services.ErrPaymentNotReadyToCancel.Error(), err, nil).Render(w)
case errors.Is(err, services.ErrPaymentStatusCantBeChanged):
httperror.BadRequest(services.ErrPaymentStatusCantBeChanged.Error(), err, nil).Render(w)
default:
msg := fmt.Sprintf("Cannot update payment ID %s with status: %s", paymentID, toStatus)
httperror.InternalError(ctx, msg, err, nil).Render(w)
}
return
}

httpjson.RenderStatus(w, http.StatusOK, response, httpjson.JSON)
}
Loading

0 comments on commit e308c77

Please sign in to comment.