From 6792126d63a1e983713c3886eeba64038cb7cf34 Mon Sep 17 00:00:00 2001 From: Travis Vachon Date: Thu, 25 Jan 2024 15:07:48 -0800 Subject: [PATCH] feat: add `initialize` method to `PlansStorage` (#1278) 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. --- packages/capabilities/src/types.ts | 5 ++- packages/upload-api/src/plan.js | 44 +++++++++++++++++++ packages/upload-api/src/types/plans.ts | 31 +++++++++++-- packages/upload-api/test/handlers/plan.js | 3 +- .../test/storage/plans-storage-tests.js | 39 ++++++++++++++-- .../upload-api/test/storage/plans-storage.js | 28 +++++++++--- 6 files changed, 134 insertions(+), 16 deletions(-) diff --git a/packages/capabilities/src/types.ts b/packages/capabilities/src/types.ts index f1eb89800..37c6c05e3 100644 --- a/packages/capabilities/src/types.ts +++ b/packages/capabilities/src/types.ts @@ -634,6 +634,9 @@ export type PlanSet = InferInvokedCapability 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' } @@ -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 diff --git a/packages/upload-api/src/plan.js b/packages/upload-api/src/plan.js index 0f22c7b3c..47a80a224 100644 --- a/packages/upload-api/src/plan.js +++ b/packages/upload-api/src/plan.js @@ -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 */ diff --git a/packages/upload-api/src/types/plans.ts b/packages/upload-api/src/types/plans.ts index a94a204ae..6bfc06a12 100644 --- a/packages/upload-api/src/types/plans.ts +++ b/packages/upload-api/src/types/plans.ts @@ -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> + + /** + * Get plan information for a customer * * @param account account DID */ @@ -17,13 +40,13 @@ export interface PlansStorage { ) => Promise> /** - * 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> } diff --git a/packages/upload-api/test/handlers/plan.js b/packages/upload-api/test/handlers/plan.js index 9c8e08855..a718fb4a5 100644 --- a/packages/upload-api/test/handlers/plan.js +++ b/packages/upload-api/test/handlers/plan.js @@ -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), diff --git a/packages/upload-api/test/storage/plans-storage-tests.js b/packages/upload-api/test/storage/plans-storage-tests.js index d07ed0f06..00dda9661 100644 --- a/packages/upload-api/test/storage/plans-storage-tests.js +++ b/packages/upload-api/test/storage/plans-storage-tests.js @@ -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) }, } diff --git a/packages/upload-api/test/storage/plans-storage.js b/packages/upload-api/test/storage/plans-storage.js index 041b3248c..6a892ef88 100644 --- a/packages/upload-api/test/storage/plans-storage.js +++ b/packages/upload-api/test/storage/plans-storage.js @@ -1,3 +1,4 @@ +import { CustomerNotFound, CustomerExists } from '../../src/plan.js' import * as Types from '../../src/types.js' /** @@ -6,14 +7,28 @@ import * as Types from '../../src/types.js' export class PlansStorage { constructor() { /** - * @type {Record} + * @type {Record} */ 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) { @@ -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: {} } } }