From 57f855adaea5f6f56441b09737ce0b781c24f648 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cosmin=20P=C3=A2rvulescu?= Date: Fri, 25 Aug 2023 21:57:36 +0300 Subject: [PATCH] feat(console): Connected group email accounts & group billing (#2614) --- apps/console/app/components/AppBox/index.tsx | 2 +- .../Applications/ApplicationListItem.tsx | 2 +- apps/console/app/components/Billing/index.tsx | 936 +++++++++ .../EarlyAccess/EarlyAccessPanel.tsx | 4 +- .../app/components/SiteMenu/appSelect.tsx | 2 +- .../console/app/components/SiteMenu/index.tsx | 2 +- apps/console/app/root.tsx | 6 +- .../app/routes/__layout/billing/details.tsx | 43 +- .../app/routes/__layout/billing/index.tsx | 1748 +---------------- .../app/routes/__layout/billing/ops.ts | 326 +++ .../app/routes/__layout/billing/payment.tsx | 33 +- .../app/routes/__layout/billing/personal.tsx | 578 ++++++ .../app/routes/__layout/billing/portal.tsx | 31 +- .../app/routes/__layout/billing/spuorg.tsx | 47 + .../__layout/billing/spuorg/$groupID.tsx | 594 ++++++ .../routes/__layout/billing/spuorg/index.tsx | 79 + .../app/routes/__layout/billing/update.tsx | 33 +- .../app/routes/__layout/billing/webhook.tsx | 40 +- .../routes/__layout/spuorg/$groupID/index.tsx | 1 + .../app/routes/apps/$clientId/billing.tsx | 46 +- .../app/routes/apps/$clientId/designer.tsx | 35 +- .../app/routes/apps/$clientId/domain.tsx | 2 +- .../app/routes/apps/$clientId/team.tsx | 2 +- ...spuorg.enroll.$groupID.$invitationCode.tsx | 2 +- apps/console/app/services/billing/stripe.ts | 40 +- apps/console/app/types.ts | 2 +- apps/console/app/utils/billing.ts | 47 +- apps/console/app/utils/planGate.ts | 4 +- .../__layout/billing => utils}/plans.ts | 2 +- .../authenticate/$clientId/email/verify.tsx | 67 +- .../routes/authenticate/$clientId/index.tsx | 4 +- .../app/routes/authenticate/cancel.tsx | 2 +- apps/passport/app/routes/connect/email/otp.ts | 2 +- .../passport/app/routes/settings/advanced.tsx | 2 +- apps/passport/app/session.server.ts | 13 +- apps/passport/app/utils/authorize.server.ts | 2 +- .../platform-middleware/inputValidators.ts | 12 + packages/security/persona.ts | 6 +- packages/types/billing.ts | 23 + packages/types/identity.ts | 24 - packages/urns/identity-ref.ts | 4 + .../sendReconciliationNotificationMethod.ts | 2 +- .../jsonrpc/methods/revokeAppAuthorization.ts | 2 +- platform/billing/.eslintrc.json | 18 + platform/billing/.gitignore | 1 + platform/billing/.prettierrc.json | 6 + platform/billing/README.md | 11 + platform/billing/package.json | 43 + platform/billing/src/context.ts | 1 + platform/billing/src/index.ts | 0 .../src/jsonrpc/methods/cancelServicePlans.ts | 42 + .../src/jsonrpc/methods/getEntitlements.ts | 27 +- .../src/jsonrpc/methods/stripePaymentData.ts | 55 +- .../src/jsonrpc/methods/updateEntitlements.ts | 44 + platform/billing/src/jsonrpc/router.ts | 59 + platform/billing/src/types.ts | 4 + platform/billing/tsconfig.json | 103 + platform/core/package.json | 1 + platform/core/src/router.ts | 2 + .../sendSuccesfullPaymentNotification.ts | 1 - .../src/schema/resolvers/utils/index.ts | 2 +- .../src/jsonrpc/methods/cancelServicePlans.ts | 29 - .../src/jsonrpc/methods/getAccounts.ts | 6 +- .../src/jsonrpc/methods/getOwnAccounts.ts | 6 +- .../src/jsonrpc/methods/getProfile.ts | 2 +- .../src/jsonrpc/methods/getPublicAccounts.ts | 4 +- .../acceptIdentityGroupMemberInvitation.ts | 2 +- .../connectIdentityGroupEmail.ts | 92 + .../hasIdentityGroupPermissions.ts | 46 + .../identity-groups/listIdentityGroups.ts | 10 + .../src/jsonrpc/methods/updateEntitlements.ts | 30 - platform/identity/src/jsonrpc/router.ts | 72 +- platform/identity/src/nodes/identity-group.ts | 64 + platform/identity/src/nodes/identity.ts | 2 +- platform/identity/src/types.ts | 2 - .../starbase/src/jsonrpc/methods/createApp.ts | 2 +- .../methods/deleteSubscriptionPlans.ts | 10 +- .../src/jsonrpc/methods/getAppPlan.ts | 3 +- .../src/jsonrpc/methods/getAppPublicProps.ts | 2 +- .../starbase/src/jsonrpc/methods/listApps.ts | 7 +- .../methods/reconcileAppSubscriptions.ts | 12 +- .../src/jsonrpc/methods/setAppPlan.ts | 18 +- .../starbase/src/jsonrpc/validators/app.ts | 2 +- platform/starbase/src/nodes/application.ts | 2 +- yarn.lock | 29 + 85 files changed, 3628 insertions(+), 2100 deletions(-) create mode 100644 apps/console/app/components/Billing/index.tsx create mode 100644 apps/console/app/routes/__layout/billing/ops.ts create mode 100644 apps/console/app/routes/__layout/billing/personal.tsx create mode 100644 apps/console/app/routes/__layout/billing/spuorg.tsx create mode 100644 apps/console/app/routes/__layout/billing/spuorg/$groupID.tsx create mode 100644 apps/console/app/routes/__layout/billing/spuorg/index.tsx rename apps/console/app/{routes/__layout/billing => utils}/plans.ts (96%) create mode 100644 packages/types/billing.ts create mode 100644 packages/urns/identity-ref.ts create mode 100644 platform/billing/.eslintrc.json create mode 100644 platform/billing/.gitignore create mode 100644 platform/billing/.prettierrc.json create mode 100644 platform/billing/README.md create mode 100644 platform/billing/package.json create mode 100644 platform/billing/src/context.ts create mode 100644 platform/billing/src/index.ts create mode 100644 platform/billing/src/jsonrpc/methods/cancelServicePlans.ts rename platform/{identity => billing}/src/jsonrpc/methods/getEntitlements.ts (56%) rename platform/{identity => billing}/src/jsonrpc/methods/stripePaymentData.ts (58%) create mode 100644 platform/billing/src/jsonrpc/methods/updateEntitlements.ts create mode 100644 platform/billing/src/jsonrpc/router.ts create mode 100644 platform/billing/src/types.ts create mode 100644 platform/billing/tsconfig.json delete mode 100644 platform/identity/src/jsonrpc/methods/cancelServicePlans.ts create mode 100644 platform/identity/src/jsonrpc/methods/identity-groups/connectIdentityGroupEmail.ts create mode 100644 platform/identity/src/jsonrpc/methods/identity-groups/hasIdentityGroupPermissions.ts delete mode 100644 platform/identity/src/jsonrpc/methods/updateEntitlements.ts diff --git a/apps/console/app/components/AppBox/index.tsx b/apps/console/app/components/AppBox/index.tsx index f914b8de42..d11072c290 100644 --- a/apps/console/app/components/AppBox/index.tsx +++ b/apps/console/app/components/AppBox/index.tsx @@ -3,7 +3,7 @@ */ // TODO migrate to FolderPlusIcon and remove bespoke version -import { type ServicePlanType } from '@proofzero/types/identity' +import { ServicePlanType } from '@proofzero/types/billing' import { ApplicationList } from '../Applications/ApplicationList' // AppBox diff --git a/apps/console/app/components/Applications/ApplicationListItem.tsx b/apps/console/app/components/Applications/ApplicationListItem.tsx index 1b649e3619..1f46651393 100644 --- a/apps/console/app/components/Applications/ApplicationListItem.tsx +++ b/apps/console/app/components/Applications/ApplicationListItem.tsx @@ -1,7 +1,7 @@ import { Menu, Transition } from '@headlessui/react' import { Pill } from '@proofzero/design-system/src/atoms/pills/Pill' import { Text } from '@proofzero/design-system/src/atoms/text/Text' -import { ServicePlanType } from '@proofzero/types/identity' +import { ServicePlanType } from '@proofzero/types/billing' import { Fragment } from 'react' import { HiDotsVertical, HiOutlineCog } from 'react-icons/hi' import { HiOutlineTrash } from 'react-icons/hi2' diff --git a/apps/console/app/components/Billing/index.tsx b/apps/console/app/components/Billing/index.tsx new file mode 100644 index 0000000000..5bbda51253 --- /dev/null +++ b/apps/console/app/components/Billing/index.tsx @@ -0,0 +1,936 @@ +import { Button } from '@proofzero/design-system' +import { Text } from '@proofzero/design-system/src/atoms/text/Text' +import { FaCheck, FaTrash } from 'react-icons/fa' +import { + HiChevronDown, + HiChevronUp, + HiMinus, + HiPlus, + HiOutlineShoppingCart, + HiArrowUp, + HiOutlineX, +} from 'react-icons/hi' +import { + type FetcherWithComponents, + NavLink, + useNavigate, + type SubmitFunction, +} from '@remix-run/react' +import type { AppLoaderData } from '~/root' +import { Popover, Transition } from '@headlessui/react' +import { Listbox } from '@headlessui/react' +import { TbHourglassHigh } from 'react-icons/tb' +import classnames from 'classnames' +import { Modal } from '@proofzero/design-system/src/molecules/modal/Modal' +import { useState } from 'react' +import { ToastWithLink } from '@proofzero/design-system/src/atoms/toast/ToastWithLink' +import { HiArrowNarrowRight } from 'react-icons/hi' +import _ from 'lodash' +import iSvg from '@proofzero/design-system/src/atoms/info/i.svg' +import plans, { PlanDetails } from '~/utils/plans' +import { PaymentData, ServicePlanType } from '@proofzero/types/billing' +import { Spinner } from '@proofzero/packages/design-system/src/atoms/spinner/Spinner' + +export const PlanFeatures = ({ + plan, + featuresColor, +}: { + plan: PlanDetails + featuresColor: 'text-indigo-500' | 'text-gray-500' +}) => { + return ( + + ) +} + +const PurchaseProModal = ({ + isOpen, + setIsOpen, + plan, + entitlements, + paymentData, + submit, +}: { + isOpen: boolean + setIsOpen: (open: boolean) => void + plan: PlanDetails + entitlements: number + paymentData?: PaymentData + submit: SubmitFunction +}) => { + const [proEntitlementDelta, setProEntitlementDelta] = useState(1) + + return ( + setIsOpen(false)}> +
+
+ + Purchase Entitlement(s) + +
{ + setIsOpen(false) + }} + > + +
+
+ + {!paymentData?.paymentMethodID ? ( +
+ +
+ ) : null} + +
+
+ + {plan.title} + + + + {plan.description} + + + +
+ +
+ +
+
+ + Number of Entitlements + + + {proEntitlementDelta} x ${plan.price}/month + +
+ +
+ + + + + +
+
+ +
+ +
+ + Changes to your subscription + + +
+ {`+$${ + plan.price * proEntitlementDelta + }`} + + per month + +
+
+
+ +
+ +
+ + +
+
+
+ ) +} + +const AssignEntitlementModal = ({ + isOpen, + setIsOpen, + entitlements, + paymentData, + entitlementUsage, + fetcher, + apps, +}: { + isOpen: boolean + setIsOpen: (open: boolean) => void + entitlements: number + entitlementUsage: number + paymentData?: PaymentData + fetcher: FetcherWithComponents + apps: AppLoaderData[] +}) => { + const navigate = useNavigate() + + return ( + setIsOpen(false)}> +
+
+
+ + Assign Entitlement(s) + + + {entitlementUsage} of {entitlements} Entitlements used + +
+
{ + setIsOpen(false) + }} + > + +
+
+ {!paymentData?.paymentMethodID ? ( +
+ +
+ ) : null} +
+
+
    + {apps.map((app) => { + return ( +
  • +
    + {app.name} + + {app.appPlan[0] + app.appPlan.slice(1).toLowerCase()}{' '} + Plan + +
    + {app.appPlan === ServicePlanType.PRO ? ( + + ) : ( + <> + {entitlementUsage < entitlements ? ( + + ) : ( + + )} + + )} +
  • + ) + })} +
+
+
+ +
+
+
+
+ ) +} + +const RemoveEntitelmentModal = ({ + isOpen, + setIsOpen, + plan, + entitlements, + entitlementUsage, + paymentData, + submit, +}: { + isOpen: boolean + setIsOpen: (open: boolean) => void + plan: PlanDetails + entitlements: number + entitlementUsage: number + paymentData?: PaymentData + submit: SubmitFunction +}) => { + const [proEntitlementNew, setProEntitlementNew] = useState(entitlementUsage) + + return ( + setIsOpen(false)}> +
+
+ + Remove Entitlement(s) + +
{ + setIsOpen(false) + }} + > + +
+
+
+
+
+ + {plan.title} + +
    +
  • + You are currently using {entitlementUsage}/{entitlements}{' '} + {plan.title} entitlements +
  • +
  • + You can downgrade some of your applications if you'd like to + pay for fewer Entitlements. +
  • +
+
+
+
+
+ + Number of Entitlements + + {`${entitlementUsage} x ${ + plans[ServicePlanType.PRO].price + }/month`} +
+ +
+
+ {entitlements} Entitlements + +
+ +
+ + {({ open }) => { + return ( +
+ + {proEntitlementNew} + {open ? ( + + ) : ( + + )} + + + + {Array.apply(null, Array(entitlements + 1)).map( + (_, i) => { + return i >= entitlementUsage ? ( + + {({ selected }) => { + return ( +
+ {i} +
+ ) + }} +
+ ) : null + } + )} +
+
+
+ ) + }} +
+
+
+
+
+ +
+ + Changes to your subscription + + +
+ {`${ + plan.price * (entitlements - proEntitlementNew) !== 0 + ? '-' + : '' + }$${plan.price * (entitlements - proEntitlementNew)}`} + + per month + +
+
+
+
+
+ + +
+
+
+ ) +} + +const AssignedAppModal = ({ + apps, + isOpen, + setIsOpen, +}: { + apps: AppLoaderData[] + isOpen: boolean + setIsOpen: (isOpen: boolean) => void +}) => { + return ( + setIsOpen(false)}> +
+
+ + Assigned Application(s) + +
{ + setIsOpen(false) + }} + > + +
+
+ +
+
    + {apps.map((app) => ( +
  • +
    + + {app.name} + + + {plans[app.appPlan].title} + +
    + + + + +
  • + ))} +
