Skip to content

Commit

Permalink
feat(billing): stripe integration
Browse files Browse the repository at this point in the history
  • Loading branch information
Ninjeneer committed May 7, 2023
1 parent e264317 commit a231022
Show file tree
Hide file tree
Showing 19 changed files with 500 additions and 9 deletions.
13 changes: 13 additions & 0 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,17 @@
"source.fixAll.eslint": true
},
"jest.autoRun": {},
"search.exclude": {
"**/node_modules": true,
"**/bower_components": true,
"**/*.code-search": true,
"data": true
},
"files.watcherExclude": {
"**/.git/objects/**": true,
"**/.git/subtree-cache/**": true,
"**/node_modules/*/**": true,
"**/.hg/store/**": true,
"data": true
}
}
2 changes: 1 addition & 1 deletion @types/fastify/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { User } from "@supabase/supabase-js";
import { User } from "../../src/models/user";

declare module 'fastify' {
export interface FastifyRequest {
Expand Down
21 changes: 21 additions & 0 deletions billing.dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
FROM node:alpine as builder
WORKDIR /app
COPY --chown=node:node package.json yarn.lock ./
RUN yarn install --frozen-lockfile
COPY --chown=node:node . .
RUN yarn build


# Dev stage
FROM builder as dev
WORKDIR /app
EXPOSE 3000
CMD ["yarn", "dev:billing"]


# Prod stage
FROM node:alpine as prod
WORKDIR /app
COPY --from=builder /app/node_modules ./dist/node_modules
COPY --from=builder /app/dist/src ./dist/src
CMD ["node", "dist/src/services/billing/server.js"]
14 changes: 14 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,20 @@ services:
networks:
- vulnscanner

billing-service:
build:
dockerfile: ./billing.dockerfile
target: dev
context: ./

image: ninjeneer/vuln-scanner-billing-service
ports:
- 3002:3000
volumes:
- .:/app
networks:
- vulnscanner

networks:
vulnscanner:
name: vulnscanner
Expand Down
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
"dev:requests": "nodemon src/services/requests/server.ts",
"dev:reports": "nodemon src/services/reports/server.ts",
"dev:jobs": "nodemon src/services/jobs/index.ts",
"dev:billing": "nodemon src/services/billing/server.ts",
"build": "swc src -d dist/src",
"test": "jest test"
},
Expand Down Expand Up @@ -34,6 +35,7 @@
"jsonwebtoken": "^9.0.0",
"mongoose": "^7.0.3",
"node-cron": "^3.0.2",
"stripe": "^12.4.0",
"uuid": "^9.0.0",
"zod": "^3.19.1"
}
Expand Down
14 changes: 14 additions & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
export const isProd = process.env.NODE_ENV === 'production'

export const getConfig = () => {
const commonConfig = {
checkoutSuccessURL: process.env.CHECKOUT_SUCCESS_URL,
checkoutCancelURL: process.env.CHECKOUT_CANCEL_URL
}

return isProd ? {
...commonConfig
} : {
...commonConfig
}
}
24 changes: 24 additions & 0 deletions src/exceptions/exceptions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,4 +20,28 @@ export class NoProbeResultsForReport extends Error {
constructor(id = "") {
super(`No probe results for report ${id} found`)
}
}

export class UserDoesNotExist extends Error {
constructor(id = "") {
super(`User id ${id} does not exist`)
}
}

export class StripePriceDoesNotExist extends Error {
constructor(id = "") {
super(`Stripe Price ${id} does not exist`)
}
}

export class MissingData extends Error {
constructor(fieldName = "", source = "") {
super(`Missing ${fieldName} in ${source}`)
}
}

