Skip to content

Commit 89850fd

Browse files
committed
prepare for Paddle checkout
1 parent 34b9dca commit 89850fd

7 files changed

Lines changed: 126 additions & 36 deletions

File tree

.github/workflows/master.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,8 @@ jobs:
4646
VSH_ADMIN_API_KEY: ${{ secrets.VSH_ADMIN_API_KEY }}
4747
STRIPE_API_KEY: ${{ secrets.STRIPE_API_KEY }}
4848
STRIPE_WEBHOOK_SECRET: ${{ secrets.STRIPE_WEBHOOK_SECRET }}
49+
PADDLE_API_KEY: ${{ secrets.PADDLE_API_KEY }}
50+
PADDLE_WEBHOOK_SECRET: ${{ secrets.PADDLE_WEBHOOK_SECRET }}
4951
MOMENTO_TOKEN: ${{ secrets.MOMENTO_TOKEN }}
5052
- name: Serverless doctor advice
5153
uses: serverless/github-action@v3.2

.github/workflows/sandbox.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,8 @@ jobs:
4747
VSH_ADMIN_API_KEY: ${{ secrets.VSH_ADMIN_API_KEY_SANDBOX }}
4848
STRIPE_API_KEY: ${{ secrets.STRIPE_API_KEY_SANDBOX }}
4949
STRIPE_WEBHOOK_SECRET: ${{ secrets.STRIPE_WEBHOOK_SECRET_SANDBOX }}
50+
PADDLE_API_KEY: ${{ secrets.PADDLE_API_KEY_SANDBOX }}
51+
PADDLE_WEBHOOK_SECRET: ${{ secrets.PADDLE_WEBHOOK_SECRET_SANDBOX }}
5052
MOMENTO_TOKEN: ${{ secrets.MOMENTO_TOKEN_SANDBOX }}
5153
- name: Serverless doctor advice
5254
uses: serverless/github-action@v3.2

Authorization.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ export interface UserRecord {
1919
allowedDeviceCount?: number
2020
plan?: string
2121
stripeCustomerId?: string
22+
paddleCustomerId?: string
2223
deleteAtUnixTime?: number
2324
}
2425

backendApi.ts

Lines changed: 114 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import * as log from 'log'
77
import * as logger from 'log-aws-lambda'
88
import * as jwt from 'jsonwebtoken'
99
import Stripe from 'stripe'
10+
import { Paddle, EventName as PaddleEventName } from '@paddle/paddle-node-sdk'
1011

1112
import { fetchProfile, isProd, proactivelyUndiscoverDevices } from './helper'
1213
import AWS = require('aws-sdk')
@@ -31,6 +32,8 @@ const stripe = new Stripe(process.env.STRIPE_API_KEY, {
3132
apiVersion: '2023-08-16',
3233
})
3334

