Skip to content

Commit 530c73d

Browse files
authored
stripe initial commit (#66)
* stripe initial commit
1 parent 29116e8 commit 530c73d

File tree

14 files changed

+377
-19
lines changed

14 files changed

+377
-19
lines changed

Makefile

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ PROJECT_NAME := ${PROJECT_NAME}
1212

1313
.EXPORT_ALL_VARIABLES:
1414

15-
run: ci_setup
15+
run: ci_setup billing_setup
1616
@echo "\nDone"
1717

1818
ci_setup:
@@ -24,6 +24,11 @@ ifeq ($(CIVendor), circleci)
2424
ci_setup: circle_ci_setup
2525
endif
2626

27+
billing_setup:
28+
ifeq ($(billingEnabled), yes)
29+
sh scripts/setup-stripe-secrets.sh
30+
endif
31+
2732
circle_ci_setup:
2833
@echo "Set CIRCLECI environment variables\n"
2934
export AWS_ACCESS_KEY_ID=$(shell aws secretsmanager get-secret-value --region ${region} --secret-id=${PROJECT_NAME}-ci-user-aws-keys${randomSeed} | jq -r '.SecretString'| jq -r .access_key_id)

README.md

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,14 @@ This repository is language/business-logic agnostic; mainly showcasing some univ
2121
|-- Makefile #make command triggers the initialization of repository
2222
|-- zero-module.yml #module declares required parameters and credentials
2323
| # files in templates become the repo for users
24+
| scripts/
25+
| | # these are scripts called only once during zero apply, and we don't
26+
| | # expect a need to rerun them throughout development of the repository
27+
| | # used for checking binary requires / setting up CI / secrets
28+
| | |-- check.sh
29+
| | |-- gha-setup.sh
30+
| | |-- required-bins.sh
31+
| | |-- setup-stripe-secrets.sh
2432
| templates/
2533
| | # this makefile is used both during init and
2634
| | # on-going needs/utilities for user to maintain their infrastructure
@@ -31,8 +39,8 @@ This repository is language/business-logic agnostic; mainly showcasing some univ
3139
| | |-- deployment.yml
3240
| | |-- kustomization.yml
3341
| | |-- service.yml
34-
│   ├── migration/
35-
│   │   └── job.yml
42+
| |-- migration/
43+
| | |-- job.yml
3644
| |-- overlays/
3745
| | |-- production/
3846
| | | |-- deployment.yml

scripts/setup-stripe-secrets.sh

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
#!/bin/bash
2+
3+
# This script runs only when billingEnabled = "yes", invoked from makefile
4+
# modify the kubernetes application secret and appends STRIPE_API_SECRET_KEY
5+
# the deployment by default will pick up all key-value pairs as env-vars from the secret
6+
7+
if [[ "$ENVIRONMENT" == "" ]]; then
8+
echo "Must specify \$ENVIRONMENT to create stripe secret ">&2; exit 1;
9+
elif [[ "$ENVIRONMENT" == "stage" ]]; then
10+
PUBLISHABLE_API_KEY=$stagingStripePublicApiKey
11+
SECRET_API_KEY=$stagingStripeSecretApiKey
12+
elif [[ "$ENVIRONMENT" == "prod" ]]; then
13+
PUBLISHABLE_API_KEY=$productionStripePublicApiKey
14+
SECRET_API_KEY=$productionStripeSecretApiKey
15+
fi
16+
17+
CLUSTER_NAME=${PROJECT_NAME}-${ENVIRONMENT}-${REGION}
18+
NAMESPACE=${PROJECT_NAME}
19+
20+
BASE64_TOKEN=$(printf ${SECRET_API_KEY} | base64)
21+
## Modify existing application secret to have stripe api key
22+
kubectl --context $CLUSTER_NAME -n $NAMESPACE get secret ${PROJECT_NAME} -o json | \
23+
jq --arg STRIPE_API_SECRET_KEY $BASE64_TOKEN '.data["STRIPE_API_SECRET_KEY"]=$STRIPE_API_SECRET_KEY' \
24+
| kubectl apply -f -
25+
26+
sh ${PROJECT_DIR}/scripts/stripe-example-setup.sh

templates/README.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -210,7 +210,26 @@ ALTER TABLE address
210210
ADD COLUMN city VARCHAR(30) AFTER street_name,
211211
ADD COLUMN province VARCHAR(30) AFTER city
212212
```
213+
<%if eq (index .Params `billingEnabled`) "yes" %>
214+
## Billing example
215+
A subscription and checkout example using [Stripe](https://stripe.com), coupled with the frontend repository to provide an end-to-end checkout example for you to customize. We also setup a webhook and an endpoint in the backend to receive webhook when events occur.
213216

217+
### Setup
218+
The following example content has been set up in Stripe:
219+
- 1 product
220+
- 3 prices(subscriptions) [annual, monthly, daily]
221+
- 1 webhook [`charge.failed`, `charge.succeeded`, `customer.created`, `subscription_schedule.created`]
222+
See link for available webhooks: https://stripe.com/docs/api/webhook_endpoints/create?lang=curl#create_webhook_endpoint-enabled_events
223+
224+
this is setup using the script [scripts/stripe-example-setup.sh](scripts/stripe-example-setup.sh)
225+
226+
### Deployment
227+
The deployment only requires the environment variables:
228+
- STRIPE_API_SECRET_KEY (created in AWS secret then deployed via Kubernetes Secret)
229+
- FRONTEND_HOST (used for sending user back to frontend upon checkouts)
230+
- BACKEND_HOST (used for redirects after checkout and webhooks)
231+
232+
<% end %>
214233
<!-- Links -->
215234
[base-cronjob]: ./kubernetes/base/cronjob.yml
216235
[base-deployment]: ./kubernetes/base/deployment.yml

templates/go.mod

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,9 @@ go 1.14
44

55
require (
66
github.com/aws/aws-sdk-go v1.37.6
7-
github.com/jinzhu/gorm v1.9.16
7+
github.com/jinzhu/gorm v1.9.16
88
github.com/joho/godotenv v1.3.0
9+
<%- if eq (index .Params `billingEnabled`) "yes" %>
10+
github.com/stripe/stripe-go/v72 v72.43.0
11+
<%- end %>
912
)
Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,184 @@
1+
package billing
2+
3+
import (
4+
"encoding/json"
5+
"fmt"
6+
"net/http"
7+
"net/url"
8+
"os"
9+
"strings"
10+
11+
Stripe "github.com/stripe/stripe-go/v72"
12+
"github.com/stripe/stripe-go/v72/client"
13+
<%- if eq (index .Params `userAuth`) "yes" %>
14+
"<% .Files.Repository %>/internal/auth"
15+
<%- end %>
16+
)
17+
18+
var backendHost = os.Getenv("BACKEND_HOST")
19+
var frontendHost = os.Getenv("FRONTEND_HOST")
20+
21+
var Handler = getHandler()
22+
var stripe *client.API
23+
24+
// Subscriptions for frontend to display available plans available for checkout
25+
type Subscriptions struct {
26+
Nickname string `json:"nickname"`
27+
Interval string `json:"interval"`
28+
Type string `json:"type"`
29+
ID string `json:"id"`
30+
Price string `json:"price"`
31+
}
32+
33+
func getHandler() http.Handler {
34+
setupStripe()
35+
mux := http.NewServeMux()
36+
mux.HandleFunc("/billing/products", getProducts)
37+
mux.HandleFunc("/billing/checkout", checkout)
38+
mux.HandleFunc("/billing/success", success)
39+
mux.HandleFunc("/billing/cancel", cancel)
40+
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
41+
w.Write([]byte("Not found"))
42+
})
43+
return mux
44+
}
45+
46+
func setupStripe() {
47+
stripe = &client.API{}
48+
apiKey := os.Getenv("STRIPE_API_SECRET_KEY")
49+
stripe.Init(apiKey, nil)
50+
}
51+
52+
func getProducts(w http.ResponseWriter, r *http.Request) {
53+
active := true
54+
listParams := &Stripe.PriceListParams{Active: &active}
55+
stripePriceIterator := stripe.Prices.List(listParams).Iter
56+
57+
subs := []Subscriptions{}
58+
// Data to display subscriptions for the frontend example
59+
for stripePriceIterator.Next() {
60+
stripePrice := stripePriceIterator.Current().(*Stripe.Price)
61+
amount := float64(stripePrice.UnitAmount) / 100
62+
displayPrice := fmt.Sprintf("%s $%.2f/%s", strings.ToUpper(string(stripePrice.Currency)), amount, string(stripePrice.Recurring.Interval))
63+
sub := Subscriptions{
64+
Nickname: stripePrice.Nickname,
65+
Interval: string(stripePrice.Recurring.Interval),
66+
Type: string(stripePrice.Type),
67+
ID: stripePrice.ID,
68+
Price: displayPrice,
69+
}
70+
subs = append(subs, sub)
71+
}
72+
output, err := json.Marshal(&subs)
73+
if err != nil {
74+
http.Error(w, err.Error(), http.StatusInternalServerError)
75+
}
76+
w.Write(output)
77+
}
78+
79+
func checkout(w http.ResponseWriter, r *http.Request) {
80+
if r.Method != "POST" {
81+
http.Error(w, "Incorrect method", http.StatusBadRequest)
82+
return
83+
}
84+
85+
price := struct {
86+
ID string `json:"price_id"`
87+
}{}
88+
err := json.NewDecoder(r.Body).Decode(&price)
89+
if err != nil {
90+
http.Error(w, err.Error(), http.StatusBadRequest)
91+
return
92+
}
93+
94+
paymentMethod := "card"
95+
paymentMode := "subscription"
96+
checkoutQuantity := int64(1)
97+
lineItem := &Stripe.CheckoutSessionLineItemParams{
98+
Price: &price.ID,
99+
Quantity: &checkoutQuantity,
100+
}
101+
successURL := "https://" + backendHost + "/billing/success?session_id={CHECKOUT_SESSION_ID}"
102+
cancelURL := "https://" + backendHost + "/billing/cancel?session_id={CHECKOUT_SESSION_ID}"
103+
<% if eq (index .Params `userAuth`) "yes" %>
104+
authErr, userInfo := auth.GetUserInfoFromHeaders(r)
105+
clientReferenceID := userInfo.ID
106+
if authErr != nil {
107+
http.Error(w, authErr.Error(), http.StatusUnauthorized)
108+
return
109+
}
110+
<%- else %>
111+
clientReferenceID := "internal-app-reference-id"
112+
<%- end %>
113+
114+
checkoutParams := &Stripe.CheckoutSessionParams{
115+
Mode: &paymentMode,
116+
PaymentMethodTypes: []*string{&paymentMethod},
117+
ClientReferenceID: &clientReferenceID,
118+
LineItems: []*Stripe.CheckoutSessionLineItemParams{lineItem},
119+
SuccessURL: &successURL,
120+
CancelURL: &cancelURL,
121+
}
122+
session, err := stripe.CheckoutSessions.New(checkoutParams)
123+
if err != nil {
124+
http.Error(w, err.Error(), http.StatusInternalServerError)
125+
return
126+
}
127+
fmt.Fprintf(w, `{"sessionId": "%s"}`, session.ID)
128+
}
129+
130+
func success(w http.ResponseWriter, r *http.Request) {
131+
sessionId := string(r.URL.Query().Get("session_id"))
132+
133+
session, err := stripe.CheckoutSessions.Get(sessionId, &Stripe.CheckoutSessionParams{})
134+
if err != nil {
135+
http.Error(w, err.Error(), http.StatusInternalServerError)
136+
return
137+
}
138+
data := map[string]string{
139+
"payment_status": string(session.PaymentStatus),
140+
"amount": fmt.Sprintf("%d", session.AmountSubtotal),
141+
"currency": string(session.Currency),
142+
"customer": string(session.CustomerDetails.Email),
143+
"reference": string(session.ClientReferenceID),
144+
}
145+
146+
baseUrl := url.URL{
147+
Scheme: "https",
148+
Host: frontendHost,
149+
Path: "/billing/confirmation",
150+
}
151+
redirectURL := fmt.Sprintf("%s?%s", baseUrl.String(), mapToQueryString(data))
152+
http.Redirect(w, r, redirectURL, 302)
153+
}
154+
155+
func cancel(w http.ResponseWriter, r *http.Request) {
156+
sessionId := string(r.URL.Query().Get("session_id"))
157+
158+
session, err := stripe.CheckoutSessions.Get(sessionId, &Stripe.CheckoutSessionParams{})
159+
if err != nil {
160+
http.Error(w, err.Error(), http.StatusInternalServerError)
161+
return
162+
}
163+
data := map[string]string{
164+
"payment_status": string(session.PaymentStatus),
165+
"amount": fmt.Sprintf("%d", session.AmountSubtotal),
166+
"currency": string(session.Currency),
167+
"reference": string(session.ClientReferenceID),
168+
}
169+
baseUrl := url.URL{
170+
Scheme: "https",
171+
Host: frontendHost,
172+
Path: "/billing/confirmation",
173+
}
174+
redirectURL := fmt.Sprintf("%s?%s", baseUrl.String(), mapToQueryString(data))
175+
http.Redirect(w, r, redirectURL, 302)
176+
}
177+
178+
func mapToQueryString(data map[string]string) string {
179+
queryString := url.Values{}
180+
for k, v := range data {
181+
queryString.Add(k, v)
182+
}
183+
return queryString.Encode()
184+
}

templates/kubernetes/base/deployment.yml

Lines changed: 2 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -41,23 +41,15 @@ spec:
4141
envFrom:
4242
- configMapRef:
4343
name: <% .Name %>-config
44+
- secretRef:
45+
name: <% .Name %>
4446
env:
4547
- name: SERVER_PORT
4648
value: "80"
4749
- name: POD_NAME
4850
valueFrom:
4951
fieldRef:
5052
fieldPath: metadata.name
51-
- name: DATABASE_USERNAME
52-
valueFrom:
53-
secretKeyRef:
54-
name: <% .Name %>
55-
key: DATABASE_USERNAME
56-
- name: DATABASE_PASSWORD
57-
valueFrom:
58-
secretKeyRef:
59-
name: <% .Name %>
60-
key: DATABASE_PASSWORD
6153
---
6254
apiVersion: autoscaling/v1
6355
kind: HorizontalPodAutoscaler

templates/kubernetes/overlays/production/auth.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ metadata:
77
name: public-backend-endpoints
88
spec:
99
match:
10-
url: http://<% index .Params `productionBackendSubdomain` %><% index .Params `productionHostRoot` %>/status/<.*>
10+
url: http://<% index .Params `productionBackendSubdomain` %><% index .Params `productionHostRoot` %>/<(status|webhook)\/.*>
1111
---
1212
## Backend User-restricted endpoint
1313
# pattern: http://<proxy>/<not [/.ory/kratos and /status]>
@@ -21,5 +21,5 @@ metadata:
2121
name: authenticated-backend-endpoints
2222
spec:
2323
match:
24-
url: http://<% index .Params `productionBackendSubdomain` %><% index .Params `productionHostRoot` %>/<(?!(status|\.ory\/kratos)).*>
24+
url: http://<% index .Params `productionBackendSubdomain` %><% index .Params `productionHostRoot` %>/<(?!(status|webhook|\.ory\/kratos)).*>
2525

templates/kubernetes/overlays/production/kustomization.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,3 +17,5 @@ configMapGenerator:
1717
behavior: merge
1818
literals:
1919
- ENVIRONMENT=production
20+
- BACKEND_HOST=<% index .Params `productionBackendSubdomain` %><% index .Params `productionHostRoot` %>
21+
- FRONTEND_HOST=<% index .Params `productionFrontendSubdomain` %><% index .Params `productionHostRoot` %>

templates/kubernetes/overlays/staging/auth.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ metadata:
77
name: public-backend-endpoints
88
spec:
99
match:
10-
url: http://<% index .Params `stagingBackendSubdomain` %><% index .Params `stagingHostRoot` %>/status/<.*>
10+
url: http://<% index .Params `stagingBackendSubdomain` %><% index .Params `stagingHostRoot` %>/<(status|webhook)\/.*>
1111
---
1212
## Backend User-restricted endpoint
1313
# pattern: http://<proxy>/<not `status`/`.ory/kratos`>, everything else should be authenticated
@@ -21,4 +21,4 @@ metadata:
2121
name: authenticated-backend-endpoints
2222
spec:
2323
match:
24-
url: http://<% index .Params `stagingBackendSubdomain` %><% index .Params `stagingHostRoot` %>/<(?!(status|\.ory\/kratos)).*>
24+
url: http://<% index .Params `stagingBackendSubdomain` %><% index .Params `stagingHostRoot` %>/<(?!(status|webhook|\.ory\/kratos)).*>

0 commit comments

Comments
 (0)