Skip to content

Commit

Permalink
Implement stripe checkout.
Browse files Browse the repository at this point in the history
  • Loading branch information
zensh committed Sep 6, 2023
1 parent f368891 commit 88b1c17
Show file tree
Hide file tree
Showing 14 changed files with 499 additions and 15 deletions.
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ jobs:
image: eqalpha/keydb:latest
ports:
- 6379:6379
options: --health-cmd "redis-cli ping " --health-interval 5s --health-retries 10
options: --server-threads 1 --health-cmd "redis-cli ping " --health-interval 5s --health-retries 10
volumes:
- ${{ github.workspace }}:/workspace
steps:
Expand Down
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -21,4 +21,5 @@
go.work
config.toml
debug
dist
dist
.env
2 changes: 1 addition & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ BUILD_TIME := $(shell date -u +"%FT%TZ")
BUILD_COMMIT := $(shell git rev-parse HEAD)

run-dev:
@CONFIG_FILE_PATH=${PWD}/config.toml APP_ENV=dev go run main.go
@APP_ENV=dev go run main.go

test:
@CONFIG_FILE_PATH=${PWD}/config/default.toml APP_ENV=test go test ./...
Expand Down
5 changes: 5 additions & 0 deletions config/default.toml
Original file line number Diff line number Diff line change
Expand Up @@ -18,3 +18,8 @@ node = "127.0.0.1:6379"
userbase = "http://127.0.0.1:8080"
logbase = "http://127.0.0.1:8080"
walletbase = "http://127.0.0.1:8080"

[stripe]
pub_key = ""
price_id = ""
success_url = "http://127.0.0.1:8080/wallet"
2 changes: 2 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,10 @@ require (
github.com/go-http-utils/negotiator v1.0.0 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/joho/godotenv v1.5.1 // indirect
github.com/leodido/go-urn v1.2.4 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/stripe/stripe-go/v75 v75.3.0 // indirect
github.com/teambition/trie-mux v1.5.2 // indirect
github.com/x448/float16 v0.8.4 // indirect
golang.org/x/crypto v0.13.0 // indirect
Expand Down
10 changes: 10 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ github.com/go-playground/validator/v10 v10.15.3 h1:S+sSpunYjNPDuXkWbK+x+bA7iXiW2
github.com/go-playground/validator/v10 v10.15.3/go.mod h1:9iXMNT7sEkjXb0I+enO7QXmzG6QCsPWY4zveKFVRSyU=
github.com/google/uuid v1.3.1 h1:KjJaJ9iWZ3jOFZIf1Lqf4laDRCasjl0BCmnEGxkdLb4=
github.com/google/uuid v1.3.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM=
github.com/klauspost/compress v1.16.7 h1:2mk3MPGNzKyxErAw8YaohYh69+pa4sIQSC0fPGCFR9I=
github.com/klauspost/compress v1.16.7/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE=
Expand All @@ -50,6 +52,8 @@ github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO
github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stripe/stripe-go/v75 v75.3.0 h1:V4fKAkzKjFG1vE+Ae9sNF2l496mDDOnH1KKr6/EbpNc=
github.com/stripe/stripe-go/v75 v75.3.0/go.mod h1:wT44gah+eCY8Z0aSpY/vQlYYbicU9uUAbAqdaUxxDqE=
github.com/teambition/gear v1.27.3 h1:iWUOJYdBwxU+SZP5aZ2ZYR5FnRGmdgrMbbSpOCZo0go=
github.com/teambition/gear v1.27.3/go.mod h1:d3Nmr6rRPnH5lYSK33W9IDhsaxp/8n14vRrUZu9dP9c=
github.com/teambition/trie-mux v1.5.2 h1:ALTagFwKZXkn1vfSRlODlmoZg+NMeWAm4dyBPQI6a8w=
Expand All @@ -62,18 +66,24 @@ golang.org/x/crypto v0.12.0 h1:tFM/ta59kqch6LlvYnPa0yx5a83cL2nHflFhYKvv9Yk=
golang.org/x/crypto v0.12.0/go.mod h1:NF0Gs7EO5K4qLn+Ylc+fih8BSTeIjAP05siRnAh98yw=
golang.org/x/crypto v0.13.0 h1:mvySKfSWJ+UKUii46M40LOvyWfN0s2U+46/jDd0e6Ck=
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
golang.org/x/net v0.0.0-20210520170846-37e1c6afe023/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.14.0 h1:BONx9s002vGdD9umnlX1Po8vOZmrgH34qlHcD1MfK14=
golang.org/x/net v0.14.0/go.mod h1:PpSgVXXLK0OxS0F31C1/tv6XNguvCrnXIDrFMspZIUI=
golang.org/x/net v0.15.0 h1:ugBLEUaxABaB5AJqW9enI0ACdci2RUd4eP51NTBvuJ8=
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.11.0 h1:eG7RXZHdqOJ1i+0lgLgCpSXAp6M3LYlAo6osgSi0xOM=
golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0 h1:CM0HF96J0hcLAwsHPJZjfdNzs0gftsLfgKt57wWHJ0o=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.12.0 h1:k+n5B8goJNdU7hSvEtMUz3d1Q6D/XW4COJSJR6fN0mc=
golang.org/x/text v0.12.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.13.0 h1:ablQoSUd0tRdKxZewP80B+BaqeKJuVhuRxj/dkrun3k=
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
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.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
Expand Down
6 changes: 5 additions & 1 deletion src/api/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,11 @@ func NewApp() *gear.App {
})

