From b696c2f73fce9d644f133575cd1457a3a2865086 Mon Sep 17 00:00:00 2001 From: Horacio Duran Date: Tue, 8 Dec 2020 18:19:57 +0000 Subject: [PATCH 1/5] Add base nonfunctional checkout --- conferences/ticketing_storage.go | 102 +++++++++++++++++++++++++++++++ go.mod | 3 + go.sum | 31 +++++++--- 3 files changed, 127 insertions(+), 9 deletions(-) diff --git a/conferences/ticketing_storage.go b/conferences/ticketing_storage.go index 3c1a63d..d2d184b 100644 --- a/conferences/ticketing_storage.go +++ b/conferences/ticketing_storage.go @@ -160,6 +160,108 @@ func createConferenceSlot(ctx context.Context, tx *sqldb.Tx, cslot *ConferenceSl return &results, nil } +// ErrSlotFull should be returned when a claim is attempted on a full slot. +type ErrSlotFull struct { + claimFullID uint64 +} + +func (err *ErrSlotFull) Error() string { + return fmt.Sprintf("conference with slot %d is full", err.claimFullID) +} + +// ErrDependencyUnmet should be returned when a claim is made that depends on another. +type ErrDependencyUnmet struct { + missing uint64 +} + +func (err *ErrDependencyUnmet) Error() string { + return fmt.Sprintf("at least one claim dependeny was unmet, missing: %d", err.missing) +} + +func ensureSlotsCanBeClaimed(ctx context.Context, claimIDs []uint64, + attendeeEmail string) error { + attendee, err := readAttendeeByEmail(ctx, nil, attendeeEmail) + if err != nil { + return fmt.Errorf("reading attendee to check slot validity: %w", err) + } + if attendee == nil { + return fmt.Errorf("no such attendee") + } + allClaims := claimIDs[:] + if attendee.Claims != nil { + for _, c := range attendee.Claims { + allClaims = append(allClaims, uint64(c.ID)) + } + } + rows, err := sqldb.Query(ctx, `SELECT + conference_slot.id + COUNT(slot_claim.id) < conference_slot.capacity, + conference_slot.depends_on IS NOT NULL, + conference_slot.depends_on = ANY($1), + COALESCE(conference_slot.depends_on, 0) + FROM slot_claim + JOIN slot_claim ON slot_claim.conference_slot_id=conference_slot.id + WHERE conference_slot.id = ANY($2) GROUP BY conference_slot.id`, + allClaims, allClaims) + if err != nil { + return fmt.Errorf("querying claim availability: %w", err) + } + for rows.Next() { + var slotID, dependsOn uint64 + var capacityAvailable, hasDependency, dependencyMet bool + if err := rows.Scan(&slotID, + &capacityAvailable, &hasDependency, &dependencyMet, + &dependsOn); err != nil { + return fmt.Errorf("reding row from slot availability query: %w", err) + } + if !capacityAvailable { + return &ErrSlotFull{claimFullID: slotID} + } + if hasDependency && !dependencyMet { + return &ErrDependencyUnmet{missing: dependsOn} + } + } + return nil +} +func readConferenceSlotsByIDs(ctx context.Context, tx *sqldb.Tx, id []uint64) ([]ConferenceSlot, error) { + results := []ConferenceSlot{} + var rows *sqldb.Rows + var err error + sqlStatement := `SELECT id, name, description, cost, capacity, start_date, end_date, purchaseable_from, purchaseable_until, available_to_public, COALESCE(depends_on, 0) + FROM conference_slot + WHERE id = $1` + sqlArgs := []interface{}{id} + if tx != nil { + rows, err = sqldb.QueryTx(tx, ctx, sqlStatement, sqlArgs...) + } else { + rows, err = sqldb.Query(ctx, sqlStatement, sqlArgs...) + } + if err != nil { + return nil, fmt.Errorf("querying for conference slots: %w", err) + } + + for rows.Next() { + result := ConferenceSlot{} + err := rows.Scan(&result.ID, + &result.Name, + &result.Description, + &result.Cost, + &result.Capacity, + &result.StartDate, + &result.EndDate, + &result.PurchaseableFrom, + &result.PurchaseableUntil, + &result.AvailableToPublic, + &result.DependsOn) + + if err != nil { + return nil, fmt.Errorf("reading conference slots by id: %w", err) + } + results = append(results, result) + } + return results, nil +} + func readConferenceSlotByID(ctx context.Context, tx *sqldb.Tx, id uint64, loadDeps bool) (*ConferenceSlot, error) { results := ConferenceSlot{} var row *sqldb.Row diff --git a/go.mod b/go.mod index 44b7163..444fac2 100644 --- a/go.mod +++ b/go.mod @@ -7,4 +7,7 @@ require ( github.com/dgrijalva/jwt-go v3.2.0+incompatible github.com/gofrs/uuid v3.3.0+incompatible github.com/lib/pq v1.8.0 + github.com/stretchr/testify v1.6.1 // indirect + github.com/stripe/stripe-go v70.15.0+incompatible + golang.org/x/net v0.0.0-20201110031124-69a78807bb2b // indirect ) diff --git a/go.sum b/go.sum index 8464b9f..bf6b2a6 100644 --- a/go.sum +++ b/go.sum @@ -1,20 +1,33 @@ encore.dev v0.7.0 h1:zrYU21r6DfB0ftQFgS9yh3o6KrJg7MV4kZGY4fkv+is= encore.dev v0.7.0/go.mod h1:eKOQ6G72uYiV0DbDJam6BB07KsIfvpqT3cQfadCShao= +github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dgrijalva/jwt-go v3.2.0+incompatible h1:7qlOGliEKZXTDg6OTjfoBKDXWrumCAMpl/TFQ4/5kLM= github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ= -github.com/coreos/go-systemd v0.0.0-20190321100706-95778dfbb74e/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4= github.com/gofrs/uuid v3.3.0+incompatible h1:8K4tyRfvU1CYPgJsveYFQMhpFd/wXNM7iK6rR7UHz84= github.com/gofrs/uuid v3.3.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM= github.com/lib/pq v1.8.0 h1:9xohqzkUwzR4Ga4ivdTcawVS89YSDVxXMa3xJX3cGzg= github.com/lib/pq v1.8.0/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= -github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= -github.com/rs/xid v1.2.1/go.mod h1:+uKXf+4Djp6Md1KODXJxgGQPKngRmWyn10oCKFzNHOQ= -github.com/rs/zerolog v1.15.0/go.mod h1:xYTKnLHcpfU2225ny5qZjxnj9NvkumZYjJHlAThCjNc= -github.com/zenazn/goji v0.9.0/go.mod h1:7S9M489iMyHBNxwZnk9/EHS098H4/F6TATF2mIxtB1Q= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.6.1 h1:hDPOHmpOpP40lSULcqw7IrRb/u7w6RpDC9399XyoNd0= +github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stripe/stripe-go v70.15.0+incompatible h1:hNML7M1zx8RgtepEMlxyu/FpVPrP7KZm1gPFQquJQvM= +github.com/stripe/stripe-go v70.15.0+incompatible/go.mod h1:A1dQZmO/QypXmsL0T8axYZkSN/uA/T/A64pfKdBAMiY= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= -golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= -golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20201110031124-69a78807bb2b h1:uwuIcX0g4Yl1NC5XAz37xsr2lTtcqevgzYNVt49waME= +golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= -golang.org/x/tools v0.0.0-20190425163242-31fd60d6bfdc h1:N3zlSgxkefUH/ecsl37RWTkESTB026kmXzNly8TuZCI= -golang.org/x/tools v0.0.0-20190425163242-31fd60d6bfdc/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= +golang.org/x/text v0.3.3 h1:cokOdA+Jmi5PJGXLlLllQSgYigAEfHXJAERHVMaCc2k= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= From dd93770d349582005af1f4074c649f396e6f755c Mon Sep 17 00:00:00 2001 From: Horacio Duran Date: Sun, 20 Dec 2020 17:17:23 +0000 Subject: [PATCH 2/5] Change computers --- conferences/checkout.go | 99 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 99 insertions(+) create mode 100644 conferences/checkout.go diff --git a/conferences/checkout.go b/conferences/checkout.go new file mode 100644 index 0000000..a5ebfd4 --- /dev/null +++ b/conferences/checkout.go @@ -0,0 +1,99 @@ +package conferences + +import ( + "context" + "errors" + "fmt" + + "github.com/stripe/stripe-go" + "github.com/stripe/stripe-go/checkout/session" +) + +// CheckoutParams is the payload of checkout which contains the information about the +// slot claim purchase about to happen. +type CheckoutParams struct { + SlotsToClaim []uint64 +} + +// CheckoutResponse returns information about the outcome of the checkout initiation +// process. +type CheckoutResponse struct { + StripeSessionID string + NoStockOfSlot *ConferenceSlot + MissingSlotDependency *ConferenceSlot +} + +// Checkout initiates the checkout process for purchasing slot attendance in a conference. +func Checkout(ctx context.Context, params *CheckoutParams) (*CheckoutResponse, error) { + // Check that we have capacity remaining in the slot and that customer is puchasing + // all dependencies. + attendeeEmail := "" // FIXME: Find this from user in request. + if err := ensureSlotsCanBeClaimed(ctx, params.SlotsToClaim, attendeeEmail); err != nil { + noStock := &ErrSlotFull{} + missingDep := &ErrDependencyUnmet{} + switch { + case errors.As(err, &noStock): + cs, err := readConferenceSlotByID(ctx, nil, noStock.claimFullID, false) + if err != nil { + return nil, fmt.Errorf("reading conference slot for slot full error: %w", err) + } + return &CheckoutResponse{NoStockOfSlot: cs}, nil + case errors.As(err, &missingDep): + cs, err := readConferenceSlotByID(ctx, nil, missingDep.missing, false) + if err != nil { + return nil, fmt.Errorf("reading conference slot for missing dependency error: %w", err) + } + return &CheckoutResponse{MissingSlotDependency: cs}, nil + } + return nil, fmt.Errorf("ensuring slots can be claimed: %w", err) + } + // do the unpaid claims. + attendee, err := readAttendeeByEmail(ctx, nil, attendeeEmail) + if err != nil { + return nil, fmt.Errorf("reading attendee to claim slots") + } + slots, err := readConferenceSlotsByIDs(ctx, nil, params.SlotsToClaim) + if err != nil { + return nil, fmt.Errorf("reading slots to claim from database: %w", err) + } + claims, err := claimSlots(ctx, attendee, slots) + if err != nil { + return nil, fmt.Errorf("claiming slots: %w", err) + } + + // Initiate payment + stripe.Key = "sk_test_4eC39HqLyjWDarjtT1zdp7dc" + + lineItems := make([]*stripe.CheckoutSessionLineItemParams, 0, len(claims)) + usd := string(stripe.CurrencyUSD) + for i := range claims { + cost := int64(claims[i].ConferenceSlot.Cost) + //FIXME apply voucher + lineItems = append(lineItems, + &stripe.CheckoutSessionLineItemParams{ + Amount: &cost, + Currency: &usd, + Name: &claims[i].ConferenceSlot.Name, + Description: &claims[i].ConferenceSlot.Description, + Quantity: stripe.Int64(1), + }, + ) + } + + stripeParams := &stripe.CheckoutSessionParams{ + SuccessURL: stripe.String("https://example.com/success"), // FIXME triky one + CancelURL: stripe.String("https://example.com/cancel"), + PaymentMethodTypes: stripe.StringSlice([]string{ + "card", + }), + LineItems: lineItems, + Mode: stripe.String(string(stripe.CheckoutSessionModePayment)), + } + + s, err := session.New(stripeParams) + if err != nil { + return nil, fmt.Errorf("creating new stripe session to checkout: %w", err) + } + + return &CheckoutResponse{StripeSessionID: s.ID}, nil +} From 3ec35879576f937c66c56fab012682ecf6c4d711 Mon Sep 17 00:00:00 2001 From: Horacio Duran Date: Tue, 8 Dec 2020 18:19:57 +0000 Subject: [PATCH 3/5] Add base nonfunctional checkout --- conferences/ticketing_storage.go | 102 +++++++++++++++++++++++++++++++ 1 file changed, 102 insertions(+) diff --git a/conferences/ticketing_storage.go b/conferences/ticketing_storage.go index 3c1a63d..d2d184b 100644 --- a/conferences/ticketing_storage.go +++ b/conferences/ticketing_storage.go @@ -160,6 +160,108 @@ func createConferenceSlot(ctx context.Context, tx *sqldb.Tx, cslot *ConferenceSl return &results, nil } +// ErrSlotFull should be returned when a claim is attempted on a full slot. +type ErrSlotFull struct { + claimFullID uint64 +} + +func (err *ErrSlotFull) Error() string { + return fmt.Sprintf("conference with slot %d is full", err.claimFullID) +} + +// ErrDependencyUnmet should be returned when a claim is made that depends on another. +type ErrDependencyUnmet struct { + missing uint64 +} + +func (err *ErrDependencyUnmet) Error() string { + return fmt.Sprintf("at least one claim dependeny was unmet, missing: %d", err.missing) +} + +func ensureSlotsCanBeClaimed(ctx context.Context, claimIDs []uint64, + attendeeEmail string) error { + attendee, err := readAttendeeByEmail(ctx, nil, attendeeEmail) + if err != nil { + return fmt.Errorf("reading attendee to check slot validity: %w", err) + } + if attendee == nil { + return fmt.Errorf("no such attendee") + } + allClaims := claimIDs[:] + if attendee.Claims != nil { + for _, c := range attendee.Claims { + allClaims = append(allClaims, uint64(c.ID)) + } + } + rows, err := sqldb.Query(ctx, `SELECT + conference_slot.id + COUNT(slot_claim.id) < conference_slot.capacity, + conference_slot.depends_on IS NOT NULL, + conference_slot.depends_on = ANY($1), + COALESCE(conference_slot.depends_on, 0) + FROM slot_claim + JOIN slot_claim ON slot_claim.conference_slot_id=conference_slot.id + WHERE conference_slot.id = ANY($2) GROUP BY conference_slot.id`, + allClaims, allClaims) + if err != nil { + return fmt.Errorf("querying claim availability: %w", err) + } + for rows.Next() { + var slotID, dependsOn uint64 + var capacityAvailable, hasDependency, dependencyMet bool + if err := rows.Scan(&slotID, + &capacityAvailable, &hasDependency, &dependencyMet, + &dependsOn); err != nil { + return fmt.Errorf("reding row from slot availability query: %w", err) + } + if !capacityAvailable { + return &ErrSlotFull{claimFullID: slotID} + } + if hasDependency && !dependencyMet { + return &ErrDependencyUnmet{missing: dependsOn} + } + } + return nil +} +func readConferenceSlotsByIDs(ctx context.Context, tx *sqldb.Tx, id []uint64) ([]ConferenceSlot, error) { + results := []ConferenceSlot{} + var rows *sqldb.Rows + var err error + sqlStatement := `SELECT id, name, description, cost, capacity, start_date, end_date, purchaseable_from, purchaseable_until, available_to_public, COALESCE(depends_on, 0) + FROM conference_slot + WHERE id = $1` + sqlArgs := []interface{}{id} + if tx != nil { + rows, err = sqldb.QueryTx(tx, ctx, sqlStatement, sqlArgs...) + } else { + rows, err = sqldb.Query(ctx, sqlStatement, sqlArgs...) + } + if err != nil { + return nil, fmt.Errorf("querying for conference slots: %w", err) + } + + for rows.Next() { + result := ConferenceSlot{} + err := rows.Scan(&result.ID, + &result.Name, + &result.Description, + &result.Cost, + &result.Capacity, + &result.StartDate, + &result.EndDate, + &result.PurchaseableFrom, + &result.PurchaseableUntil, + &result.AvailableToPublic, + &result.DependsOn) + + if err != nil { + return nil, fmt.Errorf("reading conference slots by id: %w", err) + } + results = append(results, result) + } + return results, nil +} + func readConferenceSlotByID(ctx context.Context, tx *sqldb.Tx, id uint64, loadDeps bool) (*ConferenceSlot, error) { results := ConferenceSlot{} var row *sqldb.Row From a03578c2078a627df5fa77e3490d969dca663d6a Mon Sep 17 00:00:00 2001 From: Horacio Duran Date: Sun, 20 Dec 2020 17:17:23 +0000 Subject: [PATCH 4/5] Change computers --- conferences/checkout.go | 99 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 99 insertions(+) create mode 100644 conferences/checkout.go diff --git a/conferences/checkout.go b/conferences/checkout.go new file mode 100644 index 0000000..a5ebfd4 --- /dev/null +++ b/conferences/checkout.go @@ -0,0 +1,99 @@ +package conferences + +import ( + "context" + "errors" + "fmt" + + "github.com/stripe/stripe-go" + "github.com/stripe/stripe-go/checkout/session" +) + +// CheckoutParams is the payload of checkout which contains the information about the +// slot claim purchase about to happen. +type CheckoutParams struct { + SlotsToClaim []uint64 +} + +// CheckoutResponse returns information about the outcome of the checkout initiation +// process. +type CheckoutResponse struct { + StripeSessionID string + NoStockOfSlot *ConferenceSlot + MissingSlotDependency *ConferenceSlot +} + +// Checkout initiates the checkout process for purchasing slot attendance in a conference. +func Checkout(ctx context.Context, params *CheckoutParams) (*CheckoutResponse, error) { + // Check that we have capacity remaining in the slot and that customer is puchasing + // all dependencies. + attendeeEmail := "" // FIXME: Find this from user in request. + if err := ensureSlotsCanBeClaimed(ctx, params.SlotsToClaim, attendeeEmail); err != nil { + noStock := &ErrSlotFull{} + missingDep := &ErrDependencyUnmet{} + switch { + case errors.As(err, &noStock): + cs, err := readConferenceSlotByID(ctx, nil, noStock.claimFullID, false) + if err != nil { + return nil, fmt.Errorf("reading conference slot for slot full error: %w", err) + } + return &CheckoutResponse{NoStockOfSlot: cs}, nil + case errors.As(err, &missingDep): + cs, err := readConferenceSlotByID(ctx, nil, missingDep.missing, false) + if err != nil { + return nil, fmt.Errorf("reading conference slot for missing dependency error: %w", err) + } + return &CheckoutResponse{MissingSlotDependency: cs}, nil + } + return nil, fmt.Errorf("ensuring slots can be claimed: %w", err) + } + // do the unpaid claims. + attendee, err := readAttendeeByEmail(ctx, nil, attendeeEmail) + if err != nil { + return nil, fmt.Errorf("reading attendee to claim slots") + } + slots, err := readConferenceSlotsByIDs(ctx, nil, params.SlotsToClaim) + if err != nil { + return nil, fmt.Errorf("reading slots to claim from database: %w", err) + } + claims, err := claimSlots(ctx, attendee, slots) + if err != nil { + return nil, fmt.Errorf("claiming slots: %w", err) + } + + // Initiate payment + stripe.Key = "sk_test_4eC39HqLyjWDarjtT1zdp7dc" + + lineItems := make([]*stripe.CheckoutSessionLineItemParams, 0, len(claims)) + usd := string(stripe.CurrencyUSD) + for i := range claims { + cost := int64(claims[i].ConferenceSlot.Cost) + //FIXME apply voucher + lineItems = append(lineItems, + &stripe.CheckoutSessionLineItemParams{ + Amount: &cost, + Currency: &usd, + Name: &claims[i].ConferenceSlot.Name, + Description: &claims[i].ConferenceSlot.Description, + Quantity: stripe.Int64(1), + }, + ) + } + + stripeParams := &stripe.CheckoutSessionParams{ + SuccessURL: stripe.String("https://example.com/success"), // FIXME triky one + CancelURL: stripe.String("https://example.com/cancel"), + PaymentMethodTypes: stripe.StringSlice([]string{ + "card", + }), + LineItems: lineItems, + Mode: stripe.String(string(stripe.CheckoutSessionModePayment)), + } + + s, err := session.New(stripeParams) + if err != nil { + return nil, fmt.Errorf("creating new stripe session to checkout: %w", err) + } + + return &CheckoutResponse{StripeSessionID: s.ID}, nil +} From de21cb4d54124b673eead68d85b676711ff998e5 Mon Sep 17 00:00:00 2001 From: Horacio Duran Date: Thu, 24 Dec 2020 17:27:09 -0300 Subject: [PATCH 5/5] Add checkout and success payment processing, this is a large WIP --- conferences/checkout.go | 256 +++++++++++++++++- .../migrations/10_add_transaction_id.up.sql | 2 + go.mod | 3 +- go.sum | 7 +- 4 files changed, 254 insertions(+), 14 deletions(-) create mode 100644 conferences/migrations/10_add_transaction_id.up.sql diff --git a/conferences/checkout.go b/conferences/checkout.go index a5ebfd4..00ff6c6 100644 --- a/conferences/checkout.go +++ b/conferences/checkout.go @@ -2,9 +2,16 @@ package conferences import ( "context" + "encoding/json" "errors" "fmt" + "io/ioutil" + "net/http" + "github.com/stripe/stripe-go/webhook" + + "encore.dev/beta/auth" + "encore.dev/storage/sqldb" "github.com/stripe/stripe-go" "github.com/stripe/stripe-go/checkout/session" ) @@ -12,6 +19,8 @@ import ( // CheckoutParams is the payload of checkout which contains the information about the // slot claim purchase about to happen. type CheckoutParams struct { + ConferenceID uint64 + VoucherID string SlotsToClaim []uint64 } @@ -19,16 +28,25 @@ type CheckoutParams struct { // process. type CheckoutResponse struct { StripeSessionID string + ClaimPayment *ClaimPayment NoStockOfSlot *ConferenceSlot MissingSlotDependency *ConferenceSlot } +type secrets struct { + StripeKey string + EndpointSecret string +} + // Checkout initiates the checkout process for purchasing slot attendance in a conference. func Checkout(ctx context.Context, params *CheckoutParams) (*CheckoutResponse, error) { - // Check that we have capacity remaining in the slot and that customer is puchasing + usr, ok := auth.Data().(*User) + if !ok { + return nil, fmt.Errorf("unable to use %T %v as a user", usr, usr) + } + // Check that we have capacity remaining in the slot and that customer is purchasing // all dependencies. - attendeeEmail := "" // FIXME: Find this from user in request. - if err := ensureSlotsCanBeClaimed(ctx, params.SlotsToClaim, attendeeEmail); err != nil { + if err := ensureSlotsCanBeClaimed(ctx, params.SlotsToClaim, usr.Email); err != nil { noStock := &ErrSlotFull{} missingDep := &ErrDependencyUnmet{} switch { @@ -48,27 +66,82 @@ func Checkout(ctx context.Context, params *CheckoutParams) (*CheckoutResponse, e return nil, fmt.Errorf("ensuring slots can be claimed: %w", err) } // do the unpaid claims. - attendee, err := readAttendeeByEmail(ctx, nil, attendeeEmail) - if err != nil { - return nil, fmt.Errorf("reading attendee to claim slots") - } + slots, err := readConferenceSlotsByIDs(ctx, nil, params.SlotsToClaim) if err != nil { return nil, fmt.Errorf("reading slots to claim from database: %w", err) } - claims, err := claimSlots(ctx, attendee, slots) + claims, err := claimSlots(ctx, usr, slots) if err != nil { return nil, fmt.Errorf("claiming slots: %w", err) } // Initiate payment - stripe.Key = "sk_test_4eC39HqLyjWDarjtT1zdp7dc" + scrt := secrets{} + stripe.Key = scrt.StripeKey + + voucherRow := sqldb.QueryRow(ctx, `SELECT + valid_from, + valid_to, + discount_percentage, + discount_amount_cents, + discount_percentage_max_amount_cents, + conference_id + FROM discount_vouchers + WHERE voucher_id = $1 AND + conference_id = $2 AND + backing_payment_id IS NULL AND + transaction_id = ''`, // basically, not used or being used + params.VoucherID, params.ConferenceID) + + voucherInfo := VoucherInformation{} + err = voucherRow.Scan( + &voucherInfo.ValidFrom, + &voucherInfo.ValidTo, + &voucherInfo.Percentage, + &voucherInfo.AmountInCents, + &voucherInfo.LimitInCents, + &voucherInfo.ConferenceID, + ) + if err != nil { + return nil, fmt.Errorf("reading voucher information: %w", err) + } + + totalToDiscount := voucherInfo.AmountInCents + if voucherInfo.Percentage != 0 { + totalCost := 0 + for _, claim := range claims { + totalCost += claim.ConferenceSlot.Cost + } + totalToDiscountFloating := float64(totalCost) * (float64(voucherInfo.Percentage) / 100) + if totalToDiscountFloating > float64(voucherInfo.LimitInCents) && voucherInfo.LimitInCents > 0 { + totalToDiscountFloating = float64(voucherInfo.LimitInCents) + } + // yes, there is loss + totalToDiscount = int64(totalToDiscountFloating) + } + remainingDiscount := totalToDiscount lineItems := make([]*stripe.CheckoutSessionLineItemParams, 0, len(claims)) usd := string(stripe.CurrencyUSD) + var claimIDs []int64 + var totalCost int64 for i := range claims { + claimIDs = append(claimIDs, claims[i].ID) cost := int64(claims[i].ConferenceSlot.Cost) - //FIXME apply voucher + if remainingDiscount > 0 { + // FIXME: Apply discount to payment + // FIXME: Mark stripe session to voucher for discount + switch { + case remainingDiscount >= cost: + remainingDiscount -= cost + cost = 0 + case remainingDiscount < cost: + cost -= remainingDiscount + remainingDiscount = 0 + } + totalCost += cost + } lineItems = append(lineItems, &stripe.CheckoutSessionLineItemParams{ Amount: &cost, @@ -80,8 +153,28 @@ func Checkout(ctx context.Context, params *CheckoutParams) (*CheckoutResponse, e ) } + if totalCost == 0 { + p := &PaymentMethodConferenceDiscount{ + Detail: params.VoucherID, + AmountCents: totalToDiscount, + } + payment, err := payClaims(ctx, usr, claims, []FinancialInstrument{p}) + if err != nil { + return nil, fmt.Errorf("paying in full with voucher: %w", err) + } + + _, err = sqldb.Exec(ctx, `UPDATE discount_vouchers + SET backing_payment_id = $1 + WHERE voucher_id = $2 AND + conference_id = $3`, payment.ID, params.VoucherID, params.ConferenceID) + if err != nil { + return nil, fmt.Errorf("setting backing payment ID to voucher: %w", err) + } + return &CheckoutResponse{ClaimPayment: payment}, nil + } + stripeParams := &stripe.CheckoutSessionParams{ - SuccessURL: stripe.String("https://example.com/success"), // FIXME triky one + SuccessURL: stripe.String("https://example.com/success"), // FIXME tricky one CancelURL: stripe.String("https://example.com/cancel"), PaymentMethodTypes: stripe.StringSlice([]string{ "card", @@ -95,5 +188,146 @@ func Checkout(ctx context.Context, params *CheckoutParams) (*CheckoutResponse, e return nil, fmt.Errorf("creating new stripe session to checkout: %w", err) } + // We will use transaction to retrieve vouchers and claims in case of payment or cancellation + _, err = sqldb.Exec(ctx, `UPDATE discount_vouchers + SET transaction_id = $1 + WHERE voucher_id = $2 AND + conference_id = $2`, s.ID, params.VoucherID, params.ConferenceID) + if err != nil { + return nil, fmt.Errorf("setting session ID to voucher: %w", err) + } + + _, err = sqldb.Exec(ctx, `UPDATE slot_claim + SET transaction_id = $1 + WHERE id = ANY($2)`, s.ID, claimIDs) + if err != nil { + return nil, fmt.Errorf("setting session ID to claims: %w", err) + } + return &CheckoutResponse{StripeSessionID: s.ID}, nil } + +//encore:api public raw +func SuccessWebhook(w http.ResponseWriter, req *http.Request) { + const MaxBodyBytes = int64(65536) + req.Body = http.MaxBytesReader(w, req.Body, MaxBodyBytes) + + body, err := ioutil.ReadAll(req.Body) + if err != nil { + // do we have logging? + //fmt.Errorf("reading request body: %w", err) + w.WriteHeader(http.StatusServiceUnavailable) + return + } + + // Pass the request body and Stripe-Signature header to ConstructEvent, along with the webhook signing key + // You can find your endpoint's secret in your webhook settings + endpointSecret := secrets{}.EndpointSecret + event, err := webhook.ConstructEvent(body, req.Header.Get("Stripe-Signature"), endpointSecret) + + if err != nil { + //fmt.Errorf("verifying webhook signature: %w", err) + w.WriteHeader(http.StatusBadRequest) // Return a 400 error on a bad signature + return + } + + // Handle the checkout.session.completed event + if event.Type == "checkout.session.completed" { + var session stripe.CheckoutSession + err := json.Unmarshal(event.Data.Raw, &session) + if err != nil { + // fmt.Errorf("parsing webhook JSON: %w", err) + w.WriteHeader(http.StatusBadRequest) + return + } + + // FIXME: Load user + usr := &User{} + + rows, err := sqldb.Query(req.Context(), + `SELECT id, ticket_id, redeemed FROM slot_claim WHERE transaction_id = $1`, + session.ID) + if err != nil { + // fmt.Errorf("reading claims for payment: %w", err) + w.WriteHeader(http.StatusInternalServerError) + return + } + defer rows.Close() + var claims []SlotClaim + for rows.Next() { + + claim := SlotClaim{} + err := rows.Scan(&claim.ID, &claim.TicketID, &claim.Redeemed) + if err != nil { + // fmt.Errorf("scanning slot_claim for attendee: %w", err) + w.WriteHeader(http.StatusInternalServerError) + return + } + claims = append(claims, claim) + } + + voucherRow := sqldb.QueryRow(req.Context(), `SELECT + valid_from, + valid_to, + discount_percentage, + discount_amount_cents, + discount_percentage_max_amount_cents, + conference_id + FROM discount_vouchers + WHERE transaction_id = $1`, + session.ID) + + voucherInfo := VoucherInformation{} + err = voucherRow.Scan( + &voucherInfo.ValidFrom, + &voucherInfo.ValidTo, + &voucherInfo.Percentage, + &voucherInfo.AmountInCents, + &voucherInfo.LimitInCents, + &voucherInfo.ConferenceID, + ) + if err != nil { + // fmt.Errorf("reading voucher information: %w", err) + w.WriteHeader(http.StatusInternalServerError) + return + } + totalToDiscount := voucherInfo.AmountInCents + if voucherInfo.Percentage != 0 { + totalCost := 0 + for _, claim := range claims { + totalCost += claim.ConferenceSlot.Cost + } + totalToDiscountFloating := float64(totalCost) * (float64(voucherInfo.Percentage) / 100) + if totalToDiscountFloating > float64(voucherInfo.LimitInCents) && voucherInfo.LimitInCents > 0 { + totalToDiscountFloating = float64(voucherInfo.LimitInCents) + } + // yes, there is loss + totalToDiscount = int64(totalToDiscountFloating) + } + + paymentMethods := []FinancialInstrument{&PaymentMethodMoney{ + AmountCents: session.PaymentIntent.Amount, + PaymentRef: session.ID, + }} + + if totalToDiscount > 0 { + paymentMethods = append(paymentMethods, + &PaymentMethodConferenceDiscount{ + AmountCents: totalToDiscount, + Detail: session.ID, + }) + } + + payment, err := payClaims(req.Context(), usr, claims, paymentMethods) + if err != nil { + // fmt.Errorf("registering claims payment: %w", err) + w.WriteHeader(http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(payment) // FIXME: do something with this err? + } + + w.WriteHeader(http.StatusOK) +} diff --git a/conferences/migrations/10_add_transaction_id.up.sql b/conferences/migrations/10_add_transaction_id.up.sql new file mode 100644 index 0000000..8ff155e --- /dev/null +++ b/conferences/migrations/10_add_transaction_id.up.sql @@ -0,0 +1,2 @@ +ALTER TABLE discount_vouchers ADD COLUMN transaction_id TEXT NOT NULL DEFAULT ''; +ALTER TABLE slot_claim ADD COLUMN payment_session TEXT NOT NULL DEFAULT ''; \ No newline at end of file diff --git a/go.mod b/go.mod index 2a4a6e1..a1cbcaf 100644 --- a/go.mod +++ b/go.mod @@ -6,6 +6,7 @@ require ( encore.dev v0.7.0 github.com/coreos/go-oidc/v3 v3.0.0-alpha.1 github.com/gofrs/uuid v3.3.0+incompatible - github.com/lib/pq v1.9.0 + github.com/lib/pq v1.8.0 + github.com/stripe/stripe-go v70.15.0+incompatible golang.org/x/oauth2 v0.0.0-20201208152858-08078c50e5b5 // indirect ) diff --git a/go.sum b/go.sum index e4a3dd2..821d898 100644 --- a/go.sum +++ b/go.sum @@ -113,8 +113,8 @@ github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORN github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= -github.com/lib/pq v1.9.0 h1:L8nSXQQzAYByakOFMTwpjRoHsMJklur4Gi59b6VivR8= -github.com/lib/pq v1.9.0/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= +github.com/lib/pq v1.8.0 h1:9xohqzkUwzR4Ga4ivdTcawVS89YSDVxXMa3xJX3cGzg= +github.com/lib/pq v1.8.0/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pquerna/cachecontrol v0.0.0-20180517163645-1555304b9b35 h1:J9b7z+QKAmPf4YLrFg6oQUotqHQeUNWwkvo7jZp1GLU= @@ -125,6 +125,8 @@ github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+ github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.5.1 h1:nOGnQDM7FYENwehXlg/kFVnos3rEvtKTjRvOWSzb6H4= github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= +github.com/stripe/stripe-go v70.15.0+incompatible h1:hNML7M1zx8RgtepEMlxyu/FpVPrP7KZm1gPFQquJQvM= +github.com/stripe/stripe-go v70.15.0+incompatible/go.mod h1:A1dQZmO/QypXmsL0T8axYZkSN/uA/T/A64pfKdBAMiY= github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= @@ -244,6 +246,7 @@ golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fq golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= +golang.org/x/text v0.3.3 h1:cokOdA+Jmi5PJGXLlLllQSgYigAEfHXJAERHVMaCc2k= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=