Skip to content

Commit

Permalink
Cancel Order (#889)
Browse files Browse the repository at this point in the history
* adding ability to cancel an order, if cancelled, and stripe subscription, cancel subscription

* correct package, only do on cancel

* update checkout session in event it is expired

* ignore no rows error on expired check

* adding brave talk premium sku (single-use) for production

* fixing 500s on checkout session renewal, and cancel order

* restrict cancel endpoint to authorized, move business logic to service

* Update cmd/macaroon/brave-talk/premium_prod.yaml

Co-authored-by: Albert Wang <amwang217@gmail.com>

Co-authored-by: Albert Wang <amwang217@gmail.com>
  • Loading branch information
husobee and yachtcaptain23 authored Jul 29, 2021
1 parent 5af0c39 commit f3b81bc
Show file tree
Hide file tree
Showing 6 changed files with 196 additions and 3 deletions.
19 changes: 19 additions & 0 deletions cmd/macaroon/brave-talk/premium_prod.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
tokens:
id: "brave-talk-premium-prod sku token v1"
version: 1
location: "talk.brave.com"
first_party_caveats:
sku: "brave-talk-premium-prod"
price: 7.00
currency: "USD"
description: "Premium access to Brave Talk"
credential_type: "single-use"
allowed_payment_methods: "stripe"
metadata: >-
{
"stripe_product_id": "prod_Jw4zQxdHkpxSOe",
"stripe_item_id": "price_1JICpEBSm1mtrN9nwCKvpYQ4",
"stripe_success_uri": "https://account.brave.com/account/?intent=provision",
"stripe_cancel_uri": "https://account.brave.com/plans/?intent=checkout"
}
27 changes: 25 additions & 2 deletions payment/controllers.go
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ func Router(service *Service) chi.Router {

r.Method("OPTIONS", "/{orderID}", middleware.InstrumentHandler("GetOrderOptions", corsMiddleware([]string{"GET"})(nil)))
r.Method("GET", "/{orderID}", middleware.InstrumentHandler("GetOrder", corsMiddleware([]string{"GET"})(GetOrder(service))))
r.Method("DELETE", "/{orderID}", middleware.InstrumentHandler("CancelOrder", corsMiddleware([]string{"DELETE"})(middleware.SimpleTokenAuthorizedOnly(CancelOrder(service)))))

r.Method("GET", "/{orderID}/transactions", middleware.InstrumentHandler("GetTransactions", GetTransactions(service)))
r.Method("POST", "/{orderID}/transactions/uphold", middleware.InstrumentHandler("CreateUpholdTransaction", CreateUpholdTransaction(service)))
Expand Down Expand Up @@ -259,6 +260,28 @@ func CreateOrder(service *Service) handlers.AppHandler {
})
}

// CancelOrder is the handler for cancelling an order
func CancelOrder(service *Service) handlers.AppHandler {
return handlers.AppHandler(func(w http.ResponseWriter, r *http.Request) *handlers.AppError {
var orderID = new(inputs.ID)
if err := inputs.DecodeAndValidateString(context.Background(), orderID, chi.URLParam(r, "orderID")); err != nil {
return handlers.ValidationError(
"Error validating request url parameter",
map[string]interface{}{
"orderID": err.Error(),
},
)
}

err := service.CancelOrder(*orderID.UUID())
if err != nil {
return handlers.WrapError(err, "Error retrieving the order", http.StatusInternalServerError)
}

return handlers.RenderContent(r.Context(), nil, w, http.StatusOK)
})
}

