From 06b099c85cb1b590374de21089964f0450f3c746 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cosmin=20P=C3=A2rvulescu?= Date: Fri, 30 Jun 2023 17:04:45 +0300 Subject: [PATCH] feat(console): Service Plan - Application entitlement assignment (#2451) * feat(console): Service Plan - Application entitlement assignment * Added outlet context allotance counting * Added sync payment infra * Added different entitlement stages per app * Made a smarter plan button text function * Added purchase confirmation modal * Fixed error condition * Added compare plans icon & href * Added entitlement button icons * Added message toasts * Add assigned app modal * Removed revalidator * Updated JWT check * Added referer check * Extracted purchase and update methods * Fixed button sizing * Removed subtitle for current app * Updated available entitlement copy --- apps/console/app/root.tsx | 28 +- .../app/routes/__layout/gnillib/index.tsx | 362 +++++----- .../app/routes/__layout/gnillib/payment.tsx | 14 +- .../app/routes/__layout/gnillib/plans.ts | 11 +- .../app/routes/__layout/gnillib/webhook.tsx | 48 +- apps/console/app/routes/apps/$clientId.tsx | 19 +- .../app/routes/apps/$clientId/gnillib.tsx | 658 ++++++++++++++++++ .../billing/{stripe.tsx => stripe.ts} | 48 +- apps/console/app/types.ts | 2 + packages/design-system/package.json | 1 + .../src/atoms/pills/StatusPill.tsx | 24 + .../design-system/src/atoms/toast/Toast.tsx | 2 +- packages/types/account.ts | 3 +- packages/types/graph.ts | 1 + .../src/jsonrpc/methods/getEntitlements.ts | 5 +- .../src/jsonrpc/methods/setAppPlan.ts | 60 ++ platform/starbase/src/jsonrpc/router.ts | 9 + .../starbase/src/jsonrpc/validators/app.ts | 2 + platform/starbase/src/nodes/application.ts | 7 +- yarn.lock | 1 + 20 files changed, 1083 insertions(+), 222 deletions(-) create mode 100644 apps/console/app/routes/apps/$clientId/gnillib.tsx rename apps/console/app/services/billing/{stripe.tsx => stripe.ts} (74%) create mode 100644 packages/design-system/src/atoms/pills/StatusPill.tsx create mode 100644 platform/starbase/src/jsonrpc/methods/setAppPlan.ts diff --git a/apps/console/app/root.tsx b/apps/console/app/root.tsx index 1c93e9e0ee..980bf3d746 100644 --- a/apps/console/app/root.tsx +++ b/apps/console/app/root.tsx @@ -46,10 +46,11 @@ import { generateTraceContextHeaders } from '@proofzero/platform-middleware/trac import type { AccountURN } from '@proofzero/urns/account' import { NonceContext } from '@proofzero/design-system/src/atoms/contexts/nonce-context' -import { InternalServerError } from '@proofzero/errors' import useTreeshakeHack from '@proofzero/design-system/src/hooks/useTreeshakeHack' import { getRollupReqFunctionErrorWrapper } from '@proofzero/utils/errors' +import { ServicePlanType } from '@proofzero/types/account' +import { BadRequestError } from '@proofzero/errors' export const links: LinksFunction = () => { return [ @@ -68,27 +69,35 @@ export const meta: MetaFunction = () => ({ viewport: 'width=device-width,initial-scale=1', }) +export type AppLoaderData = { + clientId: string + name?: string + icon?: string + published?: boolean + createdTimestamp?: number + appPlan: ServicePlanType +} + export type LoaderData = { - apps: { - clientId: string - name?: string - icon?: string - published?: boolean - createdTimestamp?: number - }[] + apps: AppLoaderData[] avatarUrl: string PASSPORT_URL: string displayName: string ENV: { INTERNAL_GOOGLE_ANALYTICS_TAG: string REMIX_DEV_SERVER_WS_PORT?: number - WALLET_CONNECT_PROJECT_ID: string, + WALLET_CONNECT_PROJECT_ID: string } } export const loader: LoaderFunction = getRollupReqFunctionErrorWrapper( async ({ request, context }) => { const jwt = await requireJWT(request) + if (!jwt) { + throw new BadRequestError({ + message: 'No JWT found in request.', + }) + } const traceHeader = generateTraceContextHeaders(context.traceSpan) const parsedJwt = parseJwt(jwt) const accountURN = parsedJwt.sub as AccountURN @@ -110,6 +119,7 @@ export const loader: LoaderFunction = getRollupReqFunctionErrorWrapper( icon: a.app?.icon, published: a.published, createdTimestamp: a.createdTimestamp, + appPlan: a.appPlan, } }) diff --git a/apps/console/app/routes/__layout/gnillib/index.tsx b/apps/console/app/routes/__layout/gnillib/index.tsx index fb9b35edda..9ee768d2ed 100644 --- a/apps/console/app/routes/__layout/gnillib/index.tsx +++ b/apps/console/app/routes/__layout/gnillib/index.tsx @@ -20,19 +20,18 @@ import { getFlashSession, requireJWT, } from '~/utilities/session.server' -import createStarbaseClient from '@proofzero/platform-clients/starbase' import createAccountClient from '@proofzero/platform-clients/account' import { getAuthzHeaderConditionallyFromToken, parseJwt, } from '@proofzero/utils' import { - useActionData, + NavLink, useLoaderData, useOutletContext, useSubmit, } from '@remix-run/react' -import type { LoaderData as OutletContextData } from '~/root' +import type { AppLoaderData, LoaderData as OutletContextData } from '~/root' import { Menu, Transition } from '@headlessui/react' import { Listbox } from '@headlessui/react' import { ChevronDownIcon, ChevronUpIcon } from '@heroicons/react/20/solid' @@ -68,18 +67,10 @@ import { updateSubscription, } from '~/services/billing/stripe' -type EntitlementDetails = { - alloted: number - allotedClientIds: string[] -} - type LoaderData = { paymentData?: PaymentData entitlements: { - [ServicePlanType.PRO]: EntitlementDetails - FREE: { - appClientIds: string[] - } + [ServicePlanType.PRO]: number } successToast?: string connectedEmails: DropdownSelectListItem[] @@ -93,13 +84,6 @@ export const loader: LoaderFunction = getRollupReqFunctionErrorWrapper( const traceHeader = generateTraceContextHeaders(context.traceSpan) - const starbaseClient = createStarbaseClient(Starbase, { - ...getAuthzHeaderConditionallyFromToken(jwt), - ...traceHeader, - }) - const apps = await starbaseClient.listApps.query() - const appClientIds = apps.map((a) => a.clientId) - const accountClient = createAccountClient(Account, { ...getAuthzHeaderConditionallyFromToken(jwt), ...traceHeader, @@ -107,20 +91,6 @@ export const loader: LoaderFunction = getRollupReqFunctionErrorWrapper( const { plans } = await accountClient.getEntitlements.query() - const proAllotedEntitlements = - plans?.[ServicePlanType.PRO]?.entitlements ?? 0 - - // Capping this to 2 for demo purposes - const proUsage = Math.min(2, proAllotedEntitlements) - // Setting first two apps to pro for demo purposes - const proAppClientIds = appClientIds.slice(0, proUsage) - - // Rest become free apps for demo purposes... - let freeAppClientIds: any[] = [] - if (appClientIds.length > proUsage) { - freeAppClientIds = appClientIds.slice(proUsage) - } - const flashSession = await getFlashSession(request.headers.get('Cookie')) const successToast = flashSession.get('success_toast') @@ -137,13 +107,8 @@ export const loader: LoaderFunction = getRollupReqFunctionErrorWrapper( { paymentData: spd, entitlements: { - [ServicePlanType.PRO]: { - alloted: proAllotedEntitlements, - allotedClientIds: proAppClientIds, - }, - FREE: { - appClientIds: freeAppClientIds, - }, + [ServicePlanType.PRO]: + plans?.[ServicePlanType.PRO]?.entitlements ?? 0, }, successToast, connectedEmails, @@ -188,15 +153,24 @@ export const action: ActionFunction = getRollupReqFunctionErrorWrapper( planID: STRIPE_PRO_PLAN_ID, quantity: +quantity, accountURN, + handled: true, }) } else { sub = await updateSubscription({ subscriptionID: entitlements.subscriptionID, planID: STRIPE_PRO_PLAN_ID, quantity: +quantity, + handled: true, }) } + await accountClient.updateEntitlements.mutate({ + accountURN: accountURN, + subscriptionID: sub.id, + quantity: +quantity, + type: ServicePlanType.PRO, + }) + const flashSession = await getFlashSession(request.headers.get('Cookie')) if (txType === 'buy') { flashSession.flash('success_toast', 'Entitlement(s) successfully bought') @@ -205,20 +179,15 @@ export const action: ActionFunction = getRollupReqFunctionErrorWrapper( flashSession.flash('success_toast', 'Entitlement(s) successfully removed') } - return json( - { - updatedProEntitlements: quantity, + return new Response(null, { + headers: { + 'Set-Cookie': await commitFlashSession(flashSession), }, - { - headers: { - 'Set-Cookie': await commitFlashSession(flashSession), - }, - } - ) + }) } ) -const PlanFeatures = ({ plan }: { plan: PlanDetails }) => { +export const PlanFeatures = ({ plan }: { plan: PlanDetails }) => { return ( + + + ) +} + const PlanCard = ({ plan, entitlements, + apps, paymentData, submit, }: { plan: PlanDetails - entitlements: EntitlementDetails + entitlements: number + apps: AppLoaderData[] paymentData?: PaymentData submit: (data: any, options: any) => void }) => { const [purchaseProModalOpen, setPurchaseProModalOpen] = useState(false) const [removeEntitlementModalOpen, setRemoveEntitlementModalOpen] = useState(false) + const [assignedAppModalOpen, setAssignedAppModalOpen] = useState(false) return ( <> +
@@ -706,7 +693,7 @@ const PlanCard = ({
- + +
+ + {`${apps.length} out of ${entitlements} Entitlements used`} + +
- ${entitlements.alloted * plans.PRO.price} + ${entitlements * plans.PRO.price} per month
- - {`${entitlements.allotedClientIds.length} out of ${entitlements.alloted} Entitlements used`} - )}
- {entitlements.alloted === 0 && ( + {entitlements === 0 && (
)} - {entitlements.alloted > entitlements.allotedClientIds.length && ( + {entitlements > apps.length && (
- This will be used to create a custumer ID and for notifications + This will be used to create a customer ID and for notifications about your billing @@ -941,7 +932,7 @@ export default () => { Submit
-
+
{ />
-
+
{connectedEmails && connectedEmails.length === 0 && ( - + + +
+
+
+ +
+
+
+
+ ${plan.price} per month +
+
+ + ) +} + +const PurchaseConfirmationModal = ({ + isOpen, + setIsOpen, + plan, + paymentData, +}: { + isOpen: boolean + setIsOpen: (open: boolean) => void + plan: PlanDetails + paymentData?: PaymentData +}) => { + const submit = useSubmit() + + return ( + setIsOpen(false)}> + + Purchase Entitlement(s) + + + {!paymentData?.customerID && ( +
+ +
+ )} + + {paymentData?.customerID && !paymentData.paymentMethodID && ( +
+ +
+ )} + +
+
+ + {plan.title} + + + + {plan.description} + + + +
+ +
+ +
+
+ + Number of Entitlements + + + 1 x ${plan.price}/month + +
+
+ +
+ +
+ + Changes to your subscription + + +
+ {`+$${plan.price}`} + + per month + +
+
+
+ +
+ +
+ + +
+
+ ) +} + +const EntitlementsCardButton = ({ + currentPlan, + entitlement, + paymentData, +}: { + currentPlan: ServicePlanType + entitlement: { + planType: ServicePlanType + totalEntitlements?: number + usedEntitlements?: number + } + paymentData: PaymentData +}) => { + const [showPurchaseModal, setShowPurchaseModal] = useState(false) + + const getOperation = ( + planType: ServicePlanType, + currentPlanType: ServicePlanType + ) => { + const typeImportance = [ServicePlanType.FREE, ServicePlanType.PRO] + if ( + typeImportance.findIndex((ty) => ty === planType) < + typeImportance.findIndex((ty) => ty === currentPlanType) + ) { + return <>Downgrade + } else { + return ( + + Upgrade to {plans[planType].title.split(' ')[0]} + + ) + } + } + + const op = + entitlement.planType === ServicePlanType.FREE || + getAvailableEntitlements(entitlement) > 0 + ? 'update' + : 'purchase' + + const submit = useSubmit() + + return ( + <> + + + + ) +} + +const EntitlementsCard = ({ + currentPlan, + entitlements, + paymentData, +}: { + currentPlan: ServicePlanType + entitlements: { + planType: ServicePlanType + totalEntitlements?: number + usedEntitlements?: number + }[] + paymentData: PaymentData +}) => { + return ( +
+
+
+ + Assigned Entitlements + +
+
+
+
+
+ {entitlements.map((entitlement, i) => ( +
+
+
+
+ + {plans[entitlement.planType].title} + + + {entitlement.planType !== currentPlan && + entitlement.planType !== ServicePlanType.FREE && + `${getAvailableEntitlements( + entitlement + )} Entitlement(s) available`} + +
+ + {currentPlan === entitlement.planType && ( + + )} +
+ + {entitlement.planType !== currentPlan && ( + + )} +
+ {i < entitlements.length - 1 && ( +
+ )} +
+ ))} +
+
+
+ ) +} + +export default () => { + const { + entitlements: { plans: entitlements }, + paymentData, + successToast, + errorToast, + } = useLoaderData<{ + entitlements: GetEntitlementsOutput + paymentData: PaymentData + successToast: string + errorToast: string + }>() + + const { apps, appDetails } = useOutletContext<{ + apps: AppLoaderData[] + appDetails: appDetailsProps + }>() + + useEffect(() => { + if (successToast) { + toast(ToastType.Success, { + message: successToast, + }) + } + }, [successToast]) + + useEffect(() => { + if (errorToast) { + toast(ToastType.Error, { + message: errorToast, + }) + } + }, [errorToast]) + + return ( + <> + + +
+ + a.appPlan === ServicePlanType.PRO + ).length, + }, + ]} + paymentData={paymentData} + /> +
+ + ) +} diff --git a/apps/console/app/services/billing/stripe.tsx b/apps/console/app/services/billing/stripe.ts similarity index 74% rename from apps/console/app/services/billing/stripe.tsx rename to apps/console/app/services/billing/stripe.ts index 9faab5ef39..d576b76d75 100644 --- a/apps/console/app/services/billing/stripe.tsx +++ b/apps/console/app/services/billing/stripe.ts @@ -17,6 +17,7 @@ type UpdateCustomerParams = { type UpdatePaymentMethodParams = { customerID: string + returnURL: string } type CreateSubscriptionParams = { @@ -24,14 +25,21 @@ type CreateSubscriptionParams = { planID: string quantity: number accountURN: AccountURN + handled?: boolean } type UpdateSubscriptionParams = { subscriptionID: string planID: string quantity: number + handled?: boolean } +type SubscriptionMetadata = Partial<{ + accountURN: AccountURN + handled: string | null +}> + export const createCustomer = async ({ email, name, @@ -71,6 +79,7 @@ export const updateCustomer = async ({ export const updatePaymentMethod = async ({ customerID, + returnURL, }: UpdatePaymentMethodParams) => { const stripeClient = new Stripe(STRIPE_API_SECRET, { apiVersion: '2022-11-15', @@ -78,7 +87,7 @@ export const updatePaymentMethod = async ({ const session = await stripeClient.billingPortal.sessions.create({ customer: customerID, - return_url: 'http://localhost:10002/gnillib', + return_url: returnURL, flow_data: { type: 'payment_method_update', }, @@ -92,11 +101,17 @@ export const createSubscription = async ({ planID, quantity, accountURN, + handled = false, }: CreateSubscriptionParams) => { const stripeClient = new Stripe(STRIPE_API_SECRET, { apiVersion: '2022-11-15', }) + const metadata: SubscriptionMetadata = {} + metadata.accountURN = accountURN + + if (handled) metadata.handled = handled.toString() + const subscription = await stripeClient.subscriptions.create({ customer: customerID, items: [ @@ -105,9 +120,7 @@ export const createSubscription = async ({ quantity, }, ], - metadata: { - accountURN, - }, + metadata, }) return subscription @@ -117,11 +130,15 @@ export const updateSubscription = async ({ subscriptionID, planID, quantity, + handled = false, }: UpdateSubscriptionParams) => { const stripeClient = new Stripe(STRIPE_API_SECRET, { apiVersion: '2022-11-15', }) + let metadata: SubscriptionMetadata = {} + if (handled) metadata.handled = handled.toString() + let subscription = await stripeClient.subscriptions.retrieve(subscriptionID) const planItem = subscription.items.data.find((i) => i.price.id === planID) if (!planItem) @@ -137,7 +154,30 @@ export const updateSubscription = async ({ quantity, }, ], + metadata, }) return subscription } + +export const updateSubscriptionMetadata = async ({ + id, + metadata, +}: { + id: string + metadata: SubscriptionMetadata +}) => { + const stripeClient = new Stripe(STRIPE_API_SECRET, { + apiVersion: '2022-11-15', + }) + + const subscription = await stripeClient.subscriptions.retrieve(id) + const updatedSubscription = await stripeClient.subscriptions.update( + subscription.id, + { + metadata, + } + ) + + return updatedSubscription +} diff --git a/apps/console/app/types.ts b/apps/console/app/types.ts index 203538cedd..d04fbeba4a 100644 --- a/apps/console/app/types.ts +++ b/apps/console/app/types.ts @@ -4,6 +4,7 @@ import type { EdgesMetadata, CustomDomain, } from '@proofzero/platform/starbase/src/types' +import { ServicePlanType } from '@proofzero/types/account' export enum RollType { RollAPIKey = 'roll_api_key', @@ -22,6 +23,7 @@ export type appDetailsProps = { secretTimestamp?: number apiKeyTimestamp?: number customDomain?: CustomDomain + appPlan: ServicePlanType } export type errorsAuthProps = { diff --git a/packages/design-system/package.json b/packages/design-system/package.json index d5ea0e06bf..171e564179 100644 --- a/packages/design-system/package.json +++ b/packages/design-system/package.json @@ -8,6 +8,7 @@ "dependencies": { "@headlessui/react": "1.7.5", "@radix-ui/react-popover": "1.0.5", + "classnames": "2.3.2", "cra-template-typescript": "1.2.0", "react": "18.2.0", "react-countdown-circle-timer": "3.2.1", diff --git a/packages/design-system/src/atoms/pills/StatusPill.tsx b/packages/design-system/src/atoms/pills/StatusPill.tsx new file mode 100644 index 0000000000..e5e62d3394 --- /dev/null +++ b/packages/design-system/src/atoms/pills/StatusPill.tsx @@ -0,0 +1,24 @@ +import React from 'react' +import classNames from 'classnames' +import { Pill } from './Pill' +import { Text } from '../text/Text' + +export type StatusPillProps = { + text: string + status: 'success' | 'warning' | 'danger' +} + +export const StatusPill = ({ text, status }: StatusPillProps) => ( + +
+ + {text} + +
+) diff --git a/packages/design-system/src/atoms/toast/Toast.tsx b/packages/design-system/src/atoms/toast/Toast.tsx index 1dc4b8bfab..68fcde9925 100644 --- a/packages/design-system/src/atoms/toast/Toast.tsx +++ b/packages/design-system/src/atoms/toast/Toast.tsx @@ -27,7 +27,7 @@ export const Toast = ({ }`} > {PreMessage &&
{PreMessage}
} - {message}{' '} + {message}{' '} {PostMessage &&
{PostMessage}
} {remove && (