if err != nil {
logging.Panicf("DigInvoke error: %v", err)
if conf.Config.Env == "prod" {
logging.Panicf("DigInvoke error: %v", err)
} else {
logging.Warningf("DigInvoke error: %v", err)
}
}

return app
Expand Down
277 changes: 277 additions & 0 deletions src/api/checkout.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,277 @@
package api

import (
"encoding/json"
"io"
"strings"

"github.com/fxamacker/cbor/v2"
"github.com/stripe/stripe-go/v75"
"github.com/stripe/stripe-go/v75/checkout/session"
"github.com/stripe/stripe-go/v75/price"
"github.com/stripe/stripe-go/v75/webhook"
"github.com/teambition/gear"

"github.com/yiwen-ai/wallet-api/src/bll"
"github.com/yiwen-ai/wallet-api/src/conf"
"github.com/yiwen-ai/wallet-api/src/logging"
"github.com/yiwen-ai/wallet-api/src/middleware"
"github.com/yiwen-ai/wallet-api/src/util"
)

func init() {
stripe.Key = conf.Config.Stripe.SecretKey
}

type Checkout struct {
blls *bll.Blls
cfg conf.Stripe
}

type CheckoutConfig struct {
Provider string `json:"provider" cbor:"provider"`
PublicKey string `json:"public_key" cbor:"public_key"`
UnitAmount int64 `json:"unit_amount" cbor:"unit_amount"`
Currency string `json:"currency" cbor:"currency"`
}

func (a *Checkout) GetConfig(ctx *gear.Context) error {
p, err := price.Get(
a.cfg.PriceID,
nil,
)
if err != nil {
return gear.ErrInternalServerError.From(err)
}

return ctx.OkJSON(bll.SuccessResponse[CheckoutConfig]{Result: CheckoutConfig{
Provider: "stripe",
PublicKey: a.cfg.PubKey,
UnitAmount: p.UnitAmount,
Currency: string(p.Currency),
}})
}

func (a *Checkout) Get(ctx *gear.Context) error {
input := &bll.QueryIdCn{}
if err := ctx.ParseURL(input); err != nil {
return err
}
sess := gear.CtxValue[middleware.Session](ctx)

output, err := a.blls.Walletbase.GetCharge(ctx, sess.UserID, input.ID, input.Fields)
if err != nil {
return gear.ErrInternalServerError.From(err)
}
output.ChargePayload = nil
return ctx.OkSend(bll.SuccessResponse[*bll.ChargeOutput]{Result: output})
}

type CheckoutInput struct {
Quantity uint `json:"quantity" cbor:"quantity" validate:"gte=50,lte=1000000"`
Currency *string `json:"currency" cbor:"currency"`
}

func (i *CheckoutInput) Validate() error {
if err := util.Validator.Struct(i); err != nil {
return gear.ErrBadRequest.From(err)
}

return nil
}

type CheckoutOutput struct {
ID util.ID `json:"id" cbor:"id"`
PaymentURL string `json:"payment_url" cbor:"payment_url"`
}

func (a *Checkout) Create(ctx *gear.Context) error {
input := &CheckoutInput{}
if err := ctx.ParseBody(input); err != nil {
return err
}

if input.Currency != nil {
input.Currency = util.Ptr(strings.ToLower(*input.Currency))
if err := a.blls.Walletbase.Currencies.Validate(*input.Currency); err != nil {
return err
}
}

sess := gear.CtxValue[middleware.Session](ctx)
output, err := a.blls.Walletbase.CreateCharge(ctx, &bll.ChargeInput{
UID: sess.UserID,
Provider: "stripe",
Quantity: input.Quantity,
})
if err != nil {
return gear.ErrInternalServerError.From(err)
}

logging.SetTo(ctx, "chargeId", output.ID.String())
err = a.createSession(ctx, output.ID, &stripe.CheckoutSessionParams{
SuccessURL: util.Ptr(a.cfg.SuccessUrl),
Mode: util.Ptr(string(stripe.CheckoutSessionModePayment)),
Currency: input.Currency,
CustomerCreation: util.Ptr("always"),
LineItems: []*stripe.CheckoutSessionLineItemParams{
{
Quantity: util.Ptr(int64(input.Quantity)),
Price: util.Ptr(a.cfg.PriceID),
},
},
Metadata: map[string]string{
"uid": sess.UserID.String(),
"cid": output.ID.String(),
},
})

if err != nil {
logging.SetTo(ctx, "createSessionError", err.Error())
}
return err
}

