Skip to content

Commit

Permalink
Tracks user purchases
Browse files Browse the repository at this point in the history
  • Loading branch information
joshnuss committed May 29, 2024
1 parent 290a0a6 commit cdd6b79
Show file tree
Hide file tree
Showing 8 changed files with 146 additions and 5 deletions.
1 change: 1 addition & 0 deletions apps/app/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ model User {
subscriptionStatus SubscriptionStatus?
plan String?
priceId String?
purchases Json @default("[]")
}

// customization for AirBadge
Expand Down
1 change: 1 addition & 0 deletions apps/docs/src/routes/getting-started/+page.md
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,7 @@ model User {
subscriptionStatus SubscriptionStatus?
plan String?
priceId String?
purchases Json @default("[]")
}
// customization for AirBadge
Expand Down
2 changes: 1 addition & 1 deletion apps/docs/src/routes/session/+page.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,9 +59,9 @@ This is an example of what session data would look like:
"emailVerified": null,
"image": "https://avatars.githubusercontent.com/u/###"
},
"customerId": "cus_1234",
"subscription": {
"id": "sub_1234",
"customerId": "cus_1234",
"status": "active",
"priceId": "price_1234",
"plan": "basic_monthly"
Expand Down
1 change: 1 addition & 0 deletions packages/sveltekit/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ model User {
subscriptionStatus SubscriptionStatus?
plan String?
priceId String?
purchases Json @default("[]")
}

enum SubscriptionStatus {
Expand Down
17 changes: 16 additions & 1 deletion packages/sveltekit/src/lib/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -37,16 +37,31 @@ function authHandler(options) {
async session({ session }) {
const user = await options.adapter.getUserByEmail(session.user.email)

if (user?.customerId) {
session.customerId = user.customerId
}

if (user?.subscriptionId) {
session.subscription = {
id: user.subscriptionId,
customerId: user.customerId,
priceId: user.priceId,
plan: user.plan,
status: user.subscriptionStatus.toLowerCase()
}
}

if (user?.purchases) {
let purchases = []

user.purchases.forEach(({ productId, priceId, lookupKey }) => {
purchases.push(productId)
purchases.push(priceId)
purchases.push(lookupKey)
})

session.purchases = purchases
}

if (options?.callbacks?.session) {
return await options.callbacks.session(arguments)
}
Expand Down
21 changes: 20 additions & 1 deletion packages/sveltekit/src/lib/server/billing.js
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,8 @@ export function createBillingService(adapter, urls) {
const metadata = {
userId: user.id,
productId: price.product,
priceId: price.id
priceId: price.id,
lookupKey: price.lookup_key
}
const recurring = price.type == 'recurring'
const subscription_data = {
Expand Down Expand Up @@ -72,7 +73,21 @@ export function createBillingService(adapter, urls) {

async syncCheckout(sessionId) {
const checkout = await stripe.checkout.sessions.retrieve(sessionId)
const { metadata } = checkout
const { userId, productId, priceId, lookupKey } = metadata

if (!userId) throw new Error(`Missing user id metadata for checkout '${sessionId}'`)

const user = await adapter.getUser(userId)
const purchase = { productId, priceId, lookupKey, paymentIntent: checkout.payment_intent }

if (!hasPurchase(user, checkout.payment_intent)) {
await adapter.updateUser({
id: userId,
customerId: checkout.customer,
purchases: [...user.purchases, purchase]
})
}

if (checkout.mode == 'subscription') {
return this.syncSubscription(checkout.subscription)
Expand Down Expand Up @@ -125,6 +140,10 @@ export function createBillingService(adapter, urls) {
}
}

function hasPurchase(user, paymentIntent) {
return user.purchases.find((purchase) => purchase.paymentIntent == paymentIntent)
}

function absoluteURL(path) {
return new URL(path, DOMAIN).toString()
}
78 changes: 77 additions & 1 deletion packages/sveltekit/src/lib/server/billing.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ vi.mock('stripe', () => {
})

const adapter = {
getUser: vi.fn(),
updateUser: vi.fn()
}

Expand Down Expand Up @@ -300,11 +301,22 @@ describe('syncCheckout', () => {
beforeEach(() => {
stripe.checkout.sessions.retrieve.mockResolvedValue({
subscription: 'sub_1234',
mode: 'subscription'
mode: 'subscription',
customer: 'cus_1234',
payment_intent: 'pi_1234',
metadata: {
userId: 'user_1234',
productId: 'prod_1234',
priceId: 'price_1234',
lookupKey: 't-shirt',
}
})
})

test('when user metadata not found, raises', async () => {
adapter.getUser.mockResolvedValue({
purchases: []
})
stripe.subscriptions.retrieve.mockResolvedValue({
id: 'sub_1234',
customer: 'cus_1234',
Expand All @@ -329,6 +341,59 @@ describe('syncCheckout', () => {
})

test('updates user', async () => {
adapter.getUser.mockResolvedValue({
purchases: []
})

stripe.subscriptions.retrieve.mockResolvedValue({
id: 'sub_1234',
customer: 'cus_1234',
metadata: {
userId: 'user_1234'
},
items: {
data: [
{
price: {
id: 'price_1234',
lookup_key: 'pro'
}
}
]
},
status: 'active'
})

await billing.syncCheckout('checkout_1234')

expect(stripe.checkout.sessions.retrieve).toHaveBeenCalledWith('checkout_1234')

expect(adapter.updateUser).toHaveBeenCalledWith({
id: 'user_1234',
customerId: 'cus_1234',
purchases: [
{ productId: 'prod_1234', priceId: 'price_1234', lookupKey: 't-shirt', paymentIntent: 'pi_1234'}
]
})

expect(adapter.updateUser).toHaveBeenCalledWith({
id: 'user_1234',
customerId: 'cus_1234',
subscriptionId: 'sub_1234',
subscriptionStatus: 'ACTIVE',
plan: 'pro',
priceId: 'price_1234'
})
})

test('when called twice, registers purchase once', async () => {
adapter.getUser.mockResolvedValueOnce({
purchases: []
})
adapter.getUser.mockResolvedValueOnce({
purchases: [{ productId: 'prod_1234', priceId: 'price_1234', lookupKey: 't-shirt', paymentIntent: 'pi_1234'}]
})

stripe.subscriptions.retrieve.mockResolvedValue({
id: 'sub_1234',
customer: 'cus_1234',
Expand All @@ -348,9 +413,20 @@ describe('syncCheckout', () => {
status: 'active'
})

await billing.syncCheckout('checkout_1234')
await billing.syncCheckout('checkout_1234')

expect(stripe.checkout.sessions.retrieve).toHaveBeenCalledWith('checkout_1234')

expect(adapter.updateUser).toHaveBeenCalledTimes(3)
expect(adapter.updateUser).toHaveBeenCalledWith({
id: 'user_1234',
customerId: 'cus_1234',
purchases: [
{ productId: 'prod_1234', priceId: 'price_1234', lookupKey: 't-shirt', paymentIntent: 'pi_1234'}
]
})

expect(adapter.updateUser).toHaveBeenCalledWith({
id: 'user_1234',
customerId: 'cus_1234',
Expand Down
30 changes: 29 additions & 1 deletion packages/sveltekit/tests/signIn.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -33,12 +33,40 @@ test('sign in with subscription', async ({ page }) => {
email: user.email,
name: user.name
},
customerId: 'cus_1234',
subscription: {
id: 'sub_1234',
customerId: 'cus_1234',
status: 'active',
priceId: 'price_1234',
plan: 'pro',
}
})
})

test('sign in with purchases', async ({ page }) => {
const user = await createUser({
customerId: 'cus_1234',
purchases: [
{ priceId: 'price_123', productId: 'prod_123', lookupKey: 't-shirt'},
{ priceId: 'price_456', productId: 'prod_456', lookupKey: 'socks'}
]
})

const session = await signIn(page, user)

expect(session).toMatchObject({
user: {
email: user.email,
name: user.name
},
customerId: 'cus_1234',
purchases: [
'prod_123',
'price_123',
't-shirt',
'prod_456',
'price_456',
'socks'
]
})
})

0 comments on commit cdd6b79

Please sign in to comment.