Skip to content

Commit

Permalink
feat: log terms of service acceptance (#2028)
Browse files Browse the repository at this point in the history
This adds terms of service acceptance logging. When a customer accepts
the terms of service, we then send `web3.storage-tos-v1` as the
agreement value to the API. This then gets stored in a table with a
foreign key of the userId.

Co-authored-by: Benjamin Goering <171782+gobengo@users.noreply.github.com>
  • Loading branch information
e-schneid and gobengo committed Oct 23, 2022
1 parent 62dadb1 commit 47c3540
Show file tree
Hide file tree
Showing 19 changed files with 782 additions and 367 deletions.
13 changes: 13 additions & 0 deletions packages/api/src/errors.js
Expand Up @@ -117,6 +117,19 @@ export class MagicTokenRequiredError extends HTTPError {
}
MagicTokenRequiredError.CODE = 'ERROR_MAGIC_TOKEN_REQUIRED'

export class AgreementsRequiredError extends HTTPError {
/**
* @param {import("./utils/billing-types").Agreement[]} agreements
* @param {string} message
*/
constructor (agreements, message = `Missing required agreements ${agreements.join(', ')}`) {
super(message, 400)
this.name = 'AgreementRequired'
this.code = AgreementsRequiredError.CODE
}
}
AgreementsRequiredError.CODE = 'AGREEMENTS_REQUIRED'

export class InvalidCidError extends Error {
/**
* @param {string} cid
Expand Down
25 changes: 14 additions & 11 deletions packages/api/src/user.js
Expand Up @@ -666,7 +666,7 @@ export async function userPaymentGet (request, env) {
* Save a user's payment settings.
*
* @param {AuthenticatedRequest} request
* @param {Pick<BillingEnv, 'billing'|'customers'|'subscriptions'>} env
* @param {Pick<BillingEnv, 'billing'|'customers'|'subscriptions'|'agreements'>} env
*/
export async function userPaymentPut (request, env) {
const requestBody = await request.json()
Expand All @@ -675,11 +675,11 @@ export async function userPaymentPut (request, env) {
throw Object.assign(new Error('Invalid payment method'), { status: 400 })
}
const subscriptionInput = requestBody?.subscription
if (typeof subscriptionInput !== 'object') {
throw Object.assign(new Error(`subscription must be an object, but got ${typeof subscriptionInput}`), { status: 400 })
if (!['object', 'undefined'].includes(typeof subscriptionInput)) {
throw Object.assign(new Error(`subscription must be of type object or undefined, but got ${typeof subscriptionInput}`), { status: 400 })
}
const subscriptionStorageInput = subscriptionInput?.storage
if (!(typeof subscriptionStorageInput === 'object' || subscriptionStorageInput === null)) {
if (subscriptionInput && !(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') {
Expand All @@ -691,22 +691,25 @@ export async function userPaymentPut (request, env) {
status: 400
})
}
const subscriptionStorage = storagePrice
? { price: storagePrice }
: null
/** @type {import('../src/utils/billing-types').W3PlatformSubscription|undefined} */
const subscription = (typeof subscriptionInput === 'undefined')
? undefined
: {
storage: storagePrice ? { price: storagePrice } : null
}
const paymentMethod = { id: paymentMethodId }
await savePaymentSettings(
{
billing: env.billing,
customers: env.customers,
user: { ...request.auth.user, id: request.auth.user._id },
subscriptions: env.subscriptions
subscriptions: env.subscriptions,
agreements: env.agreements
},
{
paymentMethod,
subscription: {
storage: subscriptionStorage
}
subscription,
agreement: requestBody.agreement
},
{
name: request.auth.user.name,
Expand Down
29 changes: 28 additions & 1 deletion packages/api/src/utils/billing-types.ts
Expand Up @@ -91,19 +91,46 @@ export interface BillingEnv {
billing: BillingService
customers: CustomersService
subscriptions: SubscriptionsService
agreements: AgreementService
}

export type PaymentSettings = {
paymentMethod: null | PaymentMethod
subscription: W3PlatformSubscription
agreement?: Agreement
}

export interface UserCustomerService {
getUserCustomer: (userId: string) => Promise<null|{ id: string }>
upsertUserCustomer: (userId: string, customerId: string) => Promise<void>
}

export interface AgreementService {
createUserAgreement: (userId: string, agreement: Agreement) => Promise<void>
}

export interface UserCreationOptions {
email?: string
name?: string
}
}

export type Agreement = 'web3.storage-tos-v1'

/**
* Command instructing system to update the web3.storage subscription for a user
*/
export interface UpdateSubscriptionCommand {
agreement: Agreement
paymentMethod: Pick<PaymentMethod, 'id'>
subscription: W3PlatformSubscription
}

/**
* Command instructing system to update the default paymentMethod for a user.
* It will not update the payment method of old subscriptions.
*/
export interface UpdateDefaultPaymentMethodCommand {
paymentMethod: Pick<PaymentMethod, 'id'>
}

export type SavePaymentSettingsCommand = UpdateSubscriptionCommand | UpdateDefaultPaymentMethodCommand
110 changes: 103 additions & 7 deletions packages/api/src/utils/billing.js
@@ -1,26 +1,109 @@
/* eslint-disable no-void */

import { AgreementsRequiredError } from '../../src/errors.js'

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

/** @type {Record<string, import('./billing-types').Agreement>} */
export const agreements = {
web3StorageTermsOfServiceVersion1: /** @type {const} */('web3.storage-tos-v1')
}

/**
* Type guard whether provided value is a valid Agreement kind
* @param {any} maybeAgreement
* @returns {agreement is import('./billing-types').Agreement}
*/
export function isAgreement (maybeAgreement) {
for (const agreement of Object.values(agreements)) {
if (maybeAgreement === agreement) {
return true
}
}
return false
}

/**
* Given a W3PlatformSubscription, return the agreements that are required to subscribe to it.
* e.g. a priced storage subscription requires signing a terms of service agreement
* @param {import('./billing-types').W3PlatformSubscription} subscription
* @returns {Set<import('./billing-types').Agreement>}
*/
function getRequiredAgreementsForSubscription (subscription) {
/** @type {Set<import('./billing-types').Agreement>} */
const requiredAgreements = new Set([])
if (subscription.storage !== null) {
requiredAgreements.add(agreements.web3StorageTermsOfServiceVersion1)
}
return requiredAgreements
}

/**
* @param {import('./billing-types').W3PlatformSubscription} subscription
* @param {Set<import('./billing-types').Agreement>} agreements
* @returns {Set<import('./billing-types').Agreement>}
*/
function getMissingRequiredSubscriptionAgreements (
subscription,
agreements
) {
/** @type {Set<import('./billing-types').Agreement>} */
const missing = new Set()
for (const requiredAgreement of getRequiredAgreementsForSubscription(subscription)) {
if (!agreements.has(requiredAgreement)) {
missing.add(requiredAgreement)
}
}
return missing
}

/**
* 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').AgreementService} ctx.agreements
* @param {import('./billing-types').BillingUser} ctx.user
* @param {object} paymentSettings
* @param {Pick<import('./billing-types').PaymentMethod, 'id'>} paymentSettings.paymentMethod
* @param {import('./billing-types').W3PlatformSubscription} paymentSettings.subscription
* @param {import('./billing-types').SavePaymentSettingsCommand} command
* @param {import('./billing-types').UserCreationOptions} [userCreationOptions]
*/
export async function savePaymentSettings (ctx, paymentSettings, userCreationOptions) {
const { billing, customers, user } = ctx
export async function savePaymentSettings (ctx, command, userCreationOptions) {
const { billing, customers, user, agreements } = ctx
const updatedSubscription = hasOwnProperty(command, 'subscription') ? command.subscription : undefined
const commandAgreement = hasOwnProperty(command, 'agreement') ? command.agreement : undefined
// if updating subscription, check for required agreements
if (updatedSubscription) {
const agreementsFromSettings = new Set()
if (commandAgreement) { agreementsFromSettings.add(commandAgreement) }
const missingAgreements = getMissingRequiredSubscriptionAgreements(updatedSubscription, agreementsFromSettings)
if (missingAgreements.size > 0) {
throw new AgreementsRequiredError(Array.from(missingAgreements))
}
}
const customer = await customers.getOrCreateForUser(user, userCreationOptions)
await billing.savePaymentMethod(customer.id, paymentSettings.paymentMethod.id)
await ctx.subscriptions.saveSubscription(customer.id, paymentSettings.subscription)
if (commandAgreement) {
await agreements.createUserAgreement(user.id, commandAgreement)
}
await billing.savePaymentMethod(customer.id, command.paymentMethod.id)
if (updatedSubscription) {
await ctx.subscriptions.saveSubscription(customer.id, updatedSubscription)
}
}

/**
* Object.hasOwnProperty as typescript type guard
* @template {unknown} X
* @template {PropertyKey} Y
* @param {X} obj
* @param {Y} prop
* @returns {obj is X & Record<Y, unknown>}
* https://fettblog.eu/typescript-hasownproperty/
*/
export function hasOwnProperty (obj, prop) {
return Object.prototype.hasOwnProperty.call(obj, prop)
}

/**
Expand Down Expand Up @@ -87,6 +170,7 @@ export function createMockUserCustomerService () {
const upsertUserCustomer = async (userId, customerId) => {
userIdToCustomerId.set(userId, customerId)
}

return {
userIdToCustomerId,
getUserCustomer,
Expand Down Expand Up @@ -183,6 +267,16 @@ function createTestEnvCustomerService () {
}
}

/**
* Create a AgreementService for use in testing the app.
* @returns {import('./billing-types').AgreementService}
*/
export function createMockAgreementService () {
return {
async createUserAgreement (userId, agreement) {}
}
}

/**
* Create BillingEnv to use when testing.
* Use stubs/mocks instead of real billing service (e.g. stripe.com and/or a networked db)
Expand All @@ -191,7 +285,9 @@ function createTestEnvCustomerService () {
export function createMockBillingContext () {
const billing = createMockBillingService()
const customers = createTestEnvCustomerService()
const agreements = createMockAgreementService()
return {
agreements,
billing,
customers,
subscriptions: createMockSubscriptionsService()
Expand Down
11 changes: 10 additions & 1 deletion packages/api/src/utils/stripe.js
Expand Up @@ -156,6 +156,10 @@ export function stripeToStripeCardPaymentMethod (paymentMethod) {
* @typedef {import('./billing-types').UserCustomerService} UserCustomerService
*/

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

/**
* A CustomersService that uses stripe.com for storage
*/
Expand Down Expand Up @@ -393,7 +397,7 @@ export function createMockStripeCustomer (options = {}) {
* Otherwise the mock implementations will be used.
* @param {object} env
* @param {string} env.STRIPE_SECRET_KEY
* @param {Pick<DBClient, 'upsertUserCustomer'|'getUserCustomer'>} env.db
* @param {Pick<DBClient, 'upsertUserCustomer'|'getUserCustomer'|'createUserAgreement'>} env.db
* @returns {import('./billing-types').BillingEnv}
*/
export function createStripeBillingContext (env) {
Expand All @@ -412,6 +416,10 @@ export function createStripeBillingContext (env) {
getUserCustomer: env.db.getUserCustomer.bind(env.db)
}
const customers = StripeCustomersService.create(stripe, userCustomerService)
/** @type {AgreementService} */
const agreements = {
createUserAgreement: env.db.createUserAgreement.bind(env.db)
}
// attempt to get stripe price IDs from env vars
let stripePrices
try {
Expand All @@ -427,6 +435,7 @@ export function createStripeBillingContext (env) {
}
const subscriptions = StripeSubscriptionsService.create(stripe, stripePrices)
return {
agreements,
billing,
customers,
subscriptions
Expand Down
7 changes: 5 additions & 2 deletions packages/api/test/stripe.spec.js
@@ -1,6 +1,6 @@
/* eslint-env mocha */
import assert from 'assert'
import { createMockUserCustomerService, CustomerNotFound, randomString, storagePriceNames } from '../src/utils/billing.js'
import { createMockAgreementService, createMockUserCustomerService, CustomerNotFound, randomString, storagePriceNames } from '../src/utils/billing.js'
import { createMockStripeCustomer, createMockStripeForBilling, createMockStripeForCustomersService, createMockStripeForSubscriptions, createMockStripeSubscription, createStripe, createStripeBillingContext, createStripeStorageEnvVar, createStripeStoragePricesFromEnv, stagingStripePrices, StripeBillingService, StripeCustomersService, StripeSubscriptionsService } from '../src/utils/stripe.js'

/**
Expand Down Expand Up @@ -238,7 +238,10 @@ describe('createStripeBillingContext', function () {
return this.skip()
}
const billingContext = createStripeBillingContext({
db: createMockUserCustomerService(),
db: {
...createMockUserCustomerService(),
...createMockAgreementService()
},
STRIPE_SECRET_KEY: stripeSecretKey
})
const user = { id: '1', issuer: `user-${randomString()}` }
Expand Down

0 comments on commit 47c3540

Please sign in to comment.