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: {} } } }