export class StripeProductDoesNotExist extends Error {
constructor(id = "") {
super(`Stripe Product ${id} does not exist`)
}
}
3 changes: 3 additions & 0 deletions src/models/settings.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export type UserSettings = Partial<{
plan: string
}>
5 changes: 5 additions & 0 deletions src/models/user.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,4 +23,9 @@ interface Usermetadata {
interface Appmetadata {
provider: string;
providers: string[];
}

export type User = {
id: string
email: string
}
54 changes: 54 additions & 0 deletions src/services/billing/DAL/stripe/stripe.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import Stripe from 'stripe'
import { StripeProduct } from './stripeTypes'
import { MissingData, StripePriceDoesNotExist, StripeProductDoesNotExist } from '../../../../exceptions/exceptions'
import { getConfig } from '../../../../config'

const stripeClient = new Stripe(process.env.STRIPE_SECRET_KEY, {
apiVersion: '2022-11-15'
})

export const createCheckoutSession = async (priceId: string, userId: string, userEmail: string, isUpdate = false) => {
const price = await getPrice(priceId)
if (!price) {
throw new StripePriceDoesNotExist(priceId)
}
const product = await getProduct(price.product as string)
if (!product) {
throw new StripeProductDoesNotExist(price.product as string)
}

if (!product?.metadata?.plan) {
throw new MissingData('plan', `Price ${priceId} metadata`)
}
const session = await stripeClient.checkout.sessions.create({
customer_email: userEmail,
metadata: {
userId,
userEmail,
plan: product.metadata?.plan
},
mode: 'subscription',
line_items: [
{
price: priceId,
quantity: 1
}
],
success_url: `${getConfig().checkoutSuccessURL}${isUpdate ? '?fromSettings=true' : ''}`,
cancel_url: `${getConfig().checkoutCancelURL}${isUpdate ? '?fromSettings=true' : ''}`,
})

return session
}

export const constructEvent = (event, signature, secret) => {
return stripeClient.webhooks.constructEvent(event, signature, secret)
}

export const getPrice = async (priceId: string): Promise<Stripe.Price> => {
return await stripeClient.prices.retrieve(priceId)
}

export const getProduct = async (productId: string): Promise<StripeProduct> => {
return await stripeClient.products.retrieve(productId)
}
132 changes: 132 additions & 0 deletions src/services/billing/DAL/stripe/stripeTypes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
import Stripe from "stripe";

export type StripeCheckoutSessionCompleted = {
id: string;
object: string;
api_version: string;
created: number;
data: Data;
livemode: boolean;
pending_webhooks: number;
request: Request;
type: string;
}

type Request = {
id?: any;
idempotency_key?: any;
}

type Data = {
object: Object;
}

type Object = {
id: string;
object: string;
after_expiration?: any;
allow_promotion_codes?: any;
amount_subtotal: number;
amount_total: number;
automatic_tax: Automatictax;
billing_address_collection?: any;
cancel_url: string;
client_reference_id?: any;
consent?: any;
consent_collection?: any;
created: number;
currency: string;
currency_conversion?: any;
custom_fields: any[];
custom_text: Customtext;
customer?: any;
customer_creation: string;
customer_details: Customerdetails;
customer_email?: any;
expires_at: number;
invoice?: any;
invoice_creation: Invoicecreation;
livemode: boolean;
locale?: any;
metadata: Metadata;
mode: string;
payment_intent: string;
payment_link?: any;
payment_method_collection: string;
payment_method_options: Metadata;
payment_method_types: string[];
payment_status: string;
phone_number_collection: Phonenumbercollection;
recovered_from?: any;
setup_intent?: any;
shipping_address_collection?: any;
shipping_cost?: any;
shipping_details?: any;
shipping_options: any[];
status: string;
submit_type?: any;
subscription?: any;
success_url: string;
total_details: Totaldetails;
url?: any;
}

type Totaldetails = {
amount_discount: number;
amount_shipping: number;
amount_tax: number;
}

type Phonenumbercollection = {
enabled: boolean;
}

type Invoicecreation = {
enabled: boolean;
invoice_data: Invoicedata;
}

type Invoicedata = {
account_tax_ids?: any;
custom_fields?: any;
description?: any;
footer?: any;
metadata: Metadata;
rendering_options?: any;
}

type Metadata = Record<string, any>

type Customerdetails = {
address: Address;
email: string;
name?: any;
phone?: any;
tax_exempt: string;
tax_ids: any[];
}

type Address = {
city?: any;
country?: any;
line1?: any;
line2?: any;
postal_code?: any;
state?: any;
}

type Customtext = {
shipping_address?: any;
submit?: any;
}

type Automatictax = {
enabled: boolean;
status?: any;
}

export type StripeProduct = Stripe.Product & {
metadata: {
plan?: string
}
}
52 changes: 52 additions & 0 deletions src/services/billing/billing.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { UserDoesNotExist } from '../../exceptions/exceptions'
import { User } from '../../models/user'
import { getUserById, getUserSettings, updateUserSettings } from '../../storage/user.storage'
import * as billing from './DAL/stripe/stripe'
import { StripeCheckoutSessionCompleted } from './DAL/stripe/stripeTypes'

export const createCheckoutSession = async (priceId: string, user: User, isUpdate = false) => {
const session = await billing.createCheckoutSession(priceId, user.id, user.email, isUpdate)
return session
}

export const buildWebhookEvent = (event, signature, secret) => {
return billing.constructEvent(event, signature, secret)
}

export const handleEvent = (eventType: string, data: any) => {
const handlers = {
'checkout.session.completed': handleCheckoutSession,
}

const handler = handlers[eventType]
if (handler) {
handler(data)
}

}

const handleCheckoutSession = async (data: StripeCheckoutSessionCompleted['data']) => {
const { userId, plan } = data.object.metadata
if (!userId) {
console.error('Missing userId in billing hook event')
return
}
if (!plan) {
console.error('Missing plan in billing hook event')
return
}

const user = await getUserById(userId)
if (!user) {
throw new UserDoesNotExist()
}
console.log(`[BILLING][CHECKOUT][NEW SUB] User ${user.email} subscribed to plan ${plan}`)
const userSettings = await getUserSettings(userId) || {}

if (userSettings.plan !== plan) {
// Only trigger an update if the plan has changed
console.log(`[BILLING][CHECKOUT][NEW SUB] Updating settings of user ${user.email}...`)
await updateUserSettings(userId, { ...userSettings, plan })
console.log(`[BILLING][CHECKOUT][NEW SUB] Updated settings of user ${user.email}`)
}
}
Loading

0 comments on commit a231022

Please sign in to comment.