35+
const paddle = new Paddle(process.env.PADDLE_API_KEY)
36+
3437
interface AuthenticatedRequest extends express.Request {
3538
userId: string
3639
jwt?: { [key: string]: any }
@@ -193,7 +196,7 @@ app.post('/stripe_webhook', async function (req, res) {
193196
break
194197
// ... handle other event types
195198
default:
196-
log.warn(`Unhandled event type ${event.type}: %j`, event)
199+
log.warn(`Unhandled Stripe event type ${event.type}: %j`, event)
197200
}
198201

199202
// Return a 200 response to acknowledge receipt of the event
@@ -204,6 +207,46 @@ app.post('/stripe_webhook', async function (req, res) {
204207
}
205208
})
206209

210+
app.post('/paddle_webhook', async function (req, res) {
211+
const signature = (req.headers['paddle-signature'] as string) || ''
212+
// req.body should be of type `buffer`, convert to string before passing it to `unmarshal`.
213+
// If express returned a JSON, remove any other middleware that might have processed raw request to object
214+
const rawRequestBody = (req as any).rawBody
215+
// Replace `WEBHOOK_SECRET_KEY` with the secret key in notifications from vendor dashboard
216+
const secretKey = process.env.PADDLE_WEBHOOK_SECRET || ''
217+
218+
try {
219+
if (signature && rawRequestBody) {
220+
// The `unmarshal` function will validate the integrity of the webhook and return an entity
221+
const eventData = await paddle.webhooks.unmarshal(
222+
rawRequestBody,
223+
secretKey,
224+
signature
225+
)
226+
switch (eventData.eventType) {
227+
// case PaddleEventName.ProductUpdated:
228+
// console.log(`Product ${eventData.data.id} was updated`)
229+
// break
230+
// case PaddleEventName.SubscriptionUpdated:
231+
// console.log(`Subscription ${eventData.data.id} was updated`)
232+
// break
233+
default:
234+
log.warn(
235+
`Unhandled Paddle event type ${eventData.eventType}: %j`,
236+
eventData
237+
)
238+
}
239+
} else {
240+
console.log('Signature missing in header')
241+
}
242+
} catch (e) {
243+
// Handle signature mismatch or other runtime errors
244+
console.log(e)
245+
}
246+
// Return a response to acknowledge
247+
res.send('Processed Paddle webhook event')
248+
})
249+
207250
//applying middlewares for all endpoints below these lines!
208251
app.use(cors())
209252
app.use(express.urlencoded({ extended: true })) // for parsing application/x-www-form-urlencoded
@@ -409,9 +452,10 @@ app.get('/plan', needsAuth, async function (req: AuthenticatedRequest, res) {
409452
{
410453
aud: 'checkout',
411454
sub: req.userId,
455+
productName: 'VSH PRO - Yearly',
412456
priceId: isProd()
413-
? 'price_1Li1C4C3eSYquofeqstbOGi9'
414-
: 'price_1LgSpdC3eSYquofeNk3MClG1',
457+
? 'pri_01jtxvka1st9m2m6bns9cha8hp'
458+
: 'pri_01jtwt97ekjxb7xn6q1a9p8g5g',
415459
},
416460
process.env.HASH_SECRET,
417461
{ expiresIn: '30m' }
@@ -424,9 +468,10 @@ app.get('/plan', needsAuth, async function (req: AuthenticatedRequest, res) {
424468
{
425469
aud: 'checkout',
426470
sub: req.userId,
471+
productName: 'VSH PRO - Monthly',
427472
priceId: isProd()
428-
? 'price_1Li1C4C3eSYquofekuqBmkqk'
429-
: 'price_1LgSpdC3eSYquofegmyiZdQv',
473+
? 'pri_01jtxvmaxj28qyjwyfbz6qrby4'
474+
: 'pri_01jtwt8adq0rh6t605dc4b4avn',
430475
},
431476
process.env.HASH_SECRET,
432477
{ expiresIn: '30m' }
@@ -446,47 +491,80 @@ app.get(
446491
'/checkout',
447492
needsTokenForAudience('checkout'),
448493
async function (req: AuthenticatedRequest, res) {
449-
const { stripeCustomerId, email } = await getUserRecord(req.userId)
450-
451-
//init Stripe checkout session and redirect to their checkout experience
452-
const stripeSession = await stripe.checkout.sessions.create({
453-
mode: 'subscription',
454-
client_reference_id: req.userId,
455-
...(stripeCustomerId && { customer: stripeCustomerId }), //include customer property if stripeCustomerId is truthy
456-
...(!stripeCustomerId && { customer_email: email }), //include customer_email property if stripeCustomerId is falsy
457-
line_items: [
458-
{
459-
price: req.jwt.priceId,
460-
quantity: 1,
461-
},
462-
],
463-
allow_promotion_codes: true,
464-
// {CHECKOUT_SESSION_ID} is a string literal; do not change it!
465-
// the actual Session ID is returned in the query parameter when your customer
466-
// is redirected to the success page.
467-
success_url: `https://${req.hostname}/dev/stripe_redirect?session_id={CHECKOUT_SESSION_ID}`,
468-
cancel_url: `https://${req.hostname}/dev/stripe_redirect?cancelled=true`,
469-
})
494+
// redirect to the checkout page that uses Paddle Billing
495+
const { email } = await getUserRecord(req.userId)
496+
497+
const checkoutIntend = {
498+
userId: req.userId,
499+
email: email,
500+
priceId: req.jwt.priceId,
501+
jwt: req.jwt,
502+
sandbox: !isProd(),
503+
productName: req.jwt.productName,
504+
}
505+
506+
res.redirect(
507+
303,
508+
'https://vsh.csuermann.de/checkout/?checkoutIntend=' +
509+
encode(JSON.stringify(checkoutIntend))
510+
)
470511

471-
// Redirect to the URL returned on the Checkout Session.
472-
res.redirect(303, stripeSession.url)
512+
// before 2025-05-10 we used Stripe
513+
// const { stripeCustomerId, email } = await getUserRecord(req.userId)
514+
// //init Stripe checkout session and redirect to their checkout experience
515+
// const stripeSession = await stripe.checkout.sessions.create({
516+
// mode: 'subscription',
517+
// client_reference_id: req.userId,
518+
// ...(stripeCustomerId && { customer: stripeCustomerId }), //include customer property if stripeCustomerId is truthy
519+
// ...(!stripeCustomerId && { customer_email: email }), //include customer_email property if stripeCustomerId is falsy
520+
// line_items: [
521+
// {
522+
// price: req.jwt.priceId,
523+
// quantity: 1,
524+
// },
525+
// ],
526+
// allow_promotion_codes: true,
527+
// // {CHECKOUT_SESSION_ID} is a string literal; do not change it!
528+
// // the actual Session ID is returned in the query parameter when your customer
529+
// // is redirected to the success page.
530+
// success_url: `https://${req.hostname}/dev/stripe_redirect?session_id={CHECKOUT_SESSION_ID}`,
531+
// cancel_url: `https://${req.hostname}/dev/stripe_redirect?cancelled=true`,
532+
// })
533+
534+
// // Redirect to the URL returned on the Checkout Session.
535+
// res.redirect(303, stripeSession.url)
473536
}
474537
)
475538

476539
app.get(
477540
'/subscription',
478541
needsTokenForAudience('subscription'),
479542
async function (req: AuthenticatedRequest, res) {
480-
const { stripeCustomerId } = await getUserRecord(req.userId)
543+
const { stripeCustomerId, paddleCustomerId } = await getUserRecord(
544+
req.userId
545+
)
481546

482-
//init Stripe customer portal session and redirect to there
483-
const portalSession = await stripe.billingPortal.sessions.create({
484-
customer: stripeCustomerId,
485-
return_url: `https://${req.hostname}/dev/stripe_redirect`,
486-
})
547+
if (paddleCustomerId) {
548+
const paddlePortalSession = await paddle.customerPortalSessions.create(
549+
paddleCustomerId,
550+
[]
551+
)
552+
553+
return res.redirect(303, paddlePortalSession.urls.general.overview)
554+
}
555+
556+
if (stripeCustomerId) {
557+
//init Stripe customer portal session and redirect to there
558+
const portalSession = await stripe.billingPortal.sessions.create({
559+
customer: stripeCustomerId,
560+
return_url: `https://${req.hostname}/dev/stripe_redirect`,
561+
})
562+
563+
// Redirect to the URL returned on the portal session.
564+
return res.redirect(303, portalSession.url)
565+
}
487566

488-
// Redirect to the URL returned on the portal session.
489-
res.redirect(303, portalSession.url)
567+
res.status(400).send({ error: 'no payment provider customer found' })
490568
}
491569
)
492570

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
},
1414
"dependencies": {
1515
"@gomomento/sdk": "^1.41.2",
16+
"@paddle/paddle-node-sdk": "^2.7.1",
1617
"@types/semver": "^7.5.3",
1718
"ask-sdk": "^2.14.0",
1819
"aws-sdk": "^2.1472.0",

readme.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,10 @@ Follow these steps in order to set up the virtual-smart-home backend in your own
104104

105105
`export STRIPE_WEBHOOK_SECRET=xyz`
106106

107+
`export PADDLE_API_KEY=abc`
108+
109+
`export PADDLE_WEBHOOK_SECRET=xyz`
110+
107111
20. run `npm install`
108112
21. install the serverless framework by running `npm install -g serverless@3.38.0`
109113
22. run `serverless deploy`

serverless.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,8 @@ functions:
103103
Ref: 'vshClientPolicy2'
104104
STRIPE_API_KEY: ${env:STRIPE_API_KEY}
105105
STRIPE_WEBHOOK_SECRET: ${env:STRIPE_WEBHOOK_SECRET}
106+
PADDLE_API_KEY: ${env:PADDLE_API_KEY}
107+
PADDLE_WEBHOOK_SECRET: ${env:PADDLE_WEBHOOK_SECRET}
106108
events:
107109
- http:
108110
path: provision

0 commit comments

Comments
 (0)