Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: update Stripe plan in PlansStorage#set #318

Closed
wants to merge 13 commits into from
Closed
1 change: 1 addition & 0 deletions .env.tpl
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ R2_SECRET_ACCESS_KEY = ''
R2_UCAN_BUCKET_NAME = ''
R2_DELEGATION_BUCKET_NAME = ''
SATNAV_BUCKET_NAME = ''
STRIPE_SECRET_KEY = ''

# Following variables are only required to run integration tests

Expand Down
4 changes: 2 additions & 2 deletions billing/data/customer.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import { EncodeFailure, DecodeFailure, Schema } from './lib.js'

const schema = Schema.struct({
customer: Schema.did({ method: 'mailto' }),
account: Schema.uri({ protocol: 'stripe:' }),
account: Schema.uri({ protocol: 'stripe:' }).optional(),
product: Schema.text(),
insertedAt: Schema.date(),
updatedAt: Schema.date().optional()
Expand Down Expand Up @@ -47,7 +47,7 @@ export const decode = input => {
return {
ok: {
customer: Schema.did({ method: 'mailto' }).from(input.customer),
account: Schema.uri({ protocol: 'stripe:' }).from(input.account),
account: input.account ? Schema.uri({ protocol: 'stripe:' }).from(input.account) : undefined,
product: /** @type {string} */ (input.product),
insertedAt: new Date(input.insertedAt),
updatedAt: input.updatedAt ? new Date(input.updatedAt) : undefined
Expand Down
2 changes: 1 addition & 1 deletion billing/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ export interface Customer {
* Opaque identifier representing an account in the payment system
* e.g. Stripe customer ID (stripe:cus_9s6XKzkNRiz8i3)
*/
account: AccountID
account?: AccountID
/** Unique identifier of the product a.k.a tier. */
product: string
/** Time the record was added to the database. */
Expand Down
4 changes: 2 additions & 2 deletions billing/tables/customer.js
Original file line number Diff line number Diff line change
Expand Up @@ -56,8 +56,8 @@ export const createCustomerStore = (conf, { tableName }) => ({
Key: marshall({ customer }),
UpdateExpression: 'SET product = :product, updatedAt = :updatedAt',
ExpressionAttributeValues: marshall({
product,
updatedAt: new Date().toISOString()
':product': product,
':updatedAt': new Date().toISOString()
travis marked this conversation as resolved.
Show resolved Hide resolved
})
}))
if (res.$metadata.httpStatusCode !== 200) {
Expand Down
7 changes: 5 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions stacks/upload-api-stack.js
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,7 @@ export function UploadApiStack({ stack, app }) {
R2_DUDEWHERE_BUCKET_NAME: process.env.R2_DUDEWHERE_BUCKET_NAME ?? '',
R2_DELEGATION_BUCKET_NAME: process.env.R2_DELEGATION_BUCKET_NAME ?? '',
R2_ENDPOINT: process.env.R2_ENDPOINT ?? '',
STRIPE_SECRET_KEY: process.env.STRIPE_SECRET_KEY ?? '',
REQUIRE_PAYMENT_PLAN: process.env.REQUIRE_PAYMENT_PLAN ?? '',
UPLOAD_API_DID: process.env.UPLOAD_API_DID ?? '',
STRIPE_PRICING_TABLE_ID: process.env.STRIPE_PRICING_TABLE_ID ?? '',
Expand Down
89 changes: 89 additions & 0 deletions upload-api/billing.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import { Failure } from '@ucanto/core'
import { toEmail } from '@web3-storage/did-mailto'

export class InvalidSubscriptionState extends Failure {
/**
* @param {string} [message] Context for the message.
* @param {ErrorOptions} [options]
*/
constructor(message, options) {
super(undefined, options)
this.name = /** @type {const} */ ('InvalidSubscriptionState')
this.detail = message
}

describe() {
return `subscription cannot be updated because it is not in a valid state: ${this.detail}`
}
}

export class BillingProviderUpdateError extends Failure {
/**
* @param {string} [message] Context for the message.
* @param {ErrorOptions} [options]
*/
constructor(message, options) {
super(undefined, options)
this.name = /** @type {const} */ ('BillingProviderUpdateError')
this.detail = message
}

describe() {
return `encountered an error updating subscription: ${this.detail}`
}
}

/**
*
* @param {import('stripe').Stripe} stripe
* @returns {import("./types").BillingProvider}
*/
export function createStripeBillingProvider(stripe) {
return {
async hasCustomer(customer) {
const customersResponse = await stripe.customers.list({ email: toEmail(/** @type {import('@web3-storage/did-mailto').DidMailto} */(customer)) })
return { ok: (customersResponse.data.length > 0) }
},

async setPlan(customerDID, plan) {
/** @type {import('stripe').Stripe.SubscriptionItem[] | undefined} */
let subscriptionItems
/** @type {string | undefined} */
let priceID
try {
const prices = await stripe.prices.list({ lookup_keys: [plan] })
priceID = prices.data.find(price => price.lookup_key === plan)?.id
if (!priceID) return (
{ error: new InvalidSubscriptionState(`could not find Stripe price with lookup_key ${plan} - cannot set plan`) }
)

const email = toEmail(/** @type {import('@web3-storage/did-mailto').DidMailto} */(customerDID))
const customers = await stripe.customers.list({ email, expand: ['data.subscriptions'] })
if (customers.data.length != 1) return (
{ error: new InvalidSubscriptionState(`found ${customers.data.length} Stripe customer(s) with email ${email} - cannot set plan`) }
)

const customer = customers.data[0]
const subscriptions = customer.subscriptions?.data
if (subscriptions?.length != 1) return (
{ error: new InvalidSubscriptionState(`found ${subscriptions?.length} Stripe subscriptions(s) for customer with email ${email} - cannot set plan`) }
)

subscriptionItems = customer.subscriptions?.data[0].items.data
if (subscriptionItems?.length != 1) return (
{ error: new InvalidSubscriptionState(`found ${subscriptionItems?.length} Stripe subscriptions item(s) for customer with email ${email} - cannot set plan`) }
)
} catch (/** @type {any} */ err) {
return { error: new InvalidSubscriptionState(err.message, { cause: err }) }
travis marked this conversation as resolved.
Show resolved Hide resolved
}

try {
await stripe.subscriptionItems.update(subscriptionItems[0].id, { price: priceID })

return { ok: {} }
} catch (/** @type {any} */ err) {
return { error: new BillingProviderUpdateError(err.message, { cause: err }) }
travis marked this conversation as resolved.
Show resolved Hide resolved
}
}
}
}
7 changes: 6 additions & 1 deletion upload-api/functions/ucan-invocation-router.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import * as Server from '@ucanto/server'
import { Kinesis } from '@aws-sdk/client-kinesis'
import * as Sentry from '@sentry/serverless'
import * as DID from '@ipld/dag-ucan/did'
import Stripe from 'stripe'

import { processAgentMessageArchive } from '../ucan-invocation.js'
import { createCarStore } from '../buckets/car-store.js'
Expand Down Expand Up @@ -37,6 +38,7 @@ import { createCustomerStore } from '@web3-storage/w3infra-billing/tables/custom
import { createSpaceDiffStore } from '@web3-storage/w3infra-billing/tables/space-diff.js'
import { createSpaceSnapshotStore } from '@web3-storage/w3infra-billing/tables/space-snapshot.js'
import { useUsageStore } from '../stores/usage.js'
import { createStripeBillingProvider } from '../billing.js'

Sentry.AWSLambda.init({
environment: process.env.SST_STAGE,
Expand All @@ -61,6 +63,7 @@ const R2_SECRET_ACCESS_KEY = process.env.R2_SECRET_ACCESS_KEY || ''
const R2_REGION = process.env.R2_REGION || 'auto'
const R2_DUDEWHERE_BUCKET_NAME = process.env.R2_DUDEWHERE_BUCKET_NAME || ''
const R2_ENDPOINT = process.env.R2_ENDPOINT || ``
const STRIPE_SECRET_KEY = process.env.STRIPE_SECRET_KEY

/**
* We define a ucanto codec that will switch encoder / decoder based on the
Expand Down Expand Up @@ -152,7 +155,9 @@ export async function ucanInvocationRouter(request) {
endpoint: dbEndpoint
});
const customerStore = createCustomerStore({ region: AWS_REGION }, { tableName: customerTableName })
const plansStorage = usePlansStore(customerStore)
if (!STRIPE_SECRET_KEY) throw new Error('missing secret: STRIPE_SECRET_KEY')
const stripe = new Stripe(STRIPE_SECRET_KEY, { apiVersion: '2023-10-16' })
const plansStorage = usePlansStore(customerStore, createStripeBillingProvider(stripe))
const rateLimitsStorage = createRateLimitTable(AWS_REGION, rateLimitTableName)
const spaceMetricsTable = createSpaceMetricsTable(AWS_REGION, spaceMetricsTableName)

Expand Down
7 changes: 7 additions & 0 deletions upload-api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
"@web-std/fetch": "^4.1.0",
"@web3-storage/access": "^18.0.5",
"@web3-storage/capabilities": "^13.0.0",
"@web3-storage/did-mailto": "^2.1.0",
"@web3-storage/upload-api": "^8.0.0",
"multiformats": "^12.1.2",
"nanoid": "^5.0.2",
Expand All @@ -44,8 +45,14 @@
"@web3-storage/sigv4": "^1.0.2",
"ava": "^4.3.3",
"aws-lambda-test-utils": "^1.3.0",
"dotenv": "^16.3.2",
"testcontainers": "^8.13.0"
},
"ava": {
"require": [
"dotenv/config"
]
},
"engines": {
"node": ">=16.15"
},
Expand Down
21 changes: 19 additions & 2 deletions upload-api/stores/plans.js
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
import { Failure } from '@ucanto/server'

/**
*
* @param {import("@web3-storage/w3infra-billing/lib/api").CustomerStore} customerStore
* @param {import('../types.js').BillingProvider} billingProvider
* @returns {import("@web3-storage/upload-api").PlansStorage}
*/
export function usePlansStore(customerStore) {
export function usePlansStore(customerStore, billingProvider) {
return {
get: async (customer) => {
const result = await customerStore.get({ customer })
Expand All @@ -22,7 +25,21 @@ export function usePlansStore(customerStore) {
},

set: async (customer, plan) => {
return await customerStore.updateProduct(customer, plan)
try {
const hasCustomerResponse = await billingProvider.hasCustomer(customer)
if (hasCustomerResponse.ok) {
await billingProvider.setPlan(customer, plan)
return await customerStore.updateProduct(customer, plan)
} else {
if (hasCustomerResponse.error) {
return hasCustomerResponse
} else {
return { error: new Failure(`billing provider does not have customer for ${customer}`) }
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So this would maybe be a BillingCustomerNotFoundError that we can use in the types for PlansStorage?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ah yep - I want to replace all the Failures in this PR with typed error responses like that, great catch!

}
}
} catch (/** @type {any} */ err) {
return { error: err }
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just let it throw...this is what ucanto will do for us essentially.

}
}
}
}
93 changes: 93 additions & 0 deletions upload-api/test/billing.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import { createStripeBillingProvider } from '../billing.js'
import { test } from './helpers/context.js'
import { toEmail } from '@web3-storage/did-mailto'
import dotenv from 'dotenv'

import Stripe from 'stripe'
import { fileURLToPath } from 'node:url'

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

/**
*
* @param {Stripe} stripe
* @param {string} email
* @returns {Promise<string | null | undefined>}
*/
async function getCustomerPlanByEmail(stripe, email) {
const customers = await stripe.customers.list({ email, expand: ['data.subscriptions'] })
if (customers.data.length > 1) throw new Error(`found more than one customer with email ${email}`)
const customer = customers.data[0]
if (customer) {
return customer.subscriptions?.data[0].items.data[0].price.lookup_key
}
}

/**
*
* @param {Stripe} stripe
* @param {string} email
* @returns {Promise<Stripe.Customer>}
*/
async function setupCustomer(stripe, email) {
const customer = await stripe.customers.create({ email })

// set up a payment method - otherwise we won't be able to update the plan later
let setupIntent = await stripe.setupIntents.create({
customer: customer.id,
payment_method_types: ['card'],
});
setupIntent = await stripe.setupIntents.confirm(
setupIntent.id,
{
payment_method: 'pm_card_visa',
}
)
const paymentMethod = /** @type {string} */(setupIntent.payment_method)
await stripe.customers.update(customer.id, { invoice_settings: { default_payment_method: paymentMethod } })
return customer
}

test('stripe plan can be updated', async (t) => {
const stripeSecretKey = process.env.STRIPE_TEST_SECRET_KEY
if (stripeSecretKey) {
const stripe = new Stripe(stripeSecretKey, { apiVersion: '2023-10-16' })
const billingProvider = createStripeBillingProvider(stripe)
const customerDID = /** @type {import('@web3-storage/did-mailto').DidMailto} */(
`did:mailto:example.com:w3up-billing-test-${Date.now()}`
)
const email = toEmail(customerDID)

const initialPlan = 'did:web:starter.dev.web3.storage'
const updatedPlan = 'did:web:lite.dev.web3.storage'

const prices = await stripe.prices.list({ lookup_keys: [initialPlan] })
const initialPriceID = prices.data.find(price => price.lookup_key === initialPlan)?.id
let customer
try {
// create a new customer and set up its subscription with "initialPlan"
customer = await setupCustomer(stripe, email)

// create a subscription to initialPlan
await stripe.subscriptions.create({ customer: customer.id, items: [{ price: initialPriceID }] })

// use the stripe API to verify plan has been initialized correctly
const initialStripePlan = await getCustomerPlanByEmail(stripe, email)
t.deepEqual(initialPlan, initialStripePlan)

// this is the actual code under test!
await billingProvider.setPlan(customerDID, updatedPlan)

// use the stripe API to verify plan has been updated
const updatedStripePlan = await getCustomerPlanByEmail(stripe, email)
t.deepEqual(updatedPlan, updatedStripePlan)
} finally {
if (customer) {
// clean up the user we created
await stripe.customers.del(customer.id)
}
}
} else {
t.fail('STRIPE_TEST_SECRET_KEY environment variable is not set')
}
})
28 changes: 28 additions & 0 deletions upload-api/test/helpers/billing.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
/**
* @returns {import("../../types").BillingProvider}
*/
export function createTestBillingProvider() {
/**
*
* Initialize this with data that matches the test defined in @web3-storage/w3up.
*
* Normally this will be set up in the billing provider itself (in the
* current implementation this means sending them to Stripe) so we
* initialize it with values that will allow the test to pass.
*
travis marked this conversation as resolved.
Show resolved Hide resolved
* @type {Record<import("@web3-storage/upload-api").AccountDID, import("@ucanto/interface").DID>}
*/
const customers = {
'did:mailto:example.com:alice': 'did:web:initial.web3.storage'
}
return {
async hasCustomer(customer) {
return { ok: !!customers[customer] }
},

async setPlan(customer, product) {
customers[customer] = product
return { ok: {} }
}
}
}
Loading
Loading