Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: log terms of service acceptance #2028

Merged
merged 15 commits into from Oct 23, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
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
gobengo marked this conversation as resolved.
Show resolved Hide resolved
}

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