Skip to content

Commit

Permalink
feat: add check order receipt endpoint (#2327)
Browse files Browse the repository at this point in the history
* feat: implement endpoint for checking order receipt

* test: implement tests for checkOrderReceipt

* refactor: move functions to legacy handler

* refactor: move code for easier diffs

* refactor: use functions for now

* chore: rollback some changes for submitReceipt for better diff

* fix: change from PATCH to POST

* fix: do not allow stripe on mobile orders for Leo
  • Loading branch information
pavelbrm authored Feb 2, 2024
1 parent fc29a86 commit a32650a
Show file tree
Hide file tree
Showing 8 changed files with 193 additions and 24 deletions.
94 changes: 80 additions & 14 deletions services/skus/controllers.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import (
"github.com/brave-intl/bat-go/libs/middleware"
"github.com/brave-intl/bat-go/libs/requestutils"
"github.com/brave-intl/bat-go/libs/responses"

"github.com/brave-intl/bat-go/services/skus/handler"
"github.com/brave-intl/bat-go/services/skus/model"
)
Expand Down Expand Up @@ -102,8 +103,10 @@ func Router(
// Receipt validation.
{
valid := validator.New()
r.Method(http.MethodPost, "/{orderID}/submit-receipt", metricsMwr("SubmitReceipt", corsMwrPost(SubmitReceipt(svc, valid))))
r.Method(http.MethodPost, "/receipt", metricsMwr("createOrderFromReceipt", corsMwrPost(createOrderFromReceipt(svc, valid))))

r.Method(http.MethodPost, "/{orderID}/submit-receipt", metricsMwr("SubmitReceipt", corsMwrPost(handleSubmitReceipt(svc, valid))))
r.Method(http.MethodPost, "/receipt", metricsMwr("createOrderFromReceipt", corsMwrPost(handleCreateOrderFromReceipt(svc, valid))))
r.Method(http.MethodPost, "/{orderID}/receipt", metricsMwr("checkOrderReceipt", authMwr(handleCheckOrderReceipt(svc, valid))))
}

r.Route("/{orderID}/credentials", func(cr chi.Router) {
Expand Down Expand Up @@ -1399,18 +1402,18 @@ func HandleStripeWebhook(service *Service) handlers.AppHandler {
}
}

// SubmitReceipt handles receipt submission requests.
func SubmitReceipt(svc *Service, valid *validator.Validate) handlers.AppHandler {
return handlers.AppHandler(func(w http.ResponseWriter, r *http.Request) *handlers.AppError {
func handleSubmitReceipt(svc *Service, valid *validator.Validate) handlers.AppHandler {
return func(w http.ResponseWriter, r *http.Request) *handlers.AppError {
ctx := r.Context()

l := logging.Logger(ctx, "skus").With().Str("func", "SubmitReceipt").Logger()

orderID := &inputs.ID{}
if err := inputs.DecodeAndValidateString(ctx, orderID, chi.URLParam(r, "orderID")); err != nil {
orderID, err := uuid.FromString(chi.URLParamFromCtx(ctx, "orderID"))
if err != nil {
l.Warn().Err(err).Msg("failed to decode orderID")

return handlers.ValidationError("request", map[string]interface{}{"orderID": err.Error()})
// Preserve the legacy error in case anything depends on it.
return handlers.ValidationError("request", map[string]interface{}{"orderID": inputs.ErrIDDecodeNotUUID})
}

payload, err := requestutils.Read(ctx, r.Body)
Expand Down Expand Up @@ -1465,7 +1468,7 @@ func SubmitReceipt(svc *Service, valid *validator.Validate) handlers.AppHandler

mdata := newMobileOrderMdata(req, extID)

if err := svc.UpdateOrderStatusPaidWithMetadata(ctx, orderID.UUID(), mdata); err != nil {
if err := svc.UpdateOrderStatusPaidWithMetadata(ctx, &orderID, mdata); err != nil {
l.Warn().Err(err).Msg("failed to update order with vendor metadata")
return handlers.WrapError(err, "failed to store status of order", http.StatusInternalServerError)
}
Expand All @@ -1476,19 +1479,19 @@ func SubmitReceipt(svc *Service, valid *validator.Validate) handlers.AppHandler
}{ExternalID: extID, Vendor: req.Type.String()}

return handlers.RenderContent(ctx, result, w, http.StatusOK)
})
}
}

func createOrderFromReceipt(svc *Service, valid *validator.Validate) handlers.AppHandler {
func handleCreateOrderFromReceipt(svc *Service, valid *validator.Validate) handlers.AppHandler {
return func(w http.ResponseWriter, r *http.Request) *handlers.AppError {
return createOrderFromReceiptH(w, r, svc, valid)
return handleCreateOrderFromReceiptH(w, r, svc, valid)
}
}

func createOrderFromReceiptH(w http.ResponseWriter, r *http.Request, svc *Service, valid *validator.Validate) *handlers.AppError {
func handleCreateOrderFromReceiptH(w http.ResponseWriter, r *http.Request, svc *Service, valid *validator.Validate) *handlers.AppError {
ctx := r.Context()

lg := logging.Logger(ctx, "skus").With().Str("func", "createOrderFromReceipt").Logger()
lg := logging.Logger(ctx, "skus").With().Str("func", "handleCreateOrderFromReceipt").Logger()

raw, err := io.ReadAll(io.LimitReader(r.Body, reqBodyLimit10MB))
if err != nil {
Expand Down Expand Up @@ -1545,7 +1548,70 @@ func createOrderFromReceiptH(w http.ResponseWriter, r *http.Request, svc *Servic
result := model.CreateOrderWithReceiptResponse{ID: ord.ID.String()}

return handlers.RenderContent(ctx, result, w, http.StatusCreated)
}

func handleCheckOrderReceipt(svc *Service, valid *validator.Validate) handlers.AppHandler {
return func(w http.ResponseWriter, r *http.Request) *handlers.AppError {
return handleCheckOrderReceiptH(w, r, svc, valid)
}
}

func handleCheckOrderReceiptH(w http.ResponseWriter, r *http.Request, svc *Service, valid *validator.Validate) *handlers.AppError {
ctx := r.Context()

lg := logging.Logger(ctx, "skus").With().Str("func", "handleCheckOrderReceipt").Logger()

orderID, err := uuid.FromString(chi.URLParamFromCtx(ctx, "orderID"))
if err != nil {
lg.Warn().Err(err).Msg("failed to parse orderID")

return handlers.ValidationError("request", map[string]interface{}{"orderID": err.Error()})
}

raw, err := io.ReadAll(io.LimitReader(r.Body, reqBodyLimit10MB))
if err != nil {
lg.Warn().Err(err).Msg("failed to read request")

return handlers.ValidationError("request", map[string]interface{}{"request-body": err.Error()})
}

req, err := parseSubmitReceiptRequest(raw)
if err != nil {
lg.Warn().Err(err).Msg("failed to deserialize request")

return handlers.ValidationError("request", map[string]interface{}{"request-body": err.Error()})
}

if err := valid.StructCtx(ctx, &req); err != nil {
verrs, ok := collectValidationErrors(err)
if !ok {
return handlers.ValidationError("request", map[string]interface{}{"request-body": err.Error()})
}

return handlers.ValidationError("request", verrs)
}

extID, err := svc.validateReceipt(ctx, req)
if err != nil {
lg.Warn().Err(err).Msg("failed to validate receipt with vendor")

return handleReceiptErr(err)
}

if err := svc.checkOrderReceipt(ctx, orderID, extID); err != nil {
lg.Warn().Err(err).Msg("failed to check order receipt")

switch {
case errors.Is(err, model.ErrOrderNotFound):
return handlers.WrapError(err, "order not found by receipt", http.StatusNotFound)
case errors.Is(err, model.ErrNoMatchOrderReceipt):
return handlers.WrapError(err, "order_id does not match receipt order", http.StatusFailedDependency)
default:
return handlers.WrapError(model.ErrSomethingWentWrong, "failed to check order receipt", http.StatusInternalServerError)
}
}

return handlers.RenderContent(ctx, struct{}{}, w, http.StatusOK)
}

func NewCORSMwr(opts cors.Options, methods ...string) func(next http.Handler) http.Handler {
Expand Down
4 changes: 1 addition & 3 deletions services/skus/handler/handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,6 @@ import (

const (
reqBodyLimit10MB = 10 << 20

errSomethingWentWrong model.Error = "something went wrong"
)

type orderService interface {
Expand Down Expand Up @@ -116,7 +114,7 @@ func (h *Order) CreateNew(w http.ResponseWriter, r *http.Request) *handlers.AppE
return handlers.WrapError(err, "Invalid order data supplied", http.StatusUnprocessableEntity)
}

return handlers.WrapError(errSomethingWentWrong, "Couldn't finish creating order", http.StatusInternalServerError)
return handlers.WrapError(model.ErrSomethingWentWrong, "Couldn't finish creating order", http.StatusInternalServerError)
}

return handlers.RenderContent(ctx, result, w, http.StatusCreated)
Expand Down
2 changes: 2 additions & 0 deletions services/skus/model/model.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import (
)

const (
ErrSomethingWentWrong Error = "something went wrong"
ErrOrderNotFound Error = "model: order not found"
ErrOrderItemNotFound Error = "model: order item not found"
ErrIssuerNotFound Error = "model: issuer not found"
Expand All @@ -38,6 +39,7 @@ const (
ErrInvalidNumPerInterval Error = "model: invalid order: invalid numPerInterval"
ErrInvalidNumIntervals Error = "model: invalid order: invalid numIntervals"
ErrInvalidMobileProduct Error = "model: invalid mobile product"
ErrNoMatchOrderReceipt Error = "model: order_id does not match receipt order"

// The text of the following errors is preserved as is, in case anything depends on them.
ErrInvalidSKU Error = "Invalid SKU Token provided in request"
Expand Down
4 changes: 1 addition & 3 deletions services/skus/receipt.go
Original file line number Diff line number Diff line change
Expand Up @@ -158,8 +158,6 @@ func (v *receiptVerifier) validateGoogle(ctx context.Context, req model.ReceiptR
}

// Check order expiration.
// There seems to be a mistake here?
// Unix expects nanoseconds as the second param, but ms are passed.
if time.Unix(0, resp.ExpiryTimeMillis*int64(time.Millisecond)).Before(time.Now()) {
return "", errPurchaseExpired
}
Expand All @@ -177,7 +175,7 @@ func (v *receiptVerifier) validateGoogle(ctx context.Context, req model.ReceiptR
return req.Blob, nil

case androidPaymentStatePending:
// Checl for cancel reason.
// Check for cancel reason.
switch resp.CancelReason {
case androidCancelReasonUser:
return "", errPurchaseUserCanceled
Expand Down
17 changes: 17 additions & 0 deletions services/skus/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -1897,6 +1897,23 @@ func (s *Service) createOrderWithReceipt(ctx context.Context, req model.ReceiptR
return createOrderWithReceipt(ctx, s, s.newItemReqSet, s.payProcCfg, req, extID)
}

func (s *Service) checkOrderReceipt(ctx context.Context, orderID uuid.UUID, extID string) error {
return checkOrderReceipt(ctx, s.Datastore.RawDB(), s.orderRepo, orderID, extID)
}

func checkOrderReceipt(ctx context.Context, dbi sqlx.QueryerContext, repo orderStoreSvc, orderID uuid.UUID, extID string) error {
ord, err := repo.GetByExternalID(ctx, dbi, extID)
if err != nil {
return err
}

if !uuid.Equal(orderID, ord.ID) {
return model.ErrNoMatchOrderReceipt
}

return nil
}

// paidOrderCreator creates an order and sets its status to paid.
//
// This interface exists because in its current form Service is hardly testable.
Expand Down
92 changes: 92 additions & 0 deletions services/skus/service_nonint_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"testing"
"time"

"github.com/jmoiron/sqlx"
"github.com/lib/pq"
uuid "github.com/satori/go.uuid"
"github.com/shopspring/decimal"
Expand All @@ -15,6 +16,7 @@ import (
"github.com/brave-intl/bat-go/libs/datastore"

"github.com/brave-intl/bat-go/services/skus/model"
"github.com/brave-intl/bat-go/services/skus/storage/repository"
)

func TestCheckNumBlindedCreds(t *testing.T) {
Expand Down Expand Up @@ -850,6 +852,96 @@ func TestCreateOrderWithReceipt(t *testing.T) {
}
}

func TestService_checkOrderReceipt(t *testing.T) {
type tcGiven struct {
orderID uuid.UUID
extID string
repo *repository.MockOrder
}

type testCase struct {
name string
given tcGiven
exp error
}

tests := []testCase{
{
name: "order_not_found",
given: tcGiven{
orderID: uuid.Must(uuid.FromString("facade00-0000-4000-a000-000000000000")),
extID: "extID_01",
repo: &repository.MockOrder{
FnGetByExternalID: func(ctx context.Context, dbi sqlx.QueryerContext, extID string) (*model.Order, error) {
return nil, model.ErrOrderNotFound
},
},
},
exp: model.ErrOrderNotFound,
},

{
name: "some_error",
given: tcGiven{
orderID: uuid.Must(uuid.FromString("facade00-0000-4000-a000-000000000000")),
extID: "extID_01",
repo: &repository.MockOrder{
FnGetByExternalID: func(ctx context.Context, dbi sqlx.QueryerContext, extID string) (*model.Order, error) {
return nil, model.Error("some error")
},
},
},
exp: model.Error("some error"),
},

{
name: "order_receipt_dont_match",
given: tcGiven{
orderID: uuid.Must(uuid.FromString("facade00-0000-4000-a000-000000000000")),
extID: "extID_01",
repo: &repository.MockOrder{
FnGetByExternalID: func(ctx context.Context, dbi sqlx.QueryerContext, extID string) (*model.Order, error) {
result := &model.Order{
ID: uuid.Must(uuid.FromString("decade00-0000-4000-a000-000000000000")),
}

return result, nil
},
},
},
exp: model.ErrNoMatchOrderReceipt,
},

{
name: "happy_path",
given: tcGiven{
orderID: uuid.Must(uuid.FromString("facade00-0000-4000-a000-000000000000")),
extID: "extID_01",
repo: &repository.MockOrder{
FnGetByExternalID: func(ctx context.Context, dbi sqlx.QueryerContext, extID string) (*model.Order, error) {
result := &model.Order{
ID: uuid.Must(uuid.FromString("facade00-0000-4000-a000-000000000000")),
}

return result, nil
},
},
},
},
}

for i := range tests {
tc := tests[i]

t.Run(tc.name, func(t *testing.T) {
ctx := context.Background()

actual := checkOrderReceipt(ctx, nil, tc.given.repo, tc.given.orderID, tc.given.extID)
should.Equal(t, tc.exp, actual)
})
}
}

type mockPaidOrderCreator struct {
fnCreateOrder func(ctx context.Context, req *model.CreateOrderRequestNew, ordNew *model.OrderNew, items []model.OrderItem) (*model.Order, error)
fnUpdateOrderStatusPaidWithMetadata func(ctx context.Context, oid *uuid.UUID, mdata datastore.Metadata) error
Expand Down
1 change: 0 additions & 1 deletion services/skus/skus.go
Original file line number Diff line number Diff line change
Expand Up @@ -162,7 +162,6 @@ func newCreateOrderReqNewLeo(ppcfg *premiumPaymentProcConfig, item model.OrderIt
SuccessURI: ppcfg.successURI,
CancelURI: ppcfg.cancelURI,
},
PaymentMethods: []string{"stripe"},

Items: []model.OrderItemRequestNew{item},
}
Expand Down
3 changes: 0 additions & 3 deletions services/skus/skus_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -277,7 +277,6 @@ func TestNewCreateOrderReqNewLeo(t *testing.T) {
SuccessURI: "https://account.brave.software/account/?intent=provision",
CancelURI: "https://account.brave.software/plans/?intent=checkout",
},
PaymentMethods: []string{"stripe"},

Items: []model.OrderItemRequestNew{
{
Expand Down Expand Up @@ -313,7 +312,6 @@ func TestNewCreateOrderReqNewLeo(t *testing.T) {
SuccessURI: "https://account.bravesoftware.com/account/?intent=provision",
CancelURI: "https://account.bravesoftware.com/plans/?intent=checkout",
},
PaymentMethods: []string{"stripe"},

Items: []model.OrderItemRequestNew{
{
Expand Down Expand Up @@ -349,7 +347,6 @@ func TestNewCreateOrderReqNewLeo(t *testing.T) {
SuccessURI: "https://account.brave.com/account/?intent=provision",
CancelURI: "https://account.brave.com/plans/?intent=checkout",
},
PaymentMethods: []string{"stripe"},

Items: []model.OrderItemRequestNew{
{
Expand Down

0 comments on commit a32650a

Please sign in to comment.