Skip to content

Commit

Permalink
Merge pull request #9 from joshnuss/buy-products
Browse files Browse the repository at this point in the history
Products
  • Loading branch information
joshnuss authored May 18, 2024
2 parents 22c1579 + bfe910b commit 23f85a6
Show file tree
Hide file tree
Showing 6 changed files with 177 additions and 17 deletions.
25 changes: 15 additions & 10 deletions packages/sveltekit/src/lib/server/billing.js
Original file line number Diff line number Diff line change
Expand Up @@ -30,34 +30,36 @@ export function createBillingService(adapter, urls) {
})
},

async createCheckout(user, price) {
async createCheckout(user, price, quantity = 1) {
const metadata = {
userId: user.id,
productId: price.product,
priceId: price.id
}
const recurring = price.type == 'recurring'
const subscription_data = {
metadata: {
userId: user.id
}
}

return stripe.checkout.sessions.create({
success_url: absoluteURL(
'/billing/checkout/complete?checkout_session_id={CHECKOUT_SESSION_ID}'
),
cancel_url: absoluteURL(urls.checkout.cancel),
currency: 'usd',
mode: 'subscription',
mode: recurring ? 'subscription' : 'payment',
customer_email: user.email,
client_reference_id: user.id,
metadata,
subscription_data: {
metadata: {
userId: user.id
}
},
line_items: [
{
price: price.id,
quantity: 1
quantity
}
]
],
...(recurring ? { subscription_data } : {})
})
},