// GetOrder is the handler for getting an order
func GetOrder(service *Service) handlers.AppHandler {
return handlers.AppHandler(func(w http.ResponseWriter, r *http.Request) *handlers.AppError {
Expand All @@ -272,7 +295,7 @@ func GetOrder(service *Service) handlers.AppHandler {
)
}

order, err := service.Datastore.GetOrder(*orderID.UUID())
order, err := service.GetOrder(*orderID.UUID())
if err != nil {
return handlers.WrapError(err, "Error retrieving the order", http.StatusInternalServerError)
}
Expand Down Expand Up @@ -880,7 +903,7 @@ func HandleStripeWebhook(service *Service) handlers.AppHandler {
if err != nil {
return handlers.WrapError(err, "error retrieving orderID", http.StatusInternalServerError)
}
err = service.Datastore.UpdateOrder(orderID, "canceled")
err = service.Datastore.UpdateOrder(orderID, OrderStatusCanceled)
if err != nil {
return handlers.WrapError(err, "error updating order status", http.StatusInternalServerError)
}
Expand Down
54 changes: 54 additions & 0 deletions payment/datastore.go
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,9 @@ type Datastore interface {
CommitVote(ctx context.Context, vr VoteRecord, tx *sqlx.Tx) error
MarkVoteErrored(ctx context.Context, vr VoteRecord, tx *sqlx.Tx) error
InsertVote(ctx context.Context, vr VoteRecord) error

CheckExpiredCheckoutSession(uuid.UUID) (bool, string, error)
IsStripeSub(uuid.UUID) (bool, string, error)
}

// VoteRecord - how the ac votes are stored in the queue
Expand Down Expand Up @@ -350,9 +353,60 @@ func (pg *Postgres) GetTransaction(externalTransactionID string) (*Transaction,
return &transaction, nil
}

// CheckExpiredCheckoutSession - check order metadata for an expired checkout session id
func (pg *Postgres) CheckExpiredCheckoutSession(orderID uuid.UUID) (bool, string, error) {
var (
expired bool
checkoutSession string
err error
)

err = pg.RawDB().Get(&checkoutSession, `
SELECT metadata->>'stripeCheckoutSessionId'
FROM orders
WHERE id = $1
AND metadata is not null
AND status='pending'
AND updated_at<now() - interval '1 hour'
`, orderID)

if err == nil && checkoutSession != "" {
expired = true
}
if errors.Is(err, sql.ErrNoRows) {
// if there are no rows, then we are not expired
// drop this error
return expired, checkoutSession, nil
}
return expired, checkoutSession, err
}

// IsStripeSub - is this order related to a stripe subscription, if so, true, subscription id returned
func (pg *Postgres) IsStripeSub(orderID uuid.UUID) (bool, string, error) {
var (
ok bool
md datastore.Metadata
err error
)

err = pg.RawDB().Get(&md, `
SELECT metadata
FROM orders
WHERE id = $1 AND metadata is not null
`, orderID)

if err == nil {
if v, ok := md["stripeSubscriptionId"]; ok {
return ok, v, err
}
}
return ok, "", err
}

// UpdateOrder updates the orders status.
// Status should either be one of pending, paid, fulfilled, or canceled.
func (pg *Postgres) UpdateOrder(orderID uuid.UUID, status string) error {

result, err := pg.RawDB().Exec(`UPDATE orders set status = $1, updated_at = CURRENT_TIMESTAMP where id = $2`, status, orderID)

if err != nil {
Expand Down
28 changes: 28 additions & 0 deletions payment/instrumented_datastore.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,20 @@ func NewDatastoreWithPrometheus(base Datastore, instanceName string) DatastoreWi
}
}

// CheckExpiredCheckoutSession implements Datastore
func (_d DatastoreWithPrometheus) CheckExpiredCheckoutSession(u1 uuid.UUID) (b1 bool, s1 string, err error) {
_since := time.Now()
defer func() {
result := "ok"
if err != nil {
result = "error"
}

datastoreDurationSummaryVec.WithLabelValues(_d.instanceName, "CheckExpiredCheckoutSession", result).Observe(time.Since(_since).Seconds())
}()
return _d.base.CheckExpiredCheckoutSession(u1)
}

// CommitVote implements Datastore
func (_d DatastoreWithPrometheus) CommitVote(ctx context.Context, vr VoteRecord, tx *sqlx.Tx) (err error) {
_since := time.Now()
Expand Down Expand Up @@ -323,6 +337,20 @@ func (_d DatastoreWithPrometheus) InsertVote(ctx context.Context, vr VoteRecord)
return _d.base.InsertVote(ctx, vr)
}

// IsStripeSub implements Datastore
func (_d DatastoreWithPrometheus) IsStripeSub(u1 uuid.UUID) (b1 bool, s1 string, err error) {
_since := time.Now()
defer func() {
result := "ok"
if err != nil {
result = "error"
}

datastoreDurationSummaryVec.WithLabelValues(_d.instanceName, "IsStripeSub", result).Observe(time.Since(_since).Seconds())
}()
return _d.base.IsStripeSub(u1)
}

// MarkVoteErrored implements Datastore
func (_d DatastoreWithPrometheus) MarkVoteErrored(ctx context.Context, vr VoteRecord, tx *sqlx.Tx) (err error) {
_since := time.Now()
Expand Down
69 changes: 68 additions & 1 deletion payment/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,17 @@ package payment

import (
"context"
"database/sql"
"fmt"
"net/url"
"os"
"strings"
"sync"
"time"

"github.com/stripe/stripe-go/client"
session "github.com/stripe/stripe-go/v71/checkout/session"
client "github.com/stripe/stripe-go/v71/client"
sub "github.com/stripe/stripe-go/v71/sub"

"errors"

Expand All @@ -35,6 +38,11 @@ var (
voteTopic = os.Getenv("ENV") + ".payment.vote"
)

const (
// OrderStatusCanceled - string literal used in db for canceled status
OrderStatusCanceled = "canceled"
)

// Service contains datastore
type Service struct {
wallet *wallet.Service
Expand Down Expand Up @@ -230,8 +238,67 @@ func (s *Service) CreateOrderFromRequest(ctx context.Context, req CreateOrderReq
return order, err
}

// GetOrder - business logic for getting an order, needs to validate the checkout session is not expired
func (s *Service) GetOrder(orderID uuid.UUID) (*Order, error) {
// get the order
order, err := s.Datastore.GetOrder(orderID)
if err != nil {
return nil, err
}

// check if this order has an expired checkout session
expired, cs, err := s.Datastore.CheckExpiredCheckoutSession(orderID)
if expired {
// if expired update with new checkout session
if !order.IsPaid() && order.IsStripePayable() {

// get old checkout session from stripe by id
stripeSession, err := session.Get(cs, nil)
if err != nil {
return nil, fmt.Errorf("failed to get stripe checkout session: %w", err)
}

checkoutSession, err := order.CreateStripeCheckoutSession(
stripeSession.CustomerEmail,
stripeSession.SuccessURL, stripeSession.CancelURL)
if err != nil {
return nil, fmt.Errorf("failed to create checkout session: %w", err)
}

err = s.Datastore.UpdateOrderMetadata(order.ID, "stripeCheckoutSessionId", checkoutSession.SessionID)
if err != nil {
return nil, fmt.Errorf("failed to update order metadata: %w", err)
}
}

// get the order
order, err = s.Datastore.GetOrder(orderID)
if err != nil {
return nil, err
}
}
return order, err
}

// CancelOrder - cancels an order, propogates to stripe if needed
func (s *Service) CancelOrder(orderID uuid.UUID) error {
// check the order, do we have a stripe subscription?
ok, subID, err := s.Datastore.IsStripeSub(orderID)
if err != nil && err != sql.ErrNoRows {
return fmt.Errorf("failed to check stripe subscription: %w", err)
}
if ok && subID != "" {
// cancel the stripe subscription
if _, err := sub.Cancel(subID, nil); err != nil {
return fmt.Errorf("failed to cancel stripe subscription: %w", err)
}
}
return s.Datastore.UpdateOrder(orderID, OrderStatusCanceled)
}

// UpdateOrderStatus checks to see if an order has been paid and updates it if so
func (s *Service) UpdateOrderStatus(orderID uuid.UUID) error {
// get the order
order, err := s.Datastore.GetOrder(orderID)
if err != nil {
return err
Expand Down
2 changes: 2 additions & 0 deletions payment/skus.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ const (
prodUserWalletVote = "AgEJYnJhdmUuY29tAiNicmF2ZSB1c2VyLXdhbGxldC12b3RlIHNrdSB0b2tlbiB2MQACFHNrdT11c2VyLXdhbGxldC12b3RlAAIKcHJpY2U9MC4yNQACDGN1cnJlbmN5PUJBVAACDGRlc2NyaXB0aW9uPQACGmNyZWRlbnRpYWxfdHlwZT1zaW5nbGUtdXNlAAAGIOaNAUCBMKm0IaLqxefhvxOtAKB0OfoiPn0NPVfI602J"
prodAnonCardVote = "AgEJYnJhdmUuY29tAiFicmF2ZSBhbm9uLWNhcmQtdm90ZSBza3UgdG9rZW4gdjEAAhJza3U9YW5vbi1jYXJkLXZvdGUAAgpwcmljZT0wLjI1AAIMY3VycmVuY3k9QkFUAAIMZGVzY3JpcHRpb249AAIaY3JlZGVudGlhbF90eXBlPXNpbmdsZS11c2UAAAYgrMZm85YYwnmjPXcegy5pBM5C+ZLfrySZfYiSe13yp8o="
prodBraveTogetherPaid = "MDAyMGxvY2F0aW9uIHRvZ2V0aGVyLmJyYXZlLmNvbQowMDMwaWRlbnRpZmllciBicmF2ZS10b2dldGhlci1wYWlkIHNrdSB0b2tlbiB2MQowMDIwY2lkIHNrdT1icmF2ZS10b2dldGhlci1wYWlkCjAwMTBjaWQgcHJpY2U9NQowMDE1Y2lkIGN1cnJlbmN5PVVTRAowMDQzY2lkIGRlc2NyaXB0aW9uPU9uZSBtb250aCBwYWlkIHN1YnNjcmlwdGlvbiBmb3IgQnJhdmUgVG9nZXRoZXIKMDAyNWNpZCBjcmVkZW50aWFsX3R5cGU9dGltZS1saW1pdGVkCjAwMjZjaWQgY3JlZGVudGlhbF92YWxpZF9kdXJhdGlvbj1QMU0KMDAyZnNpZ25hdHVyZSAl/eGfP93lrklACcFClNPvkP3Go0HCtfYVQMs5n/NJpgo="
prodBraveTalkPremium = "MDAxY2xvY2F0aW9uIHRhbGsuYnJhdmUuY29tCjAwMzRpZGVudGlmaWVyIGJyYXZlLXRhbGstcHJlbWl1bS1wcm9kIHNrdSB0b2tlbiB2MQowMDI0Y2lkIHNrdT1icmF2ZS10YWxrLXByZW1pdW0tcHJvZAowMDEzY2lkIHByaWNlPTcuMDAKMDAxNWNpZCBjdXJyZW5jeT1VU0QKMDAzMWNpZCBkZXNjcmlwdGlvbj1QcmVtaXVtIGFjY2VzcyB0byBCcmF2ZSBUYWxrCjAwMjNjaWQgY3JlZGVudGlhbF90eXBlPXNpbmdsZS11c2UKMDAyN2NpZCBhbGxvd2VkX3BheW1lbnRfbWV0aG9kcz1zdHJpcGUKMDEwYmNpZCBtZXRhZGF0YT0geyAic3RyaXBlX3Byb2R1Y3RfaWQiOiAicHJvZF9KdzR6UXhkSGtweFNPZSIsICJzdHJpcGVfaXRlbV9pZCI6ICJwcmljZV8xSklDcEVCU20xbXRyTjlud0NLdnBZUTQiLCAic3RyaXBlX3N1Y2Nlc3NfdXJpIjogImh0dHBzOi8vYWNjb3VudC5icmF2ZS5jb20vYWNjb3VudC8/aW50ZW50PXByb3Zpc2lvbiIsICJzdHJpcGVfY2FuY2VsX3VyaSI6ICJodHRwczovL2FjY291bnQuYnJhdmUuY29tL3BsYW5zLz9pbnRlbnQ9Y2hlY2tvdXQiIH0KMDAyZnNpZ25hdHVyZSBML5oNNcmOia4F/XGJGG+E1M3ETmbnikjeVNoU7aue5go="

stagingUserWalletVote = "AgEJYnJhdmUuY29tAiNicmF2ZSB1c2VyLXdhbGxldC12b3RlIHNrdSB0b2tlbiB2MQACFHNrdT11c2VyLXdhbGxldC12b3RlAAIKcHJpY2U9MC4yNQACDGN1cnJlbmN5PUJBVAACDGRlc2NyaXB0aW9uPQACGmNyZWRlbnRpYWxfdHlwZT1zaW5nbGUtdXNlAAAGIOH4Li+rduCtFOfV8Lfa2o8h4SQjN5CuIwxmeQFjOk4W"
stagingAnonCardVote = "AgEJYnJhdmUuY29tAiFicmF2ZSBhbm9uLWNhcmQtdm90ZSBza3UgdG9rZW4gdjEAAhJza3U9YW5vbi1jYXJkLXZvdGUAAgpwcmljZT0wLjI1AAIMY3VycmVuY3k9QkFUAAIMZGVzY3JpcHRpb249AAIaY3JlZGVudGlhbF90eXBlPXNpbmdsZS11c2UAAAYgPV/WYY5pXhodMPvsilnrLzNH6MA8nFXwyg0qSWX477M="
Expand All @@ -31,6 +32,7 @@ var skuMap = map[string]map[string]bool{
prodUserWalletVote: true,
prodAnonCardVote: true,
prodBraveTogetherPaid: true,
prodBraveTalkPremium: true,
},
"staging": {
stagingUserWalletVote: true,
Expand Down

0 comments on commit f3b81bc

Please sign in to comment.