Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add check order receipt endpoint #2327

Merged
merged 8 commits into from
Feb 2, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading