Skip to content

Commit 1fa4a98

Browse files
author
Travis Vachon
authored
feat: implement "create admin session" capability (#374)
Implement plan/create-admin-session - see storacha/console#98 for an example of this in action. Originally #358, moving here to unstick the integration tests.
1 parent 3f1e4d3 commit 1fa4a98

File tree

8 files changed

+395
-363
lines changed

8 files changed

+395
-363
lines changed

billing/utils/stripe.js

+10-1
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,15 @@ import * as DidMailto from '@web3-storage/did-mailto'
66
* @typedef {import('stripe').Stripe.CustomerSubscriptionCreatedEvent} CustomerSubscriptionCreatedEvent
77
*/
88

9+
/**
10+
*
11+
* @param {string} stripeID
12+
* @returns {AccountID}
13+
*/
14+
export function stripeIDToAccountID(stripeID) {
15+
return /** @type {AccountID} */(`stripe:${stripeID}`)
16+
}
17+
918
/**
1019
*
1120
* @param {Stripe} stripe
@@ -21,7 +30,7 @@ export async function handleCustomerSubscriptionCreated(stripe, event, customerS
2130
return { error: new Error(`Invalid product: ${product}`) }
2231
}
2332

24-
const account = /** @type {AccountID} */ (`stripe:${customerId}`)
33+
const account = stripeIDToAccountID(customerId)
2534
const stripeCustomer = await stripe.customers.retrieve(customerId)
2635
if (stripeCustomer.deleted) {
2736
return {

package-lock.json

+250-312
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

upload-api/billing.js

+26-2
Original file line numberDiff line numberDiff line change
@@ -35,10 +35,11 @@ export class BillingProviderUpdateError extends Failure {
3535

3636
/**
3737
*
38-
* @param {import('stripe').Stripe} stripe
38+
* @param {import('stripe').Stripe} stripe
39+
* @param {import("@web3-storage/w3infra-billing/lib/api").CustomerStore} customerStore
3940
* @returns {import("./types").BillingProvider}
4041
*/
41-
export function createStripeBillingProvider(stripe) {
42+
export function createStripeBillingProvider(stripe, customerStore) {
4243
return {
4344
async hasCustomer(customer) {
4445
const customersResponse = await stripe.customers.list({ email: toEmail(/** @type {import('@web3-storage/did-mailto').DidMailto} */(customer)) })
@@ -84,6 +85,29 @@ export function createStripeBillingProvider(stripe) {
8485
} catch (/** @type {any} */ err) {
8586
return { error: new BillingProviderUpdateError(err.message, { cause: err }) }
8687
}
88+
},
89+
90+
async createAdminSession(account, returnURL) {
91+
const response = await customerStore.get({ customer: account })
92+
if (response.error) {
93+
return {
94+
error: {
95+
name: 'CustomerNotFound',
96+
message: 'Error getting customer',
97+
cause: response.error
98+
}
99+
}
100+
}
101+
const customer = response.ok.account.slice('stripe:'.length)
102+
const session = await stripe.billingPortal.sessions.create({
103+
customer,
104+
return_url: returnURL
105+
})
106+
return {
107+
ok: {
108+
url: session.url
109+
}
110+
}
87111
}
88112
}
89113
}

upload-api/functions/ucan-invocation-router.js

+1-1
Original file line numberDiff line numberDiff line change
@@ -186,7 +186,7 @@ export async function ucanInvocationRouter(request) {
186186
const customerStore = createCustomerStore({ region: AWS_REGION }, { tableName: customerTableName })
187187
if (!STRIPE_SECRET_KEY) throw new Error('missing secret: STRIPE_SECRET_KEY')
188188
const stripe = new Stripe(STRIPE_SECRET_KEY, { apiVersion: '2023-10-16' })
189-
const plansStorage = usePlansStore(customerStore, createStripeBillingProvider(stripe))
189+
const plansStorage = usePlansStore(customerStore, createStripeBillingProvider(stripe, customerStore))
190190
const rateLimitsStorage = createRateLimitTable(AWS_REGION, rateLimitTableName)
191191
const spaceMetricsTable = createSpaceMetricsTable(AWS_REGION, spaceMetricsTableName)
192192

upload-api/stores/plans.js

+1-3
Original file line numberDiff line numberDiff line change
@@ -90,8 +90,6 @@ export function usePlansStore(customerStore, billingProvider) {
9090
return { ok: {} }
9191
},
9292

93-
createAdminSession: (account, returnURL) => {
94-
throw new Error('not implemented')
95-
}
93+
createAdminSession: async (account, returnURL) => billingProvider.createAdminSession(account, returnURL)
9694
}
9795
}

upload-api/test/billing.test.js

+101-43
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,26 @@ import dotenv from 'dotenv'
55

66
import Stripe from 'stripe'
77
import { fileURLToPath } from 'node:url'
8+
import { createCustomerStore, customerTableProps } from '@web3-storage/w3infra-billing/tables/customer.js'
9+
import { createTable } from './helpers/resources.js'
10+
import { createDynamoDB } from '@web3-storage/w3infra-billing/test/helpers/aws.js'
11+
import { stripeIDToAccountID } from '@web3-storage/w3infra-billing/utils/stripe.js'
812

913
dotenv.config({ path: fileURLToPath(new URL('../../.env', import.meta.url)) })
1014

15+
/**
16+
* @typedef {object} BillingContext
17+
* @property {import('@web3-storage/w3infra-billing/lib/api.js').CustomerStore} BillingContext.customerStore
18+
* @property {Stripe} BillingContext.stripe
19+
* @property {import('../types.js').BillingProvider} BillingContext.billingProvider
20+
*/
21+
22+
const customerDID = /** @type {import('@web3-storage/did-mailto').DidMailto} */(
23+
`did:mailto:example.com:w3up-billing-test-${Date.now()}`
24+
)
25+
const email = toEmail(customerDID)
26+
const initialPlan = 'did:web:starter.web3.storage'
27+
1128
/**
1229
*
1330
* @param {Stripe} stripe
@@ -27,10 +44,20 @@ async function getCustomerPlanByEmail(stripe, email) {
2744
*
2845
* @param {Stripe} stripe
2946
* @param {string} email
47+
* @param {import('@web3-storage/w3infra-billing/lib/api.js').CustomerStore} customerStore
3048
* @returns {Promise<Stripe.Customer>}
3149
*/
32-
async function setupCustomer(stripe, email) {
50+
async function setupCustomer(stripe, email, customerStore) {
3351
const customer = await stripe.customers.create({ email })
52+
const customerCreation = await customerStore.put({
53+
customer: customerDID,
54+
account: stripeIDToAccountID(customer.id),
55+
product: initialPlan,
56+
insertedAt: new Date()
57+
})
58+
if (!customerCreation.ok){
59+
throw customerCreation.error
60+
}
3461

3562
// set up a payment method - otherwise we won't be able to update the plan later
3663
let setupIntent = await stripe.setupIntents.create({
@@ -45,52 +72,83 @@ async function setupCustomer(stripe, email) {
4572
)
4673
const paymentMethod = /** @type {string} */(setupIntent.payment_method)
4774
await stripe.customers.update(customer.id, { invoice_settings: { default_payment_method: paymentMethod } })
75+
// create a subscription to initialPlan
76+
const prices = await stripe.prices.list({ lookup_keys: [initialPlan] })
77+
const initialPriceID = prices.data.find(price => price.lookup_key === initialPlan)?.id
78+
if (!initialPriceID) {
79+
throw new Error(`could not find priceID ${initialPlan} in Stripe`)
80+
}
81+
await stripe.subscriptions.create({ customer: customer.id, items: [{ price: initialPriceID }] })
4882
return customer
4983
}
5084

51-
test('stripe plan can be updated', async (t) => {
85+
/**
86+
*
87+
* @param {BillingContext} context
88+
* @param {(c: BillingContext) => Promise<void>} testFn
89+
*/
90+
async function withCustomer(context, testFn) {
91+
const { stripe, customerStore } = context
92+
let customer
93+
try {
94+
// create a new customer and set up its subscription with "initialPlan"
95+
customer = await setupCustomer(stripe, email, customerStore)
96+
await testFn(context)
97+
} finally {
98+
if (customer) {
99+
// clean up the user we created
100+
await stripe.customers.del(customer.id)
101+
}
102+
}
103+
}
104+
105+
test.before(async t => {
52106
const stripeSecretKey = process.env.STRIPE_TEST_SECRET_KEY
53-
if (stripeSecretKey) {
54-
const stripe = new Stripe(stripeSecretKey, { apiVersion: '2023-10-16' })
55-
const billingProvider = createStripeBillingProvider(stripe)
56-
const customerDID = /** @type {import('@web3-storage/did-mailto').DidMailto} */(
57-
`did:mailto:example.com:w3up-billing-test-${Date.now()}`
58-
)
59-
const email = toEmail(customerDID)
60-
61-
const initialPlan = 'did:web:starter.web3.storage'
62-
const updatedPlan = 'did:web:lite.web3.storage'
63107

64-
const prices = await stripe.prices.list({ lookup_keys: [initialPlan] })
65-
const initialPriceID = prices.data.find(price => price.lookup_key === initialPlan)?.id
66-
if (!initialPriceID){
67-
t.fail(`could not find Stripe price with lookup_key ${initialPlan}`)
68-
}
69-
let customer
70-
try {
71-
// create a new customer and set up its subscription with "initialPlan"
72-
customer = await setupCustomer(stripe, email)
73-
74-
// create a subscription to initialPlan
75-
await stripe.subscriptions.create({ customer: customer.id, items: [{ price: initialPriceID }] })
76-
77-
// use the stripe API to verify plan has been initialized correctly
78-
const initialStripePlan = await getCustomerPlanByEmail(stripe, email)
79-
t.deepEqual(initialPlan, initialStripePlan)
80-
81-
// this is the actual code under test!
82-
await billingProvider.setPlan(customerDID, updatedPlan)
83-
84-
// use the stripe API to verify plan has been updated
85-
const updatedStripePlan = await getCustomerPlanByEmail(stripe, email)
86-
t.deepEqual(updatedPlan, updatedStripePlan)
87-
} finally {
88-
if (customer) {
89-
// clean up the user we created
90-
await stripe.customers.del(customer.id)
91-
}
92-
}
93-
} else {
94-
t.fail('STRIPE_TEST_SECRET_KEY environment variable is not set')
108+
if (!stripeSecretKey) {
109+
throw new Error('STRIPE_TEST_SECRET_KEY environment variable is not set')
95110
}
111+
const { client: dynamo } = await createDynamoDB()
112+
113+
const stripe = new Stripe(stripeSecretKey, { apiVersion: '2023-10-16' })
114+
115+
const customerStore = createCustomerStore(dynamo, { tableName: await createTable(dynamo, customerTableProps) })
116+
const billingProvider = createStripeBillingProvider(stripe, customerStore)
117+
118+
Object.assign(t.context, {
119+
dynamo,
120+
customerStore,
121+
stripe,
122+
billingProvider
123+
})
124+
})
125+
126+
test('stripe plan can be updated', async (t) => {
127+
const context = /** @type {typeof t.context & BillingContext } */(t.context)
128+
const { stripe, billingProvider } = context
129+
130+
await withCustomer(context, async () => {
131+
// use the stripe API to verify plan has been initialized correctly
132+
const initialStripePlan = await getCustomerPlanByEmail(stripe, email)
133+
t.deepEqual(initialPlan, initialStripePlan)
134+
135+
// this is the actual code under test!
136+
const updatedPlan = 'did:web:lite.web3.storage'
137+
await billingProvider.setPlan(customerDID, updatedPlan)
138+
139+
// use the stripe API to verify plan has been updated
140+
const updatedStripePlan = await getCustomerPlanByEmail(stripe, email)
141+
t.deepEqual(updatedPlan, updatedStripePlan)
142+
})
96143
})
144+
145+
test('stripe billing session can be generated', async (t) => {
146+
const context = /** @type {typeof t.context & BillingContext } */(t.context)
147+
const { billingProvider } = context
148+
149+
await withCustomer(context, async () => {
150+
const response = await billingProvider.createAdminSession(customerDID, 'https://example.com/return-url')
151+
t.assert(response.ok)
152+
t.assert(response.ok?.url)
153+
})
154+
})

upload-api/test/helpers/billing.js

+4
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,10 @@ export function createTestBillingProvider() {
1414
async setPlan(customer, product) {
1515
customers[customer] = product
1616
return { ok: {} }
17+
},
18+
19+
async createAdminSession(customer) {
20+
return { ok: { url: 'https://example/test-billing-admin-session' } }
1721
}
1822
}
1923
}

upload-api/types.ts

+2-1
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { DID, Link, Delegation, Signature, Block, UCANLink, ByteView, DIDKey, Re
33
import { UnknownLink } from 'multiformats'
44
import { CID } from 'multiformats/cid'
55
import { Kinesis } from '@aws-sdk/client-kinesis'
6-
import { AccountDID, ProviderDID, Service, SpaceDID, CarStoreBucket, AllocationsStorage } from '@web3-storage/upload-api'
6+
import { AccountDID, ProviderDID, Service, SpaceDID, CarStoreBucket, AllocationsStorage, PlanCreateAdminSessionSuccess, PlanCreateAdminSessionFailure } from '@web3-storage/upload-api'
77

88
export interface StoreOperationError extends Error {
99
name: 'StoreOperationFailed'
@@ -282,6 +282,7 @@ type SetPlanFailure = InvalidSubscriptionState | BillingProviderUpdateError
282282
export interface BillingProvider {
283283
hasCustomer: (customer: AccountDID) => Promise<Result<boolean, Failure>>
284284
setPlan: (customer: AccountDID, plan: DID) => Promise<Result<Unit, SetPlanFailure>>
285+
createAdminSession: (customer: AccountDID, returnURL: string) => Promise<Result<PlanCreateAdminSessionSuccess, PlanCreateAdminSessionFailure>>
285286
}
286287

287288
export {}

0 commit comments

Comments
 (0)