From 58de180300e72f7a79193657a7d995f0799bae28 Mon Sep 17 00:00:00 2001 From: Benjamin Goering <171782+gobengo@users.noreply.github.com> Date: Tue, 20 Sep 2022 08:00:29 -0700 Subject: [PATCH] feat: I can choose a storage pricing tier (issue 1869) (#1878) Motivation: * #1869 * kinda like https://trunkbaseddevelopment.com/ but not in 'main', use this branch instead. commit early and often and we can figure out together how to keep the ci checks passing Parts * [x] implement api contract stubbed in `userPaymentPut` [added test here](https://github.com/web3-storage/web3.storage/pull/1878/commits/8bfd82e89238fda023c10b76ac59358d587e0833#diff-0313c9c004c693d94c846d722a7ea9695c4c40867f3666b2f3706850d47f40eaR138) ``` const desiredPaymentSettings = { method: { id: `pm_${randomString()}` }, subscription: { storage: { price: `price_test_${randomString()}` } } } ``` * [x] @cmunns ensure /account/payment page can write the pricing tier to the PUT /account/payment api * [x] @gobengo add quick hack where GET /account/payment?mockSubscription=true will include a mock subscription with storage price - this should unblock frontend fetching/rendering it for dev/testing [added here](https://github.com/web3-storage/web3.storage/pull/1878/commits/e121a6b7300b3cb59056b2b8d9b6d2b996441bd1#diff-0313c9c004c693d94c846d722a7ea9695c4c40867f3666b2f3706850d47f40eaR76) Opt-in to getting a realistic paymentSettings.subscription: * Request `GET /user/payment?mockSubscription=true` * Response ``` { subscription: { storage: { price: 'price_mock_userPaymentGet_mockSubscription' } } } ``` * [x] create use case object `saveStorageSubscription` and use from `userPaymentPut` * [x] can `saveStorageSubscription` with services backed by stripe.com APIs * [x] @cmunns ensure /account/payment page can read the pricing tier from the GET /account/payment?mockSubscription=true api * [x] can `getStorageSubscription(customer)` * [x] `getStorageSubscription(customer) called from api userPaymentGet` * [x] test for a new user choosing a paid tier for first time * [x] test for upgrading from one paid tier to another * [x] test for downgrading from one paid tier to another * [x] test for downgrading from one paid tier to a free tier Co-authored-by: Adam Munns Co-authored-by: Yusef Napora --- packages/api/src/auth.js | 1 - packages/api/src/user.js | 58 ++- packages/api/src/utils/billing-types.ts | 44 +- packages/api/src/utils/billing.js | 111 ++++- packages/api/src/utils/logs.js | 4 + packages/api/src/utils/stripe.js | 397 +++++++++++++++++- packages/api/test/stripe.spec.js | 178 +++++++- packages/api/test/user-payment.spec.js | 174 ++++++-- packages/db/index.d.ts | 4 +- .../accountCustomPlanModal.js | 22 + .../addPaymentMethodForm.js | 43 +- .../billingPlanCards/billingPlanCards.js | 32 -- .../billingPlanCards/billingPlayCards.scss | 0 .../currentBillingPlanCard.js | 48 ++- .../paymentCustomPlan.js/paymentCustomPlan.js | 31 ++ .../paymentHistory.js/paymentHistory.js | 9 +- .../paymentMethodCard/paymentMethodCard.js | 48 ++- .../account/paymentTable.js/paymentTable.js | 79 ++++ .../accountPlansModal/accountPlansModal.js | 95 ++++- .../accountPlansModal/accountPlansModal.scss | 96 +++-- packages/website/components/button/button.js | 20 +- .../components/contexts/plansContext.js | 70 ++- .../customStorageForm/customStorageForm.js | 110 +++++ .../customStorageForm/customStorageForm.scss | 70 +++ .../website/components/loading/loading.js | 12 +- .../components/loading/loading.module.scss | 8 + packages/website/content/pages/general.json | 4 +- packages/website/lib/api.js | 19 +- .../modules/zero/components/slider/slider.js | 318 +++++++------- .../zero/components/tooltip/tooltip.scss | 25 +- .../website/modules/zero/hooks/useDebounce.js | 35 ++ packages/website/package.json | 2 +- packages/website/pages/account/index.scss | 308 +++++++++++++- packages/website/pages/account/payment.js | 203 +++++---- packages/website/styles/global.scss | 20 + packages/website/styles/variables.scss | 1 + .../website/tests/accountPayment.e2e.spec.ts | 16 +- 37 files changed, 2218 insertions(+), 497 deletions(-) create mode 100644 packages/website/components/account/accountCustomPlanModal/accountCustomPlanModal.js delete mode 100644 packages/website/components/account/billingPlanCards/billingPlanCards.js delete mode 100644 packages/website/components/account/billingPlanCards/billingPlayCards.scss create mode 100644 packages/website/components/account/paymentCustomPlan.js/paymentCustomPlan.js create mode 100644 packages/website/components/account/paymentTable.js/paymentTable.js create mode 100644 packages/website/components/customStorageForm/customStorageForm.js create mode 100644 packages/website/components/customStorageForm/customStorageForm.scss create mode 100644 packages/website/modules/zero/hooks/useDebounce.js diff --git a/packages/api/src/auth.js b/packages/api/src/auth.js index 753d97cbc6..47bce5089e 100644 --- a/packages/api/src/auth.js +++ b/packages/api/src/auth.js @@ -176,7 +176,6 @@ function authenticateMagicToken ( try { decodedToken = env.magic.token.decode(token) } catch (error) { - console.warn('error decoding magic token', error) return null } try { diff --git a/packages/api/src/user.js b/packages/api/src/user.js index d1297ca41f..4a015adf16 100644 --- a/packages/api/src/user.js +++ b/packages/api/src/user.js @@ -13,7 +13,7 @@ import { pagination } from './utils/pagination.js' import { toPinStatusResponse } from './pins.js' import { validateSearchParams } from './utils/psa.js' import { magicLinkBypassForE2ETestingInTestmode } from './magic.link.js' -import { getPaymentSettings, savePaymentSettings } from './utils/billing.js' +import { CustomerNotFound, getPaymentSettings, isStoragePriceName, savePaymentSettings } from './utils/billing.js' /** * @typedef {{ _id: string, issuer: string }} User @@ -561,38 +561,80 @@ const notifySlack = async ( * Get a user's payment settings. * * @param {AuthenticatedRequest} request - * @param {Pick} env + * @param {Pick} env */ export async function userPaymentGet (request, env) { const userPaymentSettings = await getPaymentSettings({ billing: env.billing, customers: env.customers, + subscriptions: env.subscriptions, user: { id: request.auth.user._id } }) - return new JSONResponse(userPaymentSettings) + if (userPaymentSettings instanceof Error) { + switch (userPaymentSettings.code) { + case (new CustomerNotFound().code): + return new JSONResponse({ + message: `Unexpected error fetching payment settings: ${userPaymentSettings.code}` + }, { status: 500 }) + default: // unexpected error + throw userPaymentSettings + } + } + return new JSONResponse({ + ...userPaymentSettings, + subscription: userPaymentSettings.subscription + }) } +/** + * @typedef {import('./utils/billing-types.js').BillingEnv} BillingEnv + */ + /** * Save a user's payment settings. * * @param {AuthenticatedRequest} request - * @param {Pick} env + * @param {Pick} env */ export async function userPaymentPut (request, env) { const requestBody = await request.json() - const paymentMethodId = requestBody?.method?.id + const paymentMethodId = requestBody?.paymentMethod?.id if (typeof paymentMethodId !== 'string') { throw Object.assign(new Error('Invalid payment method'), { status: 400 }) } - const method = { id: paymentMethodId } + const subscriptionInput = requestBody?.subscription + if (typeof subscriptionInput !== 'object') { + throw Object.assign(new Error(`subscription must be an object, but got ${typeof subscriptionInput}`), { status: 400 }) + } + const subscriptionStorageInput = subscriptionInput?.storage + if (!(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') { + throw Object.assign(new Error('subscription.storage.price must be a string'), { status: 400 }) + } + const storagePrice = subscriptionStorageInput?.price + if (storagePrice && !isStoragePriceName(storagePrice)) { + return new JSONResponse(new Error('invalid .subscription.storage.price'), { + status: 400 + }) + } + const subscriptionStorage = storagePrice + ? { price: storagePrice } + : null + const paymentMethod = { id: paymentMethodId } await savePaymentSettings( { billing: env.billing, customers: env.customers, - user: { id: request.auth.user._id } + user: { id: request.auth.user._id }, + subscriptions: env.subscriptions }, { - method + paymentMethod, + subscription: { + storage: subscriptionStorage + } } ) const userPaymentSettingsUrl = '/user/payment' diff --git a/packages/api/src/utils/billing-types.ts b/packages/api/src/utils/billing-types.ts index b38fc27ab7..c9c0a50779 100644 --- a/packages/api/src/utils/billing-types.ts +++ b/packages/api/src/utils/billing-types.ts @@ -1,3 +1,5 @@ +import { StripePriceId } from "./stripe"; + export type StripePaymentMethodId = string; export type CustomerId = string; @@ -43,6 +45,40 @@ export interface CustomersService { getOrCreateForUser(user): Promise } +export type StoragePriceName = 'free' | 'lite' | 'pro' + +/** + * A subscription to the web3.storage platform. + * This may be a composition of several product-specific subscriptions. + */ +export interface W3PlatformSubscription { + // details of subscription to storage functionality + storage: null | { + // the price that should be used to determine the subscription's periodic invoice/credit. + price: StoragePriceName + } +} + +export type NamedStripePrices = { + priceToName: (priceId: StripePriceId) => undefined | StoragePriceName + nameToPrice: (name: StoragePriceName) => undefined | StripePriceId +} + +/** + * storage subscription that is stored in stripe.com + */ +export interface W3StorageStripeSubscription { + id: string +} + +/** + * Keeps track of the subscription a customer has chosen to pay for web3.storage services + */ +export interface SubscriptionsService { + getSubscription(customer: CustomerId): Promise + saveSubscription(customer: CustomerId, subscription: W3PlatformSubscription): Promise +} + export interface BillingUser { id: string } @@ -54,13 +90,15 @@ export interface BillingUser { export interface BillingEnv { billing: BillingService customers: CustomersService + subscriptions: SubscriptionsService } -export interface PaymentSettings { - method: null|PaymentMethod +export type PaymentSettings = { + paymentMethod: null | PaymentMethod + subscription: W3PlatformSubscription } export interface UserCustomerService { getUserCustomer: (userId: string) => Promise - upsertUserCustomer: (userId: string, customerId: string) => Promise + upsertUserCustomer: (userId: string, customerId: string) => Promise } diff --git a/packages/api/src/utils/billing.js b/packages/api/src/utils/billing.js index 84b35ec7bb..0b2eb8592c 100644 --- a/packages/api/src/utils/billing.js +++ b/packages/api/src/utils/billing.js @@ -1,18 +1,25 @@ /* eslint-disable no-void */ +/** + * @typedef {import('./billing-types').StoragePriceName} StoragePriceName + */ + /** * 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').BillingUser} ctx.user * @param {object} paymentSettings - * @param {Pick} paymentSettings.method + * @param {Pick} paymentSettings.paymentMethod + * @param {import('./billing-types').W3PlatformSubscription} paymentSettings.subscription */ export async function savePaymentSettings (ctx, paymentSettings) { const { billing, customers, user } = ctx const customer = await customers.getOrCreateForUser(user) - await billing.savePaymentMethod(customer.id, paymentSettings.method.id) + await billing.savePaymentMethod(customer.id, paymentSettings.paymentMethod.id) + await ctx.subscriptions.saveSubscription(customer.id, paymentSettings.subscription) } /** @@ -20,8 +27,9 @@ export async function savePaymentSettings (ctx, paymentSettings) { * @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').BillingUser} ctx.user - * @returns {Promise} + * @returns {Promise} */ export async function getPaymentSettings (ctx) { const { billing, customers, user } = ctx @@ -30,8 +38,15 @@ export async function getPaymentSettings (ctx) { if (paymentMethod instanceof Error) { throw paymentMethod } + const subscription = await ctx.subscriptions.getSubscription(customer.id) + if (subscription instanceof Error) { + return subscription + } /** @type {import('./billing-types').PaymentSettings} */ - const settings = { method: paymentMethod } + const settings = { + paymentMethod, + subscription + } return settings } @@ -42,11 +57,14 @@ export function createMockUserCustomerService () { const userIdToCustomerId = new Map() const getUserCustomer = async (userId) => { const c = userIdToCustomerId.get(userId) - if (c) { + if (typeof c === 'string') { return { id: c } } return null } + /** + * @returns {Promise} + */ const upsertUserCustomer = async (userId, customerId) => { userIdToCustomerId.set(userId, customerId) } @@ -91,16 +109,26 @@ export function randomString () { } /** - * @returns {import('src/utils/billing-types.js').BillingService & { paymentMethodSaves: Array<{ customerId: string, methodId: string }> }} + * @typedef {object} MockBillingService + * @property {Array<{ customerId: string, methodId: string }>} paymentMethodSaves + * @property {Array<{ customerId: string, storageSubscription: any }>} storageSubscriptionSaves + * @property {import('./billing-types').BillingService['getPaymentMethod']} getPaymentMethod + * @property {import('./billing-types').BillingService['savePaymentMethod']} savePaymentMethod + */ + +/** + * @returns {MockBillingService} */ export function createMockBillingService () { + const storageSubscriptionSaves = [] const paymentMethodSaves = [] + /** @type {Map} */ const customerToPaymentMethod = new Map() - /** @type {import('src/utils/billing-types.js').BillingService & { paymentMethodSaves: Array<{ customerId: string, methodId: string }> }} */ + /** @type {MockBillingService} */ const billing = { async getPaymentMethod (customerId) { const pm = customerToPaymentMethod.get(customerId) - return pm + return pm ?? null }, async savePaymentMethod (customerId, methodId) { paymentMethodSaves.push({ customerId, methodId }) @@ -108,7 +136,8 @@ export function createMockBillingService () { id: methodId }) }, - paymentMethodSaves + paymentMethodSaves, + storageSubscriptionSaves } return billing } @@ -145,7 +174,8 @@ export function createMockBillingContext () { const customers = createTestEnvCustomerService() return { billing, - customers + customers, + subscriptions: createMockSubscriptionsService() } } @@ -163,3 +193,64 @@ export class CustomerNotFound extends Error { void /** @type {import('./billing-types').CustomerNotFound} */ (this) } } + +/** + * @typedef {Parameters} SaveSubscriptionCall + */ + +/** + * @returns {import('./billing-types').SubscriptionsService & { saveSubscriptionCalls: SaveSubscriptionCall[] }} + */ +export function createMockSubscriptionsService () { + /** @type {Map} */ + const customerIdToSubscription = new Map() + /** @type {Array} */ + const saveSubscriptionCalls = [] + return { + saveSubscriptionCalls, + async getSubscription (customerId) { + const fromMap = customerIdToSubscription.get(customerId) + const subscription = fromMap ?? { + storage: null + } + return subscription + }, + async saveSubscription (customerId, subscription) { + saveSubscriptionCalls.push([customerId, subscription]) + customerIdToSubscription.set(customerId, subscription) + } + } +} + +/** + * Create a W3PlatformSubscription that is 'empty' i.e. it has no product-specific subscriptions + * @returns {import('./billing-types').W3PlatformSubscription} + */ +export function createEmptyW3PlatformSubscription () { + return { + storage: null + } +} + +/** + * @type {Record} + */ +export const storagePriceNames = { + free: /** @type {const} */ ('free'), + lite: /** @type {const} */ ('lite'), + pro: /** @type {const} */ ('pro') +} + +/** + * @param {any} name + * @returns {name is StoragePriceName} + */ +export function isStoragePriceName (name) { + switch (name) { + case storagePriceNames.free: + case storagePriceNames.lite: + case storagePriceNames.pro: + return true + } + return false +} diff --git a/packages/api/src/utils/logs.js b/packages/api/src/utils/logs.js index 7f0a677bec..5835680a59 100644 --- a/packages/api/src/utils/logs.js +++ b/packages/api/src/utils/logs.js @@ -123,6 +123,10 @@ export class Logging { } async postBatch () { + if (process.env.NODE_ENV === 'development') { + return + } + if (this.logEventsBatch.length > 0) { const batchInFlight = [...this.logEventsBatch] this.logEventsBatch = [] diff --git a/packages/api/src/utils/stripe.js b/packages/api/src/utils/stripe.js index 8104d43bdf..54f7ab0b8d 100644 --- a/packages/api/src/utils/stripe.js +++ b/packages/api/src/utils/stripe.js @@ -1,6 +1,10 @@ /* eslint-disable no-void */ import Stripe from 'stripe' -import { CustomerNotFound, randomString } from './billing.js' +import { CustomerNotFound, isStoragePriceName, randomString, storagePriceNames } from './billing.js' + +/** + * @typedef {import('./billing-types').StoragePriceName} StoragePriceName + */ /** * @typedef {import('stripe').Stripe} StripeInterface @@ -174,10 +178,7 @@ export class StripeCustomersService { this.userCustomerService = userCustomerService /** @type {StripeComForCustomersService} */ this.stripe = stripe - /** - * @type {CustomersService} - */ - const instance = this // eslint-disable-line + void /** @type {CustomersService} */ (this) } /** @@ -373,6 +374,12 @@ export function createMockStripeCustomer (options = {}) { ? { default_payment_method: options.defaultPaymentMethodId } : {} ) + }, + subscriptions: { + data: [], + object: 'list', + has_more: false, + url: '' } } } @@ -383,7 +390,7 @@ export function createMockStripeCustomer (options = {}) { * Otherwise the mock implementations will be used. * @param {object} env * @param {string} env.STRIPE_SECRET_KEY - * @param {DBClient} env.db + * @param {Pick} env.db * @returns {import('./billing-types').BillingEnv} */ export function createStripeBillingContext (env) { @@ -396,13 +403,389 @@ export function createStripeBillingContext (env) { httpClient: Stripe.createFetchHttpClient() }) const billing = StripeBillingService.create(stripe) + /** @type {UserCustomerService} */ const userCustomerService = { upsertUserCustomer: env.db.upsertUserCustomer.bind(env.db), getUserCustomer: env.db.getUserCustomer.bind(env.db) } const customers = StripeCustomersService.create(stripe, userCustomerService) + // attempt to get stripe price IDs from env vars + let stripePrices + try { + stripePrices = createStripeStoragePricesFromEnv(env) + } catch (error) { + if (error instanceof EnvVarMissingError) { + console.error('env var missing, defaulting to stagingStripePrices', error) + // default prices to use staging values if we cannot set them from the env + stripePrices = stagingStripePrices + } else { + throw error + } + } + const subscriptions = StripeSubscriptionsService.create(stripe, stripePrices) return { billing, - customers + customers, + subscriptions + } +} + +export class NamedStripePrices { + /** + * @param {Record} namedPrices + */ + constructor (namedPrices) { + this.namedPrices = namedPrices + void /** @type {import('./billing-types').NamedStripePrices} */ (this) + } + + /** + * @param {StoragePriceName} name + * @returns {StripePriceId|undefined} + */ + nameToPrice (name) { + const priceId = this.namedPrices[name] + if (priceId) { + return /** @type {StripePriceId} */ (priceId) + } + } + + /** + * @param {StripePriceId} priceId + * @returns {StoragePriceName|undefined} + */ + priceToName (priceId) { + const priceName = Object.keys(this.namedPrices).find(name => this.namedPrices[name] === priceId) + if (isStoragePriceName(priceName)) { + return priceName + } + } +} + +// https://dashboard.stripe.com/test/prices/price_1Li2ISIfErzTm2rEg4wD9BR2 +export const testPriceForStorageFree = 'price_1Li2ISIfErzTm2rEg4wD9BR2' +// https://dashboard.stripe.com/test/prices/price_1LhdqgIfErzTm2rEqfl6EgnT +export const testPriceForStorageLite = 'price_1LhdqgIfErzTm2rEqfl6EgnT' +// https://dashboard.stripe.com/test/prices/price_1Li1upIfErzTm2rEIDcI6scF +export const testPriceForStoragePro = 'price_1Li1upIfErzTm2rEIDcI6scF' + +export const stagingStripePrices = new NamedStripePrices({ + free: testPriceForStorageFree, + lite: testPriceForStorageLite, + pro: testPriceForStoragePro +}) + +/** + * @typedef {object} StripeApiForSubscriptionsService + * @property {Pick} subscriptions + * @property {Pick} subscriptionItems + * @property {Pick} customers + */ + +/** + * @param {object} [options] + * @param {(...args: Parameters) => void} [options.onSubscriptionCreate] + * @param {(id: string) => Promise} [options.retrieveCustomer] + * @returns {StripeApiForSubscriptionsService} + */ +export function createMockStripeForSubscriptions (options = {}) { + return { + ...createMockStripeForBilling({ + retrieveCustomer: options.retrieveCustomer + }), + subscriptions: { + async cancel (id, params) { + return { + id, + object: 'subscription', + status: 'canceled', + cancel_at_period_end: false, + canceled_at: Number(new Date()), + ...params + } + }, + async create (...args) { + options?.onSubscriptionCreate?.(...args) + /** @type {Stripe.Response} */ + const subscription = { + id: `sub_${randomString()}`, + object: 'subscription', + // @ts-ignore + lastResponse: undefined + } + return subscription + } + }, + subscriptionItems: { + async del (id, options) { + /** @type {Stripe.Response} */ + const response = { + // @ts-ignore + lastResponse: undefined + } + return response + }, + async update (id, params, options) { + /** @type {Stripe.SubscriptionItem} */ + // @ts-ignore + const item = { + id, + object: 'subscription_item', + ...params, + created: Number(new Date()) + } + /** @type {Stripe.Response} */ + const response = { + ...item, + // @ts-ignore + lastResponse: undefined + } + return response + } + } + } +} + +/** + * @param {object} [options] + * @param {Stripe.SubscriptionItem[]} [options.items] + * @returns + */ +export function createMockStripeSubscription (options = {}) { + /** @type {Stripe.Subscription} */ + // @ts-ignore + const subscription = { + id: `sub_${randomString()}`, + object: 'subscription', + items: { + object: 'list', + has_more: false, + url: '', + data: [ + ...options.items ?? [] + ] + } + } + return subscription +} + +/** + * A SubscriptionsService that uses stripe.com for storage + */ +export class StripeSubscriptionsService { + /** + * @param {StripeApiForSubscriptionsService} stripe + * @param {import('./billing-types').NamedStripePrices} prices + */ + static create (stripe, prices) { + return new StripeSubscriptionsService( + stripe, + prices + ) + } + + /** + * @param {StripeApiForSubscriptionsService} stripe + * @param {import('./billing-types').NamedStripePrices} priceNamer + * @protected + */ + constructor (stripe, priceNamer) { + /** @type {StripeApiForSubscriptionsService} */ + this.stripe = stripe + /** @type {import('./billing-types').NamedStripePrices} */ + this.priceNamer = priceNamer + void /** @type {import('./billing-types').SubscriptionsService} */ (this) + } + + /** + * @param {string} customerId + * @returns {Promise} + */ + async getSubscription (customerId) { + const storageStripeSubscription = await this.getStorageStripeSubscription(customerId) + if (storageStripeSubscription instanceof CustomerNotFound) { return storageStripeSubscription } + /** @returns {import('./billing-types').W3PlatformSubscription} */ + const subscription = { + storage: createW3StorageSubscription(storageStripeSubscription, this.priceNamer) + } + return subscription + } + + async getStorageStripeSubscription (customerId) { + const customer = await this.stripe.customers.retrieve(customerId, { + expand: ['subscriptions'] + }) + if (customer.deleted) { + return new CustomerNotFound('customer retrieved from stripe has been unexpectedly deleted') + } + const { subscriptions: stripeSubscriptions } = customer + if (!stripeSubscriptions) { + // this is unexpected, since we requested expand=subscriptions above + throw new Error('expected subscriptions to be expanded, but got falsy value') + } + const storageStripeSubscription = selectStorageStripeSubscription(customerId, stripeSubscriptions) + return storageStripeSubscription + } + + /** + * + * @param {import('./billing-types').CustomerId} customerId + * @param {import('./billing-types').W3PlatformSubscription} subscription + * @returns {Promise} + */ + async saveSubscription (customerId, subscription) { + const storageStripeSubscription = await this.getStorageStripeSubscription(customerId) + if (storageStripeSubscription instanceof Error) { return storageStripeSubscription } + await this.saveStorageSubscription(customerId, subscription.storage, storageStripeSubscription ?? undefined) + } + + /** + * @param {import('./billing-types').CustomerId} customerId + * @param {import('./billing-types').W3PlatformSubscription['storage']} storageSubscription + * @param {Stripe.Subscription} [existingStripeSubscription] + * @returns {Promise} + */ + async saveStorageSubscription (customerId, storageSubscription, existingStripeSubscription = undefined) { + const existingStorageStripeSubscriptionItem = existingStripeSubscription && selectStorageStripeSubscriptionItem(existingStripeSubscription) + if (!storageSubscription) { + if (existingStorageStripeSubscriptionItem) { + await this.stripe.subscriptions.cancel(existingStripeSubscription.id) + } + return null + } + const priceName = storageSubscription.price + const desiredPriceId = this.priceNamer.nameToPrice(priceName) + if (!desiredPriceId) { + throw new Error(`invalid price name: ${priceName}`) + } + const desiredSubscriptionItem = { + price: desiredPriceId + } + /** @type {string|undefined} */ + let subscriptionId + // if there's an existing subscription, modify it + if (existingStorageStripeSubscriptionItem && existingStripeSubscription) { + if (!storageSubscription) { + // delete + await this.stripe.subscriptions.cancel(existingStripeSubscription.id) + return null + } + // update + const updatedSubItem = await this.stripe.subscriptionItems.update( + existingStorageStripeSubscriptionItem.id, + desiredSubscriptionItem + ) + subscriptionId = updatedSubItem.subscription + } else { + // create subscription with item + const created = await this.stripe.subscriptions.create({ + customer: customerId, + items: [ + desiredSubscriptionItem + ], + payment_behavior: 'error_if_incomplete' + }) + subscriptionId = created.id + } + /** @type {import('./billing-types').W3StorageStripeSubscription} */ + const subscription = { id: subscriptionId } + return subscription + } +} + +/** + * @param {string} customerId + * @param {Stripe.ApiList} stripeSubscriptions + * @returns {Stripe.Subscription | null} + */ +function selectStorageStripeSubscription (customerId, stripeSubscriptions) { + if (stripeSubscriptions.data.length === 0) { + return null + } + if (stripeSubscriptions.data.length > 1) { + throw new Error(`customer ${customerId} has ${stripeSubscriptions?.data?.length} subscriptions, but we only expect to ever see one.`) + } + // @todo - this isn't very clever. We should be more clever, or maybe throw when there are >1 subscriptions + const stripeSubscription = stripeSubscriptions.data[0] + return stripeSubscription +} + +/** + * @param {Stripe.Subscription} stripeSubscription + * @returns {Stripe.SubscriptionItem} + */ +function selectStorageStripeSubscriptionItem (stripeSubscription) { + const { items } = stripeSubscription + if (items.data.length !== 1) { + throw new Error(`unexpected number of subscription items: ${items.data.length}`) + } + const item = items.data[0] + return item +} + +/** + * @param {null|Stripe.Subscription} stripeSubscription + * @param {import('./billing-types').NamedStripePrices} priceNamer + * @returns {import('./billing-types').W3PlatformSubscription['storage']} + */ +function createW3StorageSubscription (stripeSubscription, priceNamer) { + if (!stripeSubscription) { + return null + } + if (stripeSubscription.items.data.length > 1) { + throw new Error(`subscription ${stripeSubscription.id} has ${stripeSubscription.items.data?.length} items, but we only expect to ever see one.`) + } + // @todo - be more clever in ensuring this came from correct subscription item + // or consider throwing if there is more than one subscription item + const storagePrice = /** @type {StripePriceId} */ (stripeSubscription.items.data[0].price.id) + const storagePriceName = priceNamer.priceToName(storagePrice) + if (!storagePriceName) { + throw new Error(`unable to determien price name for stripe price ${storagePrice}`) + } + /** @type {import('./billing-types').W3PlatformSubscription['storage']} */ + const storageSubscription = { + price: storagePriceName + } + return storageSubscription +} + +/** + * @typedef {`price_${string}`} StripePriceId + */ + +/** + * Get the environment variable that may hold the price id for a + * given storage price name + * @param {StoragePriceName} priceName + */ +export function createStripeStorageEnvVar (priceName) { + return `STRIPE_STORAGE_PRICE_${priceName.toUpperCase()}` +} + +class EnvVarMissingError extends Error {} + +/** + * @param {Record} env + */ +export function createStripeStoragePricesFromEnv (env) { + /** + * @param {StoragePriceName} priceName + * @returns {StripePriceId} + */ + const readPriceNameVar = (priceName) => { + const varName = createStripeStorageEnvVar(priceName) + if (!(varName in env)) { + throw new EnvVarMissingError(`missing env var ${varName}`) + } + const priceId = /** @type {unknown} */ (env[varName]) + if (typeof priceId !== 'string') { + throw new Error(`unable to read string value for env.${varName} for storage price name ${priceName}`) + } + return /** @type {StripePriceId} */ (priceId) } + return new NamedStripePrices(/** @type {Record} */ ({ + [storagePriceNames.free]: readPriceNameVar(storagePriceNames.free), + [storagePriceNames.lite]: readPriceNameVar(storagePriceNames.lite), + [storagePriceNames.pro]: readPriceNameVar(storagePriceNames.pro) + })) } diff --git a/packages/api/test/stripe.spec.js b/packages/api/test/stripe.spec.js index b4d215c43f..aea06afe93 100644 --- a/packages/api/test/stripe.spec.js +++ b/packages/api/test/stripe.spec.js @@ -1,9 +1,11 @@ /* eslint-env mocha */ import assert from 'assert' -import { createMockUserCustomerService, CustomerNotFound, randomString } from '../src/utils/billing.js' -// eslint-disable-next-line no-unused-vars -import Stripe from 'stripe' -import { createMockStripeCustomer, createMockStripeForBilling, createMockStripeForCustomersService, createStripe, StripeBillingService, StripeCustomersService } from '../src/utils/stripe.js' +import { 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' + +/** + * @typedef {import('../src/utils/billing-types').StoragePriceName} StoragePriceName + */ describe('StripeBillingService', async function () { it('can savePaymentMethod', async function () { @@ -103,3 +105,171 @@ describe('StripeCustomersService', async function () { assert.equal(customer2ForUser2.id, customerForUser2.id, 'should return same customer for same userId2') }) }) + +describe('StripeSubscriptionsService', async function () { + it('can saveSubscription between prices using real stripe.com API', async function () { + // ensure stripeSecretKey and use it to construct stripe + const stripeSecretKey = process.env.STRIPE_SECRET_KEY + if (!stripeSecretKey) { + return this.skip() + } + assert.ok(stripeSecretKey, 'stripeSecretKey is required') + const stripe = createStripe(stripeSecretKey) + const customers = StripeCustomersService.create(stripe, createMockUserCustomerService()) + const user = { id: `user-${randomString()}` } + const customer = await customers.getOrCreateForUser(user) + const prices = stagingStripePrices + const subscriptions = StripeSubscriptionsService.create(stripe, prices) + + // change between tiers + const priceSequence = [ + storagePriceNames.free, + storagePriceNames.pro, + storagePriceNames.pro, + storagePriceNames.lite, + storagePriceNames.free + ] + for (const price of priceSequence) { + await subscriptions.saveSubscription(customer.id, { + storage: { + price + } + }) + const gotSavedSubscription2 = await subscriptions.getSubscription(customer.id) + assert.ok(!(gotSavedSubscription2 instanceof Error), 'getSubscription did not return an error') + assert.equal(gotSavedSubscription2.storage?.price, price, 'gotPaymentSettings.subscription.storage.price is same as desiredPaymentSettings.subscription.storage.price') + } + + // unsubscribe to storage + await subscriptions.saveSubscription(customer.id, { + storage: null + }) + const gotSavedSubscription3 = await subscriptions.getSubscription(customer.id) + assert.ok(!(gotSavedSubscription3 instanceof Error), 'getSubscription did not return an error') + assert.equal(gotSavedSubscription3.storage, null, 'gotPaymentSettings.subscription.storage.price is same as desiredPaymentSettings.subscription.storage.price') + }) + it('saveSubscription will convert StoragePriceName to appropriate stripe price id', async function () { + const createdCalls = [] + const prices = stagingStripePrices + const subscriptions = StripeSubscriptionsService.create( + { + ...createMockStripeForSubscriptions({ + onSubscriptionCreate (params) { + createdCalls.push(params) + }, + async retrieveCustomer (customerId) { + return { + ...createMockStripeCustomer(), + subscriptions: { + data: [], + object: 'list', + has_more: false, + url: '' + }, + id: customerId + } + } + }) + }, + prices + ) + const desiredPriceName = storagePriceNames.lite + // save a subscription using the StoragePriceName + const saved = await subscriptions.saveSubscription('customerId', { + storage: { + price: desiredPriceName + } + }) + assert.ok(!(saved instanceof Error), 'saveSubscription did not return an error') + // expect stripe to have been called with a stripe price id, not StoragePriceName + assert.equal(createdCalls.length, 1, 'should have called createSubscription once') + assert.notEqual(createdCalls[0].items[0].price, desiredPriceName, 'should not have invoked stripe.com api with StripePriceName, it should be a stripe.com price id') + assert.equal(createdCalls[0].items[0].price, prices.nameToPrice(desiredPriceName), 'should not have invoked stripe.com api with StripePriceName, it should be a stripe.com price id') + }) + it('getSubscription will convert stripe price.id to StoragePriceName', async function () { + const createdCalls = [] + const prices = stagingStripePrices + const customerId = 'customer-id' + const simulatedStoragePriceName = storagePriceNames.lite + const subscriptions = StripeSubscriptionsService.create( + { + ...createMockStripeForSubscriptions({ + onSubscriptionCreate (params) { + createdCalls.push(params) + }, + async retrieveCustomer (customerId) { + return { + ...createMockStripeCustomer(), + subscriptions: { + data: [ + createMockStripeSubscription({ + items: [ + { + // @ts-ignore + price: { + id: prices.nameToPrice(simulatedStoragePriceName) ?? '', + object: 'price' + } + } + ] + }) + ], + object: 'list', + has_more: false, + url: '' + }, + id: customerId + } + } + }) + }, + prices + ) + const gotSubscription = await subscriptions.getSubscription(customerId) + assert.ok(gotSubscription && !(gotSubscription instanceof Error), 'getSubscription did not return an error') + assert.equal(gotSubscription.storage?.price, simulatedStoragePriceName, '') + }) +}) + +describe('createStripeBillingContext', function () { + it('subscriptions can only saveSubscription to known price ids', async function () { + const stripeSecretKey = process.env.STRIPE_SECRET_KEY + if (!stripeSecretKey) { + return this.skip() + } + const billingContext = createStripeBillingContext({ + db: createMockUserCustomerService(), + STRIPE_SECRET_KEY: stripeSecretKey + }) + const user = { id: `user-${randomString()}` } + const customer = await billingContext.customers.getOrCreateForUser(user) + let saveDidError = false + try { + await billingContext.subscriptions.saveSubscription(customer.id, { + storage: { + // @ts-ignore + price: 'fake_bad_price' + } + }) + } catch (error) { + saveDidError = true + } finally { + assert.equal(saveDidError, true, 'saveSubscription should have thrown an error') + } + }) +}) + +describe('createStripeStoragePricesFromEnv', function () { + it('parses prices from env vars', function () { + const prefix = randomString() + /** @type {import('../src/utils/billing-types').NamedStripePrices} */ + const prices = createStripeStoragePricesFromEnv({ + [createStripeStorageEnvVar(storagePriceNames.free)]: `${prefix}_price_free`, + [createStripeStorageEnvVar(storagePriceNames.lite)]: `${prefix}_price_lite`, + [createStripeStorageEnvVar(storagePriceNames.pro)]: `${prefix}_price_pro` + }) + assert.equal(prices.nameToPrice(storagePriceNames.free), `${prefix}_price_free`) + assert.equal(prices.nameToPrice(storagePriceNames.lite), `${prefix}_price_lite`) + assert.equal(prices.nameToPrice(storagePriceNames.pro), `${prefix}_price_pro`) + }) +}) diff --git a/packages/api/test/user-payment.spec.js b/packages/api/test/user-payment.spec.js index 00035b36b5..e48fed87d4 100644 --- a/packages/api/test/user-payment.spec.js +++ b/packages/api/test/user-payment.spec.js @@ -3,7 +3,7 @@ import assert from 'assert' import fetch, { Request } from '@web-std/fetch' import { endpoint } from './scripts/constants.js' import { AuthorizationTestContext } from './contexts/authorization.js' -import { createMockBillingService, createMockCustomerService, createMockPaymentMethod, randomString, savePaymentSettings } from '../src/utils/billing.js' +import { createMockBillingContext, createMockBillingService, createMockCustomerService, createMockPaymentMethod, createMockSubscriptionsService, randomString, savePaymentSettings, storagePriceNames } from '../src/utils/billing.js' import { userPaymentGet, userPaymentPut } from '../src/user.js' import { createMockStripeCardPaymentMethod } from '../src/utils/stripe.js' @@ -15,6 +15,7 @@ function createBearerAuthorization (bearerToken) { * @param {object} arg * @param {string} [arg.method] - method of request * @param {string} [arg.authorization] - authorization header value + * @param {Record} [arg.searchParams] */ function createUserPaymentRequest (arg = {}) { const { path, baseUrl, authorization, accept, method } = { @@ -25,8 +26,12 @@ function createUserPaymentRequest (arg = {}) { method: 'get', ...arg } + const url = new URL(path, baseUrl) + for (const [key, value] of Object.entries(arg.searchParams || {})) { + url.searchParams.set(key, value) + } return new Request( - new URL(path, baseUrl), + url, { headers: { accept, @@ -56,7 +61,7 @@ describe('GET /user/payment', () => { assert(res.ok, 'response status is ok') const userPaymentSettings = await res.json() assert.equal(typeof userPaymentSettings, 'object') - assert.ok(!userPaymentSettings.method, 'userPaymentSettings.method is falsy') + assert.ok(!userPaymentSettings.paymentMethod, 'userPaymentSettings.paymentMethod is falsy') }) }) @@ -65,6 +70,7 @@ describe('GET /user/payment', () => { * @param {object} [arg] * @param {BodyInit|undefined|string} [arg.body] - body of request * @param {string} [arg.authorization] - authorization header value + * @param {Record} [arg.searchParams] - query string searchParams * @returns */ function createSaveUserPaymentSettingsRequest (arg = {}) { @@ -77,8 +83,12 @@ function createSaveUserPaymentSettingsRequest (arg = {}) { method: 'put', ...arg } + const requestUrl = new URL(path, baseUrl) + for (const [key, value] of Object.entries(arg.searchParams || {})) { + requestUrl.searchParams.set(key, value) + } return new Request( - new URL(path, baseUrl), + requestUrl, { method, body, @@ -105,7 +115,14 @@ describe('PUT /user/payment', () => { const token = AuthorizationTestContext.use(this).createUserToken() const authorization = createBearerAuthorization(token) const desiredPaymentMethodId = `w3-test-${Math.random().toString().slice(2)}` - const res = await fetch(createSaveUserPaymentSettingsRequest({ authorization, body: JSON.stringify({ method: { id: desiredPaymentMethodId } }) })) + /** @type {import('src/utils/billing-types.js').PaymentSettings} */ + const desiredPaymentSettings = { + paymentMethod: { id: desiredPaymentMethodId }, + subscription: { + storage: null + } + } + const res = await fetch(createSaveUserPaymentSettingsRequest({ authorization, body: JSON.stringify(desiredPaymentSettings) })) try { assert.equal(res.status, 202, 'response.status is 202') } catch (error) { @@ -119,16 +136,14 @@ describe('PUT /user/payment', () => { describe('userPaymentPut', () => { it('saves payment method using billing service', async function () { const desiredPaymentMethodId = `pm_${randomString()}` - const paymentSettings = { method: { id: desiredPaymentMethodId } } + /** @type {import('src/utils/billing-types.js').PaymentSettings} */ + const paymentSettings = { paymentMethod: { id: desiredPaymentMethodId }, subscription: { storage: null } } const authorization = createBearerAuthorization(AuthorizationTestContext.use(this).createUserToken()) - const user = { _id: randomString(), issuer: randomString() } - const request = Object.assign( - createSaveUserPaymentSettingsRequest({ authorization, body: JSON.stringify(paymentSettings) }), - { auth: { user } } - ) + const request = createMockAuthenticatedRequest(createSaveUserPaymentSettingsRequest({ authorization, body: JSON.stringify(paymentSettings) })) const billing = createMockBillingService() const customers = createMockCustomerService() const env = { + ...createMockBillingContext(), billing, customers } @@ -139,31 +154,113 @@ describe('userPaymentPut', () => { customers.mockCustomers.map(c => c.id).includes(billing.paymentMethodSaves[0].customerId), 'billing.paymentMethodSaves[0].customerId is in customers.mockCustomers') }) + it('saves storage subscription price', async function () { + /** @type {import('src/utils/billing-types.js').PaymentSettings} */ + const desiredPaymentSettings = { + paymentMethod: { id: `pm_${randomString()}` }, + subscription: { storage: { price: storagePriceNames.lite } } + } + const request = ( + createMockAuthenticatedRequest( + createSaveUserPaymentSettingsRequest({ + authorization: createBearerAuthorization(AuthorizationTestContext.use(this).createUserToken()), + body: JSON.stringify(desiredPaymentSettings) + }))) + const env = { + ...createMockBillingContext(), + subscriptions: createMockSubscriptionsService(), + billing: createMockBillingService(), + customers: createMockCustomerService() + } + const response = await userPaymentPut(request, env) + try { + assert.equal(response.status, 202, 'response.status is 202') + } catch (error) { + console.log('error with response w/ text: ', await response.text()) + throw error + } + assert.equal(env.subscriptions.saveSubscriptionCalls.length, 1, 'subscriptions.saveSubscriptionCalls.length is 1') + assert.ok( + env.customers.mockCustomers.map(c => c.id).includes(env.subscriptions.saveSubscriptionCalls[0][0]), + 'saveSubscription was called with a valid customer id') + }) + it('errors 400 when using a disallowed subscription storage price', async function () { + /** @type {import('src/utils/billing-types.js').PaymentSettings} */ + const desiredPaymentSettings = { + paymentMethod: { id: `pm_${randomString()}` }, + subscription: { + storage: { + // @ts-ignore + price: 'disallowed' + } + } + } + const request = ( + createMockAuthenticatedRequest( + createSaveUserPaymentSettingsRequest({ + authorization: createBearerAuthorization(AuthorizationTestContext.use(this).createUserToken()), + body: JSON.stringify(desiredPaymentSettings) + }))) + const env = { + ...createMockBillingContext() + } + const response = await userPaymentPut(request, env) + assert.equal(response.status, 400, 'response.status is 400') + }) }) +/** + * Create a mock user that can go on an AuthenticatedRequest + * @returns + */ +function createMockRequestUser () { + const userId = randomString() + const user = { id: userId, _id: userId, issuer: userId } + return user +} + +/** + * @param {Request} _request + */ +function createMockAuthenticatedRequest (_request, user = createMockRequestUser()) { + /** @type {import('../src/user.js').AuthenticatedRequest} */ + const request = Object.create(_request) + request.auth = { user } + return request +} + describe('/user/payment', () => { - it('can userPaymentPut and then userPaymentGet', async function () { + it('can userPaymentPut and then userPaymentGet the saved payment settings', async function () { const env = { + ...createMockBillingContext(), billing: createMockBillingService(), customers: createMockCustomerService() } const desiredPaymentMethodId = `test_pm_${randomString()}` - const paymentSettings = { method: { id: desiredPaymentMethodId } } - const user = { _id: randomString(), issuer: randomString() } + const desiredStorageSubscriptionPriceId = storagePriceNames.lite + /** @type {import('src/utils/billing-types.js').PaymentSettings} */ + const desiredPaymentSettings = { + paymentMethod: { id: desiredPaymentMethodId }, + subscription: { storage: { price: desiredStorageSubscriptionPriceId } } + } + const user = createMockRequestUser() // save settings - const savePaymentSettingsRequest = Object.assign( - createSaveUserPaymentSettingsRequest({ body: JSON.stringify(paymentSettings) }), - { auth: { user } } + const savePaymentSettingsRequest = createMockAuthenticatedRequest( + createSaveUserPaymentSettingsRequest({ body: JSON.stringify(desiredPaymentSettings) }), + user ) const savePaymentSettingsResponse = await userPaymentPut(savePaymentSettingsRequest, env) assert.equal(savePaymentSettingsResponse.status, 202, 'savePaymentSettingsResponse.status is 202') // get settings - const getPaymentSettingsRequest = Object.assign( + const getPaymentSettingsRequest = createMockAuthenticatedRequest( createUserPaymentRequest(), - { auth: { user } } + user ) const getPaymentSettingsResponse = await userPaymentGet(getPaymentSettingsRequest, env) assert.equal(getPaymentSettingsResponse.status, 200, 'getPaymentSettingsResponse.status is 200') + const gotPaymentSettings = await getPaymentSettingsResponse.json() + assert.equal(gotPaymentSettings.paymentMethod.id, desiredPaymentMethodId, 'gotPaymentSettings.paymentMethod.id is desiredPaymentMethodId') + assert.deepEqual(gotPaymentSettings, desiredPaymentSettings, 'gotPaymentSettings is desiredPaymentSettings') }) }) @@ -185,14 +282,16 @@ describe('userPaymentGet', () => { } const customers = createMockCustomerService() const response = await userPaymentGet(request, { + ...createMockBillingContext(), billing, customers }) const gotPaymentSettings = await response.json() - assert.equal(gotPaymentSettings.method.id, paymentMethod1.id, 'gotPaymentSettings.method.id is paymentMethod1.id') + assert.equal(gotPaymentSettings.paymentMethod.id, paymentMethod1.id, 'gotPaymentSettings.paymentMethod.id is paymentMethod1.id') }) it('returns stripe card info if paymentMethod is a stripe card', async function () { const env = { + ...createMockBillingContext(), customers: createMockCustomerService(), billing: { ...createMockBillingService(), @@ -201,12 +300,12 @@ describe('userPaymentGet', () => { } } } - const request = createAuthenticatedRequest(createUserPaymentRequest()) + const request = createMockAuthenticatedRequest(createUserPaymentRequest()) const response = await userPaymentGet(request, env) assert.equal(response.ok, true, 'response is ok') const gotPaymentSettings = await response.json() - assert.equal(typeof gotPaymentSettings.method.id, 'string', 'paymentSettings.method.id is a string') - assertIsStripeCard(assert, gotPaymentSettings.method.card) + assert.equal(typeof gotPaymentSettings.paymentMethod.id, 'string', 'paymentSettings.paymentMethod.id is a string') + assertIsStripeCard(assert, gotPaymentSettings.paymentMethod.card) }) }) @@ -218,23 +317,32 @@ function assertIsStripeCard (_assert, card) { _assert.equal(typeof card.exp_year, 'number', 'card.expYear is a number') } -function createAuthenticatedRequest (baseRequest, auth = { user: { _id: randomString(), issuer: randomString() } }) { - return { - ...baseRequest, - auth - } -} - describe('savePaymentSettings', async function () { it('saves payment method using billingService', async () => { const billing = createMockBillingService() - const method = { id: /** @type const */ ('pm_w3-test-1') } + const paymentMethod = { id: /** @type const */ ('pm_w3-test-1') } const customers = createMockCustomerService() const user = { id: randomString() } - await savePaymentSettings({ billing, customers, user }, { method }) + const subscriptions = createMockSubscriptionsService() + const env = { billing, customers, user, subscriptions } + await savePaymentSettings(env, { paymentMethod, subscription: { storage: null } }) const { paymentMethodSaves } = billing assert.equal(paymentMethodSaves.length, 1, 'savePaymentMethod was called once') - assert.deepEqual(paymentMethodSaves[0].methodId, method.id, 'savePaymentMethod was called with method') + assert.deepEqual(paymentMethodSaves[0].methodId, paymentMethod.id, 'savePaymentMethod was called with method') assert.equal(typeof paymentMethodSaves[0].customerId, 'string', 'savePaymentMethod was called with method') }) + it('saves subscription using billingService', async () => { + const desiredPaymentSettings = { + paymentMethod: { id: `pm_mock_${randomString()}` }, + subscription: { storage: { price: storagePriceNames.lite } } + } + const subscriptions = createMockSubscriptionsService() + const env = { + ...createMockBillingContext(), + subscriptions, + user: createMockRequestUser() + } + await savePaymentSettings(env, desiredPaymentSettings) + assert.equal(subscriptions.saveSubscriptionCalls.length, 1, 'saveSubscription was called once') + }) }) diff --git a/packages/db/index.d.ts b/packages/db/index.d.ts index 4b2799c5b7..c4d15804e5 100644 --- a/packages/db/index.d.ts +++ b/packages/db/index.d.ts @@ -69,8 +69,8 @@ export class DBClient { query(document: RequestDocument, variables: V): Promise createUserTag(userId: string, tag: UserTagInput): Promise getUserTags(userId: string): Promise - upsertUserCustomer(userId: string, customerId: string): Promise<{ _id: string }> - getUserCustomer(userId: string): Promise<{ id: string }> + upsertUserCustomer(userId: string, customerId: string): Promise + getUserCustomer(userId: string): Promise<{ id: string }|null> } export function parseTextToNumber(n: string): number diff --git a/packages/website/components/account/accountCustomPlanModal/accountCustomPlanModal.js b/packages/website/components/account/accountCustomPlanModal/accountCustomPlanModal.js new file mode 100644 index 0000000000..32338b435c --- /dev/null +++ b/packages/website/components/account/accountCustomPlanModal/accountCustomPlanModal.js @@ -0,0 +1,22 @@ +import Modal from 'modules/zero/components/modal/modal'; +import CloseIcon from 'assets/icons/close'; +import CustomStorageForm from 'components/customStorageForm/customStorageForm.js'; + +const AccountCustomPlanModal = ({ isOpen, onClose }) => { + return ( +
+ } + modalState={[isOpen, onClose]} + showCloseButton + > +
+ +
+
+
+ ); +}; + +export default AccountCustomPlanModal; diff --git a/packages/website/components/account/addPaymentMethodForm/addPaymentMethodForm.js b/packages/website/components/account/addPaymentMethodForm/addPaymentMethodForm.js index 7ff6cc53b4..87952576f0 100644 --- a/packages/website/components/account/addPaymentMethodForm/addPaymentMethodForm.js +++ b/packages/website/components/account/addPaymentMethodForm/addPaymentMethodForm.js @@ -1,29 +1,19 @@ import { useState } from 'react'; -import { CardElement, useStripe, useElements } from '@stripe/react-stripe-js'; +import { CardElement } from '@stripe/react-stripe-js'; -import { API, getToken } from '../../../lib/api'; +import { userBillingSettings } from '../../../lib/api'; import Button from '../../../components/button/button'; -export async function putPaymentMethod(pm_id) { - const putBody = { method: { id: pm_id } }; - const res = await fetch(API + '/user/payment', { - method: 'PUT', - headers: { - 'Content-Type': 'application/json', - Authorization: 'Bearer ' + (await getToken()), - }, - body: JSON.stringify(putBody), - }); - if (!res.ok) { - throw new Error(`failed to get storage info: ${await res.text()}`); - } - - return res.json(); -} - -const AddPaymentMethodForm = ({ setHasPaymentMethods }) => { - const stripe = useStripe(); - const elements = useElements(); +/** + * @param {object} obj + * @param {import('@stripe/stripe-js').Stripe | null} [obj.stripe] + * @param {import('@stripe/stripe-js').StripeElements | null} [obj.elements] + * @param {(v: boolean) => void} [obj.setHasPaymentMethods] + * @param {(v: boolean) => void} [obj.setEditingPaymentMethod] + * @param { string | null } [obj.currentPlan] + * @returns + */ +const AddPaymentMethodForm = ({ stripe, elements, setHasPaymentMethods, setEditingPaymentMethod, currentPlan }) => { const [paymentMethodError, setPaymentMethodError] = useState(''); const handlePaymentMethodAdd = async event => { @@ -42,8 +32,11 @@ const AddPaymentMethodForm = ({ setHasPaymentMethods }) => { card: cardElement, }); if (error) throw new Error(error.message); - await putPaymentMethod(paymentMethod?.id); - setHasPaymentMethods(true); + if (!paymentMethod?.id) return; + const currPricePlan = currentPlan ? { price: currentPlan } : null; + await userBillingSettings(paymentMethod.id, currPricePlan); + setHasPaymentMethods?.(true); + setEditingPaymentMethod?.(false); setPaymentMethodError(''); } catch (error) { let message; @@ -72,7 +65,7 @@ const AddPaymentMethodForm = ({ setHasPaymentMethods }) => { />
{paymentMethodError}
- diff --git a/packages/website/components/account/billingPlanCards/billingPlanCards.js b/packages/website/components/account/billingPlanCards/billingPlanCards.js deleted file mode 100644 index 81ea96e620..0000000000 --- a/packages/website/components/account/billingPlanCards/billingPlanCards.js +++ /dev/null @@ -1,32 +0,0 @@ -/** - * @typedef {Object} BillingPlanCardsProps - * @property {string} [className] - * @property {any} [plans] - */ - -/** - * - * @param {BillingPlanCardsProps} props - * @returns - */ -const BillingPlanCards = ({ className = '', plans }) => { - return ( -
- {plans.map(plan => ( -
-
-

{plan.title}

-

{plan.description}

-

- Limit: {plan.amount} - Overage: {plan.overage} -

-
{plan.price}
-
-
- ))} -
- ); -}; - -export default BillingPlanCards; diff --git a/packages/website/components/account/billingPlanCards/billingPlayCards.scss b/packages/website/components/account/billingPlanCards/billingPlayCards.scss deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/packages/website/components/account/currentBillingPlanCard/currentBillingPlanCard.js b/packages/website/components/account/currentBillingPlanCard/currentBillingPlanCard.js index 4d71004594..abb8ff6bac 100644 --- a/packages/website/components/account/currentBillingPlanCard/currentBillingPlanCard.js +++ b/packages/website/components/account/currentBillingPlanCard/currentBillingPlanCard.js @@ -1,27 +1,35 @@ -import { plans } from '../../../components/contexts/plansContext'; -const currentPlan = plans.find(p => p.current); - -const CurrentBillingPlanCard = props => { +const CurrentBillingPlanCard = ({ plan }) => { return (
- {currentPlan !== undefined && ( -
-

{currentPlan.title}

-

{currentPlan.description}

+ {plan !== undefined && ( +
+
+

{plan.title}

+
{plan.price}
+
+ +

{plan.description}

- Limit: {currentPlan.amount} - Overage: {currentPlan.overage} + {plan.id !== 'free' && ( + + Base Storage Capacity: {plan.base_storage} for{' '} + {plan.price} + + )} + + Additional Storage: {plan.additional_storage.split(' ')[0]} per GB after{' '} + {plan.base_storage.split(' ')[0]} + + + Bandwidth: {plan.bandwidth} + + + Block Limits: {plan.block_limit} + + + CAR Size Limit: {plan.car_size_limit} +

-
{currentPlan.price}
-
-

Current Usage:

-
-
- -
- 100GB -
-
)}
diff --git a/packages/website/components/account/paymentCustomPlan.js/paymentCustomPlan.js b/packages/website/components/account/paymentCustomPlan.js/paymentCustomPlan.js new file mode 100644 index 0000000000..5ffd70d741 --- /dev/null +++ b/packages/website/components/account/paymentCustomPlan.js/paymentCustomPlan.js @@ -0,0 +1,31 @@ +import { useState } from 'react'; + +import Button from '../../button/button.js'; +import AccountCustomPlanModal from '../accountCustomPlanModal/accountCustomPlanModal.js'; + +const PaymentCustomPlan = props => { + const [showContactForm, setShowContactForm] = useState(false); + + return ( + <> +
+
+

We can put together a custom plan for you based on your specific needs

+ + +
+
+ setShowContactForm(false)} /> + + ); +}; + +export default PaymentCustomPlan; diff --git a/packages/website/components/account/paymentHistory.js/paymentHistory.js b/packages/website/components/account/paymentHistory.js/paymentHistory.js index a9dcaa99af..3bcff139ad 100644 --- a/packages/website/components/account/paymentHistory.js/paymentHistory.js +++ b/packages/website/components/account/paymentHistory.js/paymentHistory.js @@ -1,19 +1,12 @@ const PaymentHistoryTable = props => { const date = new Date(); return ( -
+
-
- Date - Details - Amount - Download -
{Array.from([5, 4, 3, 2, 1]).map((_, i) => (
{new Date(date.setMonth(date.getMonth() - 1)).toLocaleDateString()} - Starter Plan Subscription $100 Invoice-00{i + 1}
diff --git a/packages/website/components/account/paymentMethodCard/paymentMethodCard.js b/packages/website/components/account/paymentMethodCard/paymentMethodCard.js index 0cae07d12e..7492626866 100644 --- a/packages/website/components/account/paymentMethodCard/paymentMethodCard.js +++ b/packages/website/components/account/paymentMethodCard/paymentMethodCard.js @@ -1,28 +1,44 @@ import { faCcVisa } from '@fortawesome/free-brands-svg-icons'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; -const PaymentMethodCard = ({ savedPaymentMethod }) => { +import Loading from '../../../components/loading/loading'; +import Button from '../../../components/button/button'; + +const PaymentMethodCard = ({ savedPaymentMethod, setEditingPaymentMethod }) => { return ( <> {savedPaymentMethod && savedPaymentMethod.card ? ( -
- -
- Card ending in: -
- .... - .... - .... - {savedPaymentMethod.card.last4} + <> +
+ +
+ Card ending in: +
+ .... + .... + .... + {savedPaymentMethod.card.last4} +
+ + Expires: + {savedPaymentMethod.card.exp_month}/{savedPaymentMethod.card.exp_year} + +
+
+ +
- - Expires: - {savedPaymentMethod.card.exp_month}/{savedPaymentMethod.card.exp_year} - -
+ ) : ( -

Loading...

+ <> + +
+ )} ); diff --git a/packages/website/components/account/paymentTable.js/paymentTable.js b/packages/website/components/account/paymentTable.js/paymentTable.js new file mode 100644 index 0000000000..b480f19bec --- /dev/null +++ b/packages/website/components/account/paymentTable.js/paymentTable.js @@ -0,0 +1,79 @@ +import Button from '../../button/button.js'; + +const PaymentTable = ({ plans, currentPlan, setPlanSelection, setIsPaymentPlanModalOpen }) => { + return ( + <> + {currentPlan && ( +

+ + Your current plan is: {currentPlan.title} + +

+ )} + +
+
+
+
+
+
+
+

Base Storage Capacity

+

Additional Storage

+

Bandwidth

+

Block Limits

+

CAR Size Limit

+

Pinning API

+
+
+ {plans.map(plan => ( +
+
+ {/*
*/} +

{plan.title}

+
+
{plan.price}
+
+ {/*
*/} + + {/*

{plan.description}

*/} +
+

{plan.base_storage}

+

{plan.additional_storage}

+

{plan.bandwidth}

+

{plan.block_limit}

+

{plan.car_size_limit}

+
+ + {currentPlan?.id !== plan.id && ( + + )} + + {currentPlan?.id === plan.id && ( + + )} +
+
+ ))} +
+
+
+ + ); +}; + +export default PaymentTable; diff --git a/packages/website/components/accountPlansModal/accountPlansModal.js b/packages/website/components/accountPlansModal/accountPlansModal.js index 05848d8417..669d8bbba7 100644 --- a/packages/website/components/accountPlansModal/accountPlansModal.js +++ b/packages/website/components/accountPlansModal/accountPlansModal.js @@ -1,12 +1,60 @@ -import BillingPlanCards from '../account/billingPlanCards/billingPlanCards.js'; +import { useState } from 'react'; +import { Elements, ElementsConsumer } from '@stripe/react-stripe-js'; + +import Loading from '../../components/loading/loading.js'; +import Button from '../../components/button/button.js'; +import CurrentBillingPlanCard from '../../components/account/currentBillingPlanCard/currentBillingPlanCard.js'; import Modal from '../../modules/zero/components/modal/modal'; import CloseIcon from '../../assets/icons/close'; -// import Button from '../button/button.js'; -import GradientBackground from '../gradientbackground/gradientbackground'; -import { plans } from '../contexts/plansContext'; +import AddPaymentMethodForm from '../../components/account/addPaymentMethodForm/addPaymentMethodForm.js'; +import { API, getToken } from '../../lib/api'; + +export async function putUserPayment(pm_id, plan_id) { + const storage = plan_id ? { price: plan_id } : null; + const paymentSettings = { + paymentMethod: { id: pm_id }, + subscription: { storage: storage }, + }; + const res = await fetch(API + '/user/payment', { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + Authorization: 'Bearer ' + (await getToken()), + }, + body: JSON.stringify(paymentSettings), + }); + if (!res.ok) { + throw new Error(`failed to get storage info: ${await res.text()}`); + } -const AccountPlansModal = ({ isOpen, onClose }) => { - // const [requesting, setRequesting] = useState(false); + return res.json(); +} + +/** + * @param {object} obj + * @param {any} obj.isOpen + * @param {any} obj.onClose + * @param {any} obj.planSelection + * @param {any} obj.planList + * @param {any} obj.stripePromise + * @param {any} obj.setCurrentPlan + * @param {any} obj.savedPaymentMethod + * @param {(v: boolean) => void} obj.setHasPaymentMethods + * @param {(v: boolean) => void} obj.setEditingPaymentMethod + */ +const AccountPlansModal = ({ + isOpen, + onClose, + planSelection, + planList, + setCurrentPlan, + savedPaymentMethod, + setHasPaymentMethods, + setEditingPaymentMethod, + stripePromise, +}) => { + const [isCreatingSub, setIsCreatingSub] = useState(false); + const currentPlan = planList.find(p => p.id === planSelection.id); return (
{ modalState={[isOpen, onClose]} showCloseButton > -
- + + {!savedPaymentMethod && ( +
+ Please add a payment method before confirming plan. + + + {({ stripe, elements }) => ( + + )} + + +
+ )} + +
+ + {isCreatingSub && }
-
); diff --git a/packages/website/components/accountPlansModal/accountPlansModal.scss b/packages/website/components/accountPlansModal/accountPlansModal.scss index a4fc930c31..74196f4c42 100644 --- a/packages/website/components/accountPlansModal/accountPlansModal.scss +++ b/packages/website/components/accountPlansModal/accountPlansModal.scss @@ -1,36 +1,76 @@ -.account-plans-modal { - .modalContainer { - padding: 4px; - border-radius: 10px; - } - .background-view-wrapper { - z-index: -1; +.modalContainer { + padding: 2rem; + border-radius: 10px; + background-color: $shark; +} + +.modalInner { + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + color: #fff; + gap: 8px; + + .billing-card { + max-width: 100%; + width: 500px; } +} + +.background-view-wrapper { + z-index: -1; +} + +.billing-plans { + // padding: 3rem; + // background-color: $vulcan; + color: #fff; + border-radius: 10px; +} - .billing-plans { - padding: 3rem; - background-color: $vulcan; +.billing-card { + border: 2px solid transparent; +} + +.billing-card.active { + border: 2px solid $malibu; +} + +.billing-card.current { + position: relative; + border-left: 0; + &:after { + content: 'Current Plan'; + position: absolute; + top: 8px; + left: -2px; + width: calc(100% + 4px); + background: $malibu; + font-size: 12px; + font-weight: 600; + letter-spacing: 1px; color: #fff; - border-radius: 10px; + padding: 2px 2rem; + border-radius: 8px 8px 0 0; + text-align: left; + text-transform: uppercase; + // transform: translateY(-100%); + // display: block; } - .billing-card:not(.current) { - border: 2px solid transparent; + + .billing-card { + border-left: 0; } +} +// } - .billing-card.current { - border: 2px solid $malibu; - position: relative; - &:after { - content: 'Current Plan'; - position: absolute; - top: 0; - right: 10px; - font-size: 12px; - font-weight: 600; - letter-spacing: 1px; - color: $malibu; - text-transform: uppercase; - } - } +.account-plans-confirm { + display: flex; + width: 100%; + justify-content: space-between; +} + +.add-payment-method-cta + .account-plans-confirm { + opacity: 0.5; } diff --git a/packages/website/components/button/button.js b/packages/website/components/button/button.js index 9af8a56583..1a638e3cc8 100644 --- a/packages/website/components/button/button.js +++ b/packages/website/components/button/button.js @@ -29,6 +29,7 @@ export const ButtonVariant = { * @prop {string} [className] * @prop {string} [href] * @prop {string} [tooltip] + * @prop {string} [tooltipPos] * @prop {TrackingProps} [tracking] * @prop {string} [variant] * @prop {React.ReactNode} [children] @@ -40,7 +41,16 @@ export const ButtonVariant = { * @param {ButtonProps & Partial, 'children'>>} props * @returns */ -const Button = ({ className, tooltip, onClick, tracking, variant = ButtonVariant.DARK, children, ...props }) => { +const Button = ({ + className, + tooltip, + tooltipPos, + onClick, + tracking, + variant = ButtonVariant.DARK, + children, + ...props +}) => { const onClickHandler = useCallback( event => { tracking && @@ -62,7 +72,13 @@ const Button = ({ className, tooltip, onClick, tracking, variant = ButtonVariant ); - return tooltip ? {btn} : btn; + return tooltip ? ( + + {btn} + + ) : ( + btn + ); }; export default Button; diff --git a/packages/website/components/contexts/plansContext.js b/packages/website/components/contexts/plansContext.js index baffa7c76c..a12f78791d 100644 --- a/packages/website/components/contexts/plansContext.js +++ b/packages/website/components/contexts/plansContext.js @@ -1,27 +1,57 @@ -// temporary placeholder for stripe API data -export const plans = [ +export const sharedPlans = [ { - title: 'Free', - description: 'The service you already know and love', - price: '$0/mo', - amount: '10GB', - overage: '', - current: false, - }, - { - title: 'Starter', + id: 'lite', + title: 'Lite', description: 'For those that want to take advantage of more storage', - price: '$10/mo', - amount: '100GB', - overage: '$4/GB', - current: true, + price: '$3/mo', + base_storage: '15GiB', + additional_storage: '$0.20 / GiB', + bandwidth: '30GiB / month', + block_limit: '10,000 / GiB', + car_size_limit: '5MiB', + pinning_api: true, }, { - title: 'Enterprise', + // id: 'price_pro', + id: 'pro', + title: 'Pro', description: 'All the sauce, all the toppings.', - price: '$50/mo', - amount: '500GB', - overage: '$2/GB', - current: false, + price: '$10/mo', + base_storage: '60GiB', + additional_storage: '$0.17 / GiB', + bandwidth: '120GiB / month', + block_limit: '10,000 / GiB', + car_size_limit: '5MiB', + pinning_api: true, }, ]; + +export const freePlan = { + id: 'free', + title: 'Free', + description: 'You are currently on the free tier. You can use our service up to 5GiB/mo without being charged.', + price: '$0/mo', + base_storage: '5GiB', + additional_storage: 'NA', + bandwidth: '10GiB / month', + block_limit: '2,500 / GiB', + car_size_limit: '5MiB', + pinning_api: false, +}; + +export const earlyAdopterPlan = { + id: null, + title: 'Early Adopter', + description: + 'As an early adopter we appreciate your support and can continue to use the storage you are already accustomed to.', + price: '$0/mo', + base_storage: '25GiB', + additional_storage: 'NA', + bandwidth: '10Gib / month', + block_limit: '2,500 / GiB', + car_size_limit: '5MiB', + pinning_api: false, +}; + +export const plans = [freePlan, ...sharedPlans]; +export const plansEarly = [earlyAdopterPlan, ...sharedPlans]; diff --git a/packages/website/components/customStorageForm/customStorageForm.js b/packages/website/components/customStorageForm/customStorageForm.js new file mode 100644 index 0000000000..deb71b154a --- /dev/null +++ b/packages/website/components/customStorageForm/customStorageForm.js @@ -0,0 +1,110 @@ +import { useEffect, useLayoutEffect } from 'react'; +import kwesforms from 'kwesforms'; + +const CustomStorageForm = ({ onClose }) => { + useEffect(() => { + kwesforms.init(); + }, []); + + useLayoutEffect(() => { + const form = document.getElementById('kwesForm'); + form?.addEventListener('kwSubmitted', function () { + // do we need to do any custom logic? + }); + }, []); + + return ( +
+

Enterprise user inquiry

+
+
+ + +
+ +
+ + +
+ +
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+
+ ); +}; + +export default CustomStorageForm; diff --git a/packages/website/components/customStorageForm/customStorageForm.scss b/packages/website/components/customStorageForm/customStorageForm.scss new file mode 100644 index 0000000000..b75f87fc3d --- /dev/null +++ b/packages/website/components/customStorageForm/customStorageForm.scss @@ -0,0 +1,70 @@ +.kwes-form { + .fields { + display: grid; + width: 100%; + grid-template-columns: 100%; + grid-gap: 1rem; + + @media screen and (min-width: 768px) { + grid-template-columns: 1fr 1fr; + grid-template-rows: auto; + grid-gap: 1rem 3rem; + } + } + + .input-wrapper { + display: flex; + flex-direction: column; + } + + label { + // text-transform: uppercase; + line-height: 1.6; + font-size: 14px; + color: rgba(255, 255, 255, 0.75); + font-family: inherit; + margin: 4px 0px; + } + + small { + margin: 0px; + } + + input { + padding: 10px 15px; + border-radius: 4px; + font-size: 1rem; + background-color: rgba(117, 122, 155, 0.15); + border: 1px solid rgba(117, 122, 155, 0.35); + } + + button { + min-width: 100px; + margin-top: 1rem; + font-size: 1rem; + &:hover { + color: #000; + } + } + + p { + max-width: 60ch; + } + + .kw-alert { + background-color: transparent; + border: 1px solid; + } + + .kw-alert-error { + border: 1px solid; + color: rgb(192, 111, 111); + background-color: transparent; + display: none; + } + + .fields .kw-border-success, + .kw-alert-success { + border: 1px solid $malibu; + } +} diff --git a/packages/website/components/loading/loading.js b/packages/website/components/loading/loading.js index d7f2b1f98f..acf1dfc3ab 100644 --- a/packages/website/components/loading/loading.js +++ b/packages/website/components/loading/loading.js @@ -20,10 +20,18 @@ export const SpinnerColor = { * @param {string} [props.className] * @param {string} [props.size] * @param {string} [props.color] + * @param {string} [props.message] */ -const Loading = ({ className, size, color }) => ( -
+const Loading = ({ className, size, color, message }) => ( +
+ {message}
); diff --git a/packages/website/components/loading/loading.module.scss b/packages/website/components/loading/loading.module.scss index 3a9312e64e..7eb96fe1ce 100644 --- a/packages/website/components/loading/loading.module.scss +++ b/packages/website/components/loading/loading.module.scss @@ -3,6 +3,14 @@ text-align: center; } +.message { + display: flex; + align-items: center; + gap: 8px; + color: #cebcf4; + font-size: 14px; +} + .spinner-small { height: 0.625rem; } diff --git a/packages/website/content/pages/general.json b/packages/website/content/pages/general.json index dc6b320aa1..0c8983a2f7 100644 --- a/packages/website/content/pages/general.json +++ b/packages/website/content/pages/general.json @@ -66,8 +66,8 @@ "url": "/tokens/?create=true" }, { - "text": "Request More Storage", - "url": "request-more-storage" + "text": "Plans & Payment", + "url": "/account/payment" }, { "text": "Status", diff --git a/packages/website/lib/api.js b/packages/website/lib/api.js index 8be91c914e..06585f05db 100644 --- a/packages/website/lib/api.js +++ b/packages/website/lib/api.js @@ -300,20 +300,29 @@ export async function deletePinRequest(requestid) { } /** - * Gets saved Stripe payment method. + * Gets/Puts saved user plan and billing settings. */ -export async function getSavedPaymentMethod() { +export async function userBillingSettings(pm_id, currPricePlan) { + const putBody = + !!pm_id && !!currPricePlan + ? JSON.stringify({ + paymentMethod: { id: pm_id }, + subscription: { storage: currPricePlan }, + }) + : null; + console.log(putBody); + const method = !!putBody ? 'PUT' : 'GET'; const token = await getToken(); const res = await fetch(API + '/user/payment', { - method: 'GET', + method: method, headers: { 'Content-Type': 'application/json', Authorization: 'Bearer ' + token, }, - body: null, + body: putBody, }); if (!res.ok) { - throw new Error(`failed to get card info: ${await res.text()}`); + throw new Error(`failed to get/put user billing info: ${await res.text()}`); } const savedPayment = await res.json(); return savedPayment; diff --git a/packages/website/modules/zero/components/slider/slider.js b/packages/website/modules/zero/components/slider/slider.js index e0b437b6df..3b2f3cd072 100644 --- a/packages/website/modules/zero/components/slider/slider.js +++ b/packages/website/modules/zero/components/slider/slider.js @@ -1,6 +1,8 @@ // ===================================================================== Imports -import { useState, useRef, useEffect, useLayoutEffect } from 'react'; -import clsx from 'clsx' +import { useState, useRef, useEffect } from 'react'; +import clsx from 'clsx'; + +import useDebounce from '../../hooks/useDebounce'; // ====================================================================== Params /** * @param {Object} props @@ -15,249 +17,255 @@ import clsx from 'clsx' /** * @param {any} props TODO: Define props */ -function Content ({}) { - return null +function Content({}) { + return null; } /** * @param {any} props TODO: Define props */ -function Previous ({}) { - return null +function Previous({}) { + return null; } /** * @param {any} props TODO: Define props */ -function Next ({}) { - return null +function Next({}) { + return null; } /** * @param {any} props TODO: Define props */ -function Thumb ({}) { - return null +function Thumb({}) { + return null; } -function mapColumnNumbertoBreakpoints (obj, cols) { - const breakpoints = {} - if (obj.hasOwnProperty('ultralarge')) { breakpoints['140.625rem'] = obj.ultralarge } - if (obj.hasOwnProperty('xlarge')) { breakpoints['90rem'] = obj.xlarge } - if (obj.hasOwnProperty('large')) { breakpoints['75rem'] = obj.large } - if (obj.hasOwnProperty('medium')) { breakpoints['60rem'] = obj.medium } - if (obj.hasOwnProperty('small')) { breakpoints['53.125rem'] = obj.small } - if (obj.hasOwnProperty('mini')) { breakpoints['40rem'] = obj.mini } - if (obj.hasOwnProperty('tiny')) { breakpoints['25.9375rem'] = obj.tiny } +function mapColumnNumbertoBreakpoints(obj, cols) { + const breakpoints = {}; + if (obj.hasOwnProperty('ultralarge')) { + breakpoints['140.625rem'] = obj.ultralarge; + } + if (obj.hasOwnProperty('xlarge')) { + breakpoints['90rem'] = obj.xlarge; + } + if (obj.hasOwnProperty('large')) { + breakpoints['75rem'] = obj.large; + } + if (obj.hasOwnProperty('medium')) { + breakpoints['60rem'] = obj.medium; + } + if (obj.hasOwnProperty('small')) { + breakpoints['53.125rem'] = obj.small; + } + if (obj.hasOwnProperty('mini')) { + breakpoints['40rem'] = obj.mini; + } + if (obj.hasOwnProperty('tiny')) { + breakpoints['25.9375rem'] = obj.tiny; + } if (obj.hasOwnProperty('default')) { - breakpoints.default = obj.default + breakpoints.default = obj.default; } else { - breakpoints.default = 3 + breakpoints.default = 3; } - const options = {} + const options = {}; for (const item in breakpoints) { - options[item] = breakpoints[item] > cols ? cols : breakpoints[item] + options[item] = breakpoints[item] > cols ? cols : breakpoints[item]; } - return options + return options; } // ====================================================================== Export -function Slider ({ - collection, - arrowSelectors, - rangeInput, - rows, - displayOptions, - children - }) { - - const [display, setDisplay] = useState(4) - const [left, setLeft] = useState(0) - const [columnWidth, setColumnWidth] = useState(0) - const [thumbPosition, setThumbPosition] = useState(0) - - const index = useRef(0) - const range = useRef(0) - const slidingRowWidth = useRef('100%') - - const animate = useRef(true) - const rowContainer = useRef(/** @type {any} */ (null)) - const sliderInput = useRef(/** @type {any} */ (null)) - - const content = children.find(child => child.type === Content) - const previous = children.find(child => child.type === Previous) - const next = children.find(child => child.type === Next) - const thumb = children.find(child => child.type === Thumb) - - const columns = Math.ceil(collection.length / rows) - const indices = columns - display + 1 - const visibleColumns = mapColumnNumbertoBreakpoints(displayOptions, columns) - - // ================================================================= Functions - useEffect(() => { - if (columns < display) { - setDisplay(columns) - } - - handleSliderResize() - - const resize = () => { handleSliderResize() } - window.addEventListener('resize', resize) - return () => window.removeEventListener('resize', resize) - }, []) - - useLayoutEffect(() => { - updateElementWidths() - }, [display]) - - const updateElementWidths = () => { - const width = rowContainer.current.clientWidth / display - animate.current = false - slidingRowWidth.current = (width * columns + 'px') - setColumnWidth(width) - setSliderPosition() - } +function Slider({ collection, arrowSelectors, rangeInput, rows, displayOptions, children }) { + const [display, setDisplay] = useState(4); + const [left, setLeft] = useState(0); + const [columnWidth, setColumnWidth] = useState(0); + const [thumbPosition, setThumbPosition] = useState(0); + const debouncedDisplay = useDebounce(display, 500); + + const index = useRef(0); + const range = useRef(0); + const slidingRowWidth = useRef('100%'); + + const animate = useRef(true); + const rowContainer = useRef(/** @type {any} */ (null)); + const sliderInput = useRef(/** @type {any} */ (null)); + + const content = children.find(child => child.type === Content); + const previous = children.find(child => child.type === Previous); + const next = children.find(child => child.type === Next); + const thumb = children.find(child => child.type === Thumb); + + const columns = Math.ceil(collection.length / rows); + const indices = columns - display + 1; + const visibleColumns = mapColumnNumbertoBreakpoints(displayOptions, columns); const setSliderPosition = () => { - setLeft((-1 * index.current) * columnWidth) - } + setLeft(-1 * index.current * columnWidth); + }; - const incrementIndex = (val) => { - animate.current = true + const updateElementWidths = () => { + const width = rowContainer.current.clientWidth / display; + animate.current = false; + slidingRowWidth.current = width * columns + 'px'; + setColumnWidth(width); + setSliderPosition(); + }; + + const incrementIndex = val => { + animate.current = true; if (val === 'up') { - index.current = (Math.min(index.current + 1, columns - display)) + index.current = Math.min(index.current + 1, columns - display); } else { - index.current = (Math.max(index.current - 1, 0)) + index.current = Math.max(index.current - 1, 0); } - setSliderPosition() - } - - const handleSliderResize = () => { - index.current = 0 - range.current = 0 - sliderInput.current.value = 0 - setThumbPosition(0) - matchBreakpointDisplayAmount() - } + setSliderPosition(); + }; const matchBreakpointDisplayAmount = () => { - let reset = true + let reset = true; for (const breakpoint in visibleColumns) { if (window.matchMedia(`(max-width: ${breakpoint})`).matches) { - if (reset) { reset = false } - setDisplay(visibleColumns[breakpoint]) + if (reset) { + reset = false; + } + setDisplay(visibleColumns[breakpoint]); } } if (reset) { - setDisplay(visibleColumns.default) + setDisplay(visibleColumns.default); } - } + }; + + const handleSliderResize = () => { + index.current = 0; + range.current = 0; + sliderInput.current.value = 0; + setThumbPosition(0); + matchBreakpointDisplayAmount(); + }; const handleSliderChange = () => { - const pos = (sliderInput.current.value - (indices / 2)) / ((indices * indices + 1) - (indices / 2)) - range.current = pos - setThumbPosition(Math.max(pos * (sliderInput.current.clientWidth - 36), 0)) - } + const pos = (sliderInput.current.value - indices / 2) / (indices * indices + 1 - indices / 2); + range.current = pos; + setThumbPosition(Math.max(pos * (sliderInput.current.clientWidth - 36), 0)); + }; + // ================================================================= Functions useEffect(() => { - animate.current = true - const i = Math.round(range.current * (indices - 1)) - const newIndex = Math.max(0, Math.min(i, indices)) + if (columns < display) { + setDisplay(columns); + } + + handleSliderResize(); + + const resize = () => { + handleSliderResize(); + }; + window.addEventListener('resize', resize); + return () => window.removeEventListener('resize', resize); + }, []); + + useEffect(() => { + if (debouncedDisplay) { + updateElementWidths(); + } + }, [debouncedDisplay]); + + useEffect(() => { + animate.current = true; + const i = Math.round(range.current * (indices - 1)); + const newIndex = Math.max(0, Math.min(i, indices)); if (newIndex !== index.current) { - index.current = newIndex - setSliderPosition() + index.current = newIndex; + setSliderPosition(); } - }, [thumbPosition]) + }, [thumbPosition]); const containerStyles = { left: `${left}px`, - width: slidingRowWidth.current - } + width: slidingRowWidth.current, + }; const contentStyles = { - width: `${columnWidth}px` - } + width: `${columnWidth}px`, + }; const thumbStyles = { - left: `${thumbPosition}px` - } + left: `${thumbPosition}px`, + }; // ========================================================= Template [Slider] return (
-
-
-
- - {content.props.children && ( +
+
+ {content.props.children && content.props.children.map((item, i) => ( -
- - { item } - +
+ {item}
- )) - )} - + ))}
- - { arrowSelectors && ( + {arrowSelectors && (
-
{incrementIndex('down')}}> +
{ + incrementIndex('down'); + }} + > {previous.props.children ? previous.props.children : ''}
-
{incrementIndex('up')}}> +
{ + incrementIndex('up'); + }} + > {next.props.children ? next.props.children : ''}
)} - { rangeInput && ( + {rangeInput && (
-
+
{thumb.props.children ? thumb.props.children : ''}
{ handleSliderChange() }} + onChange={() => { + handleSliderChange(); + }} type="range" step="0.1" min={indices / 2} - max={indices * indices + 1}/> + max={indices * indices + 1} + />
)} -
-
- ) + ); } -Slider.Content = Content -Slider.Previous = Previous -Slider.Next = Next -Slider.Thumb = Thumb +Slider.Content = Content; +Slider.Previous = Previous; +Slider.Next = Next; +Slider.Thumb = Thumb; Slider.defaultProps = { arrowSelectors: true, rangeInput: false, rows: 1, - displayOptions: { default: 3 } -} + displayOptions: { default: 3 }, +}; // ====================================================================== Export -export default Slider +export default Slider; diff --git a/packages/website/modules/zero/components/tooltip/tooltip.scss b/packages/website/modules/zero/components/tooltip/tooltip.scss index 4aebc84758..e0aaf0e183 100644 --- a/packages/website/modules/zero/components/tooltip/tooltip.scss +++ b/packages/website/modules/zero/components/tooltip/tooltip.scss @@ -17,7 +17,7 @@ $info-tooltip-center: calc(50% - #{math.div($info-icon-size, 2)}); } // Bridging the gap to the tooltip to keep it hovered &:after { - content: ""; + content: ''; width: #{$info-tooltip-arrow-size + 0.75rem}; position: absolute; left: 100%; @@ -46,7 +46,7 @@ $info-tooltip-center: calc(50% - #{math.div($info-icon-size, 2)}); color: $white; z-index: 2; &:before { - content: ""; + content: ''; width: 0; height: 0; border-top: $info-tooltip-arrow-size solid transparent; @@ -82,4 +82,25 @@ $info-tooltip-center: calc(50% - #{math.div($info-icon-size, 2)}); } } } + + &.left { + &:hover { + &:after { + left: -100%; + } + } + + .tooltip-content { + right: calc(100% + #{$info-tooltip-arrow-size + 1.5rem}); + left: auto; + &:before { + right: -$info-tooltip-arrow-size; + left: auto; + border-top: $info-tooltip-arrow-size solid transparent; + border-bottom: $info-tooltip-arrow-size solid transparent; + border-left: $info-tooltip-arrow-size solid rgba($martinique, 0.95); + border-right: none; + } + } + } } diff --git a/packages/website/modules/zero/hooks/useDebounce.js b/packages/website/modules/zero/hooks/useDebounce.js new file mode 100644 index 0000000000..7797c6c4aa --- /dev/null +++ b/packages/website/modules/zero/hooks/useDebounce.js @@ -0,0 +1,35 @@ +import { useState, useEffect } from 'react'; + +// Our hook +export default function useDebounce(value, delay) { + // State and setters for debounced value + const [debouncedValue, setDebouncedValue] = useState(value); + + useEffect( + () => { + // Set debouncedValue to value (passed in) after the specified delay + const handler = setTimeout(() => { + setDebouncedValue(value); + }, delay); + + // Return a cleanup function that will be called every time ... + // ... useEffect is re-called. useEffect will only be re-called ... + // ... if value changes (see the inputs array below). + // This is how we prevent debouncedValue from changing if value is ... + // ... changed within the delay period. Timeout gets cleared and restarted. + // To put it in context, if the user is typing within our app's ... + // ... search box, we don't want the debouncedValue to update until ... + // ... they've stopped typing for more than 500ms. + return () => { + clearTimeout(handler); + }; + }, + // Only re-call effect if value changes + // You could also add the "delay" var to inputs array if you ... + // ... need to be able to change that dynamically. + [value] + ); + + return debouncedValue; +} + diff --git a/packages/website/package.json b/packages/website/package.json index 21c7629cff..e4ad370c2c 100644 --- a/packages/website/package.json +++ b/packages/website/package.json @@ -12,7 +12,7 @@ "postbuild": "sh ./scripts/postbuild.sh", "export": "next export", "test": "eslint './**/*.js' && tsc --build", - "test:e2e": "npx playwright test", + "test:e2e": "env-cmd -f ../../.env npx playwright test", "lint": "npm run lint:ts && npm run lint:es", "lint:fix": "npm run lint:es:fix", "lint:ts": "tsc --noEmit", diff --git a/packages/website/pages/account/index.scss b/packages/website/pages/account/index.scss index 0c79a67d8f..c76773ba15 100644 --- a/packages/website/pages/account/index.scss +++ b/packages/website/pages/account/index.scss @@ -126,23 +126,183 @@ .billing-settings-layout { display: grid; - grid-template-columns: 3fr 2fr; - grid-gap: 2rem; + grid-template-columns: 1fr 1fr; + grid-template-rows: auto; + margin-top: 4rem; + grid-template-areas: 'card history'; + grid-gap: 4rem; @include medium { - grid-template-columns: 1fr; - grid-template-rows: auto auto; + display: flex; + flex-wrap: wrap; + > div { + width: 100%; + } + } + + > div:nth-child(1) { + grid-area: card; + } + > div:nth-child(2) { + grid-area: history; + } +} + +.payment-method-actions { + display: flex; + justify-content: space-between; + align-items: center; + .button.text .button-contents { + padding: 0; + color: #aaa; } } .billing-plans { + display: grid; + grid-template-columns: 1fr 1fr 1fr 1fr; + gap: 0.5rem; +} + +.billing-content-intro { display: flex; - flex-direction: column; - gap: 2rem; + justify-content: space-between; + flex-wrap: wrap; +} + +.billing-plans-table { + margin-top: 4rem; + display: grid; + grid-template-columns: 180px 1fr 1fr 1fr; + overflow: hidden; + @media screen and (max-width: 768px) { + grid-template-columns: 1fr; + grid-template-rows: auto; + } + + .billing-play-key p { + position: relative; + &:after { + content: ''; + border-bottom: 1px solid rgba(255, 255, 255, 0.2); + width: 100vw; + bottom: -2px; + left: 0; + position: absolute; + } + } + + .billing-play-key, + .billing-plan { + display: grid; + grid-template-columns: 1fr; + grid-template-rows: 50px 80px auto 100px; + @media screen and (max-width: 768px) { + grid-template-rows: 32px 60px auto auto; + grid-gap: 1rem; + } + } + + .billing-play-key { + padding: 2rem 0; + @media screen and (max-width: 768px) { + display: none; + } + } + + .billing-card { + padding: 2rem; + + @media screen and (min-width: 769px) { + background: none; + box-shadow: none; + border-radius: 0; + margin: 0; + border: 0; + border-left: 1px solid rgba(255, 255, 255, 0.2); + } + + @media screen and (max-width: 768px) { + border: 0; + } + } + .billing-card.current { + position: relative; + border: 0; + @media screen and (max-width: 768px) { + border: 1px solid #fff; + } + &:after { + content: ''; + position: absolute; + width: 100%; + height: 100%; + top: 0px; + left: 0px; + padding: 0; + @include border_Background_Waterloo_White; + } + .button.disabled { + opacity: 0.5; + } + } + + .button, + .billing-plan-usage-container { + margin-top: auto; + } + .billing-plan-usage-container .billing-label { + font-size: 12px; + margin: 0; + } +} + +.billing-play-key p, +.billing-plan-details p { + font-size: 14px; + margin: 0; +} + +.billing-plan-details p { + @media screen and (max-width: 768px) { + margin-bottom: 0.5rem; + &:before { + display: block; + text-transform: uppercase; + color: rgba(255, 255, 255, 0.4); + line-height: 1; + font-size: 80%; + } + &:nth-child(1):before { + content: 'Storage Limit: '; + } + &:nth-child(2):before { + content: 'Bandwidth: '; + } + &:nth-child(3):before { + content: 'Overage Cost: '; + } + } +} + +.billing-plan { + height: 100%; +} + +.billing-plan-overview { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 0.5rem; + > .billing-plan-title { + margin: 0; + } } .billing-card { margin: 0.5rem 0; + text-align: left; + flex: 1 0 auto; } .billing-validation { @@ -159,20 +319,24 @@ } .billing-plan-title { - font-size: 2.2rem; + font-size: 1.8rem; font-weight: bold; - margin: 0.5rem 0 0rem 0; + margin: 0.5rem 0 1rem; } .billing-plan-desc { font-size: 1rem; + line-height: 1.5; + margin: 1rem 0; color: $alto; } .billing-label { - font-size: 1rem; + font-size: 0.8rem; line-height: 1; margin-top: 1rem; + text-transform: uppercase; + color: rgba(255, 255, 255, 0.4); } .billing-plan-usage { @@ -206,14 +370,23 @@ } .billing-plan-limit { - font-size: 0.9rem; + font-size: 1rem; display: flex; - gap: 1.2rem; + flex-direction: column; + justify-content: space-between; + gap: 0.5rem; margin-bottom: 1rem; -} + line-height: 1.5; + margin: 1rem 0; + border-radius: 8px; -.payment-history-layout { - margin-top: 4rem; + span > span { + display: block; + } + + strong { + color: $moonRaker; + } } .payment-history-table { @@ -224,10 +397,14 @@ .payment-history-table-header, .payment-history-table-row { display: grid; - grid-template-columns: 20% auto 150px 150px; + grid-template-columns: 1fr 1fr 1fr; text-align: left; grid-gap: 1rem; - padding: 0.4rem 0; + padding: 0; + justify-content: space-between; + span:last-child { + text-align: right; + } } .payment-history-table-header { @@ -240,7 +417,6 @@ color: $alto; @include small { - // justify-content: space-between; grid-gap: 0 1rem; grid-template-areas: 'date date date' @@ -268,10 +444,106 @@ } .payment-history-table-row:not(:last-child) { - border-bottom: 1px solid $waterloo; + border-bottom: 1px solid $shark; } .saved-card { display: flex; justify-content: space-between; + gap: 1rem; + font-size: 16px; + margin: 0.5rem 0 1rem; + line-height: 1; + border: 0; + + svg { + font-size: 2rem; + } + + > div { + flex: 1; + } + + .card-number { + display: flex; + gap: 0.5rem; + } + + .card-label { + font-size: 0.8rem; + opacity: 0.5; + display: block; + margin-bottom: 0.5rem; + } +} + +.payments-none { + font-size: 14px; + margin: 1rem 0; +} + +.custom-plan-cta { + font-size: 1rem; + position: relative; + @include borderRadius_Large(); + align-items: center; + line-height: 1.7; + padding: 1rem 0rem; + color: $alto; + .button { + margin-top: 1.5rem; + } +} + +.custom-plan-form { + grid-column: 1/3; + max-height: 0; + padding-top: 0; + opacity: 0; + transition: all 0.2s ease-in-out; + + &.show { + padding-top: 2rem; + opacity: 1; + max-height: 600px; + } +} + +.state-changer { + appearance: none; + position: absolute; + right: 2rem; + top: 1rem; + background: none; + border: 1px solid #fff; + border-radius: 4px; + font-size: 14px; + padding: 2px 12px; + opacity: 0.5; + border-radius: 100px; +} + +.add-billing-cta { + background: $vulcan; + @include borderRadius_Large(); + padding: 1rem 2rem; + color: $warning; + border: 2px solid $warning; + margin: 0 0 2rem 0; + font-size: 0.9rem; + position: relative; + text-align: center; + line-height: 1.5; +} + +.add-payment-method-cta { + position: relative; + @include borderRadius_Large(); + margin: 0.5rem 0 2rem 0; + font-size: 1rem; + position: relative; + + h5 { + font-size: 1rem; + } } diff --git a/packages/website/pages/account/payment.js b/packages/website/pages/account/payment.js index a76781afda..a1741b3dec 100644 --- a/packages/website/pages/account/payment.js +++ b/packages/website/pages/account/payment.js @@ -2,59 +2,46 @@ * @fileoverview Account Payment Settings */ -import { useEffect, useState } from 'react'; +import { useState, useEffect } from 'react'; import { Elements, ElementsConsumer } from '@stripe/react-stripe-js'; import { loadStripe } from '@stripe/stripe-js'; -import Button from '../../components/button/button.js'; +import Loading from '../../components/loading/loading.js'; +import PaymentCustomPlan from '../../components/account/paymentCustomPlan.js/paymentCustomPlan.js'; +import PaymentTable from '../../components/account/paymentTable.js/paymentTable.js'; import PaymentMethodCard from '../../components/account/paymentMethodCard/paymentMethodCard.js'; import AccountPlansModal from '../../components/accountPlansModal/accountPlansModal.js'; -// import { plans } from '../../components/contexts/plansContext'; -import AccountPageData from '../../content/pages/app/account.json'; -// import PaymentHistoryTable from '../../components/account/paymentHistory.js/paymentHistory.js'; import AddPaymentMethodForm from '../../components/account/addPaymentMethodForm/addPaymentMethodForm.js'; -import { getSavedPaymentMethod } from '../../lib/api'; - -// const currentPlan = plans.find(p => p.current); - -// const CurrentBillingPlanCard = props => { -// return ( -//
-// {currentPlan !== undefined && ( -//
-//

{currentPlan.title}

-//

{currentPlan.description}

-//

-// Limit: {currentPlan.amount} -// Overage: {currentPlan.overage} -//

-//
{currentPlan.price}
-//
-//

Current Usage:

-//
-//
-// -//
-// 100GB -//
-//
-//
-// )} -//
-// ); -// }; +import { plans, plansEarly } from '../../components/contexts/plansContext'; +import { userBillingSettings } from '../../lib/api'; const PaymentSettingsPage = props => { - const { dashboard } = AccountPageData.page_content; const [isPaymentPlanModalOpen, setIsPaymentPlanModalOpen] = useState(false); const stripePromise = loadStripe(props.stripePublishableKey); const [hasPaymentMethods, setHasPaymentMethods] = useState(false); + const [currentPlan, setCurrentPlan] = useState(/** @type {Plan | null} */ (null)); + const [planSelection, setPlanSelection] = useState(''); + const [planList, setPlanList] = useState(/** @type {Plan[]}*/ (plans)); const [savedPaymentMethod, setSavedPaymentMethod] = useState(/** @type {PaymentMethod} */ ({})); const [editingPaymentMethod, setEditingPaymentMethod] = useState(false); + const [loadingUserSettings, setLoadingUserSettings] = useState(true); + + /** + * @typedef {Object} Plan + * @property {string | null} id + * @property {string} title + * @property {string} description + * @property {string} price + * @property {string} base_storage + * @property {string} additional_storage + * @property {string} bandwidth + * @property {string} block_limit + * @property {string} car_size_limit + * @property {boolean} pinning_api + */ /** * @typedef {Object} PaymentMethodCard - * @property {string} type * @property {string} brand * @property {string} country * @property {string} exp_month @@ -70,53 +57,114 @@ const PaymentSettingsPage = props => { useEffect(() => { const getSavedCard = async () => { - const card = await getSavedPaymentMethod(); + const card = await userBillingSettings(); if (card) { - setSavedPaymentMethod(card.method); + setSavedPaymentMethod(card.paymentMethod); } - console.log(card); return card; }; getSavedCard(); }, [hasPaymentMethods]); + useEffect(() => { + if (!currentPlan || currentPlan === null) { + setPlanList(plansEarly); + } else { + setPlanList(plans); + } + }, [currentPlan]); + + useEffect(() => { + if (savedPaymentMethod) { + const getPlan = async () => { + const userPlan = await userBillingSettings(); + setLoadingUserSettings(false); + setCurrentPlan( + planList.find(plan => { + return plan.id === userPlan?.subscription?.storage?.price ?? null; + }) || null + ); + }; + getPlan(); + } + }, [savedPaymentMethod, planList]); + return ( <> -
-

{dashboard.heading}

-
-
-
-

Payment Methods

- {savedPaymentMethod && !editingPaymentMethod ? ( - <> - - - - ) : ( -
- - - {({ stripe, elements }) => ( - - )} - - -
- )} + <> +
+
+

Payment

+
+
+ {!currentPlan && !loadingUserSettings && ( +
+

+ You don't have a payment method. Please add one to prevent storage issues beyond your plan limits + below. +

+
+ )} + + {loadingUserSettings ? ( + + ) : ( + + )} + +
+
+

Payment Methods

+ {savedPaymentMethod && !editingPaymentMethod ? ( + <> + + + ) : ( +
+ + + {({ stripe, elements }) => ( + + )} + + +
+ )} +
+ +
+

Enterprise user?

+ +
- setIsPaymentPlanModalOpen(false)} /> -
+ setIsPaymentPlanModalOpen(false)} + planList={planList} + planSelection={planSelection} + setCurrentPlan={setCurrentPlan} + savedPaymentMethod={savedPaymentMethod} + stripePromise={stripePromise} + setHasPaymentMethods={setHasPaymentMethods} + setEditingPaymentMethod={setEditingPaymentMethod} + /> + ); }; @@ -125,16 +173,19 @@ const PaymentSettingsPage = props => { * @returns {{ props: import('components/types').PageAccountProps}} */ export function getStaticProps() { - const stripePublishableKey = process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY; + const STRIPE_PUBLISHABLE_KEY_ENVVAR_NAME = 'NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY'; + const stripePublishableKey = process.env[STRIPE_PUBLISHABLE_KEY_ENVVAR_NAME]; if (!stripePublishableKey) { - console.warn(`account payment page missing required process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY`); + throw new Error( + `account payment page requires truthy stripePublishableKey, but got ${stripePublishableKey}. Did you set env.${STRIPE_PUBLISHABLE_KEY_ENVVAR_NAME}?` + ); } return { props: { - title: AccountPageData.seo.title, + title: 'Payment', isRestricted: true, redirectTo: '/login/', - stripePublishableKey: stripePublishableKey ?? '', + stripePublishableKey, }, }; } diff --git a/packages/website/styles/global.scss b/packages/website/styles/global.scss index df45a10132..5ce7e2b5a5 100644 --- a/packages/website/styles/global.scss +++ b/packages/website/styles/global.scss @@ -43,6 +43,7 @@ @import '../components/callout/callout.scss'; @import '../components/w3link_form/w3link_form.scss'; @import '../components/w3link_gateway/w3link_gateway.scss'; +@import '../components/customStorageForm/customStorageForm.scss'; @import '../components/search/search.scss'; @import '../components/account/accountBlockedModal/accountBlockedModal.scss'; @import '../components/accountPlansModal/accountPlansModal.scss'; @@ -253,6 +254,25 @@ h5, font-size: 1.875rem; } +.app-back-link { + font-size: 14px; + position: absolute; + left: 2rem; + top: 0; + color: rgba($color: #fff, $alpha: 0.5); + display: flex; + align-items: center; + gap: 5px; + &:before { + content: '←'; + margin-top: -2px; + } + + @media screen and (max-width: 960px) { + position: relative; + } +} + // Components .video-wrapper { position: relative; diff --git a/packages/website/styles/variables.scss b/packages/website/styles/variables.scss index 49c0616168..1f7e7b6472 100644 --- a/packages/website/styles/variables.scss +++ b/packages/website/styles/variables.scss @@ -49,6 +49,7 @@ $martinique: #392d52; // bluish purple $blue: #3d04fb; // bluest blue $woodsmoke: #161b24; // blue almost black $docsPrimaryPurple: #3c3cd0; +$warning: #e7be77; // Breakpoints $breakpoint_Tiny: 25.9375rem; // 415px diff --git a/packages/website/tests/accountPayment.e2e.spec.ts b/packages/website/tests/accountPayment.e2e.spec.ts index 3a0260abc8..3ff78a5325 100644 --- a/packages/website/tests/accountPayment.e2e.spec.ts +++ b/packages/website/tests/accountPayment.e2e.spec.ts @@ -47,12 +47,12 @@ test.describe('/account/payment', () => { path: await E2EScreenshotPath(testInfo, `accountPayment`), }); }); - test('can enter credit card deatils', async ({ page }) => { + test('can enter credit card details', async ({ page }) => { await LoginTester().login(page, { email: MAGIC_SUCCESS_EMAIL }); - await page.goto(AccountPaymentTester().url) - await AccountPaymentTester().fillCreditCardDetails(page) - await AccountPaymentTester().clickAddCardButton(page) - }) + await page.goto(AccountPaymentTester().url); + await AccountPaymentTester().fillCreditCardDetails(page); + await AccountPaymentTester().clickAddCardButton(page); + }); }); function LoginTester() { @@ -93,7 +93,7 @@ function AccountPaymentTester() { await stripeFrame.locator('[placeholder="CVC"]').fill('242'); }, async clickAddCardButton(page: Page) { - await page.locator('button:has-text("Add Card")').click(); - } - } + await page.locator('text=Payment MethodsAdd Card >> button').click(); + }, + }; }