+
+
+
+ ) +} + +export const PlanCard = ({ + plan, + entitlements, + apps, + paymentData, + submit, + fetcher, + hasUnpaidInvoices = false, +}: { + plan: PlanDetails + entitlements: number + apps: AppLoaderData[] + paymentData?: PaymentData + hasUnpaidInvoices: boolean + submit: SubmitFunction + fetcher: FetcherWithComponents +}) => { + const [purchaseProModalOpen, setPurchaseProModalOpen] = useState(false) + const [removeEntitlementModalOpen, setRemoveEntitlementModalOpen] = + useState(false) + const [assignedAppModalOpen, setAssignedAppModalOpen] = useState(false) + const [assignEntitlementsModalOpen, setAssignEntitlementsModalOpen] = + useState(false) + + const appsWithAssignedPlan = apps.filter( + (a) => a.appPlan === ServicePlanType.PRO + ) + return ( + <> + + + + +
+
+
+ + {plan.title} + + + {plan.description} + +
+ +
+ + +
+
+
+
+
+ +
+ +
+ + {entitlements > 0 && ( +
+
+
+ + Entitlements + + +
+
+
+ +
+
+ {appsWithAssignedPlan.length > 0 && ( + + )} +
+ + {`${appsWithAssignedPlan.length} out of ${entitlements} Entitlements used`} + +
+
+ +
+ + ${entitlements * plans.PRO.price} + + + per month + +
+
+
+ )} +
+
+ {entitlements === 0 && ( +
+ +
+ )} + {entitlements > appsWithAssignedPlan.length && ( +
+ +
+ )} +
+
+ + ) +} diff --git a/apps/console/app/components/EarlyAccess/EarlyAccessPanel.tsx b/apps/console/app/components/EarlyAccess/EarlyAccessPanel.tsx index a4075e8a4a..6ac7aa3ed2 100644 --- a/apps/console/app/components/EarlyAccess/EarlyAccessPanel.tsx +++ b/apps/console/app/components/EarlyAccess/EarlyAccessPanel.tsx @@ -3,9 +3,9 @@ import { FeaturePill } from '@proofzero/design-system/src/atoms/pills/FeaturePil import { DocumentationBadge } from '../DocumentationBadge' import type { IdentityURN } from '@proofzero/urns/identity' import ContactUs from '../ContactUs' -import { ServicePlanType } from '@proofzero/types/identity' +import { ServicePlanType } from '@proofzero/types/billing' import { isPlanGuarded } from '~/utils/planGate' -import plans from '~/routes/__layout/billing/plans' +import plans from '~/utils/plans' import _ from 'lodash' import { TbLock } from 'react-icons/tb' import { Button } from '@proofzero/design-system' diff --git a/apps/console/app/components/SiteMenu/appSelect.tsx b/apps/console/app/components/SiteMenu/appSelect.tsx index 34c3e24efd..73d4e3df7d 100644 --- a/apps/console/app/components/SiteMenu/appSelect.tsx +++ b/apps/console/app/components/SiteMenu/appSelect.tsx @@ -11,7 +11,7 @@ import { TbWorld } from 'react-icons/tb' import { Button, Text } from '@proofzero/design-system' import { useNavigate } from '@remix-run/react' -import { ServicePlanType } from '@proofzero/types/identity' +import { ServicePlanType } from '@proofzero/types/billing' import { Pill } from '@proofzero/design-system/src/atoms/pills/Pill' // Utility diff --git a/apps/console/app/components/SiteMenu/index.tsx b/apps/console/app/components/SiteMenu/index.tsx index 7774bbf273..063dad9682 100644 --- a/apps/console/app/components/SiteMenu/index.tsx +++ b/apps/console/app/components/SiteMenu/index.tsx @@ -40,7 +40,7 @@ import type { IconType } from 'react-icons' import { Avatar } from '@proofzero/design-system' import { usePostHog } from 'posthog-js/react' -import { ServicePlanType } from '@proofzero/types/identity' +import { ServicePlanType } from '@proofzero/types/billing' import _ from 'lodash' import { isPlanGuarded } from '~/utils/planGate' diff --git a/apps/console/app/root.tsx b/apps/console/app/root.tsx index 1dbcbac973..cdff430140 100644 --- a/apps/console/app/root.tsx +++ b/apps/console/app/root.tsx @@ -48,12 +48,12 @@ import { NonceContext } from '@proofzero/design-system/src/atoms/contexts/nonce- import useTreeshakeHack from '@proofzero/design-system/src/hooks/useTreeshakeHack' import { getRollupReqFunctionErrorWrapper } from '@proofzero/utils/errors' -import { type ServicePlanType } from '@proofzero/types/identity' import { BadRequestError } from '@proofzero/errors' import posthog from 'posthog-js' import { PostHogProvider } from 'posthog-js/react' import { useHydrated } from 'remix-utils' import { getCurrentAndUpcomingInvoices } from './utils/billing' +import { ServicePlanType } from '@proofzero/types/billing' export const links: LinksFunction = () => { return [ @@ -149,8 +149,8 @@ export const loader: LoaderFunction = getRollupReqFunctionErrorWrapper( WALLET_CONNECT_PROJECT_ID, } = context.env - const spd = await coreClient.identity.getStripePaymentData.query({ - identityURN, + const spd = await coreClient.billing.getStripePaymentData.query({ + URN: identityURN, }) // might be quite heavy object diff --git a/apps/console/app/routes/__layout/billing/details.tsx b/apps/console/app/routes/__layout/billing/details.tsx index 610efaf36e..a233643b26 100644 --- a/apps/console/app/routes/__layout/billing/details.tsx +++ b/apps/console/app/routes/__layout/billing/details.tsx @@ -15,6 +15,12 @@ import { createCustomer, updateCustomer } from '~/services/billing/stripe' import { IdentityURN } from '@proofzero/urns/identity' import { AccountURN } from '@proofzero/urns/account' import { ToastType } from '@proofzero/design-system/src/atoms/toast' +import { + IdentityGroupURN, + IdentityGroupURNSpace, +} from '@proofzero/urns/identity-group' +import { BadRequestError, UnauthorizedError } from '@proofzero/errors' +import { IdentityRefURN } from '@proofzero/urns/identity-ref' export const action: ActionFunction = getRollupReqFunctionErrorWrapper( async ({ request, context }) => { @@ -29,24 +35,47 @@ export const action: ActionFunction = getRollupReqFunctionErrorWrapper( ...traceHeader, }) + const returnURL = request.headers.get('Referer') + if (!returnURL) { + throw new BadRequestError({ + message: 'No Referer found in request.', + }) + } + const fd = await request.formData() - const { email, accountURN, name } = JSON.parse( + const { email, accountURN, name, URN } = JSON.parse( fd.get('payload') as string ) as { email: string accountURN: AccountURN name: string + URN?: IdentityRefURN + } + + let targetURN = URN ?? identityURN + if (IdentityGroupURNSpace.is(targetURN)) { + const authorized = + await coreClient.identity.hasIdentityGroupPermissions.query({ + identityURN, + identityGroupURN: targetURN as IdentityGroupURN, + }) + + if (!authorized) { + throw new UnauthorizedError({ + message: 'You are not authorized to update this identity group.', + }) + } } - let paymentData = await coreClient.identity.getStripePaymentData.query({ - identityURN, + let paymentData = await coreClient.billing.getStripePaymentData.query({ + URN: targetURN, }) if (!paymentData) { const customer = await createCustomer( { email, name, - identityURN, + URN: targetURN, }, context.env ) @@ -75,9 +104,9 @@ export const action: ActionFunction = getRollupReqFunctionErrorWrapper( ) } - await coreClient.identity.setStripePaymentData.mutate({ + await coreClient.billing.setStripePaymentData.mutate({ ...paymentData, - identityURN, + URN: targetURN, accountURN, }) @@ -90,7 +119,7 @@ export const action: ActionFunction = getRollupReqFunctionErrorWrapper( }) ) - return redirect('/billing', { + return redirect(returnURL, { headers: { 'Set-Cookie': await commitFlashSession(flashSession, context.env), }, diff --git a/apps/console/app/routes/__layout/billing/index.tsx b/apps/console/app/routes/__layout/billing/index.tsx index 4be33d1946..e30bfdbd42 100644 --- a/apps/console/app/routes/__layout/billing/index.tsx +++ b/apps/console/app/routes/__layout/billing/index.tsx @@ -1,1756 +1,26 @@ -import { Button } from '@proofzero/design-system' -import { Text } from '@proofzero/design-system/src/atoms/text/Text' +import createCoreClient from '@proofzero/platform-clients/core' import { generateTraceContextHeaders } from '@proofzero/platform-middleware/trace' +import { getAuthzHeaderConditionallyFromToken } from '@proofzero/utils' import { getRollupReqFunctionErrorWrapper } from '@proofzero/utils/errors' -import { - type ActionFunction, - type LoaderFunction, - json, -} from '@remix-run/cloudflare' -import { FaCheck, FaTrash } from 'react-icons/fa' -import { - HiChevronDown, - HiChevronUp, - HiMinus, - HiOutlineCreditCard, - HiOutlineMail, - HiPlus, - HiOutlineShoppingCart, - HiInformationCircle, - HiArrowUp, - HiOutlineX, -} from 'react-icons/hi' -import { - commitFlashSession, - getFlashSession, - requireJWT, -} from '~/utilities/session.server' -import createCoreClient, { - type CoreClientType, -} from '@proofzero/platform-clients/core' -import { - getAuthzHeaderConditionallyFromToken, - parseJwt, -} from '@proofzero/utils' -import { - type FetcherWithComponents, - Link, - NavLink, - useActionData, - useFetcher, - useLoaderData, - useNavigate, - useOutletContext, - useSubmit, - type SubmitFunction, -} from '@remix-run/react' -import type { AppLoaderData, LoaderData as OutletContextData } from '~/root' -import { Menu, Popover, Transition } from '@headlessui/react' -import { Listbox } from '@headlessui/react' -import { ChevronDownIcon, ChevronUpIcon } from '@heroicons/react/20/solid' -import { HiOutlineMinusCircle } from 'react-icons/hi' -import { TbHourglassHigh } from 'react-icons/tb' -import classnames from 'classnames' -import { Modal } from '@proofzero/design-system/src/molecules/modal/Modal' -import { useEffect, useState } from 'react' -import { type PaymentData, ServicePlanType } from '@proofzero/types/identity' -import { - ToastType, - Toaster, - toast, -} from '@proofzero/design-system/src/atoms/toast' -import plans, { type PlanDetails } from './plans' -import { type IdentityURN } from '@proofzero/urns/identity' -import { ToastWithLink } from '@proofzero/design-system/src/atoms/toast/ToastWithLink' -import { Input } from '@proofzero/design-system/src/atoms/form/Input' -import { HiArrowNarrowRight } from 'react-icons/hi' -import { - getEmailDropdownItems, - getEmailIcon, -} from '@proofzero/utils/getNormalisedConnectedAccounts' -import { - Dropdown, - type DropdownSelectListItem, -} from '@proofzero/design-system/src/atoms/dropdown/DropdownSelectList' -import useConnectResult from '@proofzero/design-system/src/hooks/useConnectResult' -import { DangerPill } from '@proofzero/design-system/src/atoms/pills/DangerPill' -import { reconcileAppSubscriptions } from '~/services/billing/stripe' -import { useHydrated } from 'remix-utils' -import _ from 'lodash' -import { BadRequestError, InternalServerError } from '@proofzero/errors' -import iSvg from '@proofzero/design-system/src/atoms/info/i.svg' -import { - createOrUpdateSubscription, - getCurrentAndUpcomingInvoices, - process3DSecureCard, - UnpaidInvoiceNotification, - type StripeInvoice, -} from '~/utils/billing' -import { IoWarningOutline } from 'react-icons/io5' -import { type ToastNotification } from '~/types' -import { setPurchaseToastNotification } from '~/utils' -import type Stripe from 'stripe' -import { ToastWarning } from '@proofzero/design-system/src/atoms/toast/ToastWarning' -import { Toast } from '@proofzero/design-system/src/atoms/toast/Toast' -import { Spinner } from '@proofzero/design-system/src/atoms/spinner/Spinner' - -type LoaderData = { - STRIPE_PUBLISHABLE_KEY: string - paymentData?: PaymentData - entitlements: { - [ServicePlanType.PRO]: number - } - toastNotification?: ToastNotification - connectedEmails: DropdownSelectListItem[] - invoices: StripeInvoice[] -} +import { type LoaderFunction, redirect } from '@remix-run/cloudflare' +import { requireJWT } from '~/utilities/session.server' export const loader: LoaderFunction = getRollupReqFunctionErrorWrapper( - async ({ request, params, context }) => { - const jwt = await requireJWT(request, context.env) - const parsedJwt = parseJwt(jwt!) - const identityURN = parsedJwt.sub as IdentityURN - - const traceHeader = generateTraceContextHeaders(context.traceSpan) - - const coreClient = createCoreClient(context.env.Core, { - ...getAuthzHeaderConditionallyFromToken(jwt), - ...traceHeader, - }) - - const { plans } = await coreClient.identity.getEntitlements.query({ - identityURN, - }) - - const flashSession = await getFlashSession(request, context.env) - - let toastNotification = undefined - const toastStr = flashSession.get('toast_notification') - if (toastStr) { - toastNotification = JSON.parse(toastStr) - } - - const connectedAccounts = await coreClient.identity.getAccounts.query({ - identity: identityURN, - }) - const connectedEmails = getEmailDropdownItems(connectedAccounts) - - const spd = await coreClient.identity.getStripePaymentData.query({ - identityURN, - }) - if (spd && !spd.accountURN) { - const targetAccountURN = - await coreClient.account.getAccountURNForEmail.query( - spd.email.toLowerCase() - ) - - if (!targetAccountURN) { - throw new InternalServerError({ - message: 'No account found for email', - }) - } - - await coreClient.identity.setStripePaymentData.mutate({ - ...spd, - accountURN: targetAccountURN, - identityURN, - }) - - spd.accountURN = targetAccountURN - } - - const invoices = ( - await getCurrentAndUpcomingInvoices( - spd, - context.env.SECRET_STRIPE_API_KEY - ) - ).slice(0, 7) - - return json( - { - STRIPE_PUBLISHABLE_KEY: context.env.STRIPE_PUBLISHABLE_KEY, - paymentData: spd, - entitlements: { - [ServicePlanType.PRO]: - plans?.[ServicePlanType.PRO]?.entitlements ?? 0, - }, - connectedEmails, - invoices, - toastNotification, - }, - { - headers: { - 'Set-Cookie': await commitFlashSession(flashSession, context.env), - }, - } - ) - } -) - -export const action: ActionFunction = getRollupReqFunctionErrorWrapper( async ({ request, context }) => { const jwt = await requireJWT(request, context.env) - const parsedJwt = parseJwt(jwt!) - const identityURN = parsedJwt.sub as IdentityURN const traceHeader = generateTraceContextHeaders(context.traceSpan) - const coreClient: CoreClientType = createCoreClient(context.env.Core, { + const coreClient = createCoreClient(context.env.Core, { ...getAuthzHeaderConditionallyFromToken(jwt), ...traceHeader, }) - const spd = await coreClient.identity.getStripePaymentData.query({ - identityURN, - }) - - const invoices = await getCurrentAndUpcomingInvoices( - spd, - context.env.SECRET_STRIPE_API_KEY - ) - - const flashSession = await getFlashSession(request, context.env) - - await UnpaidInvoiceNotification({ - invoices, - flashSession, - env: context.env, - }) - - const fd = await request.formData() - const { customerID, quantity, txType } = JSON.parse( - fd.get('payload') as string - ) as { - customerID: string - quantity: number - txType: 'buy' | 'remove' - } - - const apps = await coreClient.starbase.listApps.query() - const assignedEntitlementCount = apps.filter( - (a) => a.appPlan === ServicePlanType.PRO - ).length - if (assignedEntitlementCount > quantity) { - throw new BadRequestError({ - message: `Invalid quantity. Change ${ - quantity - assignedEntitlementCount - } of the ${assignedEntitlementCount} apps to a different plan first.`, - }) - } - - if ((quantity < 1 && txType === 'buy') || quantity < 0) { - throw new BadRequestError({ - message: `Invalid quantity. Please enter a valid number of entitlements.`, - }) - } - - const entitlements = await coreClient.identity.getEntitlements.query({ - identityURN, - }) - - const sub = await createOrUpdateSubscription({ - customerID, - SECRET_STRIPE_PRO_PLAN_ID: context.env.SECRET_STRIPE_PRO_PLAN_ID, - SECRET_STRIPE_API_KEY: context.env.SECRET_STRIPE_API_KEY, - quantity, - subscriptionID: entitlements.subscriptionID, - identityURN, - }) - - if ( - (txType === 'buy' && - (sub.status === 'active' || sub.status === 'trialing')) || - txType !== 'buy' - ) { - await reconcileAppSubscriptions( - { - subscriptionID: sub.id, - identityURN, - coreClient, - billingURL: `${context.env.CONSOLE_URL}/billing`, - settingsURL: `${context.env.CONSOLE_URL}`, - }, - context.env - ) - } - - if (txType === 'buy') { - setPurchaseToastNotification({ - sub, - flashSession, - }) - } - if (txType === 'remove') { - flashSession.flash( - 'toast_notification', - JSON.stringify({ - type: ToastType.Success, - message: 'Entitlement(s) successfully removed', - }) - ) - } - - let status, client_secret, payment_method - if ( - sub.latest_invoice && - (sub.latest_invoice as Stripe.Invoice).payment_intent - ) { - // lots of stripe type casting since by default many - // props are strings (not expanded versions) - ;({ status, client_secret, payment_method } = ( - sub.latest_invoice as Stripe.Invoice - ).payment_intent as Stripe.PaymentIntent) + const groups = await coreClient.identity.listIdentityGroups.query() + if (groups.length > 0) { + return redirect('/billing/spuorg') } - return json( - { - status, - client_secret, - payment_method, - subId: sub.id, - }, - { - headers: { - 'Set-Cookie': await commitFlashSession(flashSession, context.env), - }, - } - ) + return redirect('/billing/personal') } ) - -export const PlanFeatures = ({ - plan, - featuresColor, -}: { - plan: PlanDetails - featuresColor: 'text-indigo-500' | 'text-gray-500' -}) => { - return ( -
    - {plan.features.map((feature) => ( -
  • -
    - {feature.type === 'current' && ( - - )} - {feature.type === 'future' && ( - - )} -
    - - - {feature.title} - - - {feature.aggregateFeatures && ( - - - - -
      - {feature.aggregateFeatures.map((af) => ( -
    • -
      - {af.type === 'current' && ( - - )} - {af.type === 'future' && ( - - )} -
      - - - {af.title} - -
    • - ))} -
    -
    -
    - )} -
  • - ))} -
- ) -} - -const PurchaseProModal = ({ - isOpen, - setIsOpen, - plan, - entitlements, - paymentData, - submit, -}: { - isOpen: boolean - setIsOpen: (open: boolean) => void - plan: PlanDetails - entitlements: number - paymentData?: PaymentData - submit: SubmitFunction -}) => { - const [proEntitlementDelta, setProEntitlementDelta] = useState(1) - - return ( - setIsOpen(false)}> -
-
- - Purchase Entitlement(s) - -
{ - setIsOpen(false) - }} - > - -
-
- - {!paymentData?.paymentMethodID ? ( -
- -
- ) : null} - -
-
- - {plan.title} - - - - {plan.description} - - - -
- -
- -
-
- - Number of Entitlements - - - {proEntitlementDelta} x ${plan.price}/month - -
- -
- - - - - -
-
- -
- -
- - Changes to your subscription - - -
- {`+$${ - plan.price * proEntitlementDelta - }`} - - per month - -
-
-
- -
- -
- - -
-
-
- ) -} - -const AssignEntitlementModal = ({ - isOpen, - setIsOpen, - entitlements, - paymentData, - entitlementUsage, - fetcher, - apps, -}: { - isOpen: boolean - setIsOpen: (open: boolean) => void - entitlements: number - entitlementUsage: number - paymentData?: PaymentData - fetcher: FetcherWithComponents - apps: AppLoaderData[] -}) => { - const navigate = useNavigate() - - return ( - setIsOpen(false)}> -
-
-
- - Assign Entitlement(s) - - - {entitlementUsage} of {entitlements} Entitlements used - -
-
{ - setIsOpen(false) - }} - > - -
-
- {!paymentData?.paymentMethodID ? ( -
- -
- ) : null} -
-
-
    - {apps.map((app) => { - return ( -
  • -
    - {app.name} - - {app.appPlan[0] + app.appPlan.slice(1).toLowerCase()}{' '} - Plan - -
    - {app.appPlan === ServicePlanType.PRO ? ( - - ) : ( - <> - {entitlementUsage < entitlements ? ( - - ) : ( - - )} - - )} -
  • - ) - })} -
-
-
- -
-
-
-
- ) -} - -const RemoveEntitelmentModal = ({ - isOpen, - setIsOpen, - plan, - entitlements, - entitlementUsage, - paymentData, - submit, -}: { - isOpen: boolean - setIsOpen: (open: boolean) => void - plan: PlanDetails - entitlements: number - entitlementUsage: number - paymentData?: PaymentData - submit: SubmitFunction -}) => { - const [proEntitlementNew, setProEntitlementNew] = useState(entitlementUsage) - - return ( - setIsOpen(false)}> -
-
- - Remove Entitlement(s) - -
{ - setIsOpen(false) - }} - > - -
-
-
-
-
- - {plan.title} - -
    -
  • - You are currently using {entitlementUsage}/{entitlements}{' '} - {plan.title} entitlements -
  • -
  • - You can downgrade some of your applications if you'd like to - pay for fewer Entitlements. -
  • -
-
-
-
-
- - Number of Entitlements - - {`${entitlementUsage} x ${ - plans[ServicePlanType.PRO].price - }/month`} -
- -
-
- {entitlements} Entitlements - -
- -
- - {({ open }) => { - return ( -
- - {proEntitlementNew} - {open ? ( - - ) : ( - - )} - - - - {Array.apply(null, Array(entitlements + 1)).map( - (_, i) => { - return i >= entitlementUsage ? ( - - {({ selected }) => { - return ( -
- {i} -
- ) - }} -
- ) : null - } - )} -
-
-
- ) - }} -
-
-
-
-
- -
- - Changes to your subscription - - -
- {`${ - plan.price * (entitlements - proEntitlementNew) !== 0 - ? '-' - : '' - }$${plan.price * (entitlements - proEntitlementNew)}`} - - per month - -
-
-
-
-
- - -
-
-
- ) -} - -const AssignedAppModal = ({ - apps, - isOpen, - setIsOpen, -}: { - apps: AppLoaderData[] - isOpen: boolean - setIsOpen: (isOpen: boolean) => void -}) => { - return ( - setIsOpen(false)}> -
-
- - Assigned Application(s) - -
{ - setIsOpen(false) - }} - > - -
-
- -
-
    - {apps.map((app) => ( -
  • -
    - - {app.name} - - - {plans[app.appPlan].title} - -
    - - - - -
  • - ))} -
