Skip to content

Commit

Permalink
Support 2-step payments in Stripe
Browse files Browse the repository at this point in the history
Enable via payment.stripe.use_payment_intents
  • Loading branch information
mraerino committed Sep 6, 2019
1 parent 9b7badd commit 1fcc5aa
Show file tree
Hide file tree
Showing 8 changed files with 234 additions and 27 deletions.
9 changes: 4 additions & 5 deletions api/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -92,12 +92,11 @@ func NewAPIWithVersion(ctx context.Context, globalConfig *conf.GlobalConfigurati
})

r.Route("/payments", func(r *router) {
r.Use(adminRequired)

r.Get("/", api.PaymentList)
r.With(adminRequired).Get("/", api.PaymentList)
r.Route("/{payment_id}", func(r *router) {
r.Get("/", api.PaymentView)
r.With(addGetBody).Post("/refund", api.PaymentRefund)
r.With(adminRequired).Get("/", api.PaymentView)
r.With(adminRequired).With(addGetBody).Post("/refund", api.PaymentRefund)
r.Post("/confirm", api.PaymentConfirm)
})
})

Expand Down
87 changes: 83 additions & 4 deletions api/payments.go
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,7 @@ func (a *API) PaymentCreate(w http.ResponseWriter, r *http.Request) error {
if provider == nil {
return badRequestError("Payment provider '%s' not configured", params.ProviderType)
}
charge, err := provider.NewCharger(ctx, r)
charge, err := provider.NewCharger(ctx, r, log.WithField("component", "payment_provider"))
if err != nil {
return badRequestError("Error creating payment provider: %v", err)
}
Expand Down Expand Up @@ -167,6 +167,14 @@ func (a *API) PaymentCreate(w http.ResponseWriter, r *http.Request) error {
tr.ProcessorID = processorID
tr.InvoiceNumber = invoiceNumber

if pendingErr, ok := err.(*payments.PaymentPendingError); ok {
tr.Status = models.PendingState
tr.ProviderMetadata = pendingErr.Metadata()
tx.Create(tr)
tx.Commit()
return sendJSON(w, 200, tr)
}

if err != nil {
tr.FailureCode = strconv.FormatInt(http.StatusInternalServerError, 10)
tr.FailureDescription = err.Error()
Expand Down Expand Up @@ -206,6 +214,75 @@ func (a *API) PaymentCreate(w http.ResponseWriter, r *http.Request) error {
return sendJSON(w, http.StatusOK, tr)
}

// PaymentConfirm allows client to confirm if a pending transaction has been completed. Updates transaction and order
func (a *API) PaymentConfirm(w http.ResponseWriter, r *http.Request) error {
ctx := r.Context()

payID := chi.URLParam(r, "payment_id")
trans, httpErr := a.getTransaction(payID)
if httpErr != nil {
return httpErr
}

if trans.UserID != "" {
token := gcontext.GetToken(ctx)
if token == nil {
return unauthorizedError("You must be logged in to confirm this payment")
}
claims := token.Claims.(*claims.JWTClaims)
if trans.UserID != claims.Subject {
return unauthorizedError("You must be logged in to confirm this payment")
}
}

log := getLogEntry(r)
order, httpErr := queryForOrder(a.db, trans.OrderID, log)
if httpErr != nil {
return httpErr
}
if order.PaymentProcessor == "" {
return badRequestError("Order does not specify a payment provider")
}

provider := gcontext.GetPaymentProviders(ctx)[order.PaymentProcessor]
if provider == nil {
return badRequestError("Payment provider '%s' not configured", order.PaymentProcessor)
}
confirm, err := provider.NewConfirmer(ctx, r, log.WithField("component", "payment_provider"))
if err != nil {
return badRequestError("Error creating payment provider: %v", err)
}

if err := confirm(trans.ProcessorID); err != nil {
if confirmFail, ok := err.(*payments.PaymentConfirmFailError); ok {
return badRequestError("Error confirming payment: %s", confirmFail.Error())
}
return internalServerError("Error on provider while trying to confirm: %v. Try again later.", err)
}

tx := a.db.Begin()

if trans.InvoiceNumber == 0 {
invoiceNumber, err := models.NextInvoiceNumber(tx, order.InstanceID)
if err != nil {
tx.Rollback()
return internalServerError("We failed to generate a valid invoice ID, please try again later: %v", err)
}
trans.InvoiceNumber = invoiceNumber
}

trans.Status = models.PaidState
tx.Save(trans)
order.State = models.PaidState
order.InvoiceNumber = trans.InvoiceNumber
tx.Save(order)
if err := tx.Commit().Error; err != nil {
return internalServerError("Saving payment failed").WithInternalError(err)
}

return sendJSON(w, 200, trans)
}

// PaymentList will list all the payments that meet the criteria. It is only available to admins.
func (a *API) PaymentList(w http.ResponseWriter, r *http.Request) error {
log := getLogEntry(r)
Expand Down Expand Up @@ -280,7 +357,7 @@ func (a *API) PaymentRefund(w http.ResponseWriter, r *http.Request) error {
if provider == nil {
return badRequestError("Payment provider '%s' not configured", order.PaymentProcessor)
}
refund, err := provider.NewRefunder(ctx, r)
refund, err := provider.NewRefunder(ctx, r, log.WithField("component", "payment_provider"))
if err != nil {
return badRequestError("Error creating payment provider: %v", err)
}
Expand Down Expand Up @@ -328,6 +405,7 @@ func (a *API) PaymentRefund(w http.ResponseWriter, r *http.Request) error {
// PreauthorizePayment creates a new payment that can be authorized in the browser
func (a *API) PreauthorizePayment(w http.ResponseWriter, r *http.Request) error {
ctx := r.Context()
log := getLogEntry(r)
params := PaymentParams{}
ct := r.Header.Get("Content-Type")
mediaType, _, err := mime.ParseMediaType(ct)
Expand Down Expand Up @@ -362,7 +440,7 @@ func (a *API) PreauthorizePayment(w http.ResponseWriter, r *http.Request) error
if provider == nil {
return badRequestError("Payment provider '%s' not configured", providerType)
}
preauthorize, err := provider.NewPreauthorizer(ctx, r)
preauthorize, err := provider.NewPreauthorizer(ctx, r, log.WithField("component", "payment_provider"))
if err != nil {
return badRequestError("Error creating payment provider: %v", err)
}
Expand Down Expand Up @@ -426,7 +504,8 @@ func createPaymentProviders(c *conf.Configuration) (map[string]payments.Provider
provs := map[string]payments.Provider{}
if c.Payment.Stripe.Enabled {
p, err := stripe.NewPaymentProvider(stripe.Config{
SecretKey: c.Payment.Stripe.SecretKey,
SecretKey: c.Payment.Stripe.SecretKey,
UsePaymentIntents: c.Payment.Stripe.UsePaymentIntents,
})
if err != nil {
return nil, err
Expand Down
14 changes: 11 additions & 3 deletions api/payments_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import (
"testing"

"github.com/mitchellh/mapstructure"
"github.com/sirupsen/logrus"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"

Expand Down Expand Up @@ -659,15 +660,18 @@ type refundCall struct {
func (mp *memProvider) Name() string {
return mp.name
}
func (mp *memProvider) NewCharger(ctx context.Context, r *http.Request) (payments.Charger, error) {
func (mp *memProvider) NewCharger(ctx context.Context, r *http.Request, log logrus.FieldLogger) (payments.Charger, error) {
return mp.charge, nil
}
func (mp *memProvider) NewRefunder(ctx context.Context, r *http.Request) (payments.Refunder, error) {
func (mp *memProvider) NewRefunder(ctx context.Context, r *http.Request, log logrus.FieldLogger) (payments.Refunder, error) {
return mp.refund, nil
}
func (mp *memProvider) NewPreauthorizer(ctx context.Context, r *http.Request) (payments.Preauthorizer, error) {
func (mp *memProvider) NewPreauthorizer(ctx context.Context, r *http.Request, log logrus.FieldLogger) (payments.Preauthorizer, error) {
return mp.preauthorize, nil
}
func (mp *memProvider) NewConfirmer(ctx context.Context, r *http.Request, log logrus.FieldLogger) (payments.Confirmer, error) {
return mp.confirm, nil
}

func (mp *memProvider) charge(amount uint64, currency string, order *models.Order, invoiceNumber int64) (string, error) {
return "", errors.New("Shouldn't have called this")
Expand All @@ -690,6 +694,10 @@ func (mp *memProvider) preauthorize(amount uint64, currency string, description
return nil, nil
}

func (mp *memProvider) confirm(paymentID string) error {
return nil
}

type stripeCallFunc func(method, path, key string, params stripe.ParamsContainer, v interface{})

func NewTrackingStripeBackend(fn stripeCallFunc) stripe.Backend {
Expand Down
2 changes: 2 additions & 0 deletions conf/configuration.go
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,8 @@ type Configuration struct {
Enabled bool `json:"enabled"`
PublicKey string `json:"public_key" split_words:"true"`
SecretKey string `json:"secret_key" split_words:"true"`

UsePaymentIntents bool `json:"use_payment_intents" split_words:"true"`
} `json:"stripe"`
PayPal struct {
Enabled bool `json:"enabled"`
Expand Down
2 changes: 2 additions & 0 deletions models/transaction.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@ type Transaction struct {

CreatedAt time.Time `json:"created_at"`
DeletedAt *time.Time `json:"-"`

ProviderMetadata map[string]interface{} `json:"provider_metadata,omitempty" sql:"-"`
}

// TableName returns the database table name for the Transaction model.
Expand Down
46 changes: 43 additions & 3 deletions payments/payments.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"net/http"

"github.com/netlify/gocommerce/models"
"github.com/sirupsen/logrus"
)

const (
Expand All @@ -18,9 +19,10 @@ const (
// preauthorize payments.
type Provider interface {
Name() string
NewCharger(ctx context.Context, r *http.Request) (Charger, error)
NewRefunder(ctx context.Context, r *http.Request) (Refunder, error)
NewPreauthorizer(ctx context.Context, r *http.Request) (Preauthorizer, error)
NewCharger(ctx context.Context, r *http.Request, log logrus.FieldLogger) (Charger, error)
NewRefunder(ctx context.Context, r *http.Request, log logrus.FieldLogger) (Refunder, error)
NewPreauthorizer(ctx context.Context, r *http.Request, log logrus.FieldLogger) (Preauthorizer, error)
NewConfirmer(ctx context.Context, r *http.Request, log logrus.FieldLogger) (Confirmer, error)
}

// Charger wraps the Charge method which creates new payments with the provider.
Expand All @@ -37,3 +39,41 @@ type Preauthorizer func(amount uint64, currency string, description string) (*Pr
type PreauthorizationResult struct {
ID string `json:"id"`
}

// Confirmer wraps a confirm method used for checking two-step payments in a synchronous flow
type Confirmer func(paymentID string) error

// PaymentPendingError is returned when the payment provider requests additional action
// e.g. 2-step authorization through 3D secure
type PaymentPendingError struct {
metadata map[string]interface{}
}

// NewPaymentPendingError creates an error for a pending action on a payment
func NewPaymentPendingError(metadata map[string]interface{}) error {
return &PaymentPendingError{metadata}
}

func (p *PaymentPendingError) Error() string {
return "The payment provider requested additional actions on the transaction."
}

// Metadata returns fields that should be passed to the client
// for use in additional actions
func (p *PaymentPendingError) Metadata() map[string]interface{} {
return p.metadata
}

// PaymentConfirmFailError is returned when the confirmation request got a negative response
type PaymentConfirmFailError struct {
message string
}

// NewPaymentConfirmFailError creates an error to use when a payment confirmation fails
func NewPaymentConfirmFailError(msg string) error {
return &PaymentConfirmFailError{message: msg}
}

func (p *PaymentConfirmFailError) Error() string {
return p.message
}
11 changes: 8 additions & 3 deletions payments/paypal/paypal.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import (

"github.com/netlify/gocommerce/models"
"github.com/pariz/gountries"
"github.com/sirupsen/logrus"

paypalsdk "github.com/netlify/PayPal-Go-SDK"
"github.com/netlify/gocommerce/conf"
Expand Down Expand Up @@ -76,7 +77,7 @@ func (p *paypalPaymentProvider) Name() string {
return payments.PayPalProvider
}

func (p *paypalPaymentProvider) NewCharger(ctx context.Context, r *http.Request) (payments.Charger, error) {
func (p *paypalPaymentProvider) NewCharger(ctx context.Context, r *http.Request, log logrus.FieldLogger) (payments.Charger, error) {
var bp paypalBodyParams
bod, err := r.GetBody()
if err != nil {
Expand Down Expand Up @@ -193,7 +194,7 @@ func (p *paypalPaymentProvider) charge(paymentID string, userID string, amount u
return executeResult.ID, nil
}

func (p *paypalPaymentProvider) NewRefunder(ctx context.Context, r *http.Request) (payments.Refunder, error) {
func (p *paypalPaymentProvider) NewRefunder(ctx context.Context, r *http.Request, log logrus.FieldLogger) (payments.Refunder, error) {
return p.refund, nil
}

Expand All @@ -209,7 +210,7 @@ func (p *paypalPaymentProvider) refund(transactionID string, amount uint64, curr
return ref.ID, nil
}

func (p *paypalPaymentProvider) NewPreauthorizer(ctx context.Context, r *http.Request) (payments.Preauthorizer, error) {
func (p *paypalPaymentProvider) NewPreauthorizer(ctx context.Context, r *http.Request, log logrus.FieldLogger) (payments.Preauthorizer, error) {
config := gcontext.GetConfig(ctx)
return func(amount uint64, currency string, description string) (*payments.PreauthorizationResult, error) {
return p.preauthorize(config, amount, currency, description)
Expand Down Expand Up @@ -278,3 +279,7 @@ func (p *paypalPaymentProvider) getExperience() (*paypalsdk.WebProfile, error) {
func formatAmount(amount uint64) string {
return strconv.FormatFloat(float64(amount)/100, 'f', 2, 64)
}

func (p *paypalPaymentProvider) NewConfirmer(ctx context.Context, r *http.Request, log logrus.FieldLogger) (payments.Confirmer, error) {
return nil, errors.New("Paypal does not provide manual 2-step confirmation")
}
Loading

0 comments on commit 1fcc5aa

Please sign in to comment.