Skip to content

Commit

Permalink
feat: I can choose a storage pricing tier (issue 1869) (#1878)
Browse files Browse the repository at this point in the history
Motivation:
* #1869 
* kinda like https://trunkbaseddevelopment.com/ but not in 'main', use
this branch instead. commit early and often and we can figure out
together how to keep the ci checks passing

Parts
* [x] implement api contract stubbed in `userPaymentPut`
[added test
here](8bfd82e#diff-0313c9c004c693d94c846d722a7ea9695c4c40867f3666b2f3706850d47f40eaR138)
    ```
    const desiredPaymentSettings = {
      method: { id: `pm_${randomString()}` },
subscription: { storage: { price: `price_test_${randomString()}` } }
    }
    ```
* [x] @cmunns ensure /account/payment page can write the pricing tier to
the PUT /account/payment api
* [x] @gobengo add quick hack where GET
/account/payment?mockSubscription=true will include a mock subscription
with storage price - this should unblock frontend fetching/rendering it
for dev/testing
[added
here](e121a6b#diff-0313c9c004c693d94c846d722a7ea9695c4c40867f3666b2f3706850d47f40eaR76)
    Opt-in to getting a realistic paymentSettings.subscription:
    * Request `GET /user/payment?mockSubscription=true`
    * Response
        ```
        {
subscription: { storage: { price:
'price_mock_userPaymentGet_mockSubscription' } }
        }
        ```
* [x] create use case object `saveStorageSubscription` and use from
`userPaymentPut`
* [x] can `saveStorageSubscription` with services backed by stripe.com
APIs
* [x] @cmunns ensure /account/payment page can read the pricing tier
from the GET /account/payment?mockSubscription=true api
* [x] can `getStorageSubscription(customer)`
* [x] `getStorageSubscription(customer) called from api userPaymentGet`
* [x] test for a new user choosing a paid tier for first time
* [x] test for upgrading from one paid tier to another
* [x] test for downgrading from one paid tier to another
* [x] test for downgrading from one paid tier to a free tier

Co-authored-by: Adam Munns <adam@whetinteractive.com>
Co-authored-by: Yusef Napora <yusef@napora.org>
  • Loading branch information
3 people committed Sep 20, 2022
1 parent eae75d2 commit 58de180
Show file tree
Hide file tree
Showing 37 changed files with 2,218 additions and 497 deletions.
1 change: 0 additions & 1 deletion packages/api/src/auth.js
Original file line number Diff line number Diff line change
Expand Up @@ -176,7 +176,6 @@ function authenticateMagicToken (
try {
decodedToken = env.magic.token.decode(token)
} catch (error) {
console.warn('error decoding magic token', error)
return null
}
try {
Expand Down
58 changes: 50 additions & 8 deletions packages/api/src/user.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import { pagination } from './utils/pagination.js'
import { toPinStatusResponse } from './pins.js'
import { validateSearchParams } from './utils/psa.js'
import { magicLinkBypassForE2ETestingInTestmode } from './magic.link.js'
import { getPaymentSettings, savePaymentSettings } from './utils/billing.js'
import { CustomerNotFound, getPaymentSettings, isStoragePriceName, savePaymentSettings } from './utils/billing.js'

/**
* @typedef {{ _id: string, issuer: string }} User
Expand Down Expand Up @@ -561,38 +561,80 @@ const notifySlack = async (
* Get a user's payment settings.
*
* @param {AuthenticatedRequest} request
* @param {Pick<import('./env').Env, 'billing'|'customers'>} env
* @param {Pick<BillingEnv, 'billing'|'customers'|'subscriptions'>} env
*/
export async function userPaymentGet (request, env) {
const userPaymentSettings = await getPaymentSettings({
billing: env.billing,
customers: env.customers,
subscriptions: env.subscriptions,
user: { id: request.auth.user._id }
})
return new JSONResponse(userPaymentSettings)
if (userPaymentSettings instanceof Error) {
switch (userPaymentSettings.code) {
case (new CustomerNotFound().code):
return new JSONResponse({
message: `Unexpected error fetching payment settings: ${userPaymentSettings.code}`
}, { status: 500 })
default: // unexpected error
throw userPaymentSettings
}
}
return new JSONResponse({
...userPaymentSettings,
subscription: userPaymentSettings.subscription
})
}

/**
* @typedef {import('./utils/billing-types.js').BillingEnv} BillingEnv
*/

/**
* Save a user's payment settings.
*
* @param {AuthenticatedRequest} request
* @param {Pick<import('./env').Env, 'billing'|'customers'>} env
* @param {Pick<BillingEnv, 'billing'|'customers'|'subscriptions'>} env
*/
export async function userPaymentPut (request, env) {
const requestBody = await request.json()
const paymentMethodId = requestBody?.method?.id
const paymentMethodId = requestBody?.paymentMethod?.id
if (typeof paymentMethodId !== 'string') {
throw Object.assign(new Error('Invalid payment method'), { status: 400 })
}
const method = { id: paymentMethodId }
const subscriptionInput = requestBody?.subscription
if (typeof subscriptionInput !== 'object') {
throw Object.assign(new Error(`subscription must be an object, but got ${typeof subscriptionInput}`), { status: 400 })
}
const subscriptionStorageInput = subscriptionInput?.storage
if (!(typeof subscriptionStorageInput === 'object' || subscriptionStorageInput === null)) {
throw Object.assign(new Error('subscription.storage must be an object or null'), { status: 400 })
}
if (subscriptionStorageInput && typeof subscriptionStorageInput.price !== 'string') {
throw Object.assign(new Error('subscription.storage.price must be a string'), { status: 400 })
}
const storagePrice = subscriptionStorageInput?.price
if (storagePrice && !isStoragePriceName(storagePrice)) {
return new JSONResponse(new Error('invalid .subscription.storage.price'), {
status: 400
})
}
const subscriptionStorage = storagePrice
? { price: storagePrice }
: null
const paymentMethod = { id: paymentMethodId }
await savePaymentSettings(
{
billing: env.billing,
customers: env.customers,
user: { id: request.auth.user._id }
user: { id: request.auth.user._id },
subscriptions: env.subscriptions
},
{
method
paymentMethod,
subscription: {
storage: subscriptionStorage
}
}
)
const userPaymentSettingsUrl = '/user/payment'
Expand Down
44 changes: 41 additions & 3 deletions packages/api/src/utils/billing-types.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { StripePriceId } from "./stripe";

export type StripePaymentMethodId = string;
export type CustomerId = string;

Expand Down Expand Up @@ -43,6 +45,40 @@ export interface CustomersService {
getOrCreateForUser(user): Promise<Customer>
}

export type StoragePriceName = 'free' | 'lite' | 'pro'

/**
* A subscription to the web3.storage platform.
* This may be a composition of several product-specific subscriptions.
*/
export interface W3PlatformSubscription {
// details of subscription to storage functionality
storage: null | {
// the price that should be used to determine the subscription's periodic invoice/credit.
price: StoragePriceName
}
}

export type NamedStripePrices = {
priceToName: (priceId: StripePriceId) => undefined | StoragePriceName
nameToPrice: (name: StoragePriceName) => undefined | StripePriceId
}

/**
* storage subscription that is stored in stripe.com
*/
export interface W3StorageStripeSubscription {
id: string
}

/**
* Keeps track of the subscription a customer has chosen to pay for web3.storage services
*/
export interface SubscriptionsService {
getSubscription(customer: CustomerId): Promise<W3PlatformSubscription|CustomerNotFound>
saveSubscription(customer: CustomerId, subscription: W3PlatformSubscription): Promise<void|CustomerNotFound>
}

export interface BillingUser {
id: string
}
Expand All @@ -54,13 +90,15 @@ export interface BillingUser {
export interface BillingEnv {
billing: BillingService
customers: CustomersService
subscriptions: SubscriptionsService
}

export interface PaymentSettings {
method: null|PaymentMethod
export type PaymentSettings = {
paymentMethod: null | PaymentMethod
subscription: W3PlatformSubscription
}

export interface UserCustomerService {
getUserCustomer: (userId: string) => Promise<null|{ id: string }>
upsertUserCustomer: (userId: string, customerId: string) => Promise<any>
upsertUserCustomer: (userId: string, customerId: string) => Promise<void>
}
111 changes: 101 additions & 10 deletions packages/api/src/utils/billing.js
Original file line number Diff line number Diff line change
@@ -1,27 +1,35 @@
/* eslint-disable no-void */

/**
* @typedef {import('./billing-types').StoragePriceName} StoragePriceName
*/

/**
* Save a user's payment settings
* @param {object} ctx
* @param {import('./billing-types').BillingService} ctx.billing
* @param {import('./billing-types').CustomersService} ctx.customers
* @param {import('./billing-types').SubscriptionsService} ctx.subscriptions
* @param {import('./billing-types').BillingUser} ctx.user
* @param {object} paymentSettings
* @param {Pick<import('./billing-types').PaymentMethod, 'id'>} paymentSettings.method
* @param {Pick<import('./billing-types').PaymentMethod, 'id'>} paymentSettings.paymentMethod
* @param {import('./billing-types').W3PlatformSubscription} paymentSettings.subscription
*/
export async function savePaymentSettings (ctx, paymentSettings) {
const { billing, customers, user } = ctx
const customer = await customers.getOrCreateForUser(user)
await billing.savePaymentMethod(customer.id, paymentSettings.method.id)
await billing.savePaymentMethod(customer.id, paymentSettings.paymentMethod.id)
await ctx.subscriptions.saveSubscription(customer.id, paymentSettings.subscription)
}

/**
* Get a user's payment settings
* @param {object} ctx
* @param {import('./billing-types').BillingService} ctx.billing
* @param {import('./billing-types').CustomersService} ctx.customers
* @param {import('./billing-types').SubscriptionsService} ctx.subscriptions
* @param {import('./billing-types').BillingUser} ctx.user
* @returns {Promise<import('./billing-types').PaymentSettings>}
* @returns {Promise<import('./billing-types').PaymentSettings|CustomerNotFound>}
*/
export async function getPaymentSettings (ctx) {
const { billing, customers, user } = ctx
Expand All @@ -30,8 +38,15 @@ export async function getPaymentSettings (ctx) {
if (paymentMethod instanceof Error) {
throw paymentMethod
}
const subscription = await ctx.subscriptions.getSubscription(customer.id)
if (subscription instanceof Error) {
return subscription
}
/** @type {import('./billing-types').PaymentSettings} */
const settings = { method: paymentMethod }
const settings = {
paymentMethod,
subscription
}
return settings
}

Expand All @@ -42,11 +57,14 @@ export function createMockUserCustomerService () {
const userIdToCustomerId = new Map()
const getUserCustomer = async (userId) => {
const c = userIdToCustomerId.get(userId)
if (c) {
if (typeof c === 'string') {
return { id: c }
}
return null
}
/**
* @returns {Promise<void>}
*/
const upsertUserCustomer = async (userId, customerId) => {
userIdToCustomerId.set(userId, customerId)
}
Expand Down Expand Up @@ -91,24 +109,35 @@ export function randomString () {
}

/**
* @returns {import('src/utils/billing-types.js').BillingService & { paymentMethodSaves: Array<{ customerId: string, methodId: string }> }}
* @typedef {object} MockBillingService
* @property {Array<{ customerId: string, methodId: string }>} paymentMethodSaves
* @property {Array<{ customerId: string, storageSubscription: any }>} storageSubscriptionSaves
* @property {import('./billing-types').BillingService['getPaymentMethod']} getPaymentMethod
* @property {import('./billing-types').BillingService['savePaymentMethod']} savePaymentMethod
*/

/**
* @returns {MockBillingService}
*/
export function createMockBillingService () {
const storageSubscriptionSaves = []
const paymentMethodSaves = []
/** @type {Map<string,import('./billing-types').PaymentMethod|undefined>} */
const customerToPaymentMethod = new Map()
/** @type {import('src/utils/billing-types.js').BillingService & { paymentMethodSaves: Array<{ customerId: string, methodId: string }> }} */
/** @type {MockBillingService} */
const billing = {
async getPaymentMethod (customerId) {
const pm = customerToPaymentMethod.get(customerId)
return pm
return pm ?? null
},
async savePaymentMethod (customerId, methodId) {
paymentMethodSaves.push({ customerId, methodId })
customerToPaymentMethod.set(customerId, {
id: methodId
})
},
paymentMethodSaves
paymentMethodSaves,
storageSubscriptionSaves
}
return billing
}
Expand Down Expand Up @@ -145,7 +174,8 @@ export function createMockBillingContext () {
const customers = createTestEnvCustomerService()
return {
billing,
customers
customers,
subscriptions: createMockSubscriptionsService()
}
}

Expand All @@ -163,3 +193,64 @@ export class CustomerNotFound extends Error {
void /** @type {import('./billing-types').CustomerNotFound} */ (this)
}
}

/**
* @typedef {Parameters<import('./billing-types').SubscriptionsService['saveSubscription']>} SaveSubscriptionCall
*/

/**
* @returns {import('./billing-types').SubscriptionsService & { saveSubscriptionCalls: SaveSubscriptionCall[] }}
*/
export function createMockSubscriptionsService () {
/** @type {Map<string, import('./billing-types').W3PlatformSubscription|undefined>} */
const customerIdToSubscription = new Map()
/** @type {Array<SaveSubscriptionCall>} */
const saveSubscriptionCalls = []
return {
saveSubscriptionCalls,
async getSubscription (customerId) {
const fromMap = customerIdToSubscription.get(customerId)
const subscription = fromMap ?? {
storage: null
}
return subscription
},
async saveSubscription (customerId, subscription) {
saveSubscriptionCalls.push([customerId, subscription])
customerIdToSubscription.set(customerId, subscription)
}
}
}

/**
* Create a W3PlatformSubscription that is 'empty' i.e. it has no product-specific subscriptions
* @returns {import('./billing-types').W3PlatformSubscription}
*/
export function createEmptyW3PlatformSubscription () {
return {
storage: null
}
}

/**
* @type {Record<StoragePriceName, StoragePriceName>}
*/
export const storagePriceNames = {
free: /** @type {const} */ ('free'),
lite: /** @type {const} */ ('lite'),
pro: /** @type {const} */ ('pro')
}

/**
* @param {any} name
* @returns {name is StoragePriceName}
*/
export function isStoragePriceName (name) {
switch (name) {
case storagePriceNames.free:
case storagePriceNames.lite:
case storagePriceNames.pro:
return true
}
return false
}
4 changes: 4 additions & 0 deletions packages/api/src/utils/logs.js
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,10 @@ export class Logging {
}

async postBatch () {
if (process.env.NODE_ENV === 'development') {
return
}

if (this.logEventsBatch.length > 0) {
const batchInFlight = [...this.logEventsBatch]
this.logEventsBatch = []
Expand Down

0 comments on commit 58de180

Please sign in to comment.