Skip to content

Commit 4d9dd37

Browse files
authored
Switch from Stripe to Paddle Billing (#34)
* skip cleaning up devices of pro plan customers * use serverless/[email protected] * prepare for Paddle checkout * sync pnpm-lock.yaml * fix jwt - should be transmitted encoded * add first Paddle webhook handler * expose paddle_webhook endpoint * use correct Paddle environment * add handlePaddleSubscriptionCanceled * fix hasActivePaddleSubscription * add more Paddle webhook event handlers * improve past due handling
1 parent 1b1132e commit 4d9dd37

9 files changed

Lines changed: 2697 additions & 4616 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/[email protected]

.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/[email protected]

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: 144 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,11 @@ 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 {
11+
Environment,
12+
Paddle,
13+
EventName as PaddleEventName,
14+
} from '@paddle/paddle-node-sdk'
1015

1116
import { fetchProfile, isProd, proactivelyUndiscoverDevices } from './helper'
1217
import AWS = require('aws-sdk')
@@ -21,16 +26,24 @@ import {
2126
} from './version'
2227
import { Plan, PlanName } from './Plan'
2328
import {
24-
handleCheckoutSessionCompleted,
25-
handleCustomerSubscriptionDeleted,
26-
handleInvoicePaymentFailed,
27-
handleInvoicePaymentSucceeded,
29+
handlePaddleSubscriptionActivated,
30+
handlePaddleSubscriptionCanceled,
31+
handlePaddleSubscriptionPastDue,
32+
handlePaddleTransactionCompleted,
33+
handleStripeCheckoutSessionCompleted,
34+
handleStripeCustomerSubscriptionDeleted,
35+
handleStripeInvoicePaymentFailed,
36+
handleStripeInvoicePaymentSucceeded,
2837
} from './subscription'
2938

3039
const stripe = new Stripe(process.env.STRIPE_API_KEY, {
3140
apiVersion: '2023-08-16',
3241
})
3342

43+
const paddle = new Paddle(process.env.PADDLE_API_KEY, {
44+
environment: isProd() ? Environment.production : Environment.sandbox,
45+
})
46+
3447
interface AuthenticatedRequest extends express.Request {
3548
userId: string
3649
jwt?: { [key: string]: any }
@@ -176,24 +189,28 @@ app.post('/stripe_webhook', async function (req, res) {
176189

177190
switch (event.type) {
178191
case 'checkout.session.completed': //https://stripe.com/docs/api/checkout/sessions/object
179-
await handleCheckoutSessionCompleted(
192+
await handleStripeCheckoutSessionCompleted(
180193
event.data.object as Stripe.Checkout.Session
181194
)
182195
break
183196
case 'customer.subscription.deleted': //https://stripe.com/docs/api/subscriptions/object
184-
await handleCustomerSubscriptionDeleted(
197+
await handleStripeCustomerSubscriptionDeleted(
185198
event.data.object as Stripe.Subscription
186199
)
187200
break
188201
case 'invoice.payment_succeeded': //https://stripe.com/docs/api/invoices/object
189-
await handleInvoicePaymentSucceeded(event.data.object as Stripe.Invoice)
202+
await handleStripeInvoicePaymentSucceeded(
203+
event.data.object as Stripe.Invoice
204+
)
190205
break
191206
case 'invoice.payment_failed': //https://stripe.com/docs/api/invoices/object
192-
await handleInvoicePaymentFailed(event.data.object as Stripe.Invoice)
207+
await handleStripeInvoicePaymentFailed(
208+
event.data.object as Stripe.Invoice
209+
)
193210
break
194211
// ... handle other event types
195212
default:
196-
log.warn(`Unhandled event type ${event.type}: %j`, event)
213+
log.warn(`Unhandled Stripe event type ${event.type}: %j`, event)
197214
}
198215

199216
// Return a 200 response to acknowledge receipt of the event
@@ -204,6 +221,52 @@ app.post('/stripe_webhook', async function (req, res) {
204221
}
205222
})
206223

224+
app.post('/paddle_webhook', async function (req, res) {
225+
const signature = (req.headers['paddle-signature'] as string) || ''
226+
// req.body should be of type `buffer`, convert to string before passing it to `unmarshal`.
227+
// If express returned a JSON, remove any other middleware that might have processed raw request to object
228+
const rawRequestBody = (req as any).rawBody
229+
// Replace `WEBHOOK_SECRET_KEY` with the secret key in notifications from vendor dashboard
230+
const secretKey = process.env.PADDLE_WEBHOOK_SECRET || ''
231+
232+
try {
233+
if (signature && rawRequestBody) {
234+
// The `unmarshal` function will validate the integrity of the webhook and return an entity
235+
const eventData = await paddle.webhooks.unmarshal(
236+
rawRequestBody,
237+
secretKey,
238+
signature
239+
)
240+
switch (eventData.eventType) {
241+
case PaddleEventName.SubscriptionActivated:
242+
await handlePaddleSubscriptionActivated(eventData)
243+
break
244+
case PaddleEventName.SubscriptionCanceled:
245+
await handlePaddleSubscriptionCanceled(eventData)
246+
break
247+
case PaddleEventName.SubscriptionPastDue:
248+
await handlePaddleSubscriptionPastDue(eventData)
249+
break
250+
case PaddleEventName.TransactionCompleted:
251+
await handlePaddleTransactionCompleted(eventData)
252+
break
253+
default:
254+
log.warn(
255+
`Unhandled Paddle event type ${eventData.eventType}: %j`,
256+
eventData
257+
)
258+
}
259+
} else {
260+
console.log('Signature missing in header')
261+
}
262+
} catch (e) {
263+
// Handle signature mismatch or other runtime errors
264+
console.log(e)
265+
}
266+
// Return a response to acknowledge
267+
res.send('Processed Paddle webhook event')
268+
})
269+
207270
//applying middlewares for all endpoints below these lines!
208271
app.use(cors())
209272
app.use(express.urlencoded({ extended: true })) // for parsing application/x-www-form-urlencoded
@@ -404,29 +467,31 @@ app.get('/plan', needsAuth, async function (req: AuthenticatedRequest, res) {
404467
priceTags: [
405468
{
406469
name: 'vsh-pro-yearly',
407-
tag: '12 EUR per year',
470+
tag: '14 EUR per year',
408471
checkoutToken: jwt.sign(
409472
{
410473
aud: 'checkout',
411474
sub: req.userId,
475+
productName: 'VSH PRO - Yearly',
412476
priceId: isProd()
413-
? 'price_1Li1C4C3eSYquofeqstbOGi9'
414-
: 'price_1LgSpdC3eSYquofeNk3MClG1',
477+
? 'pri_01jtxvka1st9m2m6bns9cha8hp'
478+
: 'pri_01jtwt97ekjxb7xn6q1a9p8g5g',
415479
},
416480
process.env.HASH_SECRET,
417481
{ expiresIn: '30m' }
418482
),
419483
},
420484
{
421485
name: 'vsh-pro-monthly',
422-
tag: '1.49 EUR per month',
486+
tag: '1.79 EUR per month',
423487
checkoutToken: jwt.sign(
424488
{
425489
aud: 'checkout',
426490
sub: req.userId,
491+
productName: 'VSH PRO - Monthly',
427492
priceId: isProd()
428-
? 'price_1Li1C4C3eSYquofekuqBmkqk'
429-
: 'price_1LgSpdC3eSYquofegmyiZdQv',
493+
? 'pri_01jtxvmaxj28qyjwyfbz6qrby4'
494+
: 'pri_01jtwt8adq0rh6t605dc4b4avn',
430495
},
431496
process.env.HASH_SECRET,
432497
{ expiresIn: '30m' }
@@ -446,47 +511,80 @@ app.get(
446511
'/checkout',
447512
needsTokenForAudience('checkout'),
448513
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-
})
514+
// redirect to the checkout page that uses Paddle Billing
515+
const { email } = await getUserRecord(req.userId)
516+
517+
const checkoutIntend = {
518+
userId: req.userId,
519+
email: email,
520+
priceId: req.jwt.priceId,
521+
jwt: req.query.token,
522+
sandbox: !isProd(),
523+
productName: req.jwt.productName,
524+
}
470525

471-
// Redirect to the URL returned on the Checkout Session.
472-
res.redirect(303, stripeSession.url)
526+
res.redirect(
527+
303,
528+
'https://vsh.csuermann.de/checkout/?checkoutIntend=' +
529+
encode(JSON.stringify(checkoutIntend))
530+
)
531+
532+
// before 2025-05-10 we used Stripe
533+
// const { stripeCustomerId, email } = await getUserRecord(req.userId)
534+
// //init Stripe checkout session and redirect to their checkout experience
535+
// const stripeSession = await stripe.checkout.sessions.create({
536+
// mode: 'subscription',
537+
// client_reference_id: req.userId,
538+
// ...(stripeCustomerId && { customer: stripeCustomerId }), //include customer property if stripeCustomerId is truthy
539+
// ...(!stripeCustomerId && { customer_email: email }), //include customer_email property if stripeCustomerId is falsy
540+
// line_items: [
541+
// {
542+
// price: req.jwt.priceId,
543+
// quantity: 1,
544+
// },
545+
// ],
546+
// allow_promotion_codes: true,
547+
// // {CHECKOUT_SESSION_ID} is a string literal; do not change it!
548+
// // the actual Session ID is returned in the query parameter when your customer
549+
// // is redirected to the success page.
550+
// success_url: `https://${req.hostname}/dev/stripe_redirect?session_id={CHECKOUT_SESSION_ID}`,
551+
// cancel_url: `https://${req.hostname}/dev/stripe_redirect?cancelled=true`,
552+
// })
553+
554+
// // Redirect to the URL returned on the Checkout Session.
555+
// res.redirect(303, stripeSession.url)
473556
}
474557
)
475558

476559
app.get(
477560
'/subscription',
478561
needsTokenForAudience('subscription'),
479562
async function (req: AuthenticatedRequest, res) {
480-
const { stripeCustomerId } = await getUserRecord(req.userId)
563+
const { stripeCustomerId, paddleCustomerId } = await getUserRecord(
564+
req.userId
565+
)
481566

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-
})
567+
if (paddleCustomerId) {
568+
const paddlePortalSession = await paddle.customerPortalSessions.create(
569+
paddleCustomerId,
570+
[]
571+
)
572+
573+
return res.redirect(303, paddlePortalSession.urls.general.overview)
574+
}
575+
576+
if (stripeCustomerId) {
577+
//init Stripe customer portal session and redirect to there
578+
const portalSession = await stripe.billingPortal.sessions.create({
579+
customer: stripeCustomerId,
580+
return_url: `https://${req.hostname}/dev/stripe_redirect`,
581+
})
582+
583+
// Redirect to the URL returned on the portal session.
584+
return res.redirect(303, portalSession.url)
585+
}
487586

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

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",

0 commit comments

Comments
 (0)