func (a *Checkout) createSession(ctx *gear.Context, chargeID util.ID, params *stripe.CheckoutSessionParams) error {
sess := gear.CtxValue[middleware.Session](ctx)
if customer, _ := a.blls.Walletbase.GetCustomer(ctx, sess.UserID, "stripe", util.Ptr("customer")); customer != nil {
params.Customer = stripe.String(customer.Customer)
}
cs, err := session.New(params)
if err != nil {
return gear.ErrInternalServerError.From(err)
}

logging.SetTo(ctx, "checkoutId", cs.ID)
payload, err := cbor.Marshal(cs)
if err != nil {
return gear.ErrInternalServerError.From(err)
}

_, err = a.blls.Walletbase.UpdateCharge(ctx, &bll.UpdateChargeInput{
UID: sess.UserID,
ID: chargeID,
CurrentStatus: 0,
Status: 1,
Currency: util.Ptr(string(cs.Currency)),
Amount: util.Ptr(uint(cs.AmountTotal)),
ChargeID: util.Ptr(cs.ID),
ChargePayload: util.Ptr(util.Bytes(payload)),
})
if err != nil {
return gear.ErrInternalServerError.From(err)
}

return ctx.OkSend(bll.SuccessResponse[CheckoutOutput]{Result: CheckoutOutput{
ID: chargeID,
PaymentURL: cs.URL,
}})
}

func (a *Checkout) ListCharges(ctx *gear.Context) error {
input := &bll.UIDPagination{}
if err := ctx.ParseBody(input); err != nil {
return err
}
sess := gear.CtxValue[middleware.Session](ctx)
input.UID = &sess.UserID

output, err := a.blls.Walletbase.ListCharges(ctx, input)
if err != nil {
return gear.ErrInternalServerError.From(err)
}

// for i := range output {
// output[i].UID = nil
// }

return ctx.OkSend(bll.SuccessResponse[[]bll.ChargeOutput]{Result: output})
}

func (a *Checkout) StripeWebhook(ctx *gear.Context) error {
b, err := io.ReadAll(ctx.Req.Body)
if err != nil {
return gear.ErrBadRequest.WithMsgf("read body failed: %v", err)
}

event, err := webhook.ConstructEvent(b, ctx.Req.Header.Get("Stripe-Signature"), a.cfg.WebhookKey)
if err != nil {
return gear.ErrBadRequest.WithMsgf("webhook.ConstructEvent failed: %v", err)
}

logging.SetTo(ctx, "eventType", event.Type)
logging.SetTo(ctx, "eventId", event.ID)

var obj map[string]interface{}
if event.Data != nil && len(event.Data.Object) > 0 {
obj = event.Data.Object
}

logging.SetTo(ctx, "objectType", obj["object"])
logging.SetTo(ctx, "objectId", obj["id"])

if event.Type == "checkout.session.completed" {
if err = a.completeSession(ctx, event.Data.Raw); err != nil {
logging.SetTo(ctx, "completeSessionError", err.Error())
return ctx.Error(err)
}
}

return ctx.OkJSON(bll.SuccessResponse[bool]{Result: true})
}

func (a *Checkout) completeSession(ctx *gear.Context, data []byte) error {
cs := &stripe.CheckoutSession{}
if err := json.Unmarshal(data, cs); err != nil {
return gear.ErrBadRequest.WithMsgf("json.Unmarshal failed: %v", err)
}
uid, err := util.ParseID(cs.Metadata["uid"])
if err != nil {
return gear.ErrBadRequest.WithMsgf("parse uid failed: %v", err)
}

logging.SetTo(ctx, "uid", uid)
cid, err := util.ParseID(cs.Metadata["cid"])
if err != nil {
return gear.ErrBadRequest.WithMsgf("parse uid failed: %v", err)
}

logging.SetTo(ctx, "chargeId", cid)
charge, err := a.blls.Walletbase.CompleteCharge(ctx, &bll.CompleteChargeInput{
UID: uid,
ID: cid,
ChargeID: cs.ID,
ChargePayload: util.Bytes(data),
})

if err != nil {
return gear.ErrInternalServerError.From(err)
}

if cs.Customer != nil && cs.CustomerDetails != nil {
data, err := cbor.Marshal(cs.CustomerDetails)
if err == nil {
_, err = a.blls.Walletbase.UpsertCustomer(ctx, &bll.CustomerInput{
UID: uid,
Provider: "stripe",
Customer: cs.Customer.ID,
Payload: util.Bytes(data),
})
}
if err != nil {
logging.SetTo(ctx, "upsertCustomerError", err.Error())
} else {
logging.SetTo(ctx, "customer", cs.Customer.ID)
}
}

if _, err = a.blls.Logbase.Log(ctx, bll.LogActionUserTopup, 1, uid, &bll.Payload{
Kind: "charge",
ID: charge.ID,
Amount: int64(charge.Quantity),
}); err != nil {
logging.SetTo(ctx, "writeLogError", err.Error())
}

return nil
}
Loading

0 comments on commit 88b1c17

Please sign in to comment.