diff --git a/apps/console/app/routes/__layout/spuorg/$groupID/apps.new.tsx b/apps/console/app/routes/__layout/spuorg/$groupID/apps.new.tsx index 3a723db330..3473519f06 100644 --- a/apps/console/app/routes/__layout/spuorg/$groupID/apps.new.tsx +++ b/apps/console/app/routes/__layout/spuorg/$groupID/apps.new.tsx @@ -1,4 +1,4 @@ -import { Form, Link, useOutletContext } from '@remix-run/react' +import { Form, Link, NavLink, useOutletContext } from '@remix-run/react' import { GroupDetailsContextData } from '../$groupID' import Breadcrumbs from '@proofzero/design-system/src/atoms/breadcrumbs/Breadcrumbs' import { Text } from '@proofzero/design-system' @@ -81,7 +81,7 @@ export const action: ActionFunction = getRollupReqFunctionErrorWrapper( ) export default () => { - const { group, groupID } = useOutletContext() + const { group, groupID, apps } = useOutletContext() return ( <> @@ -129,6 +129,21 @@ export default () => { + + {apps.filter((a) => !a.groupID) && ( +
+ + Would you like to transfer an existing application
+ into this group?{' '} + + Go to transfer + +
+
+ )} ) } diff --git a/apps/console/app/routes/__layout/spuorg/$groupID/apps.transfer.tsx b/apps/console/app/routes/__layout/spuorg/$groupID/apps.transfer.tsx new file mode 100644 index 0000000000..609b820b46 --- /dev/null +++ b/apps/console/app/routes/__layout/spuorg/$groupID/apps.transfer.tsx @@ -0,0 +1,761 @@ +import { + Form, + Link, + useActionData, + useLoaderData, + useOutletContext, + useSubmit, +} from '@remix-run/react' +import { GroupDetailsContextData } from '../$groupID' +import Breadcrumbs from '@proofzero/design-system/src/atoms/breadcrumbs/Breadcrumbs' +import { Text } from '@proofzero/design-system' +import { Input } from '@proofzero/design-system/src/atoms/form/Input' +import { Button } from '@proofzero/design-system/src/atoms/buttons/Button' +import { + ActionFunction, + LoaderFunction, + json, + redirect, +} from '@remix-run/cloudflare' +import { getRollupReqFunctionErrorWrapper } from '@proofzero/utils/errors' +import { appendToastToFlashSession } from '~/utils/toast.server' +import { ToastType } from '@proofzero/design-system/src/atoms/toast' +import { + commitFlashSession, + getFlashSession, + requireJWT, +} from '~/utilities/session.server' +import { BadRequestError } from '@proofzero/errors' +import createCoreClient from '@proofzero/platform-clients/core' +import { generateTraceContextHeaders } from '@proofzero/platform-middleware/trace' +import { + IdentityGroupURNSpace, + IdentityGroupURN, +} from '@proofzero/urns/identity-group' +import { getAuthzHeaderConditionallyFromToken } from '@proofzero/utils' +import { Listbox, Transition } from '@headlessui/react' +import { useEffect, useState } from 'react' +import { AppLoaderData } from '~/root' +import { + CheckIcon, + ChevronDownIcon, + ChevronUpIcon, +} from '@heroicons/react/20/solid' +import classNames from 'classnames' +import _ from 'lodash' +import { ServicePlanType } from '@proofzero/types/billing' +import { GetEntitlementsOutput } from '@proofzero/platform.billing/src/jsonrpc/methods/getEntitlements' +import { + StripeInvoice, + createOrUpdateSubscription, + process3DSecureCard, +} from '~/utils/billing' +import Stripe from 'stripe' +import plans from '~/utils/plans' +import { + getEmailDropdownItems, + getEmailIcon, +} from '@proofzero/utils/getNormalisedConnectedAccounts' +import { + Dropdown, + DropdownSelectListItem, +} from '@proofzero/design-system/src/atoms/dropdown/DropdownSelectList' +import { redirectToPassport } from '~/utils' +import { HiOutlineMail } from 'react-icons/hi' +import { AccountURN } from '@proofzero/urns/account' + +type GroupAppTransferLoaderData = { + connectedEmails: DropdownSelectListItem[] + hasPaymentMethod: boolean + entitlements: GetEntitlementsOutput + STRIPE_PUBLISHABLE_KEY: string +} + +export const loader: LoaderFunction = getRollupReqFunctionErrorWrapper( + async ({ request, context, params }) => { + const jwt = await requireJWT(request, context.env) + const traceHeader = generateTraceContextHeaders(context.traceSpan) + const coreClient = createCoreClient(context.env.Core, { + ...getAuthzHeaderConditionallyFromToken(jwt), + ...traceHeader, + }) + + const groupURN = IdentityGroupURNSpace.urn( + params.groupID as string + ) as IdentityGroupURN + + const [spd, entitlements, connectedAccounts] = await Promise.all([ + await coreClient.billing.getStripePaymentData.query({ + URN: groupURN, + }), + await coreClient.billing.getEntitlements.query({ + URN: groupURN, + }), + await coreClient.identity.getAccounts.query({ + URN: groupURN, + }), + ]) + + const connectedEmails = getEmailDropdownItems(connectedAccounts) + + return json({ + connectedEmails, + hasPaymentMethod: spd && spd.paymentMethodID ? true : false, + entitlements, + STRIPE_PUBLISHABLE_KEY: context.env.STRIPE_PUBLISHABLE_KEY, + }) + } +) + +export const action: ActionFunction = getRollupReqFunctionErrorWrapper( + async ({ request, context, params }) => { + const groupID = params.groupID as string + const groupURN = IdentityGroupURNSpace.urn( + groupID as string + ) as IdentityGroupURN + + const jwt = await requireJWT(request, context.env) + + const fd = await request.formData() + const clientID = fd.get('app[clientId]') + if (!clientID) { + throw new BadRequestError({ + message: 'app[clientId] is required', + }) + } + + const emailURN = fd.get('emailURN') as AccountURN | undefined + + const traceHeader = generateTraceContextHeaders(context.traceSpan) + const coreClient = createCoreClient(context.env.Core, { + ...getAuthzHeaderConditionallyFromToken(jwt), + ...traceHeader, + }) + + const appDetails = await coreClient.starbase.getAppDetails.query({ + clientId: clientID as string, + }) + + if (appDetails.published && !emailURN) { + throw new BadRequestError({ + message: 'emailURN is required', + }) + } + + if (appDetails.appPlan !== ServicePlanType.FREE) { + const spd = await coreClient.billing.getStripePaymentData.query({ + URN: groupURN, + }) + if (!spd.paymentMethodID) { + throw new BadRequestError({ + message: 'Group has no payment method configured', + }) + } + + const entitlements = await coreClient.billing.getEntitlements.query({ + URN: groupURN, + }) + + const groupApps = await coreClient.starbase.listGroupApps.query() + const currentGroupApps = groupApps.filter((a) => a.groupURN === groupURN) + const currentGroupPlanApps = currentGroupApps.filter( + (a) => a.appPlan === appDetails.appPlan + ) + + if ( + (entitlements.plans[appDetails.appPlan]?.entitlements ?? 0) - + currentGroupPlanApps.length <= + 0 + ) { + const quantity = entitlements.subscriptionID + ? entitlements.plans[appDetails.appPlan]?.entitlements + ? entitlements.plans[appDetails.appPlan]?.entitlements! + 1 + : 1 + : 1 + + const sub = await createOrUpdateSubscription({ + customerID: spd.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: groupURN, + }) + + const invoiceStatus = (sub.latest_invoice as Stripe.Invoice)?.status + + if ( + (sub.status === 'active' || sub.status === 'trialing') && + invoiceStatus === 'paid' + ) { + await coreClient.billing.updateEntitlements.mutate({ + URN: groupURN, + subscriptionID: sub.id, + quantity: quantity, + type: appDetails.appPlan, + }) + } else { + let toastSession = await getFlashSession(request, context.env) + + if ( + (sub.latest_invoice as unknown as StripeInvoice)?.payment_intent + ?.status === 'requires_action' + ) { + await coreClient.billing.updateEntitlements.mutate({ + URN: groupURN, + subscriptionID: sub.id, + quantity: quantity - 1, + type: appDetails.appPlan, + }) + + toastSession = await appendToastToFlashSession( + request, + { + message: `Payment requires additional action`, + type: ToastType.Warning, + }, + context.env + ) + + let status, client_secret, payment_method + ;({ status, client_secret, payment_method } = ( + sub.latest_invoice as Stripe.Invoice + ).payment_intent as Stripe.PaymentIntent) + + return json( + { + subId: sub.id, + status, + client_secret, + payment_method, + clientID, + }, + { + headers: { + 'Set-Cookie': await commitFlashSession( + toastSession, + context.env + ), + }, + } + ) + } else { + toastSession = await appendToastToFlashSession( + request, + { + message: `Payment failed - correct the failed transaction in Billing & Invoicing and retry application transfer`, + type: ToastType.Error, + }, + context.env + ) + + return new Response(null, { + headers: { + 'Set-Cookie': await commitFlashSession( + toastSession, + context.env + ), + }, + }) + } + } + } + } + + try { + await coreClient.starbase.transferAppToGroup.mutate({ + clientID: clientID as string, + identityGroupURN: groupURN, + emailURN, + }) + + const toastSession = await appendToastToFlashSession( + request, + { + message: `Application transferred.`, + type: ToastType.Success, + }, + context.env + ) + + return redirect(`/spuorg/${groupID}`, { + headers: { + 'Set-Cookie': await commitFlashSession(toastSession, context.env), + }, + }) + } catch (ex) { + const toastSession = await appendToastToFlashSession( + request, + { + message: `There was an issue transferring the application. Please try again.`, + type: ToastType.Error, + }, + context.env + ) + + return redirect(`/spuorg/${params.groupID}/apps/transfer`, { + headers: { + 'Set-Cookie': await commitFlashSession(toastSession, context.env), + }, + }) + } + } +) + +export default () => { + const { group, groupID, groupURN, apps, PASSPORT_URL } = + useOutletContext() + const { + hasPaymentMethod, + entitlements, + STRIPE_PUBLISHABLE_KEY, + connectedEmails, + } = useLoaderData() + + const actionData = useActionData() + + const [selectedApp, setSelectedApp] = useState(null) + + const [needsGroupBilling, setNeedsGroupBilling] = useState(false) + const [needsEntitlement, setNeedsEntitlement] = useState(false) + + const [selectedEmailURN, setSelectedEmailURN] = useState() + + const submit = useSubmit() + + useEffect(() => { + if (!selectedApp) { + setNeedsEntitlement(false) + setNeedsGroupBilling(false) + return + } + + if (selectedApp.appPlan !== ServicePlanType.FREE) { + if (!hasPaymentMethod) { + setNeedsGroupBilling(true) + } else { + if ( + (entitlements.plans[selectedApp.appPlan]?.entitlements ?? 0) - + apps.filter( + (a) => a.groupID === groupID && a.appPlan === selectedApp.appPlan + ).length <= + 0 + ) { + setNeedsEntitlement(true) + } else { + setNeedsEntitlement(false) + } + } + } + }, [selectedApp, entitlements]) + + useEffect(() => { + if (actionData && selectedApp) { + const { status, client_secret, payment_method, subId } = actionData + process3DSecureCard({ + submit, + subId, + STRIPE_PUBLISHABLE_KEY, + status, + client_secret, + payment_method, + redirectUrl: `/spuorg/${groupID}/apps/transfer/`, + URN: groupURN, + }) + } + }, [actionData]) + + return ( + <> + {group && ( +
+ +
+ )} + +
+ + Transfer Application + + +
+ + + + + + + + + + + + + + + Proceed with caution! Once the transfer is completed application + cannot be transferred back to your personal account. + +
+
+ +
+
+ + + !a.groupID).length === 0} + > + {({ open }) => ( +
+ + {apps.filter((a) => !a.groupID).length > 0 && ( + <> + {selectedApp && ( +
+ {!selectedApp.icon && ( +
+ + {selectedApp.name?.substring(0, 1)} + +
+ )} + {selectedApp.icon && ( + app icon + )} + + {_.upperFirst(selectedApp?.name)} + +
+ )} + + {!selectedApp && ( + + Select an Application + + )} + + )} + + {apps.filter((a) => !a.groupID).length === 0 && ( + + No Application Available + + )} + + {open ? ( + + ) : ( + + )} +
+ + + + {apps + .filter((a) => !a.groupID) + .map((app) => ( + + classNames( + 'flex flex-row items-center gap-2 hover:bg-gray-100 py-2 px-4 rounded-lg cursor-pointer', + { + 'bg-gray-100': active, + } + ) + } + > + {({ selected }) => ( +
+
+ {!app.icon && ( +
+ + {app.name?.substring(0, 1)} + +
+ )} + {app.icon && ( + app icon + )} + + {_.upperFirst(app?.name)} + +
+ + {selected && ( +
+ )} +
+ ))} +
+
+
+ )} +
+ + {selectedApp?.published && ( +
+ {connectedEmails && connectedEmails.length === 0 && ( + + )} + + {connectedEmails && connectedEmails.length > 0 && ( + <> + + + { + email.value === '' + ? (email.selected = true) + : (email.selected = false) + 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 Account" + ConnectButtonCallback={() => + redirectToPassport({ + PASSPORT_URL, + login_hint: 'email', + rollup_action: `groupemailconnect_${groupID}`, + }) + } + ConnectButtonPhrase="Connect New Email Address" + onSelect={(selected) => { + if (!Array.isArray(selected)) { + if (!selected || !selected.value) { + console.error('Error selecting email, try again') + return + } + + setSelectedEmailURN(selected.value as AccountURN) + } + }} + /> + + )} +
+ )} + + + + {selectedApp && needsEntitlement && ( +
+
+ + + + + + Application you are trying to transfer is on{' '} + + Pro Plan + + .
There are{' '} + + {(entitlements.plans[selectedApp.appPlan]?.entitlements ?? + 0) - + apps.filter( + (a) => + a.groupID === groupID && + a.appPlan === selectedApp.appPlan + ).length}{' '} + {plans[selectedApp.appPlan].title} Entitlements + {' '} + available in your group.{' '} +
+
+
+ )} + + {selectedApp && needsGroupBilling && ( +
+
+ + + + + + Please add Billing Information + +
+ + + We are missing Billing contact information for the group.
+ Please use the link below to add the information. +
+ + + + Add Billing Information → + + +
+ )} +
+
+ + ) +} diff --git a/apps/console/app/routes/apps/$clientId/billing.tsx b/apps/console/app/routes/apps/$clientId/billing.tsx index 7f4a0b56cd..ec18d7a06e 100644 --- a/apps/console/app/routes/apps/$clientId/billing.tsx +++ b/apps/console/app/routes/apps/$clientId/billing.tsx @@ -834,7 +834,7 @@ export default () => { totalEntitlements={entitlements[ServicePlanType.PRO]?.entitlements} usedEntitlements={ apps - .filter((a) => (groupID ? a.groupID === groupID : true)) + .filter((a) => (groupID ? a.groupID === groupID : !a.groupID)) .filter((a) => a.appPlan === ServicePlanType.PRO).length } paymentData={paymentData} diff --git a/platform/starbase/src/jsonrpc/methods/transferAppToGroup.ts b/platform/starbase/src/jsonrpc/methods/transferAppToGroup.ts new file mode 100644 index 0000000000..1879a4d573 --- /dev/null +++ b/platform/starbase/src/jsonrpc/methods/transferAppToGroup.ts @@ -0,0 +1,101 @@ +import { z } from 'zod' +import { router } from '@proofzero/platform.core' +import { Context } from '../context' +import { ApplicationURNSpace } from '@proofzero/urns/application' +import { + AccountURNInput, + IdentityGroupURNValidator, +} from '@proofzero/platform-middleware/inputValidators' +import { BadRequestError } from '@proofzero/errors' +import { EDGE_HAS_REFERENCE_TO } from '@proofzero/types/graph' +import { AccountURNSpace } from '@proofzero/urns/account' + +export const TransferAppToGroupInput = z.object({ + clientID: z.string(), + identityGroupURN: IdentityGroupURNValidator, + emailURN: AccountURNInput.optional().nullable(), +}) + +type TransferAppToGroupParams = z.infer + +export const transferAppToGroup = async ({ + input, + ctx, +}: { + input: TransferAppToGroupParams + ctx: Context +}): Promise => { + const { clientID, identityGroupURN, emailURN } = input + + if (!ctx.identityURN) { + throw new BadRequestError({ + message: 'Request received without identityURN.', + }) + } + + const appURN = ApplicationURNSpace.componentizedUrn(clientID) + if (!ctx.ownAppURNs || !ctx.ownAppURNs.includes(appURN)) + throw new BadRequestError({ + message: `Request received for clientId ${clientID} which is not owned by provided account.`, + }) + + const caller = router.createCaller(ctx) + + const { edges } = await caller.edges.getEdges({ + query: { + dst: { baseUrn: appURN }, + src: { + baseUrn: ctx.identityURN, + }, + }, + }) + + await Promise.all( + edges.map(async (edge) => { + await caller.edges.makeEdge({ + src: identityGroupURN, + tag: edge.tag, + dst: edge.dst.baseUrn, + }) + + await caller.edges.removeEdge({ + src: edge.src.baseUrn, + tag: edge.tag, + dst: edge.dst.baseUrn, + }) + }) + ) + + if (emailURN) { + // Get all edges of type has/refTo + // Which should target the app's team email + const { edges: emailEdges } = await caller.edges.getEdges({ + query: { + src: { baseUrn: appURN }, + tag: EDGE_HAS_REFERENCE_TO, + }, + }) + + // Create a new edge using + // the new email as a destination + await caller.edges.makeEdge({ + src: appURN, + tag: EDGE_HAS_REFERENCE_TO, + dst: emailURN, + }) + + // Remove any previously linked team emails + // This should be a single edge + await Promise.all( + emailEdges + .filter((edge) => AccountURNSpace.is(edge.dst.baseUrn)) + .map(async (edge) => { + await caller.edges.removeEdge({ + src: edge.src.baseUrn, + tag: edge.tag, + dst: edge.dst.baseUrn, + }) + }) + ) + } +} diff --git a/platform/starbase/src/jsonrpc/router.ts b/platform/starbase/src/jsonrpc/router.ts index 36b379d6b7..730c334889 100644 --- a/platform/starbase/src/jsonrpc/router.ts +++ b/platform/starbase/src/jsonrpc/router.ts @@ -132,6 +132,10 @@ import { GetAppPlanOutputSchema, } from './methods/getAppPlan' import { listGroupApps, ListGroupAppsOutput } from './methods/listGroupApps' +import { + transferAppToGroup, + TransferAppToGroupInput, +} from './methods/transferAppToGroup' const t = initTRPC.context().create({ errorFormatter }) @@ -382,6 +386,14 @@ export const appRouter = t.router({ .use(Analytics) .output(ListGroupAppsOutput) .query(listGroupApps), + transferAppToGroup: t.procedure + .use(LogUsage) + .use(Analytics) + .use(AuthorizationTokenFromHeader) + .use(ValidateJWT) + .use(OwnAppsMiddleware) + .input(TransferAppToGroupInput) + .mutation(transferAppToGroup), }) export type StarbaseRouter = typeof appRouter