Expand All @@ -71,7 +73,10 @@ export function createBillingService(adapter, urls) {
async syncCheckout(sessionId) {
const checkout = await stripe.checkout.sessions.retrieve(sessionId)

return this.syncSubscription(checkout.subscription)

if (checkout.mode == 'subscription') {
return this.syncSubscription(checkout.subscription)
}
},

async syncSubscription(subscriptionId) {
Expand Down
77 changes: 75 additions & 2 deletions packages/sveltekit/src/lib/server/billing.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,8 @@ describe('createSubscription', () => {
describe('createCheckout', () => {
const price = {
id: 'price_1234',
product: 'prod_1234'
product: 'prod_1234',
type: 'recurring'
}

test('creates checkout session', async () => {
Expand Down Expand Up @@ -150,6 +151,77 @@ describe('createCheckout', () => {
]
})
})

test('uses specifed quantity', async () => {
stripe.checkout.sessions.create.mockResolvedValue({
url: 'https://checkout.stripe.com/1234'
})

const result = await billing.createCheckout(user, price, 3)

expect(result).toEqual({ url: 'https://checkout.stripe.com/1234' })
expect(stripe.checkout.sessions.create).toHaveBeenCalledWith({
success_url:
'http://localhost:5173/billing/checkout/complete?checkout_session_id={CHECKOUT_SESSION_ID}',
cancel_url: 'http://localhost:5173/checkout-cancel',
currency: 'usd',
mode: 'subscription',
customer_email: '[email protected]',
client_reference_id: 'user_1234',
metadata: {
userId: 'user_1234',
priceId: 'price_1234',
productId: 'prod_1234',
},
subscription_data: {
metadata: {
userId: 'user_1234'
}
},
line_items: [
{
price: 'price_1234',
quantity: 3
}
]
})
})

test('when price type is one_time, uses payment mode', async () => {
const price = {
id: 'price_1234',
product: 'prod_1234',
type: 'one_time'
}

stripe.checkout.sessions.create.mockResolvedValue({
url: 'https://checkout.stripe.com/1234'
})

const result = await billing.createCheckout(user, price, 3)

expect(result).toEqual({ url: 'https://checkout.stripe.com/1234' })
expect(stripe.checkout.sessions.create).toHaveBeenCalledWith({
success_url:
'http://localhost:5173/billing/checkout/complete?checkout_session_id={CHECKOUT_SESSION_ID}',
cancel_url: 'http://localhost:5173/checkout-cancel',
currency: 'usd',
mode: 'payment',
customer_email: '[email protected]',
client_reference_id: 'user_1234',
metadata: {
userId: 'user_1234',
priceId: 'price_1234',
productId: 'prod_1234',
},
line_items: [
{
price: 'price_1234',
quantity: 3
}
]
})
})
})

describe('createPortalSession', () => {
Expand Down Expand Up @@ -227,7 +299,8 @@ describe('syncSubscription', () => {
describe('syncCheckout', () => {
beforeEach(() => {
stripe.checkout.sessions.retrieve.mockResolvedValue({
subscription: 'sub_1234'
subscription: 'sub_1234',
mode: 'subscription'
})
})

Expand Down
6 changes: 4 additions & 2 deletions packages/sveltekit/src/lib/server/routes/checkout.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,19 +5,21 @@ const expiredStates = [ 'INCOMPLETE_EXPIRED', 'CANCELED' ]

export default async function handler({ url }, { user, catalog, billing, options }) {
if (!user) return redirect(303, `/auth/signin?callbackUrl=${url.pathname}${url.search}`)
if (user.subscriptionId && !expiredStates.includes(user.subscriptionStatus)) return redirect(303, '/?event=already-subscribed')

const id = url.searchParams.get('id')
const quantity = +(url.searchParams.get('quantity') || 1)
const price = await catalog.get(id)

if (!price) error(406, 'Price could not be found. Please specify a valid Stripe price/product/lookup key in the URL.')

if (user.subscriptionId && price.type == 'recurring' && !expiredStates.includes(user.subscriptionStatus)) return redirect(303, '/?event=already-subscribed')

if (price.type == 'recurring' && price.unit_amount == 0) {
await billing.createSubscription(user, price)

return redirect(303, options.pages.checkout.success)
} else {
const checkout = await billing.createCheckout(user, price)
const checkout = await billing.createCheckout(user, price, quantity)

return redirect(303, checkout.url)
}
Expand Down
63 changes: 61 additions & 2 deletions packages/sveltekit/src/lib/server/routes/checkout.test.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import handler from './checkout'

afterEach(() => {
vi.restoreAllMocks()
})

describe('checkout', () => {
describe('without user, redirects to sign in', () => {
test('without id', async () => {
Expand All @@ -22,16 +26,53 @@ describe('checkout', () => {
})

test('when user already subscribed, raises error', async () => {
const event = {
url: new URL('http://localhost/billing/checkout')
}
const state = {
user: {
subscriptionId: 'sub_1234'
},
catalog: {
get() {
return { type: 'recurring' }
}
}
}
const response = handler({}, state)
const response = handler(event, state)

await expect(response).toRedirect(303, '/?event=already-subscribed')
})

test('when user already subscribed, but buying a one time product, doesnt raises error', async () => {
const event = {
url: new URL('http://localhost/billing/checkout')
}
const price = { id: 'price_999', type: 'one_time', unit_amount: 1000 }

const state = {
billing: {
createCheckout: vi.fn(),
},
user: {
subscriptionId: 'sub_1234'
},
catalog: {
get: async () => price
},
}

state.billing.createCheckout.mockReturnValueOnce({
url: 'https://checkout.stripe.com/checkout_1234'
})

const response = handler(event, state)

await expect(response).toRedirect(303, 'https://checkout.stripe.com/checkout_1234')

expect(state.billing.createCheckout).toHaveBeenCalledWith(state.user, price, 1)
})

test('when user canceled subscribed, doesnt redirect', async () => {
const state = {
user: {
Expand Down Expand Up @@ -134,7 +175,25 @@ describe('checkout', () => {

await expect(response).toRedirect(303, 'https://checkout.stripe.com/checkout_1234')

expect(billing.createCheckout).toHaveBeenCalledWith(user, price)
expect(billing.createCheckout).toHaveBeenCalledWith(user, price, 1)
})

test('when quantity param is specified, uses it to create checkout', async () => {
price.unit_amount = 10000

billing.createCheckout.mockReturnValueOnce({
url: 'https://checkout.stripe.com/checkout_1234'
})

const event = {
url: new URL('http://localhost/billing/checkout?id=price_1234&quantity=3')
}

const response = handler(event, state)

await expect(response).toRedirect(303, 'https://checkout.stripe.com/checkout_1234')

expect(billing.createCheckout).toHaveBeenCalledWith(user, price, 3)
})
})
})
5 changes: 5 additions & 0 deletions packages/sveltekit/src/lib/server/webhooks.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,11 @@ export async function handleWebhook(billing, body, signature) {
const { object } = event.data

switch (event.type) {
case 'checkout.session.completed':
await billing.syncCheckout(object.id)
console.log(`Synced checkout ${object.id}`)
break

case 'customer.subscription.created':
case 'customer.subscription.updated':
case 'customer.subscription.deleted':
Expand Down
18 changes: 17 additions & 1 deletion packages/sveltekit/src/lib/server/webhooks.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,8 @@ afterEach(() => vi.resetAllMocks())

describe('handleWebhook', () => {
const billing = {
syncSubscription: vi.fn()
syncSubscription: vi.fn(),
syncCheckout: vi.fn()
}

test('when signature fails, raises error', async () => {
Expand Down Expand Up @@ -102,4 +103,19 @@ describe('handleWebhook', () => {

expect(billing.syncSubscription).toHaveBeenCalledWith('sub_1234')
})

test('when checkout.session.completed event, syncs checkout', async () => {
stripe.webhooks.constructEvent.mockReturnValue({
type: 'checkout.session.completed',
data: {
object: {
id: 'cs_1234'
}
}
})

await handleWebhook(billing, 'fake-body', 'fake-sig')

expect(billing.syncCheckout).toHaveBeenCalledWith('cs_1234')
})
})

0 comments on commit 23f85a6

Please sign in to comment.