diff --git a/apps/api/src/app/controllers/billing.controller.ts b/apps/api/src/app/controllers/billing.controller.ts index 54bbfbf7..123606de 100644 --- a/apps/api/src/app/controllers/billing.controller.ts +++ b/apps/api/src/app/controllers/billing.controller.ts @@ -1,7 +1,6 @@ import { ENV } from '@jetstream/api-config'; import { Request, Response } from 'express'; import { z } from 'zod'; -import * as subscriptionDbService from '../db/subscription.db'; import * as userDbService from '../db/user.db'; import * as stripeService from '../services/stripe.service'; import { redirect, sendJson } from '../utils/response.handlers'; @@ -42,6 +41,12 @@ export const routeDefinition = { hasSourceOrg: false, }, }, + createBillingPortalSession: { + controllerFn: () => createBillingPortalSession, + validators: { + hasSourceOrg: false, + }, + }, }; const stripeWebhookHandler = async (req: Request, res: Response) => { @@ -60,12 +65,12 @@ const createCheckoutSessionHandler = createRoute( async ({ user: sessionUser, body }, req, res) => { const { priceId } = body; - const user = await userDbService.findById(sessionUser.id); + const user = await userDbService.findByIdWithSubscriptions(sessionUser.id); const sessions = await stripeService.createCheckoutSession({ mode: 'subscription', priceId, - customerId: user.subscriptions?.[0]?.customerId, + customerId: user.billingAccount?.customerId, user, }); @@ -82,22 +87,42 @@ const processCheckoutSuccessHandler = createRoute( return redirect(res, `${ENV.JETSTREAM_CLIENT_URL}/settings/billing`); } - const user = await userDbService.findById(sessionUser.id); + const user = await userDbService.findByIdWithSubscriptions(sessionUser.id); if (!user.subscriptions?.[0]?.customerId) { return redirect(res, `${ENV.JETSTREAM_CLIENT_URL}/settings/billing`); } - await stripeService.saveSubscriptionFromCompletedSession({ - sessionId, - userId: user.id, - }); + await stripeService.saveSubscriptionFromCompletedSession({ sessionId }); redirect(res, `${ENV.JETSTREAM_CLIENT_URL}/settings/billing`); } ); const getSubscriptionsHandler = createRoute(routeDefinition.getSubscriptions.validators, async ({ user }, req, res) => { - const subscriptions = await subscriptionDbService.findByUserId(user.id); + const userProfile = await userDbService.findById(user.id); + // No billing account, so there are no subscriptions + if (!userProfile.billingAccount?.customerId) { + sendJson(res, { customer: null }); + return; + } - sendJson(res, subscriptions); + const customer = await stripeService.getUserFacingStripeCustomer({ customerId: userProfile.billingAccount.customerId }); + sendJson(res, { customer }); }); + +const createBillingPortalSession = createRoute( + routeDefinition.createBillingPortalSession.validators, + async ({ user: sessionUser }, req, res) => { + const user = await userDbService.findByIdWithSubscriptions(sessionUser.id); + + if (!user.billingAccount) { + throw new Error('User does not have a billing account'); + } + + const sessions = await stripeService.createBillingPortalSession({ + customerId: user.billingAccount?.customerId, + }); + + redirect(res, sessions.url); + } +); diff --git a/apps/api/src/app/db/subscription.db.ts b/apps/api/src/app/db/subscription.db.ts index e795b698..794582ae 100644 --- a/apps/api/src/app/db/subscription.db.ts +++ b/apps/api/src/app/db/subscription.db.ts @@ -1,13 +1,15 @@ /* eslint-disable @typescript-eslint/no-non-null-assertion */ import { prisma } from '@jetstream/api-config'; +import { EntitlementsAccess, EntitlementsAccessSchema } from '@jetstream/types'; import { Prisma } from '@prisma/client'; +import Stripe from 'stripe'; const SELECT = Prisma.validator()({ id: true, customerId: true, - providerId: true, + subscriptionId: true, status: true, - planId: true, + priceId: true, userId: true, createdAt: true, updatedAt: true, @@ -21,63 +23,75 @@ export const findById = async ({ id, userId }: { id: string; userId: string }) = return await prisma.subscription.findUniqueOrThrow({ where: { id, userId }, select: SELECT }); }; -export const upsertSubscription = async ({ - userId, +export const findSubscriptionsByCustomerId = async ({ customerId, - providerId, - planId, + status, }: { - userId: string; customerId: string; - providerId: string; - planId: string; + status?: 'ACTIVE' | 'CANCELLED' | 'PAST_DUE' | 'PAUSED'; }) => { - return await prisma.subscription.upsert({ - create: { userId, status: 'ACTIVE', customerId, providerId, planId }, - update: { userId, status: 'ACTIVE', customerId, providerId, planId }, - where: { uniqueSubscription: { userId, providerId, planId } }, - select: SELECT, + return await prisma.subscription.findMany({ where: { customerId, status }, select: SELECT }); +}; + +export const updateUserEntitlements = async (customerId: string, entitlementAccessUntrusted: EntitlementsAccess) => { + const entitlementAccess = EntitlementsAccessSchema.parse(entitlementAccessUntrusted); + const user = await prisma.user.findFirstOrThrow({ + where: { billingAccount: { customerId } }, + select: { id: true }, + }); + + await prisma.entitlement.upsert({ + create: { + userId: user.id, + ...entitlementAccess, + }, + update: entitlementAccess, + where: { userId: user.id }, }); }; -// export const create = async ( -// userId: string, -// payload: { -// name: string; -// description?: Maybe; -// } -// ) => { -// return await prisma.subscription.create({ -// select: SELECT, -// data: { -// userId, -// name: payload.name.trim(), -// description: payload.description?.trim(), -// }, -// }); -// }; +/** + * Given a customer's current subscriptions, cancel all other subscriptions, create any needed subscriptions, and update the subscription state + * In addition, entitlements are also updated to reflect the user's current subscription state + */ +export const updateSubscriptionStateForCustomer = async ({ + userId, + customerId, + subscriptions, +}: { + userId: string; + customerId: string; + subscriptions: Stripe.Subscription[]; +}) => { + const priceIds = subscriptions.flatMap((subscription) => subscription.items.data.map((item) => item.price.id)); -// export const update = async ( -// userId, -// id, -// payload: { -// name: string; -// description?: Maybe; -// } -// ) => { -// return await prisma.subscription.update({ -// select: SELECT, -// where: { userId, id }, -// data: { -// name: payload.name.trim(), -// description: payload.description?.trim() ?? null, -// }, -// }); -// }; + await prisma.$transaction([ + // Delete all subscriptions that are no longer active in Stripe + prisma.subscription.deleteMany({ + where: { userId, customerId, priceId: { notIn: priceIds } }, + }), + // Create/Update all current subscriptions from Stripe + ...subscriptions.flatMap((subscription) => + subscription.items.data.map((item) => + prisma.subscription.upsert({ + create: { + userId, + subscriptionId: subscription.id, + status: subscription.status.toUpperCase(), + customerId, + priceId: item.price.id, + }, + update: { status: subscription.status.toUpperCase() }, + where: { uniqueSubscription: { userId, subscriptionId: subscription.id, priceId: item.price.id } }, + }) + ) + ), + ]); +}; -// export const deleteOrganization = async (userId, id) => { -// return await prisma.subscription.delete({ -// select: SELECT, -// where: { userId, id }, -// }); -// }; +export const cancelAllSubscriptionsForUser = async ({ customerId }: { customerId: string }) => { + await prisma.subscription.updateMany({ + where: { customerId }, + data: { status: 'CANCELED' }, + }); +}; diff --git a/apps/api/src/app/db/user.db.ts b/apps/api/src/app/db/user.db.ts index 25e8c2a6..52546869 100644 --- a/apps/api/src/app/db/user.db.ts +++ b/apps/api/src/app/db/user.db.ts @@ -21,15 +21,9 @@ const userSelect: Prisma.UserSelect = { chromeExtension: true, }, }, - subscriptions: { - where: { status: 'ACTIVE' }, - take: 1, + billingAccount: { select: { - id: true, - providerId: true, customerId: true, - planId: true, - status: true, }, }, updatedAt: true, @@ -71,15 +65,9 @@ const FullUserFacingProfileSelect = Prisma.validator()({ emailVerified: true, picture: true, preferences: true, + billingAccount: { + select: { + customerId: true, + }, + }, + entitlements: { + select: { + chromeExtension: true, + recordSync: true, + }, + }, subscriptions: { - where: { status: 'ACTIVE' }, - take: 1, select: { id: true, - providerId: true, - customerId: true, - planId: true, + productId: true, + subscriptionId: true, + priceId: true, status: true, }, }, @@ -118,8 +115,28 @@ export const findById = (id: string) => { return prisma.user.findFirstOrThrow({ where: { id }, select: UserFacingProfileSelect }); }; +export const findByIdWithSubscriptions = (id: string) => { + return prisma.user.findFirstOrThrow({ + where: { id }, + select: { + ...userSelect, + subscriptions: { + where: { status: 'ACTIVE' }, + select: { + id: true, + customerId: true, + productId: true, + subscriptionId: true, + priceId: true, + status: true, + }, + }, + }, + }); +}; + export const findIdByUserIdUserFacing = ({ userId }: { userId: string }) => { - return prisma.user.findFirstOrThrow({ where: { id: userId }, select: UserFacingProfileSelect }).then(({ id }) => id); + return prisma.user.findFirstOrThrow({ where: { id: userId }, select: UserFacingProfileSelect }); }; export const checkUserEntitlement = ({ @@ -129,7 +146,7 @@ export const checkUserEntitlement = ({ userId: string; entitlement: keyof Omit; }): Promise => { - return prisma.entitlement.count({ where: { userId, [entitlement]: true } }).then((result) => result > 0); + return prisma.entitlement.count({ where: { id: userId, [entitlement]: true } }).then((result) => result > 0); }; export async function updateUser( @@ -178,3 +195,18 @@ export async function deleteUserAndAllRelatedData(userId: string): Promise }, }); } + +export async function findByBillingAccountByCustomerId({ customerId }: { customerId: string }) { + const billingAccount = await prisma.billingAccount.findFirst({ where: { customerId } }); + return billingAccount; +} + +export async function createBillingAccountIfNotExists({ userId, customerId }: { userId: string; customerId: string }) { + const existingCustomer = await prisma.billingAccount.findUnique({ where: { uniqueCustomer: { customerId, userId } } }); + if (existingCustomer) { + return existingCustomer; + } + return await prisma.billingAccount.create({ + data: { customerId, userId }, + }); +} diff --git a/apps/api/src/app/routes/api.routes.ts b/apps/api/src/app/routes/api.routes.ts index a25e522c..de28c565 100644 --- a/apps/api/src/app/routes/api.routes.ts +++ b/apps/api/src/app/routes/api.routes.ts @@ -64,6 +64,7 @@ routes.delete('/me/profile/2fa/:type', userController.deleteAuthFactor.controlle routes.post('/billing/checkout-session', billingController.createCheckoutSession.controllerFn()); routes.get('/billing/checkout-session/complete', billingController.processCheckoutSuccess.controllerFn()); routes.get('/billing/subscriptions', billingController.getSubscriptions.controllerFn()); +routes.post('/billing/portal', billingController.createBillingPortalSession.controllerFn()); /** * ************************************ diff --git a/apps/api/src/app/services/stripe.service.ts b/apps/api/src/app/services/stripe.service.ts index 2cd36533..4fb8abcd 100644 --- a/apps/api/src/app/services/stripe.service.ts +++ b/apps/api/src/app/services/stripe.service.ts @@ -1,9 +1,12 @@ import { ENV, logger } from '@jetstream/api-config'; import { UserProfile } from '@jetstream/auth/types'; import { getErrorMessageAndStackObj } from '@jetstream/shared/utils'; +import { EntitlementsAccess, StripeUserFacingCustomer, StripeUserFacingSubscriptionItem } from '@jetstream/types'; +import { formatISO, fromUnixTime } from 'date-fns'; import { isString } from 'lodash'; import Stripe from 'stripe'; import * as subscriptionDbService from '../db/subscription.db'; +import * as userDbService from '../db/user.db'; const stripe = new Stripe(ENV.STRIPE_API_KEY || ''); @@ -11,18 +14,32 @@ export async function handleStripeWebhook({ signature, payload }: { signature?: if (!ENV.STRIPE_WEBHOOK_SECRET) { throw new Error('Stripe Webhook secret not set'); } + if (!signature) { throw new Error('Missing webhook signature'); } + try { const event = stripe.webhooks.constructEvent(payload, signature, ENV.STRIPE_WEBHOOK_SECRET); + logger.info({ eventId: event.id, eventType: event.type }, '[STRIPE]: Handling event %s', event.type); + switch (event.type) { - case 'checkout.session.completed': - case 'checkout.session.async_payment_succeeded': - // event.data - // TODO: create subscription for user + case 'entitlements.active_entitlement_summary.updated': + { + await updateEntitlements(event.data.object); + } + break; + case 'customer.subscription.created': + case 'customer.subscription.deleted': + case 'customer.subscription.paused': + case 'customer.subscription.resumed': + case 'customer.subscription.updated': { + const { customer: customerOrId } = event.data.object; + const customer = await fetchCustomerWithSubscriptionsById({ customerId: isString(customerOrId) ? customerOrId : customerOrId.id }); + await saveOrUpdateSubscription({ customer }); break; + } default: logger.info('Unhandled Stripe webhook event: %s', event.type); break; @@ -43,11 +60,68 @@ export async function fetchCustomerWithSubscriptionsByJetstreamId({ userId }: { const customerWithSubscriptions = await stripe.customers.search({ query: `metadata["userId"]:"${userId}"`, limit: 1, - expand: ['subscriptions'], + expand: ['subscriptions', 'entitlements'], }); return customerWithSubscriptions.data[0]; } +export async function getUserFacingStripeCustomer({ customerId }: { customerId: string }): Promise { + try { + const stripeCustomer = await fetchCustomerWithSubscriptionsById({ customerId }); + if (stripeCustomer.deleted) { + return null; + } + const subscriptionInfo: StripeUserFacingCustomer = { + id: stripeCustomer.id, + balance: stripeCustomer.balance / 100, + delinquent: !!stripeCustomer.delinquent, + subscriptions: + stripeCustomer.subscriptions?.data.map( + ({ + id, + billing_cycle_anchor, + cancel_at, + cancel_at_period_end, + canceled_at, + current_period_end, + current_period_start, + ended_at, + items, + start_date, + status, + }) => ({ + id: id, + // TODO: validate that the dates are correct (should be if server is on UTC I think?) + billingCycleAnchor: formatISO(fromUnixTime(billing_cycle_anchor)), + cancelAt: cancel_at ? formatISO(fromUnixTime(cancel_at)) : null, + cancelAtPeriodEnd: cancel_at_period_end, + canceledAt: canceled_at ? formatISO(fromUnixTime(canceled_at)) : null, + currentPeriodEnd: formatISO(fromUnixTime(current_period_end)), + currentPeriodStart: formatISO(fromUnixTime(current_period_start)), + endedAt: ended_at ? formatISO(fromUnixTime(ended_at)) : null, + startDate: formatISO(fromUnixTime(start_date)), + status: status.toUpperCase() as Uppercase, + items: items.data.map(({ id, price, quantity }) => ({ + id, + priceId: price.id, + active: price.active, + product: price.product as string, + lookupKey: price.lookup_key, + unitAmount: (price.unit_amount || 0) / 100, + recurringInterval: (price.recurring?.interval?.toUpperCase() || + null) as StripeUserFacingSubscriptionItem['recurringInterval'], + recurringIntervalCount: price.recurring?.interval_count || null, + quantity: quantity ?? 1, + })), + }) + ) || [], + }; + return subscriptionInfo; + } catch (ex) { + return null; + } +} + export async function createCustomer(user: UserProfile) { return await stripe.customers.create({ email: user.email, @@ -56,22 +130,80 @@ export async function createCustomer(user: UserProfile) { }); } -export async function saveSubscriptionFromCompletedSession({ sessionId, userId }: { userId: string; sessionId: string }) { +export async function updateEntitlements(eventData: Stripe.Entitlements.ActiveEntitlementSummary) { + const { customer, entitlements } = eventData; + const entitlementAccess = entitlements.data.reduce( + (entitlementAccess: EntitlementsAccess, { lookup_key }) => { + if (lookup_key in entitlementAccess) { + entitlementAccess[lookup_key] = true; + } + return entitlementAccess; + }, + { + googleDrive: false, + chromeExtension: false, + recordSync: false, + } + ); + + await subscriptionDbService.updateUserEntitlements(customer, entitlementAccess); +} + +export async function saveSubscriptionFromCompletedSession({ sessionId }: { sessionId: string }) { const session = await stripe.checkout.sessions.retrieve(sessionId); - const sessionItems = await stripe.checkout.sessions.listLineItems(sessionId); - // since we only allow one subscription, just get first item - const priceId = sessionItems.data[0]?.price?.id; + if (!session.customer) { + throw new Error('Invalid checkout session - a customer is required to be associated with the session'); + } + + const customerOrId = session.customer; + const customer = await fetchCustomerWithSubscriptionsById({ customerId: isString(customerOrId) ? customerOrId : customerOrId.id }); + await saveOrUpdateSubscription({ customer }); +} - if (session.status !== 'complete' || !session.customer || !priceId) { - throw new Error('Invalid checkout session'); +/** + * Synchronize subscription state from Stripe to Jetstream + */ +export async function saveOrUpdateSubscription({ customer }: { customer: Stripe.Customer | Stripe.DeletedCustomer }) { + if (customer.deleted) { + logger.info({ customerId: customer.id }, '[Stripe] Customer deleted: %s', customer.id); + await subscriptionDbService.cancelAllSubscriptionsForUser({ customerId: customer.id }); + return; } - const customerId = isString(session.customer) ? session.customer : session.customer.id; + let { userId } = customer.metadata; + const subscriptions = customer.subscriptions?.data ?? []; + + // customer does not have Jetstream id attached - update Stripe to ensure data integrity (if possible) + if (!userId) { + const billingAccount = await userDbService.findByBillingAccountByCustomerId({ customerId: customer.id }); + if (!billingAccount) { + logger.error( + { + customerId: customer.id, + remedy: 'Manually create a billing account in Jetstream DB for this customer, then retry event or update subscription to re-sync', + }, + 'Billing Account does not exist, unable to save subscriptions' + ); + return; + } + userId = billingAccount.userId; + await stripe.customers.update(customer.id, { metadata: { userId } }); + } else { + // For new subscriptions, create a billing account if it does not exist + await userDbService.createBillingAccountIfNotExists({ userId: userId, customerId: customer.id }); + } - return await subscriptionDbService.upsertSubscription({ customerId, userId, providerId: priceId, planId: priceId }); + await subscriptionDbService.updateSubscriptionStateForCustomer({ + userId, + customerId: customer.id, + subscriptions, + }); } +/** + * CREATE BILLING PORTAL SESSION + */ export async function createCheckoutSession({ customerId, mode = 'subscription', @@ -88,6 +220,8 @@ export async function createCheckoutSession({ customerId = customer.id; } + await userDbService.createBillingAccountIfNotExists({ userId: user.id, customerId }); + const session = await stripe.checkout.sessions.create({ line_items: [ { @@ -97,14 +231,32 @@ export async function createCheckoutSession({ ], mode, success_url: `${ENV.JETSTREAM_SERVER_URL}/api/billing/checkout-session/complete?subscribeAction=success&sessionId={CHECKOUT_SESSION_ID}`, - cancel_url: `${ENV.JETSTREAM_CLIENT_URL}/app/settings/billing?subscribeAction=canceled`, + cancel_url: `${ENV.JETSTREAM_CLIENT_URL}/settings/billing?subscribeAction=canceled`, automatic_tax: { enabled: false }, client_reference_id: user.id, currency: 'usd', customer: customerId, customer_email: customerId ? undefined : user.email, + billing_address_collection: 'auto', + customer_update: { + address: 'auto', + }, + payment_method_data: { + allow_redisplay: 'always', + }, metadata: { userId: user.id }, }); return session; } + +/** + * CREATE BILLING PORTAL SESSION + */ +export async function createBillingPortalSession({ customerId }: { customerId: string }) { + const session = await stripe.billingPortal.sessions.create({ + customer: customerId, + return_url: `${ENV.JETSTREAM_CLIENT_URL}/settings/billing`, + }); + return session; +} diff --git a/apps/jetstream-web-extension/src/environments/environment.prod.ts b/apps/jetstream-web-extension/src/environments/environment.prod.ts index 109d4f1e..665f0939 100644 --- a/apps/jetstream-web-extension/src/environments/environment.prod.ts +++ b/apps/jetstream-web-extension/src/environments/environment.prod.ts @@ -3,4 +3,5 @@ export const environment = { serverUrl: 'https://getjetstream.app', rollbarClientAccessToken: process.env.NX_PUBLIC_ROLLBAR_KEY, amplitudeToken: process.env.NX_PUBLIC_AMPLITUDE_KEY, + isWebExtension: true, }; diff --git a/apps/jetstream-web-extension/src/environments/environment.ts b/apps/jetstream-web-extension/src/environments/environment.ts index 3e67e0ce..f8940818 100644 --- a/apps/jetstream-web-extension/src/environments/environment.ts +++ b/apps/jetstream-web-extension/src/environments/environment.ts @@ -6,4 +6,5 @@ export const environment = { serverUrl: 'http://localhost:3333', rollbarClientAccessToken: process.env.NX_PUBLIC_ROLLBAR_KEY, amplitudeToken: process.env.NX_PUBLIC_AMPLITUDE_KEY, + isWebExtension: true, }; diff --git a/apps/jetstream/src/environments/environment.prod.ts b/apps/jetstream/src/environments/environment.prod.ts index f85c3556..79723bec 100644 --- a/apps/jetstream/src/environments/environment.prod.ts +++ b/apps/jetstream/src/environments/environment.prod.ts @@ -4,12 +4,8 @@ export const environment = { rollbarClientAccessToken: import.meta.env.NX_PUBLIC_ROLLBAR_KEY, amplitudeToken: import.meta.env.NX_PUBLIC_AMPLITUDE_KEY, STRIPE_PUBLIC_KEY: import.meta.env.NX_PUBLIC_STRIPE_PUBLIC_KEY, - STRIPE_BILLING_PORTAL_URL: import.meta.env.NX_PUBLIC_STRIPE_BILLING_PORTAL, - STRIPE_ANNUAL_PRICE_ID: import.meta.env.NX_PUBLIC_STRIPE_ANNUAL_PRICE_ID, - STRIPE_MONTHLY_PRICE_ID: import.meta.env.NX_PUBLIC_STRIPE_MONTHLY_PRICE_ID, - MODE: import.meta.env.MODE, - BASE_URL: import.meta.env.BASE_URL, - PROD: import.meta.env.PROD, - DEV: import.meta.env.DEV, - SSR: import.meta.env.SSR, + BILLING_ENABLED: import.meta.env.NX_PUBLIC_BILLING_ENABLED === 'true', + STRIPE_PRO_ANNUAL_PRICE_ID: import.meta.env.NX_PUBLIC_STRIPE_PRO_ANNUAL_PRICE_ID, + STRIPE_PRO_MONTHLY_PRICE_ID: import.meta.env.NX_PUBLIC_STRIPE_PRO_MONTHLY_PRICE_ID, + isWebExtension: false, }; diff --git a/apps/jetstream/src/environments/environment.test.ts b/apps/jetstream/src/environments/environment.test.ts deleted file mode 100644 index 4ed5f6ee..00000000 --- a/apps/jetstream/src/environments/environment.test.ts +++ /dev/null @@ -1,11 +0,0 @@ -export const environment = { - name: 'JetstreamTest', - production: true, - rollbarClientAccessToken: process.env.NX_PUBLIC_ROLLBAR_KEY, - amplitudeToken: process.env.NX_PUBLIC_AMPLITUDE_KEY, - // MODE: import.meta.env.MODE, - // BASE_URL: import.meta.env.BASE_URL, - // PROD: import.meta.env.PROD, - // DEV: import.meta.env.DEV, - // SSR: import.meta.env.SSR, -}; diff --git a/apps/jetstream/src/environments/environment.ts b/apps/jetstream/src/environments/environment.ts index 8900dd58..53e3d8bd 100644 --- a/apps/jetstream/src/environments/environment.ts +++ b/apps/jetstream/src/environments/environment.ts @@ -7,12 +7,8 @@ export const environment = { rollbarClientAccessToken: import.meta.env.NX_PUBLIC_ROLLBAR_KEY, amplitudeToken: import.meta.env.NX_PUBLIC_AMPLITUDE_KEY, STRIPE_PUBLIC_KEY: import.meta.env.NX_PUBLIC_STRIPE_PUBLIC_KEY, - STRIPE_BILLING_PORTAL_URL: import.meta.env.NX_PUBLIC_STRIPE_BILLING_PORTAL, - STRIPE_ANNUAL_PRICE_ID: import.meta.env.NX_PUBLIC_STRIPE_ANNUAL_PRICE_ID, - STRIPE_MONTHLY_PRICE_ID: import.meta.env.NX_PUBLIC_STRIPE_MONTHLY_PRICE_ID, - MODE: import.meta.env.MODE, - BASE_URL: import.meta.env.BASE_URL, - PROD: import.meta.env.PROD, - DEV: import.meta.env.DEV, - SSR: import.meta.env.SSR, + BILLING_ENABLED: import.meta.env.NX_PUBLIC_BILLING_ENABLED === 'true', + STRIPE_PRO_ANNUAL_PRICE_ID: import.meta.env.NX_PUBLIC_STRIPE_PRO_ANNUAL_PRICE_ID, + STRIPE_PRO_MONTHLY_PRICE_ID: import.meta.env.NX_PUBLIC_STRIPE_PRO_MONTHLY_PRICE_ID, + isWebExtension: false, }; diff --git a/libs/api-config/src/lib/env-config.ts b/libs/api-config/src/lib/env-config.ts index b607329b..dbdaf9fa 100644 --- a/libs/api-config/src/lib/env-config.ts +++ b/libs/api-config/src/lib/env-config.ts @@ -216,8 +216,8 @@ const envSchema = z.object({ */ STRIPE_API_KEY: z.string().optional(), STRIPE_WEBHOOK_SECRET: z.string().optional(), - STRIPE_ANNUAL_PRICE_ID: z.string().optional(), - STRIPE_MONTHLY_PRICE_ID: z.string().optional(), + STRIPE_PRO_ANNUAL_PRICE_ID: z.string().optional(), + STRIPE_PRO_MONTHLY_PRICE_ID: z.string().optional(), }); const parseResults = envSchema.safeParse({ @@ -227,8 +227,8 @@ const parseResults = envSchema.safeParse({ EXAMPLE_USER_PASSWORD: ensureBoolean(process.env.EXAMPLE_USER_OVERRIDE) ? process.env.EXAMPLE_USER_PASSWORD : null, EXAMPLE_USER_FULL_PROFILE: ensureBoolean(process.env.EXAMPLE_USER_OVERRIDE) ? EXAMPLE_USER_FULL_PROFILE : null, SFDC_API_VERSION: process.env.NX_SFDC_API_VERSION || process.env.SFDC_API_VERSION, - STRIPE_ANNUAL_PRICE_ID: process.env.NX_PUBLIC_STRIPE_ANNUAL_PRICE_ID, - STRIPE_MONTHLY_PRICE_ID: process.env.NX_PUBLIC_STRIPE_MONTHLY_PRICE_ID, + STRIPE_PRO_ANNUAL_PRICE_ID: process.env.NX_PUBLIC_STRIPE_PRO_ANNUAL_PRICE_ID, + STRIPE_PRO_MONTHLY_PRICE_ID: process.env.NX_PUBLIC_STRIPE_PRO_MONTHLY_PRICE_ID, VERSION, }); diff --git a/libs/auth/server/src/lib/auth.db.service.ts b/libs/auth/server/src/lib/auth.db.service.ts index 336c2dcf..d7e2643b 100644 --- a/libs/auth/server/src/lib/auth.db.service.ts +++ b/libs/auth/server/src/lib/auth.db.service.ts @@ -633,7 +633,7 @@ async function createUserFromProvider(providerUser: ProviderUser, provider: Oaut // picture: providerUser.picture, lastLoggedIn: new Date(), preferences: { create: { skipFrontdoorLogin: false } }, - entitlements: { create: { chromeExtension: false, recordSync: false } }, + entitlements: { create: { chromeExtension: false, recordSync: false, googleDrive: false } }, identities: { create: { type: 'oauth', diff --git a/libs/icon-factory/src/lib/icon-factory.tsx b/libs/icon-factory/src/lib/icon-factory.tsx index aacce606..95b2a53a 100644 --- a/libs/icon-factory/src/lib/icon-factory.tsx +++ b/libs/icon-factory/src/lib/icon-factory.tsx @@ -22,6 +22,7 @@ import StandardIcon_Activations from './icons/standard/Activations'; import StandardIcon_Apex from './icons/standard/Apex'; import StandardIcon_AssetRelationship from './icons/standard/AssetRelationship'; import StandardIcon_BundleConfig from './icons/standard/BundleConfig'; +import StandardIcon_ConnectedApps from './icons/standard/ConnectedApps'; import StandardIcon_DataStreams from './icons/standard/DataStreams'; import StandardIcon_EmployeeOrganization from './icons/standard/EmployeeOrganization'; import StandardIcon_Entity from './icons/standard/Entity'; @@ -185,7 +186,8 @@ const standardIcons = { apex: StandardIcon_Apex, asset_relationship: StandardIcon_AssetRelationship, bundle_config: StandardIcon_BundleConfig, - data_streams: StandardIcon_DataStreams, + data_streams: StandardIcon_ConnectedApps, + connected_apps: StandardIcon_DataStreams, employee_organization: StandardIcon_EmployeeOrganization, entity: StandardIcon_Entity, events: StandardIcon_Events, diff --git a/libs/shared/data/src/lib/client-data.ts b/libs/shared/data/src/lib/client-data.ts index 311fe997..fc1049be 100644 --- a/libs/shared/data/src/lib/client-data.ts +++ b/libs/shared/data/src/lib/client-data.ts @@ -43,7 +43,7 @@ import { SalesforceApiRequest, SalesforceOrgUi, SobjectOperation, - Subscription, + StripeUserFacingCustomer, UserProfileUi, } from '@jetstream/types'; import { parseISO } from 'date-fns/parseISO'; @@ -175,7 +175,7 @@ export async function resendVerificationEmail(identity: { provider: string; user return handleRequest({ method: 'POST', url: '/api/me/profile/identity/verify-email', params: identity }).then(unwrapResponseIgnoreCache); } -export async function getSubscriptions(): Promise { +export async function getSubscriptions(): Promise<{ customer: StripeUserFacingCustomer | null }> { return handleRequest({ method: 'GET', url: '/api/billing/subscriptions' }).then(unwrapResponseIgnoreCache); } diff --git a/libs/shared/ui-app-state/src/lib/ui-app-state.ts b/libs/shared/ui-app-state/src/lib/ui-app-state.ts index 398e3fcc..891f941c 100644 --- a/libs/shared/ui-app-state/src/lib/ui-app-state.ts +++ b/libs/shared/ui-app-state/src/lib/ui-app-state.ts @@ -19,7 +19,7 @@ import localforage from 'localforage'; import isString from 'lodash/isString'; import { atom, DefaultValue, selector, selectorFamily, useRecoilValue, useSetRecoilState } from 'recoil'; -const DEFAULT_PROFILE = { +const DEFAULT_PROFILE: UserProfileUi = { id: 'unknown', userId: 'unknown', email: 'unknown', @@ -29,6 +29,12 @@ const DEFAULT_PROFILE = { preferences: { skipFrontdoorLogin: true, }, + entitlements: { + googleDrive: false, + chromeExtension: false, + recordSync: false, + }, + subscriptions: [], } as UserProfileUi; export const STORAGE_KEYS = { @@ -192,8 +198,9 @@ export const googleDriveAccessState = selector({ key: 'googleDriveAccessState', get: ({ get }) => { const isChromeExtension = get(isChromeExtensionState); - // FIXME: override this until we enable billing - const hasGoogleDriveAccess = get(userProfileEntitlementState('googleDrive')); + // TODO: This is temporary until we get entitlements working + // const hasGoogleDriveAccess = get(userProfileEntitlementState('googleDrive')); + const hasGoogleDriveAccess = true; return { hasGoogleDriveAccess: !isChromeExtension && hasGoogleDriveAccess, diff --git a/libs/types/src/index.ts b/libs/types/src/index.ts index 526cd8a4..d50b9f40 100644 --- a/libs/types/src/index.ts +++ b/libs/types/src/index.ts @@ -1,3 +1,4 @@ +export * from './lib/billing.types'; export * from './lib/jobs/types'; export * from './lib/node/types'; export * from './lib/salesforce/apex.types'; diff --git a/libs/types/src/lib/billing.types.ts b/libs/types/src/lib/billing.types.ts new file mode 100644 index 00000000..81414874 --- /dev/null +++ b/libs/types/src/lib/billing.types.ts @@ -0,0 +1,43 @@ +import type Stripe from 'stripe'; +import { z } from 'zod'; + +export const EntitlementsAccessSchema = z.object({ + googleDrive: z.boolean().optional().default(false), + recordSync: z.boolean().optional().default(false), + chromeExtension: z.boolean().optional().default(false), +}); +export type EntitlementsAccess = z.infer; +export type Entitlements = keyof EntitlementsAccess; + +export interface StripeUserFacingCustomer { + id: string; + balance: number; + delinquent: boolean; + subscriptions: StripeUserFacingSubscription[]; +} + +export interface StripeUserFacingSubscription { + id: string; + billingCycleAnchor: string; + cancelAt: string | null; + cancelAtPeriodEnd: boolean; + canceledAt: string | null; + currentPeriodEnd: string; + currentPeriodStart: string; + endedAt: string | null; + startDate: string; + status: Uppercase; + items: StripeUserFacingSubscriptionItem[]; +} + +export interface StripeUserFacingSubscriptionItem { + id: string; + priceId: string; + active: boolean; + product: string; + lookupKey: string | null; + unitAmount: number; + recurringInterval: 'DAY' | 'MONTH' | 'WEEK' | 'YEAR' | null; + recurringIntervalCount: number | null; + quantity: number; +} diff --git a/libs/types/src/lib/types.ts b/libs/types/src/lib/types.ts index 050bb77a..917ccd6e 100644 --- a/libs/types/src/lib/types.ts +++ b/libs/types/src/lib/types.ts @@ -3,17 +3,6 @@ import { SalesforceOrgEdition } from './salesforce/misc.types'; import { QueryResult } from './salesforce/query.types'; import { InsertUpdateUpsertDeleteQuery } from './salesforce/record.types'; -export interface Subscription { - type: string; - status: string; - id: string; - userId: string; - customerId: string; - providerId: string; - createdAt: Date; - updatedAt: Date; -} - export interface Announcement { id: string; title: string; @@ -131,6 +120,21 @@ export interface UserProfileUi { preferences: { skipFrontdoorLogin: boolean; }; + billingAccount?: { + customerId: string; + }; + entitlements: { + googleDrive: boolean; + chromeExtension: boolean; + recordSync: boolean; + }; + subscriptions: { + id: string; + productId: string; + subscriptionId: string; + priceId: string; + status: true; + }[]; } export interface SalesforceUserInfo { diff --git a/package.json b/package.json index 36f7e0c1..b755b815 100644 --- a/package.json +++ b/package.json @@ -213,7 +213,7 @@ "postcss": "^8.4.48", "postcss-preset-env": "7", "prettier": "2.7.0", - "prisma": "^5.22.0", + "prisma": "^6.2.1", "react-email": "3.0.1", "react-refresh": "~0.14.0", "release-it": "^17.10.0", @@ -263,7 +263,7 @@ "@oslojs/otp": "^1.0.0", "@panva/hkdf": "^1.2.1", "@popperjs/core": "^2.11.8", - "@prisma/client": "^5.22.0", + "@prisma/client": "^6.2.1", "@react-aria/dialog": "^3.5.12", "@react-aria/focus": "^3.16.2", "@react-aria/overlays": "^3.21.1", diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 32a2cf4b..55bdcccd 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -27,7 +27,7 @@ model User { identities AuthIdentity[] authFactors AuthFactors[] loginActivity LoginActivity[] - rememberdDevices RememberedDevice[] + rememberedDevices RememberedDevice[] passwordResetTokens PasswordResetToken[] subscriptions Subscription[] lastLoggedIn DateTime? @@ -35,6 +35,7 @@ model User { updatedAt DateTime @updatedAt webExtensionTokens WebExtensionToken[] entitlements Entitlement? + billingAccount BillingAccount? } model AuthFactors { @@ -65,8 +66,9 @@ model PasswordResetToken { model Entitlement { id String @id @default(dbgenerated("uuid_generate_v4()")) @db.Uuid userId String @unique @db.Uuid - recordSync Boolean @default(false) chromeExtension Boolean @default(false) + googleDrive Boolean @default(false) + recordSync Boolean @default(false) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@ -201,19 +203,34 @@ model SalesforceApi { @@map("salesforce_api") } -model Subscription { - id String @id @default(dbgenerated("uuid_generate_v4()")) @db.Uuid - userId String @db.Uuid - customerId String @map("customer_id") - providerId String @map("provider_id") - planId String - status String - createdAt DateTime @default(now()) +model BillingAccount { + userId String @unique @db.Uuid + customerId String @unique + createdAt DateTime @default(now()) @db.Timestamp(6) updatedAt DateTime @updatedAt - user User @relation(fields: [userId], references: [id], onDelete: Cascade) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + subscriptions Subscription[] + + @@unique([userId, customerId], name: "uniqueCustomer", map: "unique_customer") + @@map("billing_account") +} - @@unique([userId, providerId, planId], name: "uniqueSubscription", map: "uniqueSubscription") +model Subscription { + id String @id @default(dbgenerated("uuid_generate_v4()")) @db.Uuid + userId String @db.Uuid + customerId String + productId String? @db.Uuid + subscriptionId String + priceId String + status String + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + billingAccount BillingAccount @relation(fields: [customerId], references: [customerId], onDelete: Cascade) + + @@unique([userId, subscriptionId, priceId], name: "uniqueSubscription", map: "unique_subscription") @@map("subscription") } diff --git a/yarn.lock b/yarn.lock index 6b538c82..12a2062f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7370,46 +7370,46 @@ resolved "https://registry.npmjs.org/@popperjs/core/-/core-2.11.5.tgz" integrity sha512-9X2obfABZuDVLCgPK9aX0a/x4jaOEweTTWE2+9sr0Qqqevj2Uv5XorvusThmc9XGYpS9yI+fhh8RTafBtGposw== -"@prisma/client@^5.22.0": - version "5.22.0" - resolved "https://registry.yarnpkg.com/@prisma/client/-/client-5.22.0.tgz#da1ca9c133fbefe89e0da781c75e1c59da5f8802" - integrity sha512-M0SVXfyHnQREBKxCgyo7sffrKttwE6R8PMq330MIUF0pTwjUhLbW84pFDlf06B27XyCR++VtjugEnIHdr07SVA== - -"@prisma/debug@5.22.0": - version "5.22.0" - resolved "https://registry.yarnpkg.com/@prisma/debug/-/debug-5.22.0.tgz#58af56ed7f6f313df9fb1042b6224d3174bbf412" - integrity sha512-AUt44v3YJeggO2ZU5BkXI7M4hu9BF2zzH2iF2V5pyXT/lRTyWiElZ7It+bRH1EshoMRxHgpYg4VB6rCM+mG5jQ== - -"@prisma/engines-version@5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2": - version "5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2" - resolved "https://registry.yarnpkg.com/@prisma/engines-version/-/engines-version-5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2.tgz#d534dd7235c1ba5a23bacd5b92cc0ca3894c28f4" - integrity sha512-2PTmxFR2yHW/eB3uqWtcgRcgAbG1rwG9ZriSvQw+nnb7c4uCr3RAcGMb6/zfE88SKlC1Nj2ziUvc96Z379mHgQ== - -"@prisma/engines@5.22.0": - version "5.22.0" - resolved "https://registry.yarnpkg.com/@prisma/engines/-/engines-5.22.0.tgz#28f3f52a2812c990a8b66eb93a0987816a5b6d84" - integrity sha512-UNjfslWhAt06kVL3CjkuYpHAWSO6L4kDCVPegV6itt7nD1kSJavd3vhgAEhjglLJJKEdJ7oIqDJ+yHk6qO8gPA== - dependencies: - "@prisma/debug" "5.22.0" - "@prisma/engines-version" "5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2" - "@prisma/fetch-engine" "5.22.0" - "@prisma/get-platform" "5.22.0" - -"@prisma/fetch-engine@5.22.0": - version "5.22.0" - resolved "https://registry.yarnpkg.com/@prisma/fetch-engine/-/fetch-engine-5.22.0.tgz#4fb691b483a450c5548aac2f837b267dd50ef52e" - integrity sha512-bkrD/Mc2fSvkQBV5EpoFcZ87AvOgDxbG99488a5cexp5Ccny+UM6MAe/UFkUC0wLYD9+9befNOqGiIJhhq+HbA== - dependencies: - "@prisma/debug" "5.22.0" - "@prisma/engines-version" "5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2" - "@prisma/get-platform" "5.22.0" - -"@prisma/get-platform@5.22.0": - version "5.22.0" - resolved "https://registry.yarnpkg.com/@prisma/get-platform/-/get-platform-5.22.0.tgz#fc675bc9d12614ca2dade0506c9c4a77e7dddacd" - integrity sha512-pHhpQdr1UPFpt+zFfnPazhulaZYCUqeIcPpJViYoq9R+D/yw4fjE+CtnsnKzPYm0ddUbeXUzjGVGIRVgPDCk4Q== - dependencies: - "@prisma/debug" "5.22.0" +"@prisma/client@^6.2.1": + version "6.2.1" + resolved "https://registry.yarnpkg.com/@prisma/client/-/client-6.2.1.tgz#3d7d0c8669bba490247e1ffff67b93a516bd789f" + integrity sha512-msKY2iRLISN8t5X0Tj7hU0UWet1u0KuxSPHWuf3IRkB4J95mCvGpyQBfQ6ufcmvKNOMQSq90O2iUmJEN2e5fiA== + +"@prisma/debug@6.2.1": + version "6.2.1" + resolved "https://registry.yarnpkg.com/@prisma/debug/-/debug-6.2.1.tgz#887719967c4942d125262e48f6c47c45d17c1f61" + integrity sha512-0KItvt39CmQxWkEw6oW+RQMD6RZ43SJWgEUnzxN8VC9ixMysa7MzZCZf22LCK5DSooiLNf8vM3LHZm/I/Ni7bQ== + +"@prisma/engines-version@6.2.0-14.4123509d24aa4dede1e864b46351bf2790323b69": + version "6.2.0-14.4123509d24aa4dede1e864b46351bf2790323b69" + resolved "https://registry.yarnpkg.com/@prisma/engines-version/-/engines-version-6.2.0-14.4123509d24aa4dede1e864b46351bf2790323b69.tgz#b84ce3fab44bfa13a22669da02752330b61745b2" + integrity sha512-7tw1qs/9GWSX6qbZs4He09TOTg1ff3gYsB3ubaVNN0Pp1zLm9NC5C5MZShtkz7TyQjx7blhpknB7HwEhlG+PrQ== + +"@prisma/engines@6.2.1": + version "6.2.1" + resolved "https://registry.yarnpkg.com/@prisma/engines/-/engines-6.2.1.tgz#14ef56bb780f02871a728667161d997a14aedb69" + integrity sha512-lTBNLJBCxVT9iP5I7Mn6GlwqAxTpS5qMERrhebkUhtXpGVkBNd/jHnNJBZQW4kGDCKaQg/r2vlJYkzOHnAb7ZQ== + dependencies: + "@prisma/debug" "6.2.1" + "@prisma/engines-version" "6.2.0-14.4123509d24aa4dede1e864b46351bf2790323b69" + "@prisma/fetch-engine" "6.2.1" + "@prisma/get-platform" "6.2.1" + +"@prisma/fetch-engine@6.2.1": + version "6.2.1" + resolved "https://registry.yarnpkg.com/@prisma/fetch-engine/-/fetch-engine-6.2.1.tgz#cd7eb7428a407105e0f3761dba536aefd41fc7f7" + integrity sha512-OO7O9d6Mrx2F9i+Gu1LW+DGXXyUFkP7OE5aj9iBfA/2jjDXEJjqa9X0ZmM9NZNo8Uo7ql6zKm6yjDcbAcRrw1A== + dependencies: + "@prisma/debug" "6.2.1" + "@prisma/engines-version" "6.2.0-14.4123509d24aa4dede1e864b46351bf2790323b69" + "@prisma/get-platform" "6.2.1" + +"@prisma/get-platform@6.2.1": + version "6.2.1" + resolved "https://registry.yarnpkg.com/@prisma/get-platform/-/get-platform-6.2.1.tgz#34313cd0ee3587798ad33a7b57b6342dc8e66426" + integrity sha512-zp53yvroPl5m5/gXYLz7tGCNG33bhG+JYCm74ohxOq1pPnrL47VQYFfF3RbTZ7TzGWCrR3EtoiYMywUBw7UK6Q== + dependencies: + "@prisma/debug" "6.2.1" "@prisma/prisma-fmt-wasm@3.14.0-36.2b0c12756921c891fec4f68d9444e18c7d5d4a6a": version "3.14.0-36.2b0c12756921c891fec4f68d9444e18c7d5d4a6a" @@ -21921,12 +21921,12 @@ pretty-format@^29.7.0: ansi-styles "^5.0.0" react-is "^18.0.0" -prisma@^5.22.0: - version "5.22.0" - resolved "https://registry.yarnpkg.com/prisma/-/prisma-5.22.0.tgz#1f6717ff487cdef5f5799cc1010459920e2e6197" - integrity sha512-vtpjW3XuYCSnMsNVBjLMNkTj6OZbudcPPTPYHqX0CJfpcdWciI1dM8uHETwmDxxiqEwCIE6WvXucWUetJgfu/A== +prisma@^6.2.1: + version "6.2.1" + resolved "https://registry.yarnpkg.com/prisma/-/prisma-6.2.1.tgz#457b210326d66d0e6f583cc6f9cd2819b984408f" + integrity sha512-hhyM0H13pQleQ+br4CkzGizS5I0oInoeTw3JfLw1BRZduBSQxPILlJLwi+46wZzj9Je7ndyQEMGw/n5cN2fknA== dependencies: - "@prisma/engines" "5.22.0" + "@prisma/engines" "6.2.1" optionalDependencies: fsevents "2.3.3"