-
-
-
- ) -} - -const PlanCard = ({ - plan, - entitlements, - apps, - paymentData, - submit, - fetcher, - hasUnpaidInvoices = false, -}: { - plan: PlanDetails - entitlements: number - apps: AppLoaderData[] - paymentData?: PaymentData - hasUnpaidInvoices: boolean - submit: SubmitFunction - fetcher: FetcherWithComponents -}) => { - const [purchaseProModalOpen, setPurchaseProModalOpen] = useState(false) - const [removeEntitlementModalOpen, setRemoveEntitlementModalOpen] = - useState(false) - const [assignedAppModalOpen, setAssignedAppModalOpen] = useState(false) - const [assignEntitlementsModalOpen, setAssignEntitlementsModalOpen] = - useState(false) - - const appsWithAssignedPlan = apps.filter( - (a) => a.appPlan === ServicePlanType.PRO - ) - return ( - <> - - - - -
-
-
- - {plan.title} - - - {plan.description} - -
- -
- - -
-
-
-
-
- -
- -
- - {entitlements > 0 && ( -
-
-
- - Entitlements - - -
-
-
- -
-
- {appsWithAssignedPlan.length > 0 && ( - - )} -
- - {`${appsWithAssignedPlan.length} out of ${entitlements} Entitlements used`} - -
-
- -
- - ${entitlements * plans.PRO.price} - - - per month - -
-
-
- )} -
-
- {entitlements === 0 && ( -
- -
- )} - {entitlements > appsWithAssignedPlan.length && ( -
- -
- )} -
-
- - ) -} - -export default () => { - const loaderData = useLoaderData() - const actionData = useActionData() - - const { - STRIPE_PUBLISHABLE_KEY, - entitlements, - toastNotification, - paymentData, - connectedEmails, - invoices, - } = loaderData - - const { apps, PASSPORT_URL, hasUnpaidInvoices } = - useOutletContext() - - const submit = useSubmit() - const fetcher = useFetcher() - - // have it as PRO for now - const hasUnassignedPlans = - +entitlements[ServicePlanType.PRO] - - apps.filter((a) => a.appPlan === ServicePlanType.PRO).length - - useEffect(() => { - // Checking status for 3DS payment authentication - if (actionData?.status || fetcher.data?.status) { - const { status, client_secret, payment_method, subId } = actionData - ? actionData - : fetcher.data - - let clientId = fetcher.data?.clientId - process3DSecureCard({ - STRIPE_PUBLISHABLE_KEY, - status, - subId, - client_secret, - payment_method, - submit, - redirectUrl: '/billing', - updatePlanParams: { - clientId, - plan: ServicePlanType.PRO, - }, - }) - } - }, [actionData, fetcher.data]) - - useEffect(() => { - if (toastNotification) { - toast(toastNotification.type, { - message: toastNotification.message, - }) - } - }, [toastNotification]) - - const redirectToPassport = () => { - const currentURL = new URL(window.location.href) - currentURL.search = '' - - const qp = new URLSearchParams() - qp.append('scope', '') - qp.append('state', 'skip') - qp.append('client_id', 'console') - - qp.append('redirect_uri', currentURL.toString()) - qp.append('rollup_action', 'connect') - qp.append('login_hint', 'email microsoft google apple') - - window.location.href = `${PASSPORT_URL}/authorize?${qp.toString()}` - } - - useConnectResult() - - const [selectedEmail, setSelectedEmail] = useState( - paymentData?.email - ) - const [selectedEmailURN, setSelectedEmailURN] = useState( - paymentData?.accountURN - ) - const [fullName, setFullName] = useState( - paymentData?.name - ) - - const hydrated = useHydrated() - - const [invoiceSort, setInvoiceSort] = useState<'asc' | 'desc'>('desc') - - return ( - <> - - -
-
- - Billing & Invoicing - -
-
- -
- {paymentData && !paymentData.paymentMethodID ? ( -
- -
- ) : null} - {hydrated && hasUnassignedPlans ? ( -
- - } - className={'bg-indigo-50 text-indigo-700 w-full'} - /> -
- ) : null} - {!paymentData ? ( -
- -
- ) : null} -
- -
-
-
-
-
- - Billing Contact - - - {!paymentData && } -
- - This will be used to create a customer ID and for notifications - about your billing - -
- - -
-
-
- { - setFullName(e.target.value) - }} - /> -
- -
- {connectedEmails && connectedEmails.length === 0 && ( - - )} - - {connectedEmails && connectedEmails.length > 0 && ( - <> - - Email - * - - - { - email.value === '' - ? (email.selected = true) - : (email.selected = false) - // Substituting subtitle with icon - // on the client side - email.subtitle && !email.icon - ? (email.icon = getEmailIcon(email.subtitle)) - : null - return { - value: email.value, - selected: email.selected, - icon: email.icon, - title: email.title, - } - } - )} - placeholder="Select an Email Address" - onSelect={(selected) => { - // type casting to DropdownSelectListItem instead of array - if (!Array.isArray(selected)) { - if (!selected || !selected.value) { - console.error('Error selecting email, try again') - return - } - - setSelectedEmail(selected.title) - setSelectedEmailURN(selected.value) - } - }} - ConnectButtonCallback={redirectToPassport} - ConnectButtonPhrase="Connect New Email Address" - defaultItems={ - connectedEmails.filter( - (ce) => ce.value === paymentData?.accountURN - ) as DropdownSelectListItem[] - } - /> - - )} -
-
-
- - -
- -
-
-
- - Invoices & Payments - - - - - -
- - {invoices.length > 0 && ( - <> -
- - - - - - - - - - - {invoices - .sort((a, b) => - invoiceSort === 'desc' - ? b.timestamp - a.timestamp - : a.timestamp - b.timestamp - ) - .map((invoice, idx) => ( - - - - - - - - ))} - -
- - - - Invoice total - - - - Status - -
- {hydrated && ( -
- - {hydrated && - new Date(invoice.timestamp).toLocaleString( - 'default', - { - day: '2-digit', - month: 'short', - year: 'numeric', - } - )} - - - {(invoice.status === 'open' || - invoice.status === 'uncollectible') && ( -
- - - Payment Error - -
- )} -
- )} -
- - {invoice.amount < 0 ? '-' : ''}$ - {invoice.amount < 0 - ? (invoice.amount * -1).toFixed(2) - : invoice.amount.toFixed(2)} - - - - {invoice.status && _.startCase(invoice.status)} - - {invoice.status === 'paid' && ( - - - View Invoice - - - )} - {(invoice.status === 'open' || - invoice.status === 'uncollectible') && ( -
- - - Update Payment - - - -
- )} -
-
-
- - - View invoice history - - -
- - )} - - {invoices.length === 0 && ( -
- - - - - - - - - - - - - - - Your invoices will appear here -
- )} -
-
- - ) -} diff --git a/apps/console/app/routes/__layout/billing/ops.ts b/apps/console/app/routes/__layout/billing/ops.ts new file mode 100644 index 0000000000..790798f322 --- /dev/null +++ b/apps/console/app/routes/__layout/billing/ops.ts @@ -0,0 +1,326 @@ +import { DropdownSelectListItem } from '@proofzero/design-system/src/atoms/dropdown/DropdownSelectList' +import { ToastType } from '@proofzero/design-system/src/atoms/toast' +import { + UnauthorizedError, + InternalServerError, + BadRequestError, +} from '@proofzero/errors' +import createCoreClient, { + CoreClientType, +} from '@proofzero/platform-clients/core' +import { generateTraceContextHeaders } from '@proofzero/platform-middleware/trace' +import { reconcileAppSubscriptions } from '~/services/billing/stripe' +import { PaymentData, ServicePlanType } from '@proofzero/types/billing' +import { IdentityRefURN } from '@proofzero/urns/identity-ref' +import { IdentityURN, IdentityURNSpace } from '@proofzero/urns/identity' +import { + IdentityGroupURN, + IdentityGroupURNSpace, +} from '@proofzero/urns/identity-group' +import { + parseJwt, + getAuthzHeaderConditionallyFromToken, +} from '@proofzero/utils' +import { getRollupReqFunctionErrorWrapper } from '@proofzero/utils/errors' +import { getEmailDropdownItems } from '@proofzero/utils/getNormalisedConnectedAccounts' +import { ActionFunction, json } from '@remix-run/cloudflare' +import Stripe from 'stripe' +import { ToastNotification } from '~/types' +import { + requireJWT, + getFlashSession, + commitFlashSession, +} from '~/utilities/session.server' +import { setPurchaseToastNotification } from '~/utils' +import { + StripeInvoice, + UnpaidInvoiceNotification, + createOrUpdateSubscription, + getCurrentAndUpcomingInvoices, +} from '~/utils/billing' + +export type LoaderData = { + STRIPE_PUBLISHABLE_KEY: string + paymentData?: PaymentData + entitlements: { + [ServicePlanType.PRO]: number + } + toastNotification?: ToastNotification + connectedEmails: DropdownSelectListItem[] + invoices: StripeInvoice[] + groupURN?: IdentityGroupURN + unpaidInvoiceURL?: string +} + +export const loader = getRollupReqFunctionErrorWrapper( + async ({ request, params, context }) => { + const jwt = await requireJWT(request, context.env) + const parsedJwt = parseJwt(jwt!) + const identityURN = parsedJwt.sub as IdentityURN + + const traceHeader = generateTraceContextHeaders(context.traceSpan) + + const coreClient = createCoreClient(context.env.Core, { + ...getAuthzHeaderConditionallyFromToken(jwt), + ...traceHeader, + }) + + let groupURN + if (params.groupID) { + groupURN = IdentityGroupURNSpace.urn( + params.groupID as string + ) as IdentityGroupURN + } + + const targetURN: IdentityRefURN = groupURN ?? identityURN + if (IdentityGroupURNSpace.is(targetURN)) { + const authorized = + await coreClient.identity.hasIdentityGroupPermissions.query({ + identityURN, + identityGroupURN: targetURN as IdentityGroupURN, + }) + + if (!authorized) { + throw new UnauthorizedError({ + message: 'You are not authorized to update this identity group.', + }) + } + + groupURN = targetURN as IdentityGroupURN + } + + const { plans } = await coreClient.billing.getEntitlements.query({ + URN: targetURN, + }) + + const flashSession = await getFlashSession(request, context.env) + + let toastNotification = undefined + const toastStr = flashSession.get('toast_notification') + if (toastStr) { + toastNotification = JSON.parse(toastStr) + } + + const connectedAccounts = await coreClient.identity.getAccounts.query({ + URN: targetURN, + }) + const connectedEmails = getEmailDropdownItems(connectedAccounts) + + const spd = await coreClient.billing.getStripePaymentData.query({ + URN: targetURN, + }) + if (spd && !spd.accountURN) { + const targetAccountURN = + await coreClient.account.getAccountURNForEmail.query( + spd.email.toLowerCase() + ) + + if (!targetAccountURN) { + throw new InternalServerError({ + message: 'No address found for email', + }) + } + + await coreClient.billing.setStripePaymentData.mutate({ + ...spd, + accountURN: targetAccountURN, + URN: targetURN, + }) + + spd.accountURN = targetAccountURN + } + + const cuInvoices = await getCurrentAndUpcomingInvoices( + spd, + context.env.SECRET_STRIPE_API_KEY + ) + + let unpaidInvoiceURL + cuInvoices.some((invoice) => { + if (invoice.status) + if (['uncollectible', 'open'].includes(invoice.status)) { + unpaidInvoiceURL = invoice.url as string + } + }) + + const invoices = cuInvoices.slice(0, 7) + + return json( + { + STRIPE_PUBLISHABLE_KEY: context.env.STRIPE_PUBLISHABLE_KEY, + paymentData: spd, + entitlements: { + [ServicePlanType.PRO]: + plans?.[ServicePlanType.PRO]?.entitlements ?? 0, + }, + connectedEmails, + invoices, + toastNotification, + groupURN, + unpaidInvoiceURL, + }, + { + headers: { + 'Set-Cookie': await commitFlashSession(flashSession, context.env), + }, + } + ) + } +) + +export const action: ActionFunction = getRollupReqFunctionErrorWrapper( + async ({ request, params, context }) => { + const jwt = await requireJWT(request, context.env) + const parsedJwt = parseJwt(jwt!) + const identityURN = parsedJwt.sub as IdentityURN + + const traceHeader = generateTraceContextHeaders(context.traceSpan) + + const coreClient: CoreClientType = createCoreClient(context.env.Core, { + ...getAuthzHeaderConditionallyFromToken(jwt), + ...traceHeader, + }) + + let groupURN + if (params.groupID) { + groupURN = IdentityGroupURNSpace.urn( + params.groupID as string + ) as IdentityGroupURN + } + + const targetURN: IdentityRefURN = groupURN ?? identityURN + if (IdentityGroupURNSpace.is(targetURN)) { + const authorized = + await coreClient.identity.hasIdentityGroupPermissions.query({ + identityURN, + identityGroupURN: targetURN as IdentityGroupURN, + }) + + if (!authorized) { + throw new UnauthorizedError({ + message: 'You are not authorized to update this identity group.', + }) + } + + groupURN = targetURN as IdentityGroupURN + } + + const spd = await coreClient.billing.getStripePaymentData.query({ + URN: targetURN, + }) + + const invoices = await getCurrentAndUpcomingInvoices( + spd, + context.env.SECRET_STRIPE_API_KEY + ) + + const flashSession = await getFlashSession(request, context.env) + + await UnpaidInvoiceNotification({ + invoices, + flashSession, + env: context.env, + }) + + const fd = await request.formData() + const { customerID, quantity, txType } = JSON.parse( + fd.get('payload') as string + ) as { + customerID: string + quantity: number + txType: 'buy' | 'remove' + } + + if (IdentityURNSpace.is(targetURN)) { + const apps = await coreClient.starbase.listApps.query() + const assignedEntitlementCount = apps.filter( + (a) => a.appPlan === ServicePlanType.PRO + ).length + if (assignedEntitlementCount > quantity) { + throw new BadRequestError({ + message: `Invalid quantity. Change ${ + quantity - assignedEntitlementCount + } of the ${assignedEntitlementCount} apps to a different plan first.`, + }) + } + } + + if ((quantity < 1 && txType === 'buy') || quantity < 0) { + throw new BadRequestError({ + message: `Invalid quantity. Please enter a valid number of entitlements.`, + }) + } + + const entitlements = await coreClient.billing.getEntitlements.query({ + URN: targetURN, + }) + + const sub = await createOrUpdateSubscription({ + customerID, + SECRET_STRIPE_PRO_PLAN_ID: context.env.SECRET_STRIPE_PRO_PLAN_ID, + SECRET_STRIPE_API_KEY: context.env.SECRET_STRIPE_API_KEY, + quantity, + subscriptionID: entitlements.subscriptionID, + URN: targetURN, + }) + + if ( + (txType === 'buy' && + (sub.status === 'active' || sub.status === 'trialing')) || + txType !== 'buy' + ) { + await reconcileAppSubscriptions( + { + subscriptionID: sub.id, + URN: targetURN, + coreClient, + billingURL: `${context.env.CONSOLE_URL}/billing`, + settingsURL: `${context.env.CONSOLE_URL}`, + }, + context.env + ) + } + + if (txType === 'buy') { + setPurchaseToastNotification({ + sub, + flashSession, + }) + } + if (txType === 'remove') { + flashSession.flash( + 'toast_notification', + JSON.stringify({ + type: ToastType.Success, + message: 'Entitlement(s) successfully removed', + }) + ) + } + + let status, client_secret, payment_method + if ( + sub.latest_invoice && + (sub.latest_invoice as Stripe.Invoice).payment_intent + ) { + // lots of stripe type casting since by default many + // props are strings (not expanded versions) + ;({ status, client_secret, payment_method } = ( + sub.latest_invoice as Stripe.Invoice + ).payment_intent as Stripe.PaymentIntent) + } + + return json( + { + status, + client_secret, + payment_method, + subId: sub.id, + }, + { + headers: { + 'Set-Cookie': await commitFlashSession(flashSession, context.env), + }, + } + ) + } +) diff --git a/apps/console/app/routes/__layout/billing/payment.tsx b/apps/console/app/routes/__layout/billing/payment.tsx index 9d116ad50c..fe597dd3ca 100644 --- a/apps/console/app/routes/__layout/billing/payment.tsx +++ b/apps/console/app/routes/__layout/billing/payment.tsx @@ -8,8 +8,13 @@ import { parseJwt, } from '@proofzero/utils' import { updatePaymentMethod } from '~/services/billing/stripe' -import { type IdentityURN } from '@proofzero/urns/identity' -import { BadRequestError } from '@proofzero/errors' +import { BadRequestError, UnauthorizedError } from '@proofzero/errors' +import { + IdentityGroupURN, + IdentityGroupURNSpace, +} from '@proofzero/urns/identity-group' +import { IdentityURN } from '@proofzero/urns/identity' +import { IdentityRefURN } from '@proofzero/urns/identity-ref' export const loader: LoaderFunction = getRollupReqFunctionErrorWrapper( async ({ request, context }) => { @@ -24,11 +29,27 @@ export const loader: LoaderFunction = getRollupReqFunctionErrorWrapper( ...traceHeader, }) - const { customerID } = await coreClient.identity.getStripePaymentData.query( - { - identityURN, + const qp = new URL(request.url).searchParams + const URN = qp.get('URN') as IdentityRefURN | undefined + + let targetURN = URN ?? identityURN + if (IdentityGroupURNSpace.is(targetURN)) { + const authorized = + await coreClient.identity.hasIdentityGroupPermissions.query({ + identityURN, + identityGroupURN: targetURN as IdentityGroupURN, + }) + + if (!authorized) { + throw new UnauthorizedError({ + message: 'You are not authorized to update this identity group.', + }) } - ) + } + + const { customerID } = await coreClient.billing.getStripePaymentData.query({ + URN: targetURN, + }) const headers = request.headers let returnURL = headers.get('Referer') diff --git a/apps/console/app/routes/__layout/billing/personal.tsx b/apps/console/app/routes/__layout/billing/personal.tsx new file mode 100644 index 0000000000..4d01c1f7a8 --- /dev/null +++ b/apps/console/app/routes/__layout/billing/personal.tsx @@ -0,0 +1,578 @@ +import { Button } from '@proofzero/design-system' +import { Text } from '@proofzero/design-system/src/atoms/text/Text' +import { + HiOutlineCreditCard, + HiOutlineMail, + HiInformationCircle, +} from 'react-icons/hi' +import { + Link, + NavLink, + useActionData, + useFetcher, + useLoaderData, + useOutletContext, + useSubmit, +} from '@remix-run/react' +import type { LoaderData as OutletContextData } from '~/root' +import { useEffect, useState } from 'react' +import { Toaster, toast } from '@proofzero/design-system/src/atoms/toast' +import plans from '../../../utils/plans' +import { ToastWithLink } from '@proofzero/design-system/src/atoms/toast/ToastWithLink' +import { Input } from '@proofzero/design-system/src/atoms/form/Input' +import { getEmailIcon } from '@proofzero/utils/getNormalisedConnectedAccounts' +import { + Dropdown, + type DropdownSelectListItem, +} from '@proofzero/design-system/src/atoms/dropdown/DropdownSelectList' +import useConnectResult from '@proofzero/design-system/src/hooks/useConnectResult' +import { DangerPill } from '@proofzero/design-system/src/atoms/pills/DangerPill' +import { useHydrated } from 'remix-utils' +import _ from 'lodash' +import { process3DSecureCard } from '~/utils/billing' +import { IoWarningOutline } from 'react-icons/io5' +import { ToastWarning } from '@proofzero/design-system/src/atoms/toast/ToastWarning' +import { Toast } from '@proofzero/design-system/src/atoms/toast/Toast' +import { ServicePlanType } from '@proofzero/types/billing' +import { PlanCard } from '~/components/Billing' +import { + LoaderData, + loader as billingLoader, + action as billingAction, +} from './ops' + +export const loader = billingLoader +export const action = billingAction + +export default () => { + const loaderData = useLoaderData() + const actionData = useActionData() + + const { + STRIPE_PUBLISHABLE_KEY, + entitlements, + toastNotification, + paymentData, + connectedEmails, + invoices, + } = loaderData + + const { apps, PASSPORT_URL, hasUnpaidInvoices } = + useOutletContext() + + const submit = useSubmit() + const fetcher = useFetcher() + + // have it as PRO for now + const hasUnassignedPlans = + +entitlements[ServicePlanType.PRO] - + apps.filter((a) => a.appPlan === ServicePlanType.PRO).length + + useEffect(() => { + // Checking status for 3DS payment authentication + if (actionData?.status || fetcher.data?.status) { + const { status, client_secret, payment_method, subId } = actionData + ? actionData + : fetcher.data + + let clientId = fetcher.data?.clientId + process3DSecureCard({ + STRIPE_PUBLISHABLE_KEY, + status, + subId, + client_secret, + payment_method, + submit, + redirectUrl: '/billing/personal', + updatePlanParams: { + clientId, + plan: ServicePlanType.PRO, + }, + }) + } + }, [actionData, fetcher.data]) + + useEffect(() => { + if (toastNotification) { + toast(toastNotification.type, { + message: toastNotification.message, + }) + } + }, [toastNotification]) + + const redirectToPassport = () => { + const currentURL = new URL(window.location.href) + currentURL.search = '' + + const qp = new URLSearchParams() + qp.append('scope', '') + qp.append('state', 'skip') + qp.append('client_id', 'console') + + qp.append('redirect_uri', currentURL.toString()) + qp.append('rollup_action', 'connect') + qp.append('login_hint', 'email microsoft google apple') + + window.location.href = `${PASSPORT_URL}/authorize?${qp.toString()}` + } + + useConnectResult() + + const [selectedEmail, setSelectedEmail] = useState( + paymentData?.email + ) + const [selectedEmailURN, setSelectedEmailURN] = useState( + paymentData?.accountURN + ) + const [fullName, setFullName] = useState( + paymentData?.name + ) + + const hydrated = useHydrated() + + const [invoiceSort, setInvoiceSort] = useState<'asc' | 'desc'>('desc') + + return ( + <> + + +
+
+ + Billing & Invoicing + +
+
+ +
+ {paymentData && !paymentData.paymentMethodID ? ( +
+ +
+ ) : null} + {hydrated && hasUnassignedPlans ? ( +
+ + } + className={'bg-indigo-50 text-indigo-700 w-full'} + /> +
+ ) : null} + {!paymentData ? ( +
+ +
+ ) : null} +
+ +
+
+
+
+
+ + Billing Contact + + + {!paymentData && } +
+ + This will be used to create a customer ID and for notifications + about your billing + +
+ + +
+
+
+ { + setFullName(e.target.value) + }} + /> +
+ +
+ {connectedEmails && connectedEmails.length === 0 && ( + + )} + + {connectedEmails && connectedEmails.length > 0 && ( + <> + + Email + * + + + { + email.value === '' + ? (email.selected = true) + : (email.selected = false) + // Substituting subtitle with icon + // on the client side + email.subtitle && !email.icon + ? (email.icon = getEmailIcon(email.subtitle)) + : null + return { + value: email.value, + selected: email.selected, + icon: email.icon, + title: email.title, + } + } + )} + placeholder="Select an Email Address" + onSelect={(selected) => { + // type casting to DropdownSelectListItem instead of array + if (!Array.isArray(selected)) { + if (!selected || !selected.value) { + console.error('Error selecting email, try again') + return + } + + setSelectedEmail(selected.title) + setSelectedEmailURN(selected.value) + } + }} + ConnectButtonCallback={redirectToPassport} + ConnectButtonPhrase="Connect New Email Address" + defaultItems={ + connectedEmails.filter( + (ce) => ce.value === paymentData?.accountURN + ) as DropdownSelectListItem[] + } + /> + + )} +
+
+
+ + +
+ +
+
+
+ + Invoices & Payments + + + + + +
+ + {invoices.length > 0 && ( + <> +
+ + + + + + + + + + + {invoices + .sort((a, b) => + invoiceSort === 'desc' + ? b.timestamp - a.timestamp + : a.timestamp - b.timestamp + ) + .map((invoice, idx) => ( + + + + + + + + ))} + +
+ + + + Invoice total + + + + Status + +
+ {hydrated && ( +
+ + {hydrated && + new Date(invoice.timestamp).toLocaleString( + 'default', + { + day: '2-digit', + month: 'short', + year: 'numeric', + } + )} + + + {(invoice.status === 'open' || + invoice.status === 'uncollectible') && ( +
+ + + Payment Error + +
+ )} +
+ )} +
+ + {invoice.amount < 0 ? '-' : ''}$ + {invoice.amount < 0 + ? (invoice.amount * -1).toFixed(2) + : invoice.amount.toFixed(2)} + + + + {invoice.status && _.startCase(invoice.status)} + + {invoice.status === 'paid' && ( + + + View Invoice + + + )} + {(invoice.status === 'open' || + invoice.status === 'uncollectible') && ( +
+ + + Update Payment + + + +
+ )} +
+
+
+ + + View invoice history + + +
+ + )} + + {invoices.length === 0 && ( +
+ + + + + + + + + + + + + + + Your invoices will appear here +
+ )} +
+
+ + ) +} diff --git a/apps/console/app/routes/__layout/billing/portal.tsx b/apps/console/app/routes/__layout/billing/portal.tsx index 4d1150937f..543d09cdca 100644 --- a/apps/console/app/routes/__layout/billing/portal.tsx +++ b/apps/console/app/routes/__layout/billing/portal.tsx @@ -8,8 +8,13 @@ import { parseJwt, } from '@proofzero/utils' import { accessCustomerPortal } from '~/services/billing/stripe' +import { BadRequestError, UnauthorizedError } from '@proofzero/errors' +import { + IdentityGroupURNSpace, + IdentityGroupURN, +} from '@proofzero/urns/identity-group' import { IdentityURN } from '@proofzero/urns/identity' -import { BadRequestError } from '@proofzero/errors' +import { IdentityRefURN } from '@proofzero/urns/identity-ref' export const loader: LoaderFunction = getRollupReqFunctionErrorWrapper( async ({ request, context }) => { @@ -24,11 +29,27 @@ export const loader: LoaderFunction = getRollupReqFunctionErrorWrapper( ...traceHeader, }) - const { customerID } = await coreClient.identity.getStripePaymentData.query( - { - identityURN, + const qp = new URL(request.url).searchParams + const URN = qp.get('URN') as IdentityRefURN | undefined + + let targetURN = URN ?? identityURN + if (IdentityGroupURNSpace.is(targetURN)) { + const authorized = + await coreClient.identity.hasIdentityGroupPermissions.query({ + identityURN, + identityGroupURN: targetURN as IdentityGroupURN, + }) + + if (!authorized) { + throw new UnauthorizedError({ + message: 'You are not authorized to update this identity group.', + }) } - ) + } + + const { customerID } = await coreClient.billing.getStripePaymentData.query({ + URN: targetURN, + }) const headers = request.headers let returnURL = headers.get('Referer') diff --git a/apps/console/app/routes/__layout/billing/spuorg.tsx b/apps/console/app/routes/__layout/billing/spuorg.tsx new file mode 100644 index 0000000000..41599e8d0b --- /dev/null +++ b/apps/console/app/routes/__layout/billing/spuorg.tsx @@ -0,0 +1,47 @@ +import { Outlet, useLoaderData, useOutletContext } from '@remix-run/react' +import createCoreClient from '@proofzero/platform-clients/core' +import { generateTraceContextHeaders } from '@proofzero/platform-middleware/trace' +import { getAuthzHeaderConditionallyFromToken } from '@proofzero/utils' +import { getRollupReqFunctionErrorWrapper } from '@proofzero/utils/errors' +import { LoaderFunction, json, redirect } from '@remix-run/cloudflare' +import { requireJWT } from '~/utilities/session.server' +import type { LoaderData as OutletContextData } from '~/root' +import { ListIdentityGroupsOutput } from '@proofzero/platform/identity/src/jsonrpc/methods/identity-groups/listIdentityGroups' + +export const loader: LoaderFunction = getRollupReqFunctionErrorWrapper( + async ({ request, context }) => { + const jwt = await requireJWT(request, context.env) + + const traceHeader = generateTraceContextHeaders(context.traceSpan) + + const coreClient = createCoreClient(context.env.Core, { + ...getAuthzHeaderConditionallyFromToken(jwt), + ...traceHeader, + }) + + const groups = await coreClient.identity.listIdentityGroups.query() + if (groups.length === 0) { + return redirect('/billing/personal') + } + + return json({ + groups, + }) + } +) + +export default () => { + const { groups } = useLoaderData<{ groups: ListIdentityGroupsOutput }>() + const { PASSPORT_URL, hasUnpaidInvoices } = + useOutletContext() + + return ( + + ) +} diff --git a/apps/console/app/routes/__layout/billing/spuorg/$groupID.tsx b/apps/console/app/routes/__layout/billing/spuorg/$groupID.tsx new file mode 100644 index 0000000000..1b5160e026 --- /dev/null +++ b/apps/console/app/routes/__layout/billing/spuorg/$groupID.tsx @@ -0,0 +1,594 @@ +import { Button } from '@proofzero/design-system' +import { Text } from '@proofzero/design-system/src/atoms/text/Text' +import { HiOutlineCreditCard, HiOutlineMail } from 'react-icons/hi' +import { + Link, + NavLink, + useActionData, + useFetcher, + useLoaderData, + useOutletContext, + useSubmit, +} from '@remix-run/react' +import { useEffect, useState } from 'react' +import { Toaster, toast } from '@proofzero/design-system/src/atoms/toast' +import { ToastWithLink } from '@proofzero/design-system/src/atoms/toast/ToastWithLink' +import { Input } from '@proofzero/design-system/src/atoms/form/Input' +import { getEmailIcon } from '@proofzero/utils/getNormalisedConnectedAccounts' +import { + Dropdown, + type DropdownSelectListItem, +} from '@proofzero/design-system/src/atoms/dropdown/DropdownSelectList' +import useConnectResult from '@proofzero/design-system/src/hooks/useConnectResult' +import { DangerPill } from '@proofzero/design-system/src/atoms/pills/DangerPill' +import { useHydrated } from 'remix-utils' +import _ from 'lodash' +import { process3DSecureCard } from '~/utils/billing' +import { IoWarningOutline } from 'react-icons/io5' +import { ToastWarning } from '@proofzero/design-system/src/atoms/toast/ToastWarning' +import plans from '../../../../utils/plans' +import { ServicePlanType } from '@proofzero/types/billing' +import { PlanCard } from '~/components/Billing' +import { + LoaderData, + loader as billingLoader, + action as billingAction, +} from '../ops' +import Breadcrumbs from '@proofzero/design-system/src/atoms/breadcrumbs/Breadcrumbs' +import { ListIdentityGroupsOutput } from '@proofzero/platform/identity/src/jsonrpc/methods/identity-groups/listIdentityGroups' + +export const loader = billingLoader +export const action = billingAction + +export default () => { + const loaderData = useLoaderData() + const actionData = useActionData() + + const { + STRIPE_PUBLISHABLE_KEY, + entitlements, + toastNotification, + paymentData, + connectedEmails, + invoices, + groupURN, + unpaidInvoiceURL, + } = loaderData + + const { PASSPORT_URL, groups } = useOutletContext<{ + PASSPORT_URL: string + groups: ListIdentityGroupsOutput + }>() + + const submit = useSubmit() + const fetcher = useFetcher() + + useEffect(() => { + // Checking status for 3DS payment authentication + if (actionData?.status || fetcher.data?.status) { + const { status, client_secret, payment_method, subId } = actionData + ? actionData + : fetcher.data + + let clientId = fetcher.data?.clientId + process3DSecureCard({ + STRIPE_PUBLISHABLE_KEY, + status, + subId, + client_secret, + payment_method, + submit, + redirectUrl: `/billing/spuorg/${groupURN?.split('/')[1]}`, + updatePlanParams: { + clientId, + plan: ServicePlanType.PRO, + }, + URN: groupURN, + }) + } + }, [actionData, fetcher.data]) + + useEffect(() => { + if (toastNotification) { + toast(toastNotification.type, { + message: toastNotification.message, + }) + } + }, [toastNotification]) + + const redirectToPassport = () => { + const currentURL = new URL(window.location.href) + currentURL.search = '' + + const qp = new URLSearchParams() + qp.append('scope', '') + qp.append('state', 'skip') + qp.append('client_id', 'console') + + qp.append('redirect_uri', currentURL.toString()) + qp.append('rollup_action', `groupemailconnect_${groupURN!.split('/')[1]}`) + qp.append('login_hint', 'email') + + window.location.href = `${PASSPORT_URL}/authorize?${qp.toString()}` + } + + useConnectResult() + + const [selectedEmail, setSelectedEmail] = useState( + paymentData?.email + ) + const [selectedEmailURN, setSelectedEmailURN] = useState( + paymentData?.accountURN + ) + const [fullName, setFullName] = useState( + paymentData?.name + ) + + const hydrated = useHydrated() + + const [invoiceSort, setInvoiceSort] = useState<'asc' | 'desc'>('desc') + + const group = groups.find((g) => g.URN === groupURN) + + return ( + <> + + + {unpaidInvoiceURL && ( +
+ +
+ )} + +
+ {group && ( + + )} +
+ +
+
+ + Billing & Invoicing + +
+
+ +
+ {paymentData && !paymentData.paymentMethodID ? ( +
+ +
+ ) : null} + {!paymentData ? ( +
+ +
+ ) : null} +
+ +
+
+
+
+
+ + Billing Contact + + + {!paymentData && } +
+ + This will be used to create a customer ID and for notifications + about your billing + +
+ + +
+
+
+ { + setFullName(e.target.value) + }} + /> +
+ +
+ {connectedEmails && connectedEmails.length === 0 && ( + + )} + + {connectedEmails && connectedEmails.length > 0 && ( + <> + + Email + * + + + { + email.value === '' + ? (email.selected = true) + : (email.selected = false) + // Substituting subtitle with icon + // on the client side + email.subtitle && !email.icon + ? (email.icon = getEmailIcon(email.subtitle)) + : null + return { + value: email.value, + selected: email.selected, + icon: email.icon, + title: email.title, + } + } + )} + placeholder="Select an Email Address" + onSelect={(selected) => { + // type casting to DropdownSelectListItem instead of array + if (!Array.isArray(selected)) { + if (!selected || !selected.value) { + console.error('Error selecting email, try again') + return + } + + setSelectedEmail(selected.title) + setSelectedEmailURN(selected.value) + } + }} + ConnectButtonCallback={redirectToPassport} + ConnectButtonPhrase="Connect New Email Address" + defaultItems={ + connectedEmails.filter( + (ce) => ce.value === paymentData?.accountURN + ) as DropdownSelectListItem[] + } + /> + + )} +
+
+
+ + +
+ +
+
+
+ + Invoices & Payments + + + + + +
+ + {invoices.length > 0 && ( + <> +
+ + + + + + + + + + + {invoices + .sort((a, b) => + invoiceSort === 'desc' + ? b.timestamp - a.timestamp + : a.timestamp - b.timestamp + ) + .map((invoice, idx) => ( + + + + + + + + ))} + +
+ + + + Invoice total + + + + Status + +
+ {hydrated && ( +
+ + {hydrated && + new Date(invoice.timestamp).toLocaleString( + 'default', + { + day: '2-digit', + month: 'short', + year: 'numeric', + } + )} + + + {(invoice.status === 'open' || + invoice.status === 'uncollectible') && ( +
+ + + Payment Error + +
+ )} +
+ )} +
+ + {invoice.amount < 0 ? '-' : ''}$ + {invoice.amount < 0 + ? (invoice.amount * -1).toFixed(2) + : invoice.amount.toFixed(2)} + + + + {invoice.status && _.startCase(invoice.status)} + + {invoice.status === 'paid' && ( + + + View Invoice + + + )} + {(invoice.status === 'open' || + invoice.status === 'uncollectible') && ( +
+ + + Update Payment + + + +
+ )} +
+
+
+ + + View invoice history + + +
+ + )} + + {invoices.length === 0 && ( +
+ + + + + + + + + + + + + + + Your invoices will appear here +
+ )} +
+
+ + ) +} diff --git a/apps/console/app/routes/__layout/billing/spuorg/index.tsx b/apps/console/app/routes/__layout/billing/spuorg/index.tsx new file mode 100644 index 0000000000..0cebb21428 --- /dev/null +++ b/apps/console/app/routes/__layout/billing/spuorg/index.tsx @@ -0,0 +1,79 @@ +import { Button } from '@proofzero/design-system' +import { List } from '@proofzero/design-system/src/atoms/lists/List' +import { ListIdentityGroupsOutput } from '@proofzero/platform/identity/src/jsonrpc/methods/identity-groups/listIdentityGroups' +import { NavLink, useOutletContext } from '@remix-run/react' +import { Text } from '@proofzero/design-system' +import { DangerPill } from '@proofzero/design-system/src/atoms/pills/DangerPill' + +export default () => { + const { groups } = useOutletContext<{ + groups: ListIdentityGroupsOutput + }>() + return ( + <> +
+
+ + Billing & Invoicing + +
+
+ + ( +
+ + Personal + + +
+ + + +
+
+ )} + /> + + ({ + key: g.URN, + val: g, + }))} + itemRenderer={(item) => ( +
+ + {item.val.name} + + +
+ {!item.val.flags.billingConfigured && ( + + )} + + + +
+
+ )} + /> + + ) +} diff --git a/apps/console/app/routes/__layout/billing/update.tsx b/apps/console/app/routes/__layout/billing/update.tsx index 85b5f06453..f2196f3181 100644 --- a/apps/console/app/routes/__layout/billing/update.tsx +++ b/apps/console/app/routes/__layout/billing/update.tsx @@ -14,8 +14,13 @@ import { import { reconcileAppSubscriptions } from '~/services/billing/stripe' import { type IdentityURN } from '@proofzero/urns/identity' import { ToastType } from '@proofzero/design-system/src/atoms/toast' -import Stripe from 'stripe' -import { type ServicePlanType } from '@proofzero/types/identity' +import { ServicePlanType } from '@proofzero/types/billing' +import { UnauthorizedError } from '@proofzero/errors' +import { + IdentityGroupURNSpace, + IdentityGroupURN, +} from '@proofzero/urns/identity-group' +import { IdentityRefURN } from '@proofzero/urns/identity-ref' /** * WARNING: Here be dragons, and not the cute, cuddly kind! This code runs twice in certain scenarios because when the user @@ -40,12 +45,28 @@ export const action: ActionFunction = getRollupReqFunctionErrorWrapper( const subId = fd.get('subId') as string const redirectUrl = fd.get('redirectUrl') as string const updatePlanParams = fd.get('updatePlanParams') as string + const URN = fd.get('URN') as IdentityRefURN | undefined const coreClient = createCoreClient(context.env.Core, { ...getAuthzHeaderConditionallyFromToken(jwt), ...traceHeader, }) + let targetURN = URN ?? identityURN + if (IdentityGroupURNSpace.is(targetURN)) { + const authorized = + await coreClient.identity.hasIdentityGroupPermissions.query({ + identityURN, + identityGroupURN: targetURN as IdentityGroupURN, + }) + + if (!authorized) { + throw new UnauthorizedError({ + message: 'You are not authorized to update this identity group.', + }) + } + } + // if this method was called from "$clientId/billing" page, update the plan // and assign the new plan to the app @@ -56,7 +77,7 @@ export const action: ActionFunction = getRollupReqFunctionErrorWrapper( await reconcileAppSubscriptions( { subscriptionID: subId, - identityURN, + URN: targetURN, coreClient, billingURL: `${context.env.CONSOLE_URL}/billing`, settingsURL: `${context.env.CONSOLE_URL}`, @@ -73,8 +94,8 @@ export const action: ActionFunction = getRollupReqFunctionErrorWrapper( paymentIntentId: string } - const entitlements = await coreClient.identity.getEntitlements.query({ - identityURN, + const entitlements = await coreClient.billing.getEntitlements.query({ + URN: targetURN, }) const numberOfEntitlements = entitlements.plans[plan]?.entitlements @@ -87,7 +108,7 @@ export const action: ActionFunction = getRollupReqFunctionErrorWrapper( numberOfEntitlements > allotedApps ) { await coreClient.starbase.setAppPlan.mutate({ - identityURN, + URN: targetURN, clientId, plan, }) diff --git a/apps/console/app/routes/__layout/billing/webhook.tsx b/apps/console/app/routes/__layout/billing/webhook.tsx index c294a8c7f1..bc13e8ac79 100644 --- a/apps/console/app/routes/__layout/billing/webhook.tsx +++ b/apps/console/app/routes/__layout/billing/webhook.tsx @@ -4,14 +4,14 @@ import { type ActionFunction } from '@remix-run/cloudflare' import Stripe from 'stripe' import createCoreClient from '@proofzero/platform-clients/core' -import { type IdentityURN } from '@proofzero/urns/identity' import { getAuthzHeaderConditionallyFromToken } from '@proofzero/utils' import { reconcileAppSubscriptions } from '~/services/billing/stripe' import { InternalServerError, RollupError } from '@proofzero/errors' import { type AccountURN } from '@proofzero/urns/account' import { createAnalyticsEvent } from '@proofzero/utils/analytics' -import { ServicePlanType } from '@proofzero/types/identity' +import { ServicePlanType } from '@proofzero/types/billing' +import { IdentityRefURN } from '@proofzero/urns/identity-ref' type StripeInvoicePayload = { id: string @@ -51,6 +51,8 @@ export const action: ActionFunction = getRollupReqFunctionErrorWrapper( whSecret ) + let URN + switch (event.type) { case 'customer.subscription.created': case 'customer.subscription.updated': @@ -63,7 +65,7 @@ export const action: ActionFunction = getRollupReqFunctionErrorWrapper( id: string latest_invoice: string metadata: { - identityURN: IdentityURN + URN?: IdentityRefURN } status: string } @@ -79,8 +81,10 @@ export const action: ActionFunction = getRollupReqFunctionErrorWrapper( return null } - const entitlements = await coreClient.identity.getEntitlements.query({ - identityURN: subMeta.identityURN, + URN = subMeta.URN as IdentityRefURN + + const entitlements = await coreClient.billing.getEntitlements.query({ + URN, }) if (entitlements?.subscriptionID !== id) { throw new RollupError({ @@ -91,7 +95,7 @@ export const action: ActionFunction = getRollupReqFunctionErrorWrapper( await reconcileAppSubscriptions( { subscriptionID: id, - identityURN: subMeta.identityURN, + URN, coreClient, billingURL: `${context.env.CONSOLE_URL}/billing`, settingsURL: `${context.env.CONSOLE_URL}`, @@ -112,14 +116,16 @@ export const action: ActionFunction = getRollupReqFunctionErrorWrapper( default_payment_method: string } metadata: { - identityURN: IdentityURN + URN?: IdentityRefURN } } + URN = cusMeta.URN as IdentityRefURN + if (invoice_settings?.default_payment_method) { const paymentData = - await coreClient.identity.getStripePaymentData.query({ - identityURN: cusMeta.identityURN, + await coreClient.billing.getStripePaymentData.query({ + URN, }) paymentData.paymentMethodID = invoice_settings.default_payment_method @@ -144,10 +150,10 @@ export const action: ActionFunction = getRollupReqFunctionErrorWrapper( const accountURN = paymentData.accountURN ?? (inferredAccountURN as AccountURN) - await coreClient.identity.setStripePaymentData.mutate({ + await coreClient.billing.setStripePaymentData.mutate({ ...paymentData, accountURN, - identityURN: cusMeta.identityURN, + URN, }) } @@ -210,7 +216,7 @@ export const action: ActionFunction = getRollupReqFunctionErrorWrapper( await createAnalyticsEvent({ eventName: 'identity_purchased_entitlement', apiKey: context.env.POSTHOG_API_KEY, - distinctId: customerDataSuccess.metadata.identityURN, + distinctId: customerDataSuccess.metadata.URN, properties: { plans: purchasedItems.map((item) => ({ quantity: item.quantity, @@ -268,7 +274,7 @@ export const action: ActionFunction = getRollupReqFunctionErrorWrapper( customer: string id: string metadata: { - identityURN: IdentityURN + URN?: IdentityRefURN } } const customerDataDel = await stripeClient.customers.retrieve( @@ -277,18 +283,20 @@ export const action: ActionFunction = getRollupReqFunctionErrorWrapper( if (!customerDataDel.deleted && customerDataDel.email) { const { email, name } = customerDataDel + URN = metaDel.URN as IdentityRefURN + await Promise.all([ coreClient.account.sendBillingNotification.mutate({ email, name: name || 'Client', }), - coreClient.identity.cancelServicePlans.mutate({ - identity: metaDel.identityURN, + coreClient.billing.cancelServicePlans.mutate({ + URN, subscriptionID: subIdDel, deletePaymentData: event.type === 'customer.deleted', }), coreClient.starbase.deleteSubscriptionPlans.mutate({ - identityURN: metaDel.identityURN, + URN, }), ]) } diff --git a/apps/console/app/routes/__layout/spuorg/$groupID/index.tsx b/apps/console/app/routes/__layout/spuorg/$groupID/index.tsx index 29a0c4760e..c4e666e1da 100644 --- a/apps/console/app/routes/__layout/spuorg/$groupID/index.tsx +++ b/apps/console/app/routes/__layout/spuorg/$groupID/index.tsx @@ -517,6 +517,7 @@ export default () => { Icon={TbReceipt2} title="Group Billing & Invoicing" subtitle="Manage Billing and Entitlements" + onClick={() => navigate(`/billing/spuorg/${groupID}`)} /> diff --git a/apps/console/app/routes/apps/$clientId/billing.tsx b/apps/console/app/routes/apps/$clientId/billing.tsx index 9fc86e843a..b226a21874 100644 --- a/apps/console/app/routes/apps/$clientId/billing.tsx +++ b/apps/console/app/routes/apps/$clientId/billing.tsx @@ -1,7 +1,5 @@ import { Text } from '@proofzero/design-system/src/atoms/text/Text' -import plans, { type PlanDetails } from '~/routes/__layout/billing/plans' -import { PlanFeatures } from '~/routes/__layout/billing' -import { type PaymentData, ServicePlanType } from '@proofzero/types/identity' +import plans, { type PlanDetails } from '~/utils/plans' import { Button } from '@proofzero/design-system' import { StatusPill } from '@proofzero/design-system/src/atoms/pills/StatusPill' import { @@ -29,8 +27,6 @@ import { useOutletContext, useSubmit, } from '@remix-run/react' -import { type GetEntitlementsOutput } from '@proofzero/platform/identity/src/jsonrpc/methods/getEntitlements' -import { type IdentityURN } from '@proofzero/urns/identity' import { BadRequestError } from '@proofzero/errors' import type { ToastNotification, appDetailsProps } from '~/types' import { type AppLoaderData } from '~/root' @@ -54,6 +50,10 @@ import { } from '~/utils/billing' import { setPurchaseToastNotification } from '~/utils' import type Stripe from 'stripe' +import { PaymentData, ServicePlanType } from '@proofzero/types/billing' +import { IdentityURN } from '@proofzero/urns/identity' +import { GetEntitlementsOutput } from '@proofzero/platform/billing/src/jsonrpc/methods/getEntitlements' +import { PlanFeatures } from '~/components/Billing' export const loader: LoaderFunction = getRollupReqFunctionErrorWrapper( async ({ request, context }) => { @@ -67,11 +67,11 @@ export const loader: LoaderFunction = getRollupReqFunctionErrorWrapper( ...traceHeader, }) - const entitlements = await coreClient.identity.getEntitlements.query({ - identityURN, + const entitlements = await coreClient.billing.getEntitlements.query({ + URN: identityURN, }) - const paymentData = await coreClient.identity.getStripePaymentData.query({ - identityURN, + const paymentData = await coreClient.billing.getStripePaymentData.query({ + URN: identityURN, }) const flashSession = await getFlashSession(request, context.env) @@ -113,8 +113,8 @@ const processUpdateOp = async ( ...traceHeader, }) - const entitlements = await coreClient.identity.getEntitlements.query({ - identityURN, + const entitlements = await coreClient.billing.getEntitlements.query({ + URN: identityURN, }) const apps = await coreClient.starbase.listApps.query() @@ -130,7 +130,7 @@ const processUpdateOp = async ( } await coreClient.starbase.setAppPlan.mutate({ - identityURN, + URN: identityURN, clientId, plan, }) @@ -174,12 +174,12 @@ const processPurchaseOp = async ( ...traceHeader, }) - const entitlements = await coreClient.identity.getEntitlements.query({ - identityURN, + const entitlements = await coreClient.billing.getEntitlements.query({ + URN: identityURN, }) - const paymentData = await coreClient.identity.getStripePaymentData.query({ - identityURN, + const paymentData = await coreClient.billing.getStripePaymentData.query({ + URN: identityURN, }) if (!paymentData || !paymentData.customerID) { throw new BadRequestError({ @@ -200,7 +200,7 @@ const processPurchaseOp = async ( SECRET_STRIPE_API_KEY: env.SECRET_STRIPE_API_KEY, quantity, subscriptionID: entitlements.subscriptionID, - identityURN, + URN: identityURN, }) setPurchaseToastNotification({ @@ -214,15 +214,15 @@ const processPurchaseOp = async ( (sub.status === 'active' || sub.status === 'trialing') && invoiceStatus === 'paid' ) { - await coreClient.identity.updateEntitlements.mutate({ - identityURN: identityURN, + await coreClient.billing.updateEntitlements.mutate({ + URN: identityURN, subscriptionID: sub.id, quantity: quantity, type: plan, }) await coreClient.starbase.setAppPlan.mutate({ - identityURN, + URN: identityURN, clientId, plan, }) @@ -253,8 +253,8 @@ export const action: ActionFunction = getRollupReqFunctionErrorWrapper( ...traceHeader, }) - const spd = await coreClient.identity.getStripePaymentData.query({ - identityURN, + const spd = await coreClient.billing.getStripePaymentData.query({ + URN: identityURN, }) const invoices = await getCurrentAndUpcomingInvoices( @@ -611,7 +611,7 @@ const DowngradeConfirmationModal = ({
    {plans[currentPlan].features - .filter((f) => f.type === 'addon') + .filter((f) => f.type === 'current') .map((f) => (
  • {f.title}
  • ))} diff --git a/apps/console/app/routes/apps/$clientId/designer.tsx b/apps/console/app/routes/apps/$clientId/designer.tsx index e59bab02b3..295d7bcc2f 100644 --- a/apps/console/app/routes/apps/$clientId/designer.tsx +++ b/apps/console/app/routes/apps/$clientId/designer.tsx @@ -91,7 +91,7 @@ import { AccountURN } from '@proofzero/urns/account' import danger from '~/images/danger.svg' import { ToastType, toast } from '@proofzero/design-system/src/atoms/toast' import classNames from 'classnames' -import { ServicePlanType } from '@proofzero/types/identity' +import { ServicePlanType } from '@proofzero/types/billing' import { planGuardWithToastException } from '~/utils/planGate' import designerSVG from '~/assets/early/designer.webp' import EarlyAccessPanel from '~/components/EarlyAccess/EarlyAccessPanel' @@ -1546,13 +1546,10 @@ export const action: ActionFunction = getRollupReqFunctionErrorWrapper( message: issue.message, })) - errors = mappedIssues.reduce( - (acc, curr) => { - acc[curr.path] = curr.message - return acc - }, - {} as { [key: string]: string } - ) + errors = mappedIssues.reduce((acc, curr) => { + acc[curr.path] = curr.message + return acc + }, {} as { [key: string]: string }) } else { await coreClient.starbase.setAppTheme.mutate({ clientId, @@ -1589,13 +1586,10 @@ export const action: ActionFunction = getRollupReqFunctionErrorWrapper( message: issue.message, })) - errors = mappedIssues.reduce( - (acc, curr) => { - acc[curr.path] = curr.message - return acc - }, - {} as { [key: string]: string } - ) + errors = mappedIssues.reduce((acc, curr) => { + acc[curr.path] = curr.message + return acc + }, {} as { [key: string]: string }) } else { await coreClient.starbase.setEmailOTPTheme.mutate({ clientId, @@ -1632,13 +1626,10 @@ export const action: ActionFunction = getRollupReqFunctionErrorWrapper( message: issue.message, })) - errors = mappedIssues.reduce( - (acc, curr) => { - acc[curr.path] = curr.message - return acc - }, - {} as { [key: string]: string } - ) + errors = mappedIssues.reduce((acc, curr) => { + acc[curr.path] = curr.message + return acc + }, {} as { [key: string]: string }) } else { await coreClient.starbase.setOgTheme.mutate({ clientId, diff --git a/apps/console/app/routes/apps/$clientId/domain.tsx b/apps/console/app/routes/apps/$clientId/domain.tsx index e2992bef7e..4bedd0a0ac 100644 --- a/apps/console/app/routes/apps/$clientId/domain.tsx +++ b/apps/console/app/routes/apps/$clientId/domain.tsx @@ -39,7 +39,7 @@ import { requireJWT } from '~/utilities/session.server' import dangerVector from '~/images/danger.svg' import { planGuardWithToastException } from '~/utils/planGate' -import { ServicePlanType } from '@proofzero/types/identity' +import { ServicePlanType } from '@proofzero/types/billing' import { appDetailsProps } from '~/types' import EarlyAccessPanel from '~/components/EarlyAccess/EarlyAccessPanel' diff --git a/apps/console/app/routes/apps/$clientId/team.tsx b/apps/console/app/routes/apps/$clientId/team.tsx index edd09d92f4..ce1bbf7e12 100644 --- a/apps/console/app/routes/apps/$clientId/team.tsx +++ b/apps/console/app/routes/apps/$clientId/team.tsx @@ -47,7 +47,7 @@ export const loader: LoaderFunction = getRollupReqFunctionErrorWrapper( }) const connectedAccounts = await coreClient.identity.getAccounts.query({ - identity: identityURN, + URN: identityURN, }) const connectedEmails = getEmailDropdownItems(connectedAccounts) diff --git a/apps/console/app/routes/spuorg.enroll.$groupID.$invitationCode.tsx b/apps/console/app/routes/spuorg.enroll.$groupID.$invitationCode.tsx index f8f0f4e917..ab35ebfe21 100644 --- a/apps/console/app/routes/spuorg.enroll.$groupID.$invitationCode.tsx +++ b/apps/console/app/routes/spuorg.enroll.$groupID.$invitationCode.tsx @@ -85,7 +85,7 @@ export const loader: LoaderFunction = getRollupReqFunctionErrorWrapper( } const identityAccounts = await coreClient.identity.getOwnAccounts.query({ - identity: identityURN, + URN: identityURN, }) const invitedAccount = identityAccounts.find( (aa) => diff --git a/apps/console/app/services/billing/stripe.ts b/apps/console/app/services/billing/stripe.ts index 971fde2371..bfb5d38256 100644 --- a/apps/console/app/services/billing/stripe.ts +++ b/apps/console/app/services/billing/stripe.ts @@ -1,17 +1,17 @@ import { InternalServerError } from '@proofzero/errors' import { type CoreClientType } from '@proofzero/platform-clients/core' import { type ReconcileAppsSubscriptionsOutput } from '@proofzero/platform/starbase/src/jsonrpc/methods/reconcileAppSubscriptions' -import { ServicePlanType } from '@proofzero/types/identity' -import { type IdentityURN } from '@proofzero/urns/identity' +import { ServicePlanType } from '@proofzero/types/billing' +import { IdentityRefURN } from '@proofzero/urns/identity-ref' import { redirect } from '@remix-run/cloudflare' import { type Env } from 'bindings' import Stripe from 'stripe' -import plans from '~/routes/__layout/billing/plans' +import plans from '~/utils/plans' type CreateCustomerParams = { email: string name: string - identityURN: string + URN: IdentityRefURN } type UpdateCustomerParams = { @@ -29,7 +29,7 @@ type CreateSubscriptionParams = { customerID: string planID: string quantity: number - identityURN: IdentityURN + URN: IdentityRefURN handled?: boolean } @@ -41,7 +41,7 @@ type UpdateSubscriptionParams = { } type SubscriptionMetadata = Partial<{ - identityURN: IdentityURN + URN: IdentityRefURN handled: string | null }> @@ -50,7 +50,7 @@ type GetInvoicesParams = { } export const createCustomer = async ( - { email, name, identityURN }: CreateCustomerParams, + { email, name, URN }: CreateCustomerParams, env: Env ) => { const stripeClient = new Stripe(env.SECRET_STRIPE_API_KEY, { @@ -61,7 +61,7 @@ export const createCustomer = async ( email, name, metadata: { - identityURN, + URN, }, }) @@ -124,13 +124,13 @@ export const createSubscription = async ( customerID, planID, quantity, - identityURN, + URN, handled = false, }: CreateSubscriptionParams, stripeClient: Stripe ) => { const metadata: SubscriptionMetadata = {} - metadata.identityURN = identityURN + metadata.URN = URN if (handled) metadata.handled = handled.toString() @@ -244,13 +244,13 @@ export const voidInvoice = async ( export const reconcileAppSubscriptions = async ( { subscriptionID, - identityURN, + URN, coreClient, billingURL, settingsURL, }: { subscriptionID: string - identityURN: IdentityURN + URN: IdentityRefURN coreClient: CoreClientType billingURL: string settingsURL: string @@ -277,25 +277,25 @@ export const reconcileAppSubscriptions = async ( } const { email: billingEmail } = - await coreClient.identity.getStripePaymentData.query({ - identityURN, + await coreClient.billing.getStripePaymentData.query({ + URN, }) let reconciliations: ReconcileAppsSubscriptionsOutput = [] for (const pq of planQuantities) { const planReconciliations = await coreClient.starbase.reconcileAppSubscriptions.mutate({ - identityURN, - count: pq.quantity, + URN: URN, + count: pq.quantity!, plan: priceIdToPlanTypeDict[pq.priceID], }) reconciliations = reconciliations.concat(planReconciliations) - await coreClient.identity.updateEntitlements.mutate({ - identityURN, + await coreClient.billing.updateEntitlements.mutate({ + URN: URN, subscriptionID: subscriptionID, - quantity: pq.quantity, + quantity: pq.quantity!, type: priceIdToPlanTypeDict[pq.priceID], }) } @@ -318,7 +318,7 @@ export const reconcileAppSubscriptions = async ( ) await coreClient.account.sendReconciliationNotification.query({ - planType: plans[reconciliations[0].plan].title, // Only pro for now + planType: plans[ServicePlanType.PRO].title, // Only pro for now count: reconciliations.length, billingEmail, apps: reconciliations.map((app) => ({ diff --git a/apps/console/app/types.ts b/apps/console/app/types.ts index b75029ac6b..6f9a8bd172 100644 --- a/apps/console/app/types.ts +++ b/apps/console/app/types.ts @@ -5,7 +5,7 @@ import type { EdgesMetadata, CustomDomain, } from '@proofzero/platform/starbase/src/types' -import { type ServicePlanType } from '@proofzero/types/identity' +import { ServicePlanType } from '@proofzero/types/billing' export enum RollType { RollAPIKey = 'roll_api_key', diff --git a/apps/console/app/utils/billing.ts b/apps/console/app/utils/billing.ts index 46f17e6656..4fc9ba3a32 100644 --- a/apps/console/app/utils/billing.ts +++ b/apps/console/app/utils/billing.ts @@ -4,16 +4,16 @@ import { getUpcomingInvoices, updateSubscription, } from '~/services/billing/stripe' -import type { StripePaymentData } from '@proofzero/platform/identity/src/types' +import type { StripePaymentData } from '@proofzero/platform/billing/src/types' import { ToastType, toast } from '@proofzero/design-system/src/atoms/toast' -import { type IdentityURN } from '@proofzero/urns/identity' import { type PaymentIntent, loadStripe } from '@stripe/stripe-js' import { type SubmitFunction } from '@remix-run/react' import { type Session, type SessionData } from '@remix-run/cloudflare' import { commitFlashSession } from '~/utilities/session.server' import { type Env } from 'bindings' import Stripe from 'stripe' -import { type ServicePlanType } from '@proofzero/types/identity' +import { ServicePlanType } from '@proofzero/types/billing' +import { IdentityRefURN } from '@proofzero/urns/identity-ref' export type StripeInvoice = { id: string @@ -86,14 +86,14 @@ export const createOrUpdateSubscription = async ({ SECRET_STRIPE_PRO_PLAN_ID, SECRET_STRIPE_API_KEY, quantity, - identityURN, + URN, customerID, }: { subscriptionID?: string | null SECRET_STRIPE_PRO_PLAN_ID: string SECRET_STRIPE_API_KEY: string quantity: number - identityURN: IdentityURN + URN: IdentityRefURN customerID: string }) => { const stripeClient = new Stripe(SECRET_STRIPE_API_KEY, { @@ -107,7 +107,7 @@ export const createOrUpdateSubscription = async ({ customerID: customerID, planID: SECRET_STRIPE_PRO_PLAN_ID, quantity, - identityURN, + URN, handled: true, }, stripeClient @@ -136,6 +136,7 @@ export const process3DSecureCard = async ({ subId, redirectUrl, updatePlanParams, + URN, }: { STRIPE_PUBLISHABLE_KEY: string status: string @@ -148,6 +149,7 @@ export const process3DSecureCard = async ({ clientId?: string plan: ServicePlanType } + URN?: IdentityRefURN }) => { const stripeClient = await loadStripe(STRIPE_PUBLISHABLE_KEY) if (status === 'requires_action') { @@ -162,19 +164,26 @@ export const process3DSecureCard = async ({ return null } - submit( - { - subId, - redirectUrl: redirectUrl ? redirectUrl : '/billing', - updatePlanParams: updatePlanParams - ? JSON.stringify(updatePlanParams) - : '', - }, - { - method: 'post', - action: `/billing/update`, - } - ) + let payload: { + subId: string + redirectUrl: string + updatePlanParams: string + URN?: IdentityRefURN + } = { + subId, + redirectUrl: redirectUrl ? redirectUrl : '/billing', + updatePlanParams: updatePlanParams + ? JSON.stringify(updatePlanParams) + : '', + } + if (URN) { + payload.URN = URN + } + + submit(payload, { + method: 'post', + action: `/billing/update`, + }) } } diff --git a/apps/console/app/utils/planGate.ts b/apps/console/app/utils/planGate.ts index 2ffb237be0..ea26b066f0 100644 --- a/apps/console/app/utils/planGate.ts +++ b/apps/console/app/utils/planGate.ts @@ -1,6 +1,6 @@ -import { ServicePlanType } from '@proofzero/types/identity' +import { ServicePlanType } from '@proofzero/types/billing' import { appendToastToFlashSession } from './toast.server' -import plans from '~/routes/__layout/billing/plans' +import plans from '~/utils/plans' import { ToastType } from '@proofzero/design-system/src/atoms/toast' import { commitFlashSession } from '~/utilities/session.server' import { Env } from 'bindings' diff --git a/apps/console/app/routes/__layout/billing/plans.ts b/apps/console/app/utils/plans.ts similarity index 96% rename from apps/console/app/routes/__layout/billing/plans.ts rename to apps/console/app/utils/plans.ts index a798c362a1..7fa51c3363 100644 --- a/apps/console/app/routes/__layout/billing/plans.ts +++ b/apps/console/app/utils/plans.ts @@ -1,4 +1,4 @@ -import { ServicePlanType } from '@proofzero/types/identity' +import { ServicePlanType } from '@proofzero/types/billing' type PlanFeature = { title: string diff --git a/apps/passport/app/routes/authenticate/$clientId/email/verify.tsx b/apps/passport/app/routes/authenticate/$clientId/email/verify.tsx index 0e6ea35361..5697d7ade4 100644 --- a/apps/passport/app/routes/authenticate/$clientId/email/verify.tsx +++ b/apps/passport/app/routes/authenticate/$clientId/email/verify.tsx @@ -1,33 +1,45 @@ -import { json } from '@remix-run/cloudflare' +import { json, redirect } from '@remix-run/cloudflare' import { action as otpAction } from '~/routes/connect/email/otp' import { EmailOTPValidator } from '@proofzero/design-system/src/molecules/email-otp-validator' import { useActionData, useFetcher, useLoaderData, - useLocation, useNavigate, useOutletContext, useSubmit, useTransition, } from '@remix-run/react' -import { getAuthzCookieParams, getUserSession } from '~/session.server' +import { + getAuthzCookieParams, + getUserSession, + getValidatedSessionContext, +} from '~/session.server' import { getCoreClient } from '~/platform.server' -import { authenticateAccount } from '~/utils/authenticate.server' -import { useEffect, useState } from 'react' +import { + authenticateAccount, + getAuthzRedirectURL, +} from '~/utils/authenticate.server' +import { useState } from 'react' import type { ActionFunction, LoaderFunction } from '@remix-run/cloudflare' import { BadRequestError, ERROR_CODES, HTTP_STATUS_CODES, + UnauthorizedError, } from '@proofzero/errors' import { Button, Text } from '@proofzero/design-system' import { getRollupReqFunctionErrorWrapper } from '@proofzero/utils/errors' import { generateEmailOTP } from '~/utils/emailOTP' +import { + IdentityGroupURN, + IdentityGroupURNSpace, +} from '@proofzero/urns/identity-group' +import { AccountURNSpace } from '@proofzero/urns/account' export const loader: LoaderFunction = getRollupReqFunctionErrorWrapper( - async ({ request, context, params }) => { + async ({ request, params }) => { const qp = new URL(request.url).searchParams const email = qp.get('email') @@ -52,7 +64,48 @@ export const action: ActionFunction = getRollupReqFunctionErrorWrapper( if (successfulVerification) { const appData = await getAuthzCookieParams(request, context.env) - const coreClient = getCoreClient({ context, accountURN }) + let coreClient = getCoreClient({ context, accountURN }) + + if (appData?.rollup_action?.startsWith('groupemailconnect')) { + const { jwt } = await getValidatedSessionContext( + request, + context.authzQueryParams, + context.env, + context.traceSpan + ) + + if (!jwt) { + throw new UnauthorizedError({ + message: 'No JWT in session context', + }) + } + + coreClient = getCoreClient({ + context, + jwt, + }) + + let result = undefined + + const identityGroupID = appData.rollup_action.split('_')[1] + const identityGroupURN = IdentityGroupURNSpace.urn( + identityGroupID as string + ) as IdentityGroupURN + + const { existing } = + await coreClient.identity.connectIdentityGroupEmail.mutate({ + accountURN: accountURN, + identityGroupURN, + }) + + if (existing) { + result = 'ALREADY_CONNECTED_ERROR' + } + + const redirectURL = getAuthzRedirectURL(appData, result) + + return redirect(redirectURL) + } const { identityURN, existing } = await coreClient.account.resolveIdentity.query({ diff --git a/apps/passport/app/routes/authenticate/$clientId/index.tsx b/apps/passport/app/routes/authenticate/$clientId/index.tsx index 50fdb9ca18..06431a78ff 100644 --- a/apps/passport/app/routes/authenticate/$clientId/index.tsx +++ b/apps/passport/app/routes/authenticate/$clientId/index.tsx @@ -72,7 +72,7 @@ export const loader: LoaderFunction = getRollupReqFunctionErrorWrapper( ) let invitationData - if (authzParams.rollup_action?.startsWith('group')) { + if (authzParams.rollup_action?.startsWith('groupconnect')) { const groupID = authzParams.rollup_action.split('_')[1] const invitationCode = authzParams.rollup_action.split('_')[2] @@ -228,7 +228,7 @@ const InnerComponent = ({ size="sm" > - {!rollup_action?.startsWith('group') && ( + {!rollup_action?.startsWith('groupconnect') && (

    { diff --git a/apps/passport/app/routes/settings/advanced.tsx b/apps/passport/app/routes/settings/advanced.tsx index e48b314dda..948c471aca 100644 --- a/apps/passport/app/routes/settings/advanced.tsx +++ b/apps/passport/app/routes/settings/advanced.tsx @@ -37,7 +37,7 @@ export const action: ActionFunction = getRollupReqFunctionErrorWrapper( const coreClient = getCoreClient({ context, jwt }) const [accounts, apps, ownedApps] = await Promise.all([ coreClient.identity.getAccounts.query({ - identity: identityURN, + URN: identityURN, }), coreClient.identity.getAuthorizedApps.query({ identity: identityURN, diff --git a/apps/passport/app/session.server.ts b/apps/passport/app/session.server.ts index c1b52ce5f4..6c3fd625b7 100644 --- a/apps/passport/app/session.server.ts +++ b/apps/passport/app/session.server.ts @@ -177,7 +177,7 @@ export async function createAuthzParamsCookieAndAuthenticate( ) { let redirectURL = `/authenticate/${authzQueryParams.clientId}${ ['connect', 'reconnect'].includes(authzQueryParams.rollup_action || '') || - authzQueryParams.rollup_action?.startsWith('groupconnect') + authzQueryParams.rollup_action?.startsWith('group') ? '' : `/account` }` @@ -383,7 +383,12 @@ export function parseJwt(token: string): JWTPayload { } export function isSupportedRollupAction(rollupAction: string) { - return ['connect', 'create', 'reconnect', 'group', 'groupconnect'].includes( - rollupAction.split('_')[0] - ) + return [ + 'connect', + 'create', + 'reconnect', + 'group', + 'groupconnect', + 'groupemailconnect', + ].includes(rollupAction.split('_')[0]) } diff --git a/apps/passport/app/utils/authorize.server.ts b/apps/passport/app/utils/authorize.server.ts index 8ea5c9ea6c..dc3d50f69e 100644 --- a/apps/passport/app/utils/authorize.server.ts +++ b/apps/passport/app/utils/authorize.server.ts @@ -70,7 +70,7 @@ export const getDataForScopes = async ( const coreClient = getCoreClient({ context, jwt }) const connectedAccounts = await coreClient.identity.getAccounts.query({ - identity: identityURN, + URN: identityURN, }) if (connectedAccounts && connectedAccounts.length) { diff --git a/packages/platform-middleware/inputValidators.ts b/packages/platform-middleware/inputValidators.ts index 0c938384db..6ee901d63a 100644 --- a/packages/platform-middleware/inputValidators.ts +++ b/packages/platform-middleware/inputValidators.ts @@ -5,6 +5,7 @@ import { } from '@proofzero/urns/authorization' import { AccountURN, AccountURNSpace } from '@proofzero/urns/account' import { IdentityURN, IdentityURNSpace } from '@proofzero/urns/identity' +import { IdentityRefURN } from '@proofzero/urns/identity-ref' import { AnyURN, parseURN } from '@proofzero/urns' import { EdgeURN } from '@proofzero/urns/edge' import { CryptoAccountType } from '@proofzero/types/account' @@ -81,3 +82,14 @@ export const NodeFilterInput = z.object({ qc: z.record(z.string(), z.string().or(z.boolean()).optional()).optional(), rc: z.record(z.string(), z.string().or(z.boolean()).optional()).optional(), }) + +export const IdentityRefURNValidator = z.custom((input) => { + if ( + !IdentityURNSpace.is(input as IdentityURN) && + !IdentityGroupURNSpace.is(input as IdentityGroupURN) + ) { + throw new Error('Invalid IdentityRefURN entry') + } + + return input as IdentityRefURN +}) diff --git a/packages/security/persona.ts b/packages/security/persona.ts index ec78fd831a..b709126b77 100644 --- a/packages/security/persona.ts +++ b/packages/security/persona.ts @@ -91,7 +91,7 @@ export async function validatePersonaData( ...generateTraceContextHeaders(traceSpan), }) const identityAccounts = await coreClient.identity.getAccounts.query({ - identity: identityURN, + URN: identityURN, }) const ownedAccountURNList = @@ -345,7 +345,7 @@ async function erc4337ClaimsRetriever( const identityAccounts = ( await coreClient.identity.getAccounts.query({ - identity: identityURN, + URN: identityURN, }) )?.filter( (account) => account.rc.addr_type === CryptoAccountType.Wallet @@ -411,7 +411,7 @@ async function connectedAccountsClaimsRetriever( const identityAccounts = ( await coreClient.identity.getAccounts.query({ - identity: identityURN, + URN: identityURN, }) )?.filter( (account) => account.rc.addr_type !== CryptoAccountType.Wallet diff --git a/packages/types/billing.ts b/packages/types/billing.ts new file mode 100644 index 0000000000..609ee69f7f --- /dev/null +++ b/packages/types/billing.ts @@ -0,0 +1,23 @@ +import { AccountURN } from '@proofzero/urns/account' + +export enum ServicePlanType { + FREE = 'FREE', + PRO = 'PRO', +} + +export type ServicePlans = { + subscriptionID?: string + plans?: Partial<{ + [key in ServicePlanType]: { + entitlements: number + } + }> +} + +export type PaymentData = { + customerID: string + email: string + name: string + paymentMethodID?: string + accountURN?: AccountURN +} diff --git a/packages/types/identity.ts b/packages/types/identity.ts index af0b27add5..b3740e947c 100644 --- a/packages/types/identity.ts +++ b/packages/types/identity.ts @@ -1,5 +1,3 @@ -import { AccountURN } from '@proofzero/urns/account' - export type IdentityProfile = { displayName: string pfp: { @@ -7,25 +5,3 @@ export type IdentityProfile = { isToken?: boolean } } - -export enum ServicePlanType { - FREE = 'FREE', - PRO = 'PRO', -} - -export type ServicePlans = { - subscriptionID?: string - plans?: Partial<{ - [key in ServicePlanType]: { - entitlements: number - } - }> -} - -export type PaymentData = { - customerID: string - email: string - name: string - paymentMethodID?: string - accountURN?: AccountURN -} diff --git a/packages/urns/identity-ref.ts b/packages/urns/identity-ref.ts new file mode 100644 index 0000000000..77e235c258 --- /dev/null +++ b/packages/urns/identity-ref.ts @@ -0,0 +1,4 @@ +import { IdentityURN } from './identity' +import { IdentityGroupURN } from './identity-group' + +export type IdentityRefURN = IdentityURN | IdentityGroupURN diff --git a/platform/account/src/jsonrpc/methods/sendReconciliationNotificationMethod.ts b/platform/account/src/jsonrpc/methods/sendReconciliationNotificationMethod.ts index 2aaf3b6d85..04fa349c2f 100644 --- a/platform/account/src/jsonrpc/methods/sendReconciliationNotificationMethod.ts +++ b/platform/account/src/jsonrpc/methods/sendReconciliationNotificationMethod.ts @@ -1,8 +1,8 @@ import { z } from 'zod' import { Context } from '../../context' -import { ServicePlanType } from '@proofzero/types/identity' import { ReconciliationNotificationType } from '@proofzero/types/email' +import { ServicePlanType } from '@proofzero/types/billing' export const SendReconciliationNotificationInput = z.object({ planType: z.string(), diff --git a/platform/authorization/src/jsonrpc/methods/revokeAppAuthorization.ts b/platform/authorization/src/jsonrpc/methods/revokeAppAuthorization.ts index 9a5db67180..1775f7a609 100644 --- a/platform/authorization/src/jsonrpc/methods/revokeAppAuthorization.ts +++ b/platform/authorization/src/jsonrpc/methods/revokeAppAuthorization.ts @@ -82,7 +82,7 @@ export const revokeAppAuthorizationMethod: RevokeAppAuthorizationMethod = const accounts = (await caller.identity.getAccounts({ - identity: identityURN, + URN: identityURN, })) ?? [] for (const account of accounts) { diff --git a/platform/billing/.eslintrc.json b/platform/billing/.eslintrc.json new file mode 100644 index 0000000000..d9d41509c1 --- /dev/null +++ b/platform/billing/.eslintrc.json @@ -0,0 +1,18 @@ +{ + "env": { + "browser": true, + "es2022": true, + "node": true + }, + "extends": [ + "eslint:recommended", + "plugin:@typescript-eslint/recommended", + "prettier" + ], + "parser": "@typescript-eslint/parser", + "parserOptions": { + "ecmaVersion": "latest", + "sourceType": "module" + }, + "plugins": ["@typescript-eslint"] +} diff --git a/platform/billing/.gitignore b/platform/billing/.gitignore new file mode 100644 index 0000000000..e71378008b --- /dev/null +++ b/platform/billing/.gitignore @@ -0,0 +1 @@ +.wrangler diff --git a/platform/billing/.prettierrc.json b/platform/billing/.prettierrc.json new file mode 100644 index 0000000000..fa51da29e7 --- /dev/null +++ b/platform/billing/.prettierrc.json @@ -0,0 +1,6 @@ +{ + "trailingComma": "es5", + "tabWidth": 2, + "semi": false, + "singleQuote": true +} diff --git a/platform/billing/README.md b/platform/billing/README.md new file mode 100644 index 0000000000..0ab9bddc58 --- /dev/null +++ b/platform/billing/README.md @@ -0,0 +1,11 @@ +# Billing Worker + +## Overview + +This app interfaces with nodes with respect to billing. + +## Setup + +### Local Env + +1. Copy `.dev.vars.example` to `.dev.vars` and fill in the values. diff --git a/platform/billing/package.json b/platform/billing/package.json new file mode 100644 index 0000000000..ba9799e03c --- /dev/null +++ b/platform/billing/package.json @@ -0,0 +1,43 @@ +{ + "name": "@proofzero/platform.billing", + "version": "0.0.0", + "main": "src/index.ts", + "private": true, + "scripts": { + "check": "run-s format:check lint:check types:check", + "format": "run-s format:src", + "format:src": "prettier --write src", + "format:check": "run-s format:check:src", + "format:check:src": "prettier --check src", + "lint": "eslint --fix src", + "lint:check": "run-s lint:check:src", + "lint:check:src": "eslint src", + "types:check": "tsc --project tsconfig.json", + "test": "run-s check" + }, + "devDependencies": { + "@cloudflare/workers-types": "4.20221111.1", + "@types/node": "18.15.3", + "@typescript-eslint/eslint-plugin": "5.42.1", + "@typescript-eslint/parser": "5.42.1", + "eslint": "8.28.0", + "eslint-config-prettier": "8.5.0", + "npm-run-all": "4.1.5", + "prettier": "2.7.1", + "typescript": "5.0.4" + }, + "dependencies": { + "@ethersproject/address": "5.7.0", + "@ethersproject/bytes": "5.7.0", + "@ethersproject/random": "5.7.0", + "@proofzero/platform-middleware": "workspace:*", + "@proofzero/platform.core": "workspace:*", + "@proofzero/types": "workspace:*", + "@proofzero/utils": "workspace:*", + "@trpc/server": "10.8.1", + "do-proxy": "1.3.3", + "ts-set-utils": "0.2.0", + "typed-json-rpc": "1.1.0", + "urns": "0.6.0" + } +} diff --git a/platform/billing/src/context.ts b/platform/billing/src/context.ts new file mode 100644 index 0000000000..fd88f908f0 --- /dev/null +++ b/platform/billing/src/context.ts @@ -0,0 +1 @@ +export * from '../../../platform/core/src/context' diff --git a/platform/billing/src/index.ts b/platform/billing/src/index.ts new file mode 100644 index 0000000000..e69de29bb2 diff --git a/platform/billing/src/jsonrpc/methods/cancelServicePlans.ts b/platform/billing/src/jsonrpc/methods/cancelServicePlans.ts new file mode 100644 index 0000000000..7104f110fb --- /dev/null +++ b/platform/billing/src/jsonrpc/methods/cancelServicePlans.ts @@ -0,0 +1,42 @@ +import { z } from 'zod' +import { Context } from '../../context' +import { BadRequestError } from '@proofzero/errors' +import { IdentityGroupURNSpace } from '@proofzero/urns/identity-group' +import { IdentityURNSpace } from '@proofzero/urns/identity' +import { + initIdentityGroupNodeByName, + initIdentityNodeByName, +} from '../../../../identity/src/nodes' +import { IdentityRefURNValidator } from '@proofzero/platform-middleware/inputValidators' + +export const CancelServicePlansInput = z.object({ + URN: IdentityRefURNValidator, + subscriptionID: z.string(), + deletePaymentData: z.boolean().optional(), +}) + +export type CancelServicePlansParams = z.infer + +export const cancelServicePlans = async ({ + input, + ctx, +}: { + input: CancelServicePlansParams + ctx: Context +}) => { + let ownerNode + if (IdentityURNSpace.is(input.URN)) { + ownerNode = initIdentityNodeByName(input.URN, ctx.Identity) + } else if (IdentityGroupURNSpace.is(input.URN)) { + ownerNode = initIdentityGroupNodeByName(input.URN, ctx.IdentityGroup) + } else { + throw new BadRequestError({ + message: `URN type not supported`, + }) + } + + await ownerNode.storage.delete('servicePlans') + if (input.deletePaymentData) { + await ownerNode.storage.delete('stripePaymentData') + } +} diff --git a/platform/identity/src/jsonrpc/methods/getEntitlements.ts b/platform/billing/src/jsonrpc/methods/getEntitlements.ts similarity index 56% rename from platform/identity/src/jsonrpc/methods/getEntitlements.ts rename to platform/billing/src/jsonrpc/methods/getEntitlements.ts index 053449f164..a11be9a3b2 100644 --- a/platform/identity/src/jsonrpc/methods/getEntitlements.ts +++ b/platform/billing/src/jsonrpc/methods/getEntitlements.ts @@ -1,11 +1,17 @@ -import { ServicePlanType } from '@proofzero/types/identity' import { z } from 'zod' import { Context } from '../../context' -import { IdentityURNInput } from '@proofzero/platform-middleware/inputValidators' -import { initIdentityNodeByName } from '../../nodes' +import { IdentityRefURNValidator } from '@proofzero/platform-middleware/inputValidators' +import { ServicePlanType } from '@proofzero/types/billing' +import { BadRequestError } from '@proofzero/errors' +import { IdentityGroupURNSpace } from '@proofzero/urns/identity-group' +import { IdentityURNSpace } from '@proofzero/urns/identity' +import { + initIdentityGroupNodeByName, + initIdentityNodeByName, +} from '../../../../identity/src/nodes' export const GetEntitlementsInputSchema = z.object({ - identityURN: IdentityURNInput, + URN: IdentityRefURNValidator, }) type GetEntitlementsInput = z.infer @@ -36,9 +42,18 @@ export const getEntitlements = async ({ plans: {}, } - const identity = await initIdentityNodeByName(input.identityURN, ctx.Identity) + let ownerNode + if (IdentityURNSpace.is(input.URN)) { + ownerNode = initIdentityNodeByName(input.URN, ctx.Identity) + } else if (IdentityGroupURNSpace.is(input.URN)) { + ownerNode = initIdentityGroupNodeByName(input.URN, ctx.IdentityGroup) + } else { + throw new BadRequestError({ + message: `URN type not supported`, + }) + } - const servicePlans = await identity.class.getServicePlans() + const servicePlans = await ownerNode.class.getServicePlans() result.subscriptionID = servicePlans?.subscriptionID for (const key of Object.keys(ServicePlanType)) { diff --git a/platform/identity/src/jsonrpc/methods/stripePaymentData.ts b/platform/billing/src/jsonrpc/methods/stripePaymentData.ts similarity index 58% rename from platform/identity/src/jsonrpc/methods/stripePaymentData.ts rename to platform/billing/src/jsonrpc/methods/stripePaymentData.ts index 70853561df..4742b3ca75 100644 --- a/platform/identity/src/jsonrpc/methods/stripePaymentData.ts +++ b/platform/billing/src/jsonrpc/methods/stripePaymentData.ts @@ -3,16 +3,22 @@ import { z } from 'zod' import { EDGE_HAS_REFERENCE_TO } from '@proofzero/types/graph' import { router } from '@proofzero/platform.core' + +import { Context } from '../../context' import { - IdentityURNInput, AccountURNInput, + IdentityRefURNValidator, } from '@proofzero/platform-middleware/inputValidators' - -import { Context } from '../../context' -import { initIdentityNodeByName } from '../../nodes' +import { IdentityURNSpace } from '@proofzero/urns/identity' +import { + initIdentityGroupNodeByName, + initIdentityNodeByName, +} from '../../../../identity/src/nodes' +import { IdentityGroupURNSpace } from '@proofzero/urns/identity-group' +import { BadRequestError } from '@proofzero/errors' export const GetStripPaymentDataInputSchema = z.object({ - identityURN: IdentityURNInput, + URN: IdentityRefURNValidator, }) type GetStripPaymentDataInput = z.infer @@ -36,15 +42,24 @@ export const getStripePaymentData = async ({ ctx: Context input: GetStripPaymentDataInput }): Promise => { - const identity = await initIdentityNodeByName(input.identityURN, ctx.Identity) + let ownerNode + if (IdentityURNSpace.is(input.URN)) { + ownerNode = initIdentityNodeByName(input.URN, ctx.Identity) + } else if (IdentityGroupURNSpace.is(input.URN)) { + ownerNode = initIdentityGroupNodeByName(input.URN, ctx.IdentityGroup) + } else { + throw new BadRequestError({ + message: `URN type not supported`, + }) + } - return identity.class.getStripePaymentData() + return ownerNode.class.getStripePaymentData() } export const SetStripePaymentDataInputSchema = z.object({ + URN: IdentityRefURNValidator, customerID: z.string(), paymentMethodID: z.string().optional(), - identityURN: IdentityURNInput, name: z.string(), email: z.string(), accountURN: AccountURNInput, @@ -58,12 +73,20 @@ export const setStripePaymentData = async ({ ctx: Context input: SetStripePaymentDataInput }): Promise => { - const identity = await initIdentityNodeByName(input.identityURN, ctx.Identity) + let ownerNode + if (IdentityURNSpace.is(input.URN)) { + ownerNode = initIdentityNodeByName(input.URN, ctx.Identity) + } else if (IdentityGroupURNSpace.is(input.URN)) { + ownerNode = initIdentityGroupNodeByName(input.URN, ctx.IdentityGroup) + } else { + throw new BadRequestError({ + message: `URN type not supported`, + }) + } - const { customerID, paymentMethodID, email, name, identityURN, accountURN } = - input + const { customerID, paymentMethodID, email, name, accountURN } = input - await identity.class.setStripePaymentData({ + await ownerNode.class.setStripePaymentData({ customerID, paymentMethodID, email, @@ -76,13 +99,13 @@ export const setStripePaymentData = async ({ if (accountURN) { const { edges } = await caller.edges.getEdges({ query: { - src: { baseUrn: identityURN }, + src: { baseUrn: input.URN }, tag: EDGE_HAS_REFERENCE_TO, }, }) if (edges.length > 1) { - console.warn(`More than one edge found for ${identityURN} -> account`) + console.warn(`More than one edge found for ${input.URN} -> account`) } for (const edge of edges) { @@ -94,9 +117,9 @@ export const setStripePaymentData = async ({ } await caller.edges.makeEdge({ - src: identityURN, - dst: accountURN, + src: input.URN, tag: EDGE_HAS_REFERENCE_TO, + dst: accountURN, }) } } diff --git a/platform/billing/src/jsonrpc/methods/updateEntitlements.ts b/platform/billing/src/jsonrpc/methods/updateEntitlements.ts new file mode 100644 index 0000000000..4ec17a765d --- /dev/null +++ b/platform/billing/src/jsonrpc/methods/updateEntitlements.ts @@ -0,0 +1,44 @@ +import { z } from 'zod' +import { Context } from '../../context' +import { IdentityRefURNValidator } from '@proofzero/platform-middleware/inputValidators' +import { ServicePlanType } from '@proofzero/types/billing' +import { IdentityGroupURNSpace } from '@proofzero/urns/identity-group' +import { BadRequestError } from '@proofzero/errors' +import { IdentityURNSpace } from '@proofzero/urns/identity' +import { + initIdentityGroupNodeByName, + initIdentityNodeByName, +} from '../../../../identity/src/nodes' + +export const UpdateEntitlementsInputSchema = z.object({ + URN: IdentityRefURNValidator, + subscriptionID: z.string(), + type: z.nativeEnum(ServicePlanType), + quantity: z.number(), +}) +export type UpdateEntitlementsInput = z.infer< + typeof UpdateEntitlementsInputSchema +> + +export const updateEntitlements = async ({ + input, + ctx, +}: { + input: UpdateEntitlementsInput + ctx: Context +}): Promise => { + const { type, quantity, subscriptionID } = input + + let ownerNode + if (IdentityURNSpace.is(input.URN)) { + ownerNode = initIdentityNodeByName(input.URN, ctx.Identity) + } else if (IdentityGroupURNSpace.is(input.URN)) { + ownerNode = initIdentityGroupNodeByName(input.URN, ctx.IdentityGroup) + } else { + throw new BadRequestError({ + message: `URN type not supported`, + }) + } + + await ownerNode.class.updateEntitlements(type, quantity, subscriptionID) +} diff --git a/platform/billing/src/jsonrpc/router.ts b/platform/billing/src/jsonrpc/router.ts new file mode 100644 index 0000000000..990efc47b1 --- /dev/null +++ b/platform/billing/src/jsonrpc/router.ts @@ -0,0 +1,59 @@ +import { initTRPC } from '@trpc/server' + +import { errorFormatter } from '@proofzero/utils/trpc' + +import type { Context } from '../context' +import { Analytics } from '@proofzero/platform-middleware/analytics' +import { LogUsage } from '@proofzero/platform-middleware/log' +import { + CancelServicePlansInput, + cancelServicePlans, +} from './methods/cancelServicePlans' +import { + GetStripPaymentDataInputSchema, + GetStripePaymentDataOutputSchema, + getStripePaymentData, + SetStripePaymentDataInputSchema, + setStripePaymentData, +} from './methods/stripePaymentData' +import { + UpdateEntitlementsInputSchema, + updateEntitlements, +} from './methods/updateEntitlements' +import { + GetEntitlementsInputSchema, + GetEntitlementsOutputSchema, + getEntitlements, +} from './methods/getEntitlements' + +const t = initTRPC.context().create({ errorFormatter }) + +export const appRouter = t.router({ + getEntitlements: t.procedure + .use(LogUsage) + .use(Analytics) + .input(GetEntitlementsInputSchema) + .output(GetEntitlementsOutputSchema) + .query(getEntitlements), + updateEntitlements: t.procedure + .use(LogUsage) + .use(Analytics) + .input(UpdateEntitlementsInputSchema) + .mutation(updateEntitlements), + getStripePaymentData: t.procedure + .use(LogUsage) + .use(Analytics) + .input(GetStripPaymentDataInputSchema) + .output(GetStripePaymentDataOutputSchema) + .query(getStripePaymentData), + setStripePaymentData: t.procedure + .use(LogUsage) + .use(Analytics) + .input(SetStripePaymentDataInputSchema) + .mutation(setStripePaymentData), + cancelServicePlans: t.procedure + .use(LogUsage) + .use(Analytics) + .input(CancelServicePlansInput) + .mutation(cancelServicePlans), +}) diff --git a/platform/billing/src/types.ts b/platform/billing/src/types.ts new file mode 100644 index 0000000000..5e703b925d --- /dev/null +++ b/platform/billing/src/types.ts @@ -0,0 +1,4 @@ +import { z } from 'zod' +import { GetStripePaymentDataOutputSchema } from './jsonrpc/methods/stripePaymentData' + +export type StripePaymentData = z.infer diff --git a/platform/billing/tsconfig.json b/platform/billing/tsconfig.json new file mode 100644 index 0000000000..aca0a3d89d --- /dev/null +++ b/platform/billing/tsconfig.json @@ -0,0 +1,103 @@ +{ + "compilerOptions": { + /* Visit https://aka.ms/tsconfig.json to read more about this file */ + + /* Projects */ + // "incremental": true, /* Enable incremental compilation */ + // "composite": true, /* Enable constraints that allow a TypeScript project to be used with project references. */ + // "tsBuildInfoFile": "./", /* Specify the folder for .tsbuildinfo incremental compilation files. */ + // "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects */ + // "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */ + // "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */ + + /* Language and Environment */ + "target": "esnext" /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */, + "lib": [ + "esnext" + ] /* Specify a set of bundled library declaration files that describe the target runtime environment. */, + "jsx": "react" /* Specify what JSX code is generated. */, + "experimentalDecorators": true /* Enable experimental support for TC39 stage 2 draft decorators. */, + // "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */ + // "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h' */ + // "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */ + // "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using `jsx: react-jsx*`.` */ + // "reactNamespace": "", /* Specify the object invoked for `createElement`. This only applies when targeting `react` JSX emit. */ + // "noLib": true, /* Disable including any library files, including the default lib.d.ts. */ + // "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */ + + /* Modules */ + "module": "es2022" /* Specify what module code is generated. */, + // "rootDir": "./", /* Specify the root folder within your source files. */ + "moduleResolution": "node" /* Specify how TypeScript looks up a file from a given module specifier. */, + // "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */ + // "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */ + // "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */ + // "typeRoots": [], /* Specify multiple folders that act like `./node_modules/@types`. */ + "types": ["@cloudflare/workers-types", "node"] /* Specify type package names to be included without being referenced in a source file. */, + // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ + "resolveJsonModule": true /* Enable importing .json files */, + // "noResolve": true, /* Disallow `import`s, `require`s or ``s from expanding the number of files TypeScript should add to a project. */ + + /* JavaScript Support */ + "allowJs": true /* Allow JavaScript files to be a part of your program. Use the `checkJS` option to get errors from these files. */, + "checkJs": false /* Enable error reporting in type-checked JavaScript files. */, + // "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from `node_modules`. Only applicable with `allowJs`. */ + + /* Emit */ + // "declaration": true, /* Generate .d.ts files from TypeScript and JavaScript files in your project. */ + // "declarationMap": true, /* Create sourcemaps for d.ts files. */ + // "emitDeclarationOnly": true, /* Only output d.ts files and not JavaScript files. */ + // "sourceMap": true, /* Create source map files for emitted JavaScript files. */ + // "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If `declaration` is true, also designates a file that bundles all .d.ts output. */ + // "outDir": "./", /* Specify an output folder for all emitted files. */ + // "removeComments": true, /* Disable emitting comments. */ + "noEmit": true /* Disable emitting files from a compilation. */, + // "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */ + // "importsNotUsedAsValues": "remove", /* Specify emit/checking behavior for imports that are only used for types */ + // "downlevelIteration": true, /* Emit more compliant, but verbose and less performant JavaScript for iteration. */ + // "sourceRoot": "", /* Specify the root path for debuggers to find the reference source code. */ + // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ + // "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */ + // "inlineSources": true, /* Include source code in the sourcemaps inside the emitted JavaScript. */ + // "emitBOM": true, /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */ + // "newLine": "crlf", /* Set the newline character for emitting files. */ + // "stripInternal": true, /* Disable emitting declarations that have `@internal` in their JSDoc comments. */ + // "noEmitHelpers": true, /* Disable generating custom helper functions like `__extends` in compiled output. */ + // "noEmitOnError": true, /* Disable emitting files if any type checking errors are reported. */ + // "preserveConstEnums": true, /* Disable erasing `const enum` declarations in generated code. */ + // "declarationDir": "./", /* Specify the output directory for generated declaration files. */ + // "preserveValueImports": true, /* Preserve unused imported values in the JavaScript output that would otherwise be removed. */ + + /* Interop Constraints */ + "isolatedModules": true /* Ensure that each file can be safely transpiled without relying on other imports. */, + "allowSyntheticDefaultImports": true /* Allow 'import x from y' when a module doesn't have a default export. */, + "esModuleInterop": true /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables `allowSyntheticDefaultImports` for type compatibility. */, + // "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */ + "forceConsistentCasingInFileNames": true /* Ensure that casing is correct in imports. */, + + /* Type Checking */ + "strict": true /* Enable all strict type-checking options. */, + // "noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied `any` type.. */ + // "strictNullChecks": true, /* When type checking, take into account `null` and `undefined`. */ + // "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */ + // "strictBindCallApply": true, /* Check that the arguments for `bind`, `call`, and `apply` methods match the original function. */ + // "strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */ + // "noImplicitThis": true, /* Enable error reporting when `this` is given the type `any`. */ + // "useUnknownInCatchVariables": true, /* Type catch clause variables as 'unknown' instead of 'any'. */ + // "alwaysStrict": true, /* Ensure 'use strict' is always emitted. */ + // "noUnusedLocals": true, /* Enable error reporting when a local variables aren't read. */ + // "noUnusedParameters": true, /* Raise an error when a function parameter isn't read */ + // "exactOptionalPropertyTypes": true, /* Interpret optional property types as written, rather than adding 'undefined'. */ + // "noImplicitReturns": true, /* Enable error reporting for codepaths that do not explicitly return in a function. */ + // "noFallthroughCasesInSwitch": true, /* Enable error reporting for fallthrough cases in switch statements. */ + // "noUncheckedIndexedAccess": true, /* Include 'undefined' in index signature results */ + // "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an override modifier. */ + // "noPropertyAccessFromIndexSignature": true, /* Enforces using indexed accessors for keys declared using an indexed type */ + // "allowUnusedLabels": true, /* Disable error reporting for unused labels. */ + // "allowUnreachableCode": true, /* Disable error reporting for unreachable code. */ + + /* Completeness */ + // "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */ + "skipLibCheck": true /* Skip type checking all .d.ts files. */ + } +} diff --git a/platform/core/package.json b/platform/core/package.json index 9c2f151bb3..a38dc3a142 100644 --- a/platform/core/package.json +++ b/platform/core/package.json @@ -35,6 +35,7 @@ "dependencies": { "@proofzero/platform.account": "workspace:*", "@proofzero/platform.authorization": "workspace:*", + "@proofzero/platform.billing": "workspace:*", "@proofzero/platform.edges": "workspace:*", "@proofzero/platform.identity": "workspace:*", "@proofzero/platform.starbase": "workspace:*", diff --git a/platform/core/src/router.ts b/platform/core/src/router.ts index 04fce12898..dffc03cd23 100644 --- a/platform/core/src/router.ts +++ b/platform/core/src/router.ts @@ -5,6 +5,7 @@ import { appRouter as authorization } from '@proofzero/platform.authorization/sr import { appRouter as edges } from '@proofzero/platform.edges/src/jsonrpc/router' import { appRouter as identity } from '@proofzero/platform.identity/src/jsonrpc/router' import { appRouter as starbase } from '@proofzero/platform.starbase/src/jsonrpc/router' +import { appRouter as billing } from '@proofzero/platform.billing/src/jsonrpc/router' export const coreRouter = router({ account, @@ -12,6 +13,7 @@ export const coreRouter = router({ edges, identity, starbase, + billing, }) export type CoreRouter = typeof coreRouter diff --git a/platform/email/src/jsonrpc/methods/sendSuccesfullPaymentNotification.ts b/platform/email/src/jsonrpc/methods/sendSuccesfullPaymentNotification.ts index 065f50f82e..53f3b1a68e 100644 --- a/platform/email/src/jsonrpc/methods/sendSuccesfullPaymentNotification.ts +++ b/platform/email/src/jsonrpc/methods/sendSuccesfullPaymentNotification.ts @@ -6,7 +6,6 @@ import { getSuccessfulPaymentEmailContent, } from '../../emailFunctions' import { EmailThemePropsSchema } from '../../emailFunctions' -import { PlansSchema } from '@proofzero/platform.identity/src/jsonrpc/methods/getEntitlements' export const EmailPlansSchema = z.array( z.object({ diff --git a/platform/galaxy/src/schema/resolvers/utils/index.ts b/platform/galaxy/src/schema/resolvers/utils/index.ts index 7558441aa6..eea9ed2622 100644 --- a/platform/galaxy/src/schema/resolvers/utils/index.ts +++ b/platform/galaxy/src/schema/resolvers/utils/index.ts @@ -265,7 +265,7 @@ export const getConnectedAccounts = async ({ }) const accounts = await coreClient.identity.getAccounts.query({ - identity: identityURN, + URN: identityURN, }) // for alchemy calls they need to be lowercased diff --git a/platform/identity/src/jsonrpc/methods/cancelServicePlans.ts b/platform/identity/src/jsonrpc/methods/cancelServicePlans.ts deleted file mode 100644 index ac6e6b029e..0000000000 --- a/platform/identity/src/jsonrpc/methods/cancelServicePlans.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { z } from 'zod' -import { Context } from '../../context' -import { inputValidators } from '@proofzero/platform-middleware' -import { initIdentityNodeByName } from '../../nodes' -export const CancelServicePlansInput = z.object({ - identity: inputValidators.IdentityURNInput, - subscriptionID: z.string(), - deletePaymentData: z.boolean().optional(), -}) - -export type CancelServicePlansParams = z.infer - -export const cancelServicePlans = async ({ - input, - ctx, -}: { - input: CancelServicePlansParams - ctx: Context -}) => { - const servicePlansNode = await initIdentityNodeByName( - input.identity, - ctx.Identity - ) - - await servicePlansNode.storage.delete('servicePlans') - if (input.deletePaymentData) { - await servicePlansNode.storage.delete('stripePaymentData') - } -} diff --git a/platform/identity/src/jsonrpc/methods/getAccounts.ts b/platform/identity/src/jsonrpc/methods/getAccounts.ts index d9b61e053e..f2c5584b26 100644 --- a/platform/identity/src/jsonrpc/methods/getAccounts.ts +++ b/platform/identity/src/jsonrpc/methods/getAccounts.ts @@ -8,7 +8,7 @@ import { Context } from '../../context' import { AccountsSchema } from '../validators/profile' export const GetAccountsInput = z.object({ - identity: inputValidators.IdentityURNInput, + URN: inputValidators.AnyURNInput, filter: z .object({ type: inputValidators.CryptoAccountTypeInput.optional(), @@ -31,11 +31,11 @@ export const getAccountsMethod = async ({ const caller = router.createCaller(ctx) const getAccountsCall = - ctx.identityURN === input.identity + ctx.identityURN === input.URN ? caller.identity.getOwnAccounts : caller.identity.getPublicAccounts - const accounts = await getAccountsCall({ identity: input.identity }) + const accounts = await getAccountsCall({ URN: input.URN }) return accounts } diff --git a/platform/identity/src/jsonrpc/methods/getOwnAccounts.ts b/platform/identity/src/jsonrpc/methods/getOwnAccounts.ts index 7b1fa142be..127c60b28c 100644 --- a/platform/identity/src/jsonrpc/methods/getOwnAccounts.ts +++ b/platform/identity/src/jsonrpc/methods/getOwnAccounts.ts @@ -8,7 +8,7 @@ import { Context } from '../../context' import { Node } from '@proofzero/platform.edges/src/jsonrpc/validators/node' export const GetAccountsInput = z.object({ - identity: inputValidators.IdentityURNInput, + URN: inputValidators.AnyURNInput, filter: z .object({ type: inputValidators.CryptoAccountTypeInput.optional(), @@ -29,7 +29,7 @@ export const getOwnAccountsMethod = async ({ ctx: Context }): Promise => { // TODO: check scopes on jwt for now we will just use the identityURN to you get get your own accounts - if (input.identity !== ctx.identityURN) { + if (input.URN !== ctx.identityURN) { throw Error('Invalid identity input') } @@ -38,7 +38,7 @@ export const getOwnAccountsMethod = async ({ // terminate at the account node, assuming that identity nodes link to // the account nodes that they own. src: { - baseUrn: input.identity, + baseUrn: input.URN, }, // We only want edges that link to account nodes. diff --git a/platform/identity/src/jsonrpc/methods/getProfile.ts b/platform/identity/src/jsonrpc/methods/getProfile.ts index 7acbc1b356..79178a5619 100644 --- a/platform/identity/src/jsonrpc/methods/getProfile.ts +++ b/platform/identity/src/jsonrpc/methods/getProfile.ts @@ -35,7 +35,7 @@ export const getProfileMethod = async ({ const [profile, accounts] = await Promise.all([ node.class.getProfile(), - caller.getPublicAccounts({ identity: input.identity }), + caller.getPublicAccounts({ URN: input.identity }), ]) if (!profile) return null diff --git a/platform/identity/src/jsonrpc/methods/getPublicAccounts.ts b/platform/identity/src/jsonrpc/methods/getPublicAccounts.ts index 8042e3a81b..92f142e11e 100644 --- a/platform/identity/src/jsonrpc/methods/getPublicAccounts.ts +++ b/platform/identity/src/jsonrpc/methods/getPublicAccounts.ts @@ -9,7 +9,7 @@ import { Context } from '../../context' import { Node } from '@proofzero/platform.edges/src/jsonrpc/validators/node' export const GetAccountsInput = z.object({ - identity: inputValidators.IdentityURNInput, + URN: inputValidators.AnyURNInput, filter: z .object({ type: inputValidators.CryptoAccountTypeInput.optional(), @@ -34,7 +34,7 @@ export const getPublicAccountsMethod = async ({ // We are only interested in edges that start at the identity node and // terminate at the account node, assuming that identity nodes link to // the account nodes that they own. - src: { baseUrn: input.identity }, + src: { baseUrn: input.URN }, // We only want edges that link to account nodes. tag: EDGE_ACCOUNT, diff --git a/platform/identity/src/jsonrpc/methods/identity-groups/acceptIdentityGroupMemberInvitation.ts b/platform/identity/src/jsonrpc/methods/identity-groups/acceptIdentityGroupMemberInvitation.ts index 4f084df6fa..515f18f3b1 100644 --- a/platform/identity/src/jsonrpc/methods/identity-groups/acceptIdentityGroupMemberInvitation.ts +++ b/platform/identity/src/jsonrpc/methods/identity-groups/acceptIdentityGroupMemberInvitation.ts @@ -49,7 +49,7 @@ export const acceptIdentityGroupMemberInvitation = async ({ const caller = router.createCaller(ctx) const accounts = await caller.identity.getOwnAccounts({ - identity: ctx.identityURN!, + URN: ctx.identityURN!, }) const targetAccount = accounts.find( (account) => diff --git a/platform/identity/src/jsonrpc/methods/identity-groups/connectIdentityGroupEmail.ts b/platform/identity/src/jsonrpc/methods/identity-groups/connectIdentityGroupEmail.ts new file mode 100644 index 0000000000..75b346d8a9 --- /dev/null +++ b/platform/identity/src/jsonrpc/methods/identity-groups/connectIdentityGroupEmail.ts @@ -0,0 +1,92 @@ +import { z } from 'zod' +import { Context } from '../../../context' +import { InternalServerError, UnauthorizedError } from '@proofzero/errors' +import { + AccountURNInput, + IdentityGroupURNValidator, +} from '@proofzero/platform-middleware/inputValidators' +import { initIdentityGroupNodeByName } from '../../../nodes' +import { router } from '@proofzero/platform.core' +import { EDGE_ACCOUNT } from '@proofzero/platform.account/src/constants' +import { EDGE_MEMBER_OF_IDENTITY_GROUP } from '@proofzero/types/graph' + +export const ConnectIdentityGroupEmailInputSchema = z.object({ + identityGroupURN: IdentityGroupURNValidator, + accountURN: AccountURNInput, +}) + +type ConnectIdentityGroupEmailInput = z.infer< + typeof ConnectIdentityGroupEmailInputSchema +> + +export const ConnectIdentityGroupEmailOutputSchema = z.object({ + existing: z.boolean(), +}) + +type ConnectIdentityGroupEmailOutput = z.infer< + typeof ConnectIdentityGroupEmailOutputSchema +> + +export const connectIdentityGroupEmail = async ({ + input, + ctx, +}: { + input: ConnectIdentityGroupEmailInput + ctx: Context +}): Promise => { + const { identityGroupURN, accountURN } = input + + const node = initIdentityGroupNodeByName(identityGroupURN, ctx.IdentityGroup) + if (!node) { + throw new InternalServerError({ + message: 'Identity group DO not found', + }) + } + + const caller = router.createCaller(ctx) + + const { edges: membershipEdges } = await caller.edges.getEdges({ + query: { + src: { + baseUrn: ctx.identityURN, + }, + tag: EDGE_MEMBER_OF_IDENTITY_GROUP, + dst: { + baseUrn: identityGroupURN, + }, + }, + }) + if (membershipEdges.length === 0) { + throw new UnauthorizedError({ + message: 'Caller is not a member of the identity group', + }) + } + + const { edges } = await caller.edges.getEdges({ + query: { + src: { + baseUrn: identityGroupURN, + }, + tag: EDGE_ACCOUNT, + dst: { + baseUrn: accountURN, + }, + }, + }) + + if (edges.length > 0) { + return { + existing: true, + } + } + + await caller.edges.makeEdge({ + src: identityGroupURN, + tag: EDGE_ACCOUNT, + dst: accountURN, + }) + + return { + existing: false, + } +} diff --git a/platform/identity/src/jsonrpc/methods/identity-groups/hasIdentityGroupPermissions.ts b/platform/identity/src/jsonrpc/methods/identity-groups/hasIdentityGroupPermissions.ts new file mode 100644 index 0000000000..2f29af0a8f --- /dev/null +++ b/platform/identity/src/jsonrpc/methods/identity-groups/hasIdentityGroupPermissions.ts @@ -0,0 +1,46 @@ +import { z } from 'zod' +import { + IdentityGroupURNValidator, + IdentityURNInput, +} from '@proofzero/platform-middleware/inputValidators' +import { router } from '@proofzero/platform.core' +import { EDGE_MEMBER_OF_IDENTITY_GROUP } from '@proofzero/types/graph' + +import { Context } from '../../../context' + +export const HasIdentityGroupPermissionsInputSchema = z.object({ + identityURN: IdentityURNInput, + identityGroupURN: IdentityGroupURNValidator, +}) +export type HasIdentityGroupPermissionsInput = z.infer< + typeof HasIdentityGroupPermissionsInputSchema +> + +export const HasIdentityGroupPermissionsOutputSchema = z.boolean() +export type HasIdentityGroupPermissionsOutput = z.infer< + typeof HasIdentityGroupPermissionsOutputSchema +> + +export const hasIdentityGroupPermissions = async ({ + input, + ctx, +}: { + input: HasIdentityGroupPermissionsInput + ctx: Context +}): Promise => { + const caller = router.createCaller(ctx) + + const { edges } = await caller.edges.getEdges({ + query: { + src: { + baseUrn: input.identityURN, + }, + tag: EDGE_MEMBER_OF_IDENTITY_GROUP, + dst: { + baseUrn: input.identityGroupURN, + }, + }, + }) + + return edges.length > 0 +} diff --git a/platform/identity/src/jsonrpc/methods/identity-groups/listIdentityGroups.ts b/platform/identity/src/jsonrpc/methods/identity-groups/listIdentityGroups.ts index 46022d4bcb..0c85cdeac8 100644 --- a/platform/identity/src/jsonrpc/methods/identity-groups/listIdentityGroups.ts +++ b/platform/identity/src/jsonrpc/methods/identity-groups/listIdentityGroups.ts @@ -10,6 +10,7 @@ import { RollupError } from '@proofzero/errors' import { Context } from '../../../context' import { IdentityURN, IdentityURNSpace } from '@proofzero/urns/identity' +import { initIdentityGroupNodeByName } from '../../../nodes' export const ListIdentityGroupsOutputSchema = z.array( z.object({ @@ -21,6 +22,9 @@ export const ListIdentityGroupsOutputSchema = z.array( joinTimestamp: z.number().nullable(), }) ), + flags: z.object({ + billingConfigured: z.boolean().default(false), + }), }) ) export type ListIdentityGroupsOutput = z.infer< @@ -56,6 +60,9 @@ export const listIdentityGroups = async ({ const URN = edge.dst.baseUrn as IdentityGroupURN const name = edge.dst.qc.name + const igNode = initIdentityGroupNodeByName(URN, ctx.IdentityGroup) + const pd = await igNode.class.getStripePaymentData() + const { edges: groupMemberEdges } = await caller.edges.getEdges({ query: { tag: EDGE_MEMBER_OF_IDENTITY_GROUP, @@ -78,6 +85,9 @@ export const listIdentityGroups = async ({ URN, name, members: mappedMembers, + flags: { + billingConfigured: Boolean(pd?.paymentMethodID), + }, } }) ) diff --git a/platform/identity/src/jsonrpc/methods/updateEntitlements.ts b/platform/identity/src/jsonrpc/methods/updateEntitlements.ts deleted file mode 100644 index 0832e8bc0e..0000000000 --- a/platform/identity/src/jsonrpc/methods/updateEntitlements.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { ServicePlanType } from '@proofzero/types/identity' -import { z } from 'zod' -import { Context } from '../../context' -import { IdentityURNInput } from '@proofzero/platform-middleware/inputValidators' -import { initIdentityNodeByName } from '../../nodes' - -export const UpdateEntitlementsInputSchema = z.object({ - identityURN: IdentityURNInput, - subscriptionID: z.string(), - type: z.nativeEnum(ServicePlanType), - quantity: z.number(), -}) -export type UpdateEntitlementsInput = z.infer< - typeof UpdateEntitlementsInputSchema -> - -export const updateEntitlements = async ({ - input, - ctx, -}: { - input: UpdateEntitlementsInput - ctx: Context -}): Promise => { - const { type, quantity, subscriptionID, identityURN } = input - - const identity = await initIdentityNodeByName(identityURN, ctx.Identity) - await identity.class.updateEntitlements(type, quantity, subscriptionID) - - // await ctx.identity?.class.updateEntitlements(type, quantity, subscriptionID) -} diff --git a/platform/identity/src/jsonrpc/router.ts b/platform/identity/src/jsonrpc/router.ts index 454dadf5cc..40b309f09d 100644 --- a/platform/identity/src/jsonrpc/router.ts +++ b/platform/identity/src/jsonrpc/router.ts @@ -47,26 +47,6 @@ import { deleteIdentityNodeMethod, } from './methods/deleteIdentityNode' import { UnauthorizedError } from '@proofzero/errors' -import { - GetEntitlementsInputSchema, - GetEntitlementsOutputSchema, - getEntitlements, -} from './methods/getEntitlements' -import { - UpdateEntitlementsInputSchema, - updateEntitlements, -} from './methods/updateEntitlements' -import { - GetStripPaymentDataInputSchema, - GetStripePaymentDataOutputSchema, - SetStripePaymentDataInputSchema, - getStripePaymentData, - setStripePaymentData, -} from './methods/stripePaymentData' -import { - CancelServicePlansInput, - cancelServicePlans, -} from './methods/cancelServicePlans' import { CreateIdentityGroupInputSchema, createIdentityGroup, @@ -103,6 +83,16 @@ import { deleteIdentityGroup, } from './methods/identity-groups/deleteIdentityGroup' import { purgeIdentityGroupMemberships } from './methods/identity-groups/purgeIdentityGroupMemberships' +import { + HasIdentityGroupPermissionsInputSchema, + HasIdentityGroupPermissionsOutputSchema, + hasIdentityGroupPermissions, +} from './methods/identity-groups/hasIdentityGroupPermissions' +import { + ConnectIdentityGroupEmailInputSchema, + ConnectIdentityGroupEmailOutputSchema, + connectIdentityGroupEmail, +} from './methods/identity-groups/connectIdentityGroupEmail' const t = initTRPC.context().create({ errorFormatter }) @@ -195,33 +185,6 @@ export const appRouter = t.router({ .use(LogUsage) .input(DeleteIdentityNodeInput) .mutation(deleteIdentityNodeMethod), - getEntitlements: t.procedure - .use(LogUsage) - .use(Analytics) - .input(GetEntitlementsInputSchema) - .output(GetEntitlementsOutputSchema) - .query(getEntitlements), - updateEntitlements: t.procedure - .use(LogUsage) - .use(Analytics) - .input(UpdateEntitlementsInputSchema) - .mutation(updateEntitlements), - getStripePaymentData: t.procedure - .use(LogUsage) - .use(Analytics) - .input(GetStripPaymentDataInputSchema) - .output(GetStripePaymentDataOutputSchema) - .query(getStripePaymentData), - setStripePaymentData: t.procedure - .use(LogUsage) - .use(Analytics) - .input(SetStripePaymentDataInputSchema) - .mutation(setStripePaymentData), - cancelServicePlans: t.procedure - .use(LogUsage) - .use(Analytics) - .input(CancelServicePlansInput) - .mutation(cancelServicePlans), createIdentityGroup: t.procedure .use(AuthorizationTokenFromHeader) .use(ValidateJWT) @@ -295,4 +258,19 @@ export const appRouter = t.router({ .use(ValidateJWT) .use(RequireIdentity) .mutation(purgeIdentityGroupMemberships), + hasIdentityGroupPermissions: t.procedure + .use(LogUsage) + .use(Analytics) + .input(HasIdentityGroupPermissionsInputSchema) + .output(HasIdentityGroupPermissionsOutputSchema) + .query(hasIdentityGroupPermissions), + connectIdentityGroupEmail: t.procedure + .use(LogUsage) + .use(Analytics) + .use(AuthorizationTokenFromHeader) + .use(ValidateJWT) + .use(RequireIdentity) + .input(ConnectIdentityGroupEmailInputSchema) + .output(ConnectIdentityGroupEmailOutputSchema) + .mutation(connectIdentityGroupEmail), }) diff --git a/platform/identity/src/nodes/identity-group.ts b/platform/identity/src/nodes/identity-group.ts index f8c8f654e5..e1ca2525bf 100644 --- a/platform/identity/src/nodes/identity-group.ts +++ b/platform/identity/src/nodes/identity-group.ts @@ -4,6 +4,11 @@ import { EmailAccountType, OAuthAccountType, } from '@proofzero/types/account' +import { + ServicePlans, + ServicePlanType, + PaymentData, +} from '@proofzero/types/billing' import { DOProxy } from 'do-proxy' export type InviteMemberInput = { @@ -29,6 +34,65 @@ export default class IdentityGroup extends DOProxy { this.state = state } + async getServicePlans(): Promise { + return this.state.storage.get('servicePlans') + } + + async updateEntitlements( + type: ServicePlanType, + quantity: number, + subscriptionID: string + ): Promise { + let servicePlans = await this.state.storage.get( + 'servicePlans' + ) + if (!servicePlans) { + servicePlans = {} + } + + if (!servicePlans.subscriptionID) { + servicePlans.subscriptionID = subscriptionID + } else { + if (servicePlans.subscriptionID !== subscriptionID) { + throw new RollupError({ + message: 'Subscription ID mismatch', + }) + } + } + + if (!servicePlans.plans) { + servicePlans.plans = {} + } + + if (!servicePlans.plans[type]) { + servicePlans.plans[type] = { entitlements: 0 } + } + + // Non-null assertion operator is used + // because of checks in previous lines + servicePlans.plans[type]!.entitlements = quantity + + await this.state.storage.put('servicePlans', servicePlans) + } + + async getStripePaymentData(): Promise { + return this.state.storage.get('stripePaymentData') + } + + async setStripePaymentData(paymentData: PaymentData): Promise { + const stored = await this.state.storage.get( + 'stripePaymentData' + ) + + if (stored && stored.customerID !== paymentData.customerID) { + throw new RollupError({ + message: 'Customer ID already set', + }) + } + + await this.state.storage.put('stripePaymentData', paymentData) + } + async inviteMember({ inviter, identifier, diff --git a/platform/identity/src/nodes/identity.ts b/platform/identity/src/nodes/identity.ts index 49c1cf202b..f03a92759f 100644 --- a/platform/identity/src/nodes/identity.ts +++ b/platform/identity/src/nodes/identity.ts @@ -4,7 +4,7 @@ import { PaymentData, ServicePlanType, ServicePlans, -} from '@proofzero/types/identity' +} from '@proofzero/types/billing' import { RollupError } from '@proofzero/errors' export default class Identity extends DOProxy { diff --git a/platform/identity/src/types.ts b/platform/identity/src/types.ts index d900aa769b..79287dcb3d 100644 --- a/platform/identity/src/types.ts +++ b/platform/identity/src/types.ts @@ -2,10 +2,8 @@ import { z } from 'zod' import { AccountListSchema } from './jsonrpc/validators/accountList' import { ProfileSchema } from './jsonrpc/validators/profile' import { AccountsSchema } from './jsonrpc/validators/profile' -import { GetStripePaymentDataOutputSchema } from './jsonrpc/methods/stripePaymentData' // TODO: move to types packages export type AccountList = z.infer export type Profile = z.infer export type Accounts = z.infer -export type StripePaymentData = z.infer diff --git a/platform/starbase/src/jsonrpc/methods/createApp.ts b/platform/starbase/src/jsonrpc/methods/createApp.ts index 9877caecb0..04d7f52ac5 100644 --- a/platform/starbase/src/jsonrpc/methods/createApp.ts +++ b/platform/starbase/src/jsonrpc/methods/createApp.ts @@ -6,7 +6,7 @@ import { getApplicationNodeByClientId } from '../../nodes/application' import { ApplicationURNSpace } from '@proofzero/urns/application' import { EDGE_APPLICATION } from '../../types' import { createAnalyticsEvent } from '@proofzero/utils/analytics' -import { ServicePlanType } from '@proofzero/types/identity' +import { ServicePlanType } from '@proofzero/types/billing' export const CreateAppInputSchema = z.object({ clientName: z.string(), diff --git a/platform/starbase/src/jsonrpc/methods/deleteSubscriptionPlans.ts b/platform/starbase/src/jsonrpc/methods/deleteSubscriptionPlans.ts index e866207126..6f585869c7 100644 --- a/platform/starbase/src/jsonrpc/methods/deleteSubscriptionPlans.ts +++ b/platform/starbase/src/jsonrpc/methods/deleteSubscriptionPlans.ts @@ -4,10 +4,10 @@ import { Context } from '../context' import { getApplicationNodeByClientId } from '../../nodes/application' import { ApplicationURNSpace } from '@proofzero/urns/application' import { EDGE_PAYS_APP } from '@proofzero/types/graph' -import { IdentityURNInput } from '@proofzero/platform-middleware/inputValidators' +import { IdentityRefURNValidator } from '@proofzero/platform-middleware/inputValidators' export const DeleteSubscriptionPlansInput = z.object({ - identityURN: IdentityURNInput, + URN: IdentityRefURNValidator, }) type DeleteSubscriptionPlansParams = z.infer< typeof DeleteSubscriptionPlansInput @@ -20,13 +20,13 @@ export const deleteSubscriptionPlans = async ({ input: DeleteSubscriptionPlansParams ctx: Context }): Promise => { - const { identityURN } = input + const { URN } = input const caller = router.createCaller(ctx) const { edges } = await caller.edges.getEdges({ query: { - src: { baseUrn: identityURN }, + src: { baseUrn: URN }, tag: EDGE_PAYS_APP, }, }) @@ -40,7 +40,7 @@ export const deleteSubscriptionPlans = async ({ Promise.all( appURNs.map((appURN) => caller.edges.removeEdge({ - src: identityURN, + src: URN, tag: EDGE_PAYS_APP, dst: appURN, }) diff --git a/platform/starbase/src/jsonrpc/methods/getAppPlan.ts b/platform/starbase/src/jsonrpc/methods/getAppPlan.ts index 06740d52b5..3c63c83101 100644 --- a/platform/starbase/src/jsonrpc/methods/getAppPlan.ts +++ b/platform/starbase/src/jsonrpc/methods/getAppPlan.ts @@ -2,8 +2,7 @@ import { z } from 'zod' import { Context } from '../context' import { getApplicationNodeByClientId } from '../../nodes/application' import { AppClientIdParamSchema } from '../validators/app' -import { ApplicationURNSpace } from '@proofzero/urns/application' -import { ServicePlanType } from '@proofzero/types/identity' +import { ServicePlanType } from '@proofzero/types/billing' export const GetAppPlanInputSchema = AppClientIdParamSchema type GetAppPlanInput = z.infer diff --git a/platform/starbase/src/jsonrpc/methods/getAppPublicProps.ts b/platform/starbase/src/jsonrpc/methods/getAppPublicProps.ts index 8bafcba96e..be1c36022a 100644 --- a/platform/starbase/src/jsonrpc/methods/getAppPublicProps.ts +++ b/platform/starbase/src/jsonrpc/methods/getAppPublicProps.ts @@ -7,7 +7,7 @@ import { AppPublicPropsSchema, } from '../validators/app' import type { CustomDomain } from '../../types' -import { ServicePlanType } from '@proofzero/types/identity' +import { ServicePlanType } from '@proofzero/types/billing' export const GetAppPublicPropsInput = AppClientIdParamSchema export const GetAppPublicPropsOutput = AppPublicPropsSchema diff --git a/platform/starbase/src/jsonrpc/methods/listApps.ts b/platform/starbase/src/jsonrpc/methods/listApps.ts index 9b4d1b3d56..cb149c8d61 100644 --- a/platform/starbase/src/jsonrpc/methods/listApps.ts +++ b/platform/starbase/src/jsonrpc/methods/listApps.ts @@ -1,16 +1,11 @@ import { z } from 'zod' import { Context } from '../context' import { getApplicationNodeByClientId } from '../../nodes/application' -import { - ApplicationURN, - ApplicationURNSpace, -} from '@proofzero/urns/application' +import { ApplicationURNSpace } from '@proofzero/urns/application' import { AppReadableFieldsSchema, AppUpdateableFieldsSchema, } from '../validators/app' -import { EdgeDirection } from '@proofzero/types/graph' -import { EDGE_APPLICATION } from '../../types' import { NoInput } from '@proofzero/platform-middleware/inputValidators' export const ListAppsOutput = z.array( diff --git a/platform/starbase/src/jsonrpc/methods/reconcileAppSubscriptions.ts b/platform/starbase/src/jsonrpc/methods/reconcileAppSubscriptions.ts index 171d9a4d30..8ac71795af 100644 --- a/platform/starbase/src/jsonrpc/methods/reconcileAppSubscriptions.ts +++ b/platform/starbase/src/jsonrpc/methods/reconcileAppSubscriptions.ts @@ -1,14 +1,14 @@ import { z } from 'zod' import { router } from '@proofzero/platform.core' import { Context } from '../context' -import { ServicePlanType } from '@proofzero/types/identity' -import { IdentityURNInput } from '@proofzero/platform-middleware/inputValidators' +import { IdentityRefURNValidator } from '@proofzero/platform-middleware/inputValidators' import { EDGE_HAS_REFERENCE_TO, EDGE_PAYS_APP } from '@proofzero/types/graph' import { ApplicationURNSpace } from '@proofzero/urns/application' import { getApplicationNodeByClientId } from '../../nodes/application' +import { ServicePlanType } from '@proofzero/types/billing' export const ReconcileAppSubscriptionsInputSchema = z.object({ - identityURN: IdentityURNInput, + URN: IdentityRefURNValidator, count: z.number(), plan: z.nativeEnum(ServicePlanType), }) @@ -37,11 +37,11 @@ export const reconcileAppSubscriptions = async ({ input: ReconcileAppSubscriptionsInput ctx: Context }): Promise => { - const { identityURN, plan, count } = input + const { URN, plan, count } = input const caller = router.createCaller(ctx) const { edges } = await caller.edges.getEdges({ query: { - src: { baseUrn: identityURN }, + src: { baseUrn: URN }, tag: EDGE_PAYS_APP, }, }) @@ -90,7 +90,7 @@ export const reconcileAppSubscriptions = async ({ for (const app of targetApps) { await caller.edges.removeEdge({ - src: identityURN, + src: URN, tag: EDGE_PAYS_APP, dst: app.appURN, }) diff --git a/platform/starbase/src/jsonrpc/methods/setAppPlan.ts b/platform/starbase/src/jsonrpc/methods/setAppPlan.ts index c9db49fad5..e9138dd035 100644 --- a/platform/starbase/src/jsonrpc/methods/setAppPlan.ts +++ b/platform/starbase/src/jsonrpc/methods/setAppPlan.ts @@ -4,13 +4,13 @@ import { Context } from '../context' import { getApplicationNodeByClientId } from '../../nodes/application' import { AppClientIdParamSchema } from '../validators/app' import { ApplicationURNSpace } from '@proofzero/urns/application' -import { ServicePlanType } from '@proofzero/types/identity' +import { ServicePlanType } from '@proofzero/types/billing' import { EDGE_PAYS_APP } from '@proofzero/types/graph' -import { IdentityURNInput } from '@proofzero/platform-middleware/inputValidators' +import { IdentityRefURNValidator } from '@proofzero/platform-middleware/inputValidators' import { createAnalyticsEvent } from '@proofzero/utils/analytics' export const SetAppPlanInput = AppClientIdParamSchema.extend({ - identityURN: IdentityURNInput, + URN: IdentityRefURNValidator, plan: z.nativeEnum(ServicePlanType), }) type SetAppPlanParams = z.infer @@ -22,7 +22,7 @@ export const setAppPlan = async ({ input: SetAppPlanParams ctx: Context }): Promise => { - const { plan, clientId, identityURN } = input + const { plan, clientId } = input const appURN = ApplicationURNSpace.componentizedUrn(clientId) if (!ctx.ownAppURNs || !ctx.ownAppURNs.includes(appURN)) @@ -41,7 +41,7 @@ export const setAppPlan = async ({ if (plan && plan !== ServicePlanType.FREE) { const { edges } = await caller.edges.getEdges({ query: { - src: { baseUrn: identityURN }, + src: { baseUrn: input.URN }, tag: EDGE_PAYS_APP, dst: { baseUrn: appURN }, }, @@ -49,14 +49,14 @@ export const setAppPlan = async ({ if (edges.length === 0) { await caller.edges.makeEdge({ - src: identityURN, + src: input.URN, tag: EDGE_PAYS_APP, dst: appURN, }) } } else { await caller.edges.removeEdge({ - src: identityURN, + src: input.URN, tag: EDGE_PAYS_APP, dst: appURN, }) @@ -67,7 +67,7 @@ export const setAppPlan = async ({ await createAnalyticsEvent({ eventName: '$groupidentify', apiKey: ctx.POSTHOG_API_KEY, - distinctId: identityURN, + distinctId: input.URN, properties: { $group_type: 'app', $group_key: clientId, @@ -80,7 +80,7 @@ export const setAppPlan = async ({ await createAnalyticsEvent({ eventName: `app_set_${plan}_plan`, apiKey: ctx.POSTHOG_API_KEY, - distinctId: identityURN, + distinctId: input.URN, properties: { $groups: { app: clientId }, }, diff --git a/platform/starbase/src/jsonrpc/validators/app.ts b/platform/starbase/src/jsonrpc/validators/app.ts index fbb8091a71..c5d51cada5 100644 --- a/platform/starbase/src/jsonrpc/validators/app.ts +++ b/platform/starbase/src/jsonrpc/validators/app.ts @@ -1,6 +1,6 @@ import { z } from 'zod' import { CustomDomainSchema } from './customdomain' -import { ServicePlanType } from '@proofzero/types/identity' +import { ServicePlanType } from '@proofzero/types/billing' export const AppObjectSchema = z.object({ name: z.string(), diff --git a/platform/starbase/src/nodes/application.ts b/platform/starbase/src/nodes/application.ts index 8cdc1d3c63..9a401924de 100644 --- a/platform/starbase/src/nodes/application.ts +++ b/platform/starbase/src/nodes/application.ts @@ -37,7 +37,7 @@ import { InternalServerError } from '@proofzero/errors' import type { CustomDomain } from '../types' import { getCloudflareFetcher, getCustomHostname } from '../utils/cloudflare' import { getDNSRecordValue } from '@proofzero/utils' -import { ServicePlanType } from '@proofzero/types/identity' +import { ServicePlanType } from '@proofzero/types/billing' type AppDetails = AppUpdateableFields & AppReadableFields type AppProfile = AppUpdateableFields diff --git a/yarn.lock b/yarn.lock index a0d8c333bf..5db934518d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6304,6 +6304,34 @@ __metadata: languageName: unknown linkType: soft +"@proofzero/platform.billing@workspace:*, @proofzero/platform.billing@workspace:platform/billing": + version: 0.0.0-use.local + resolution: "@proofzero/platform.billing@workspace:platform/billing" + dependencies: + "@cloudflare/workers-types": 4.20221111.1 + "@ethersproject/address": 5.7.0 + "@ethersproject/bytes": 5.7.0 + "@ethersproject/random": 5.7.0 + "@proofzero/platform-middleware": "workspace:*" + "@proofzero/platform.core": "workspace:*" + "@proofzero/types": "workspace:*" + "@proofzero/utils": "workspace:*" + "@trpc/server": 10.8.1 + "@types/node": 18.15.3 + "@typescript-eslint/eslint-plugin": 5.42.1 + "@typescript-eslint/parser": 5.42.1 + do-proxy: 1.3.3 + eslint: 8.28.0 + eslint-config-prettier: 8.5.0 + npm-run-all: 4.1.5 + prettier: 2.7.1 + ts-set-utils: 0.2.0 + typed-json-rpc: 1.1.0 + typescript: 5.0.4 + urns: 0.6.0 + languageName: unknown + linkType: soft + "@proofzero/platform.core@workspace:*, @proofzero/platform.core@workspace:platform/core": version: 0.0.0-use.local resolution: "@proofzero/platform.core@workspace:platform/core" @@ -6311,6 +6339,7 @@ __metadata: "@cloudflare/workers-types": 4.20230518.0 "@proofzero/platform.account": "workspace:*" "@proofzero/platform.authorization": "workspace:*" + "@proofzero/platform.billing": "workspace:*" "@proofzero/platform.edges": "workspace:*" "@proofzero/platform.identity": "workspace:*" "@proofzero/platform.starbase": "workspace:*"