Skip to content

Commit 5d116ce

Browse files
authored
fix: set plan even when email in Stripe changed (#566)
If a user changes their email in Stripe then they cannot be found when trying to update their plan. This PR switches to using the Stripe ID to fetch a customer as fetching by email is brittle.
1 parent 1f4a3dd commit 5d116ce

File tree

2 files changed

+64
-35
lines changed

2 files changed

+64
-35
lines changed

upload-api/billing.js

Lines changed: 23 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { Failure } from '@ucanto/core'
1+
import { Failure, error } from '@ucanto/core'
22
import { toEmail } from '@storacha/did-mailto'
33
import { DIDMailto } from '@storacha/client/capability/access'
44

@@ -80,26 +80,34 @@ export function createStripeBillingProvider(
8080
let subscriptionItems
8181
let subscription
8282
try {
83-
const email = toEmail(
84-
/** @type {import('@storacha/did-mailto').DidMailto} */(customerDID)
85-
)
86-
const customers = await stripe.customers.list({
87-
email,
88-
expand: ['data.subscriptions'],
89-
})
90-
if (customers.data.length !== 1)
91-
return {
92-
error: new InvalidSubscriptionState(
93-
`found ${customers.data.length} Stripe customer(s) with email ${email} - cannot set plan`
94-
),
83+
const cusRes = await customerStore.get({ customer: customerDID })
84+
if (cusRes.error) {
85+
return error(new InvalidSubscriptionState(`failed to get customer from store: ${cusRes.error.message}`))
86+
}
87+
88+
const stripeID = cusRes.ok.account ?? ''
89+
if (!stripeID.startsWith('stripe:')) {
90+
return error(new InvalidSubscriptionState(`customer does not have a Stripe account: ${customerDID}`))
91+
}
92+
93+
let customer
94+
try {
95+
customer = await stripe.customers.retrieve(
96+
stripeID.replace('stripe:', ''),
97+
{ expand: ['subscriptions'] }
98+
)
99+
if (customer.deleted) {
100+
return error(new InvalidSubscriptionState(`Stripe customer is deleted: ${customerDID}`))
95101
}
102+
} catch (/** @type {any} */ err) {
103+
return error(new InvalidSubscriptionState(`failed to get customer ${customerDID} from Stripe by ID: ${err.message}`))
104+
}
96105

97-
const customer = customers.data[0]
98106
const subscriptions = customer.subscriptions?.data
99107
if (subscriptions?.length !== 1)
100108
return {
101109
error: new InvalidSubscriptionState(
102-
`found ${subscriptions?.length} Stripe subscriptions(s) for customer with email ${email} - cannot set plan`
110+
`found ${subscriptions?.length} Stripe subscriptions(s) for customer ${customerDID} - cannot set plan`
103111
),
104112
}
105113
subscription = customer.subscriptions?.data[0]

upload-api/test/billing.test.js

Lines changed: 41 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -13,17 +13,17 @@ import { FREE_TRIAL_COUPONS, PLANS_TO_LINE_ITEMS_MAPPING } from '../constants.js
1313

1414
dotenv.config({ path: fileURLToPath(new URL('../../.env.local', import.meta.url)) })
1515

16+
/** @import { DidMailto as AccountDID } from '@storacha/did-mailto' */
17+
1618
/**
1719
* @typedef {object} BillingContext
1820
* @property {import('../../billing/lib/api.js').CustomerStore} BillingContext.customerStore
1921
* @property {Stripe} BillingContext.stripe
2022
* @property {import('../types.js').BillingProvider} BillingContext.billingProvider
2123
*/
2224

23-
const customerDID = /** @type {import('@storacha/did-mailto').DidMailto} */(
24-
`did:mailto:example.com:w3up-billing-test-${Date.now()}`
25-
)
26-
const email = toEmail(customerDID)
25+
/** @returns {AccountDID} */
26+
const randomAccount = () => `did:mailto:example.com:w3up-billing-test-${Date.now()}`
2727
const initialPlan = 'did:web:starter.storacha.network'
2828

2929
/**
@@ -42,16 +42,16 @@ async function getCustomerSubscriptionPricesByEmail(stripe, email) {
4242
}
4343

4444
/**
45-
*
46-
* @param {Stripe} stripe
47-
* @param {string} email
45+
* @param {Stripe} stripe
46+
* @param {AccountDID} account
4847
* @param {import('../../billing/lib/api.js').CustomerStore} customerStore
4948
* @returns {Promise<Stripe.Customer>}
5049
*/
51-
async function setupCustomer(stripe, email, customerStore) {
50+
async function setupCustomer(stripe, account, customerStore) {
51+
const email = toEmail(account)
5252
const customer = await stripe.customers.create({ email })
5353
const customerCreation = await customerStore.put({
54-
customer: customerDID,
54+
customer: account,
5555
account: stripeIDToAccountID(customer.id),
5656
product: initialPlan,
5757
insertedAt: new Date()
@@ -84,15 +84,16 @@ async function setupCustomer(stripe, email, customerStore) {
8484
/**
8585
*
8686
* @param {BillingContext} context
87-
* @param {(c: BillingContext) => Promise<void>} testFn
87+
* @param {(c: BillingContext & { account: AccountDID, customer: Stripe.Customer }) => Promise<void>} testFn
8888
*/
8989
async function withCustomer(context, testFn) {
9090
const { stripe, customerStore } = context
9191
let customer
9292
try {
93+
const account = randomAccount()
9394
// create a new customer and set up its subscription with "initialPlan"
94-
customer = await setupCustomer(stripe, email, customerStore)
95-
await testFn(context)
95+
customer = await setupCustomer(stripe, account, customerStore)
96+
await testFn({ ...context, account, customer })
9697
} finally {
9798
if (customer) {
9899
// clean up the user we created
@@ -137,19 +138,39 @@ test('stripe plan can be updated', async (t) => {
137138
const context = /** @type {typeof t.context & BillingContext } */(t.context)
138139
const { stripe, billingProvider } = context
139140

140-
await withCustomer(context, async () => {
141+
await withCustomer(context, async ({ account }) => {
141142
// use the stripe API to verify plan has been initialized correctly
142-
const initialStripePrices = await getCustomerSubscriptionPricesByEmail(stripe, email)
143+
const initialStripePrices = await getCustomerSubscriptionPricesByEmail(stripe, toEmail(account))
143144
t.deepEqual(expectedPriceIdsByPlanId(initialPlan), initialStripePrices)
144145

145146
// this is the actual code under test!
146147
const updatedPlan = 'did:web:lite.storacha.network'
147-
const result = await billingProvider.setPlan(customerDID, updatedPlan)
148+
const result = await billingProvider.setPlan(account, updatedPlan)
149+
console.log(result)
150+
t.assert(result.ok)
151+
152+
// use the stripe API to verify plan has been updated
153+
const updatedStripePrices = await getCustomerSubscriptionPricesByEmail(stripe, toEmail(account))
154+
t.deepEqual(expectedPriceIdsByPlanId(updatedPlan), updatedStripePrices)
155+
})
156+
})
157+
158+
test('stripe plan can be updated when customer has updated their email address', async (t) => {
159+
const context = /** @type {typeof t.context & BillingContext } */(t.context)
160+
const { stripe, billingProvider } = context
161+
162+
await withCustomer(context, async ({ customer, account }) => {
163+
const updatedEmail = toEmail(randomAccount())
164+
await stripe.customers.update(customer.id, { email: updatedEmail })
165+
166+
const updatedPlan = 'did:web:lite.storacha.network'
167+
// use the account ID with the old email
168+
const result = await billingProvider.setPlan(account, updatedPlan)
148169
console.log(result)
149170
t.assert(result.ok)
150171

151172
// use the stripe API to verify plan has been updated
152-
const updatedStripePrices = await getCustomerSubscriptionPricesByEmail(stripe, email)
173+
const updatedStripePrices = await getCustomerSubscriptionPricesByEmail(stripe, updatedEmail)
153174
t.deepEqual(expectedPriceIdsByPlanId(updatedPlan), updatedStripePrices)
154175
})
155176
})
@@ -158,8 +179,8 @@ test('stripe billing admin session can be generated', async (t) => {
158179
const context = /** @type {typeof t.context & BillingContext } */(t.context)
159180
const { billingProvider } = context
160181

161-
await withCustomer(context, async () => {
162-
const response = await billingProvider.createAdminSession(customerDID, 'https://example.com/return-url')
182+
await withCustomer(context, async ({ account }) => {
183+
const response = await billingProvider.createAdminSession(account, 'https://example.com/return-url')
163184
t.assert(response.ok)
164185
t.assert(response.ok?.url)
165186
})
@@ -169,9 +190,9 @@ test('stripe checkout session can be generated', async (t) => {
169190
const context = /** @type {typeof t.context & BillingContext } */(t.context)
170191
const { billingProvider } = context
171192

172-
await withCustomer(context, async () => {
193+
await withCustomer(context, async ({ account }) => {
173194
const response = await billingProvider.createCheckoutSession(
174-
customerDID,
195+
account,
175196
'did:web:starter.storacha.network',
176197
{
177198
successURL: 'https://example.com/return-url',

0 commit comments

Comments
 (0)