Skip to content

Commit

Permalink
feat: add initialize method to PlansStorage (storacha#1278)
Browse files Browse the repository at this point in the history
We need a way to initialize an account's "plan" with information about
the product the user has selected and an billing ID used by the billing
system.

Add this and more tests that demonstrate how it is expected to be used.
  • Loading branch information
travis authored Jan 25, 2024
1 parent 3ed6ee9 commit 6792126
Show file tree
Hide file tree
Showing 6 changed files with 134 additions and 16 deletions.
5 changes: 4 additions & 1 deletion packages/capabilities/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -634,6 +634,9 @@ export type PlanSet = InferInvokedCapability<typeof PlanCaps.set>

export type PlanSetSuccess = Unit

/**
* @deprecate currently unused - used to be part of PlanSetFailure but we switched to CustomerNotFound
*/
export interface AccountNotFound extends Ucanto.Failure {
name: 'AccountNotFound'
}
Expand All @@ -642,7 +645,7 @@ export interface InvalidPlanName extends Ucanto.Failure {
name: 'InvalidPlanName'
}

export type PlanSetFailure = AccountNotFound
export type PlanSetFailure = CustomerNotFound

// Top
export type Top = InferInvokedCapability<typeof top>
Expand Down
44 changes: 44 additions & 0 deletions packages/upload-api/src/plan.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,50 @@
import * as Types from './types.js'
import * as Get from './plan/get.js'

import { Failure } from '@ucanto/server'

export class CustomerNotFound extends Failure {
/**
* @param {import('./types.js').AccountDID} accountDID
*/
constructor(accountDID) {
super()
this.accountDID = accountDID
}

/**
* @type {'CustomerNotFound'}
*/
get name() {
return 'CustomerNotFound'
}

describe() {
return `${this.accountDID} not found`
}
}

export class CustomerExists extends Failure {
/**
* @param {import('./types.js').AccountDID} accountDID
*/
constructor(accountDID) {
super()
this.accountDID = accountDID
}

/**
* @type {'CustomerExists'}
*/
get name() {
return 'CustomerExists'
}

describe() {
return `${this.accountDID} already exists`
}
}

/**
* @param {Types.PlanServiceContext} context
*/
Expand Down
31 changes: 27 additions & 4 deletions packages/upload-api/src/types/plans.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,35 @@ import { AccountDID, DID, PlanGetFailure, PlanGetSuccess, PlanSetFailure, PlanSe

export type PlanID = DID

export interface CustomerExists extends Ucanto.Failure {
name: 'CustomerExists'
}

type PlanInitializeFailure = CustomerExists

/**
* Stores subscription plan information.
*/
export interface PlansStorage {
/**
* Get plan information for an account
* Initialize a customer in our system, tracking the external billing
* system ID and the plan they have chosen.
*
* Designed to be use from, eg, a webhook handler for an account creation event
* in a third party billing system.
*
* @param account account DID
* @param billingID ID used by billing system to track this account
* @param plan the ID of the initial plan
*/
initialize: (
account: AccountDID,
billingID: string,
plan: PlanID
) => Promise<Ucanto.Result<Ucanto.Unit, PlanInitializeFailure>>

/**
* Get plan information for a customer
*
* @param account account DID
*/
Expand All @@ -17,13 +40,13 @@ export interface PlansStorage {
) => Promise<Ucanto.Result<PlanGetSuccess, PlanGetFailure>>

/**
* Set an account's plan. Update our systems and any third party billing systems.
* Set a customer's plan. Update our systems and any third party billing systems.
*
* @param account account DID
* @param plan the DID of the new plan
* @param plan the ID of the new plan
*/
set: (
account: AccountDID,
plan: DID
plan: PlanID
) => Promise<Ucanto.Result<PlanSetSuccess, PlanSetFailure>>
}
3 changes: 2 additions & 1 deletion packages/upload-api/test/handlers/plan.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,9 @@ import { Absentee } from '@ucanto/principal'
export const test = {
'an account can get plan information': async (assert, context) => {
const account = 'did:mailto:example.com:alice'
const billingID = 'stripe:abc123'
const product = 'did:web:test.web3.storage'
await context.plansStorage.set(account, product)
await context.plansStorage.initialize(account, billingID, product)
const connection = connect({
id: context.id,
channel: createServer(context),
Expand Down
39 changes: 35 additions & 4 deletions packages/upload-api/test/storage/plans-storage-tests.js
Original file line number Diff line number Diff line change
@@ -1,19 +1,50 @@
import * as API from '../../src/types.js'

const account = 'did:mailto:example.com:alice'
const billingID = 'stripe:abc123'
const product = 'did:web:free.web3.storage'

/**
* @type {API.Tests}
*/
export const test = {
'should persist plans': async (assert, context) => {
'can initialize a customer': async (assert, context) => {
const storage = context.plansStorage

const initializeResult = await storage.initialize(account, billingID, product)

assert.ok(initializeResult.ok)

const getResult = await storage.get(account)
assert.equal(getResult.ok?.product, product)
},

'should not allow plans to be updated for uninitialized customers': async (assert, context) => {
const storage = context.plansStorage

const account = 'did:mailto:example.com:alice'
const product = 'did:web:free.web3.storage'
const setResult = await storage.set(account, product)

assert.ok(setResult.ok)
assert.ok(setResult.error)
assert.equal(setResult.error?.name, 'CustomerNotFound')
},

'should allow plans to be updated for initialized customers': async (assert, context) => {
const storage = context.plansStorage

const initializeResult = await storage.initialize(account, billingID, product)

assert.ok(initializeResult.ok)

const getResult = await storage.get(account)
assert.equal(getResult.ok?.product, product)

const newProduct = 'did:web:expensive.web3.storage'

const setResult = await storage.set(account, newProduct)

assert.ok(setResult.ok)

const newGetResult = await storage.get(account)
assert.equal(newGetResult.ok?.product, newProduct)
},
}
28 changes: 22 additions & 6 deletions packages/upload-api/test/storage/plans-storage.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { CustomerNotFound, CustomerExists } from '../../src/plan.js'
import * as Types from '../../src/types.js'

/**
Expand All @@ -6,14 +7,28 @@ import * as Types from '../../src/types.js'
export class PlansStorage {
constructor() {
/**
* @type {Record<Types.DID, {product: Types.DID, updatedAt: string}>}
* @type {Record<Types.DID, {product: Types.DID, billingID: string, updatedAt: string}>}
*/
this.plans = {}
}

/**
*
* @param {Types.AccountDID} account
* @param {string} billingID
* @param {Types.DID} product
*/
async initialize(account, billingID, product) {
if (this.plans[account]) {
return { error: new CustomerExists(account) }
}
this.plans[account] = { product, billingID, updatedAt: new Date().toISOString() }
return { ok: {} }
}

/**
*
* @param {Types.DID} account
* @param {Types.AccountDID} account
* @returns
*/
async get(account) {
Expand All @@ -32,15 +47,16 @@ export class PlansStorage {

/**
*
* @param {Types.DID} account
* @param {Types.AccountDID} account
* @param {Types.DID} product
* @returns
*/
async set(account, product) {
this.plans[account] = {
product,
updatedAt: new Date().toISOString(),
if (!this.plans[account]) {
return { error: new CustomerNotFound(account) }
}
this.plans[account].product = product
this.plans[account].updatedAt = new Date().toISOString()
return { ok: {} }
}
}

0 comments on commit 6792126

Please sign in to comment.