diff --git a/.github/workflows/main-console.yaml b/.github/workflows/main-console.yaml index 5a187a1d69..93309f2e00 100644 --- a/.github/workflows/main-console.yaml +++ b/.github/workflows/main-console.yaml @@ -52,12 +52,18 @@ jobs: secrets: | SECRET_SESSION_KEY SECRET_SESSION_SALT + STRIPE_API_SECRET + STRIPE_WEBHOOK_SECRET + STRIPE_PRO_PLAN_ID env: NODE_ENV: 'development' # A secret used for session encryption. SECRET_SESSION_KEY: ${{ secrets.SECRET_SESSION_KEY_DEV }} SECRET_SESSION_SALT: ${{ secrets.SECRET_SESSION_SALT_DEV }} # CF_ROUTE: https://console-dev.kubelt.com/* + STRIPE_API_SECRET: ${{ secrets.STRIPE_API_SECRET_DEV }} + STRIPE_WEBHOOK_SECRET: ${{ secrets.STRIPE_WEBHOOK_SECRET_DEV }} + STRIPE_PRO_PLAN_ID: ${{ secrets.STRIPE_PRO_PLAN_ID_DEV }} - name: Setup Playwright working-directory: 'apps/console' diff --git a/.github/workflows/next-console.yaml b/.github/workflows/next-console.yaml index a0034d5a5e..4765fb1234 100644 --- a/.github/workflows/next-console.yaml +++ b/.github/workflows/next-console.yaml @@ -52,7 +52,13 @@ jobs: secrets: | SECRET_SESSION_KEY SECRET_SESSION_SALT + STRIPE_API_SECRET + STRIPE_WEBHOOK_SECRET + STRIPE_PRO_PLAN_ID env: # A secret used for session encryption. SECRET_SESSION_KEY: ${{ secrets.SECRET_SESSION_KEY_TEST }} SECRET_SESSION_SALT: ${{ secrets.SECRET_SESSION_SALT_TEST }} + STRIPE_API_SECRET: ${{ secrets.STRIPE_API_SECRET_TEST }} + STRIPE_WEBHOOK_SECRET: ${{ secrets.STRIPE_WEBHOOK_SECRET_TEST }} + STRIPE_PRO_PLAN_ID: ${{ secrets.STRIPE_PRO_PLAN_ID_TEST }} diff --git a/.github/workflows/release-console.yaml b/.github/workflows/release-console.yaml index aad96a5882..6d640591c0 100644 --- a/.github/workflows/release-console.yaml +++ b/.github/workflows/release-console.yaml @@ -47,7 +47,13 @@ jobs: secrets: | SECRET_SESSION_KEY SECRET_SESSION_SALT + STRIPE_API_SECRET + STRIPE_WEBHOOK_SECRET + STRIPE_PRO_PLAN_ID env: # A secret used for session encryption. SECRET_SESSION_KEY: ${{ secrets.SECRET_SESSION_KEY_PROD }} SECRET_SESSION_SALT: ${{ secrets.SECRET_SESSION_SALT_PROD }} + STRIPE_API_SECRET: ${{ secrets.STRIPE_API_SECRET_PROD }} + STRIPE_WEBHOOK_SECRET: ${{ secrets.STRIPE_WEBHOOK_SECRET_PROD }} + STRIPE_PRO_PLAN_ID: ${{ secrets.STRIPE_PRO_PLAN_ID_PROD }} diff --git a/.yarn/cache/@types-node-npm-20.3.1-86012346c0-63a393ab6d.zip b/.yarn/cache/@types-node-npm-20.3.1-86012346c0-63a393ab6d.zip new file mode 100644 index 0000000000..6c167aa113 Binary files /dev/null and b/.yarn/cache/@types-node-npm-20.3.1-86012346c0-63a393ab6d.zip differ diff --git a/.yarn/cache/qs-npm-6.11.2-b118bc1c6f-e812f3c590.zip b/.yarn/cache/qs-npm-6.11.2-b118bc1c6f-e812f3c590.zip new file mode 100644 index 0000000000..e6e6f34e19 Binary files /dev/null and b/.yarn/cache/qs-npm-6.11.2-b118bc1c6f-e812f3c590.zip differ diff --git a/.yarn/cache/stripe-npm-12.9.0-9bb9987347-a860736592.zip b/.yarn/cache/stripe-npm-12.9.0-9bb9987347-a860736592.zip new file mode 100644 index 0000000000..d845c77701 Binary files /dev/null and b/.yarn/cache/stripe-npm-12.9.0-9bb9987347-a860736592.zip differ diff --git a/apps/console/.dev.vars.example b/apps/console/.dev.vars.example index 2ef48715f8..456f30f5bf 100644 --- a/apps/console/.dev.vars.example +++ b/apps/console/.dev.vars.example @@ -1,3 +1,7 @@ SECRET_SESSION_SALT = "" SECRET_SESSION_KEY = "" INTERNAL_GOOGLE_ANALYTICS_TAG = "G-NHNH4KRWC3" + +STRIPE_API_SECRET = "" +STRIPE_WEBHOOK_SECRET = "" +STRIPE_PRO_PLAN_ID = "" diff --git a/apps/console/app/routes/__layout/gnillib/checkout.tsx b/apps/console/app/routes/__layout/gnillib/checkout.tsx deleted file mode 100644 index 24ea2c5165..0000000000 --- a/apps/console/app/routes/__layout/gnillib/checkout.tsx +++ /dev/null @@ -1,71 +0,0 @@ -import { generateTraceContextHeaders } from '@proofzero/platform-middleware/trace' -import { getRollupReqFunctionErrorWrapper } from '@proofzero/utils/errors' -import { ActionFunction, LoaderFunction, redirect } from '@remix-run/cloudflare' -import { - commitFlashSession, - getFlashSession, - requireJWT, -} from '~/utilities/session.server' -import createAccountClient from '@proofzero/platform-clients/account' -import { getAuthzHeaderConditionallyFromToken } from '@proofzero/utils' -import { ServicePlanType } from '@proofzero/types/account' -import { hexlify } from '@ethersproject/bytes' -import { randomBytes } from '@ethersproject/random' -import { beginCheckout } from '~/services/billing/stripe' - -export const loader: LoaderFunction = getRollupReqFunctionErrorWrapper( - async ({ request }) => { - const flashSession = await getFlashSession(request.headers.get('Cookie')) - - flashSession.flash('billing_toast', `Order successfully submitted`) - - return redirect('/gnillib', { - headers: { - 'Set-Cookie': await commitFlashSession(flashSession), - }, - }) - } -) - -export const action: ActionFunction = getRollupReqFunctionErrorWrapper( - async ({ request, context }) => { - const jwt = await requireJWT(request) - const traceHeader = generateTraceContextHeaders(context.traceSpan) - - const accountClient = createAccountClient(Account, { - ...getAuthzHeaderConditionallyFromToken(jwt), - ...traceHeader, - }) - - const fd = await request.formData() - const action = fd.get('action') - switch (action) { - case 'purchase': { - const { - planType, - quantity, - }: { - planType: ServicePlanType - quantity: number - } = JSON.parse(fd.get('payload') as string) - - const nonce = hexlify(randomBytes(8)) - - await accountClient.registerServicePlanOrder.mutate({ - planType: planType, - quantity: quantity, - nonce, - }) - - return beginCheckout({ - planId: '42', - planType, - quantity, - nonce, - }) - } - } - - return null - } -) diff --git a/apps/console/app/routes/__layout/gnillib/details.tsx b/apps/console/app/routes/__layout/gnillib/details.tsx new file mode 100644 index 0000000000..2275de7e63 --- /dev/null +++ b/apps/console/app/routes/__layout/gnillib/details.tsx @@ -0,0 +1,83 @@ +import { generateTraceContextHeaders } from '@proofzero/platform-middleware/trace' +import { getRollupReqFunctionErrorWrapper } from '@proofzero/utils/errors' +import { ActionFunction, redirect } from '@remix-run/cloudflare' +import { + commitFlashSession, + getFlashSession, + requireJWT, +} from '~/utilities/session.server' +import createAccountClient from '@proofzero/platform-clients/account' +import { + getAuthzHeaderConditionallyFromToken, + parseJwt, +} from '@proofzero/utils' +import { createCustomer, updateCustomer } from '~/services/billing/stripe' +import { AccountURN } from '@proofzero/urns/account' +import { AddressURN } from '@proofzero/urns/address' + +export const action: ActionFunction = getRollupReqFunctionErrorWrapper( + async ({ request, context }) => { + const jwt = await requireJWT(request) + const parsedJwt = parseJwt(jwt!) + const accountURN = parsedJwt.sub as AccountURN + + const traceHeader = generateTraceContextHeaders(context.traceSpan) + + const accountClient = createAccountClient(Account, { + ...getAuthzHeaderConditionallyFromToken(jwt), + ...traceHeader, + }) + + const fd = await request.formData() + const { email, emailURN, name } = JSON.parse( + fd.get('payload') as string + ) as { + email: string + emailURN: AddressURN + name: string + } + + let paymentData = await accountClient.getStripePaymentData.query({ + accountURN, + }) + if (!paymentData) { + const customer = await createCustomer({ + email, + name, + accountURN, + }) + + paymentData = { + customerID: customer.id, + email, + name, + } + } else { + paymentData = { + ...paymentData, + email, + name, + } + + await updateCustomer({ + customerID: paymentData.customerID, + email, + name, + }) + } + + await accountClient.setStripePaymentData.mutate({ + ...paymentData, + accountURN, + }) + + const flashSession = await getFlashSession(request.headers.get('Cookie')) + flashSession.flash('success_toast', 'Payment data updated') + + return redirect('/gnillib', { + headers: { + 'Set-Cookie': await commitFlashSession(flashSession), + }, + }) + } +) diff --git a/apps/console/app/routes/__layout/gnillib/index.tsx b/apps/console/app/routes/__layout/gnillib/index.tsx index c5701dbb78..c7c6b5cd94 100644 --- a/apps/console/app/routes/__layout/gnillib/index.tsx +++ b/apps/console/app/routes/__layout/gnillib/index.tsx @@ -2,9 +2,14 @@ import { Button } from '@proofzero/design-system' import { Text } from '@proofzero/design-system/src/atoms/text/Text' import { generateTraceContextHeaders } from '@proofzero/platform-middleware/trace' import { getRollupReqFunctionErrorWrapper } from '@proofzero/utils/errors' -import { LoaderFunction, json } from '@remix-run/cloudflare' +import { + ActionFunction, + LoaderFunction, + json, + redirect, +} from '@remix-run/cloudflare' import { FaCheck, FaShoppingCart, FaTrash } from 'react-icons/fa' -import { HiMinus, HiOutlineExternalLink, HiPlus } from 'react-icons/hi' +import { HiMinus, HiOutlineMail, HiPlus } from 'react-icons/hi' import { commitFlashSession, getFlashSession, @@ -12,11 +17,14 @@ import { } from '~/utilities/session.server' import createStarbaseClient from '@proofzero/platform-clients/starbase' import createAccountClient from '@proofzero/platform-clients/account' -import { getAuthzHeaderConditionallyFromToken } from '@proofzero/utils' import { + getAuthzHeaderConditionallyFromToken, + parseJwt, +} from '@proofzero/utils' +import { + useActionData, useLoaderData, useOutletContext, - useRevalidator, useSubmit, } from '@remix-run/react' import type { LoaderData as OutletContextData } from '~/root' @@ -27,34 +35,55 @@ 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 { ServicePlanType } from '@proofzero/types/account' +import { PaymentData, ServicePlanType } from '@proofzero/types/account' import { ToastType, Toaster, toast, } from '@proofzero/design-system/src/atoms/toast' - import plans, { PlanDetails } from './plans' +import { AccountURN } from '@proofzero/urns/account' +import { ToastWithLink } from '@proofzero/design-system/src/atoms/toast/ToastWithLink' +import { Input } from '@proofzero/design-system/src/atoms/form/Input' +import { + getEmailDropdownItems, + getEmailIcon, +} from '@proofzero/utils/getNormalisedConnectedAccounts' +import { + Dropdown, + DropdownSelectListItem, +} from '@proofzero/design-system/src/atoms/dropdown/DropdownSelectList' +import useConnectResult from '@proofzero/design-system/src/hooks/useConnectResult' +import { ToastInfo } from '@proofzero/design-system/src/atoms/toast/ToastInfo' +import { DangerPill } from '@proofzero/design-system/src/atoms/pills/DangerPill' +import { + createSubscription, + updateSubscription, +} from '~/services/billing/stripe' type EntitlementDetails = { alloted: number - pending: number allotedClientIds: string[] } type LoaderData = { + paymentData?: PaymentData entitlements: { [ServicePlanType.PRO]: EntitlementDetails FREE: { appClientIds: string[] } } - billingToast?: string + successToast?: string + connectedEmails: DropdownSelectListItem[] } export const loader: LoaderFunction = getRollupReqFunctionErrorWrapper( async ({ request, params, context }) => { const jwt = await requireJWT(request) + const parsedJwt = parseJwt(jwt!) + const accountURN = parsedJwt.sub as AccountURN + const traceHeader = generateTraceContextHeaders(context.traceSpan) const starbaseClient = createStarbaseClient(Starbase, { @@ -69,12 +98,10 @@ export const loader: LoaderFunction = getRollupReqFunctionErrorWrapper( ...traceHeader, }) - const entitlements = await accountClient.getEntitlements.query() + const { plans } = await accountClient.getEntitlements.query() const proAllotedEntitlements = - entitlements?.[ServicePlanType.PRO]?.entitlements ?? 0 - const proPendingEntitlements = - entitlements?.[ServicePlanType.PRO]?.pendingEntitlements ?? 0 + plans?.[ServicePlanType.PRO]?.entitlements ?? 0 // Capping this to 2 for demo purposes const proUsage = Math.min(2, proAllotedEntitlements) @@ -88,21 +115,86 @@ export const loader: LoaderFunction = getRollupReqFunctionErrorWrapper( } const flashSession = await getFlashSession(request.headers.get('Cookie')) - const billingToast = flashSession.get('billing_toast') + const successToast = flashSession.get('success_toast') + + const connectedAccounts = await accountClient.getAddresses.query({ + account: accountURN, + }) + const connectedEmails = getEmailDropdownItems(connectedAccounts) + + const spd = await accountClient.getStripePaymentData.query({ + accountURN, + }) return json( { + paymentData: spd, entitlements: { [ServicePlanType.PRO]: { alloted: proAllotedEntitlements, - pending: proPendingEntitlements, allotedClientIds: proAppClientIds, }, FREE: { appClientIds: freeAppClientIds, }, }, - billingToast, + successToast, + connectedEmails, + }, + { + headers: { + 'Set-Cookie': await commitFlashSession(flashSession), + }, + } + ) + } +) + +export const action: ActionFunction = getRollupReqFunctionErrorWrapper( + async ({ request, context }) => { + const jwt = await requireJWT(request) + const parsedJwt = parseJwt(jwt!) + const accountURN = parsedJwt.sub as AccountURN + + const traceHeader = generateTraceContextHeaders(context.traceSpan) + + const accountClient = createAccountClient(Account, { + ...getAuthzHeaderConditionallyFromToken(jwt), + ...traceHeader, + }) + + const fd = await request.formData() + const { customerID, quantity } = JSON.parse( + fd.get('payload') as string + ) as { + customerID: string + quantity: number + } + + const entitlements = await accountClient.getEntitlements.query() + + let sub + if (!entitlements.subscriptionID) { + sub = await createSubscription({ + customerID: customerID, + planID: STRIPE_PRO_PLAN_ID, + quantity: +quantity, + accountURN, + }) + } else { + sub = await updateSubscription({ + subscriptionID: entitlements.subscriptionID, + planID: STRIPE_PRO_PLAN_ID, + quantity: +quantity, + }) + } + + const flashSession = await getFlashSession(request.headers.get('Cookie')) + flashSession.flash('success_toast', 'Entitlements successfully bought') + + return json( + { + updatedProEntitlements: quantity, }, { headers: { @@ -190,9 +282,11 @@ const EntitlementsCard = ({ const PlanCard = ({ plan, entitlements, + paymentData, }: { plan: PlanDetails entitlements: EntitlementDetails + paymentData?: PaymentData }) => { const [purchaseProModalOpen, setPurchaseProModalOpen] = useState(false) const [proEntitlementDelta, setProEntitlementDelta] = useState(1) @@ -214,6 +308,16 @@ const PlanCard = ({ Purchase Entitlement(s) + {!paymentData?.paymentMethodID && ( +
+ +
+ )} +
)}
- {entitlements.alloted === 0 && entitlements.pending === 0 && ( -
{ - setPurchaseProModalOpen(true) - }} - > - - - Purchase Entitlement(s) - + {entitlements.alloted === 0 && ( +
+
)} {entitlements.alloted > entitlements.allotedClientIds.length && ( @@ -487,42 +589,56 @@ const PlanCard = ({ } export default () => { - const { entitlements, billingToast } = useLoaderData() + const { + entitlements: { + PRO: { alloted, allotedClientIds }, + }, + successToast, + paymentData, + connectedEmails, + } = useLoaderData() - const { apps } = useOutletContext() + const ld = useActionData<{ + updatedProEntitlements: number + }>() - const [prevProPendingEntitlements, setPrevProPendingEntitlements] = useState< - number | undefined - >(undefined) + const { apps, PASSPORT_URL } = useOutletContext() - const revalidator = useRevalidator() useEffect(() => { - if (!prevProPendingEntitlements) { - setPrevProPendingEntitlements(entitlements[ServicePlanType.PRO].pending) - } else if ( - prevProPendingEntitlements !== 0 && - entitlements[ServicePlanType.PRO].pending === 0 - ) { + if (successToast) { toast(ToastType.Success, { - message: 'Successfully purchased entitlements', + message: successToast, }) - setPrevProPendingEntitlements(undefined) } + }, [successToast]) - if (entitlements[ServicePlanType.PRO].pending > 0) { - setTimeout(() => { - revalidator.revalidate() - }, 1000) - } - }, [entitlements]) + const redirectToPassport = () => { + const currentURL = new URL(window.location.href) + currentURL.search = '' - useEffect(() => { - if (billingToast) { - toast(ToastType.Info, { - message: billingToast, - }) - } - }, [billingToast]) + 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() + const [fullName, setFullName] = useState( + paymentData?.name + ) + + const submit = useSubmit() return ( <> @@ -538,33 +654,161 @@ export default () => { Billing & Invoicing
+
+ +
+ {paymentData && !paymentData.paymentMethodID && ( +
+ +
+ )} + + {!paymentData && ( +
+ +
+ )} +
-
- - - -
- + +
+
+ { + 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.title === paymentData?.email + ) as DropdownSelectListItem[] + } + /> + + )} +
+
+ -
({ title: a.name!, - subtitle: entitlements[ - ServicePlanType.PRO - ].allotedClientIds.includes(a.clientId) + subtitle: allotedClientIds.includes(a.clientId) ? `Pro Plan ${plans.PRO.price}/month` : 'Free', }))} diff --git a/apps/console/app/routes/__layout/gnillib/payment.tsx b/apps/console/app/routes/__layout/gnillib/payment.tsx new file mode 100644 index 0000000000..08b7920d0e --- /dev/null +++ b/apps/console/app/routes/__layout/gnillib/payment.tsx @@ -0,0 +1,32 @@ +import { generateTraceContextHeaders } from '@proofzero/platform-middleware/trace' +import { getRollupReqFunctionErrorWrapper } from '@proofzero/utils/errors' +import { LoaderFunction } from '@remix-run/cloudflare' +import { requireJWT } from '~/utilities/session.server' +import createAccountClient from '@proofzero/platform-clients/account' +import { + getAuthzHeaderConditionallyFromToken, + parseJwt, +} from '@proofzero/utils' +import { updatePaymentMethod } from '~/services/billing/stripe' +import { AccountURN } from '@proofzero/urns/account' + +export const loader: LoaderFunction = getRollupReqFunctionErrorWrapper( + async ({ request, context }) => { + const jwt = await requireJWT(request) + const parsedJwt = parseJwt(jwt!) + const accountURN = parsedJwt.sub as AccountURN + + const traceHeader = generateTraceContextHeaders(context.traceSpan) + + const accountClient = createAccountClient(Account, { + ...getAuthzHeaderConditionallyFromToken(jwt), + ...traceHeader, + }) + + const { customerID } = await accountClient.getStripePaymentData.query({ + accountURN, + }) + + return updatePaymentMethod({ customerID }) + } +) diff --git a/apps/console/app/routes/__layout/gnillib/webhook.tsx b/apps/console/app/routes/__layout/gnillib/webhook.tsx new file mode 100644 index 0000000000..e7b2e3b887 --- /dev/null +++ b/apps/console/app/routes/__layout/gnillib/webhook.tsx @@ -0,0 +1,90 @@ +import { generateTraceContextHeaders } from '@proofzero/platform-middleware/trace' +import { getRollupReqFunctionErrorWrapper } from '@proofzero/utils/errors' +import { ActionFunction } from '@remix-run/cloudflare' + +import Stripe from 'stripe' +import createAccountClient from '@proofzero/platform-clients/account' +import { getAuthzHeaderConditionallyFromToken } from '@proofzero/utils' +import { AccountURN } from '@proofzero/urns/account' +import { ServicePlanType } from '@proofzero/types/account' + +export const action: ActionFunction = getRollupReqFunctionErrorWrapper( + async ({ request, context }) => { + const traceHeader = generateTraceContextHeaders(context.traceSpan) + + const accountClient = createAccountClient(Account, { + ...getAuthzHeaderConditionallyFromToken(undefined), + ...traceHeader, + }) + + const stripeClient = new Stripe(STRIPE_API_SECRET, { + apiVersion: '2022-11-15', + }) + + const whSecret = STRIPE_WEBHOOK_SECRET + + const payload = await request.text() + const sig = request.headers.get('stripe-signature') as string + + const event = await stripeClient.webhooks.constructEventAsync( + payload, + sig, + whSecret + ) + + switch (event.type) { + case 'customer.subscription.created': + case 'customer.subscription.updated': + const { + id, + quantity, + metadata: subMeta, + } = event.data.object as { + id: string + quantity: number + metadata: { + accountURN: AccountURN + } + } + + await accountClient.updateEntitlements.mutate({ + accountURN: subMeta.accountURN, + subscriptionID: id, + quantity, + type: ServicePlanType.PRO, + }) + + break + + case 'customer.updated': + const { + id: cusId, + invoice_settings, + metadata: cusMeta, + } = event.data.object as { + id: string + invoice_settings?: { + default_payment_method: string + } + metadata: { + accountURN: AccountURN + } + } + + if (invoice_settings?.default_payment_method) { + const paymentData = await accountClient.getStripePaymentData.query({ + accountURN: cusMeta.accountURN, + }) + paymentData.paymentMethodID = invoice_settings.default_payment_method + await accountClient.setStripePaymentData.mutate({ + ...paymentData, + accountURN: cusMeta.accountURN, + }) + } + + break + } + + return null + } +) diff --git a/apps/console/app/services/billing/stripe.tsx b/apps/console/app/services/billing/stripe.tsx index a6b4a9f1f7..9faab5ef39 100644 --- a/apps/console/app/services/billing/stripe.tsx +++ b/apps/console/app/services/billing/stripe.tsx @@ -1,19 +1,143 @@ -import { ServicePlanType } from '@proofzero/types/account' +import { InternalServerError } from '@proofzero/errors' +import { AccountURN } from '@proofzero/urns/account' import { redirect } from '@remix-run/cloudflare' +import Stripe from 'stripe' -type CheckoutParams = { - planId: string - planType: ServicePlanType +type CreateCustomerParams = { + email: string + name: string + accountURN: string +} + +type UpdateCustomerParams = { + customerID: string + email: string + name: string +} + +type UpdatePaymentMethodParams = { + customerID: string +} + +type CreateSubscriptionParams = { + customerID: string + planID: string + quantity: number + accountURN: AccountURN +} + +type UpdateSubscriptionParams = { + subscriptionID: string + planID: string quantity: number - nonce: string - customerID?: string } -export const beginCheckout = (params: CheckoutParams): Response => { - // TODO: Create Stripe Session - // TODO: Append params to stripe session - // TODO: Redirect to Stripe Checkout +export const createCustomer = async ({ + email, + name, + accountURN, +}: CreateCustomerParams) => { + const stripeClient = new Stripe(STRIPE_API_SECRET, { + apiVersion: '2022-11-15', + }) + + const customer = await stripeClient.customers.create({ + email, + name, + metadata: { + accountURN, + }, + }) + + return customer +} + +export const updateCustomer = async ({ + customerID, + email, + name, +}: UpdateCustomerParams) => { + const stripeClient = new Stripe(STRIPE_API_SECRET, { + apiVersion: '2022-11-15', + }) + + const customer = await stripeClient.customers.update(customerID, { + email, + name, + }) + + return customer +} + +export const updatePaymentMethod = async ({ + customerID, +}: UpdatePaymentMethodParams) => { + const stripeClient = new Stripe(STRIPE_API_SECRET, { + apiVersion: '2022-11-15', + }) + + const session = await stripeClient.billingPortal.sessions.create({ + customer: customerID, + return_url: 'http://localhost:10002/gnillib', + flow_data: { + type: 'payment_method_update', + }, + }) + + return redirect(session.url) +} + +export const createSubscription = async ({ + customerID, + planID, + quantity, + accountURN, +}: CreateSubscriptionParams) => { + const stripeClient = new Stripe(STRIPE_API_SECRET, { + apiVersion: '2022-11-15', + }) + + const subscription = await stripeClient.subscriptions.create({ + customer: customerID, + items: [ + { + price: planID, + quantity, + }, + ], + metadata: { + accountURN, + }, + }) + + return subscription +} + +export const updateSubscription = async ({ + subscriptionID, + planID, + quantity, +}: UpdateSubscriptionParams) => { + const stripeClient = new Stripe(STRIPE_API_SECRET, { + apiVersion: '2022-11-15', + }) + + let subscription = await stripeClient.subscriptions.retrieve(subscriptionID) + const planItem = subscription.items.data.find((i) => i.price.id === planID) + if (!planItem) + throw new InternalServerError({ + message: 'Plan not found', + }) + + subscription = await stripeClient.subscriptions.update(subscription.id, { + proration_behavior: 'always_invoice', + items: [ + { + id: planItem.id, + quantity, + }, + ], + }) - // This simulated a succesful Stripe checkout - return redirect('/gnillib/checkout') + return subscription } diff --git a/apps/console/bindings.d.ts b/apps/console/bindings.d.ts index 891e0a64b5..89e535d85c 100644 --- a/apps/console/bindings.d.ts +++ b/apps/console/bindings.d.ts @@ -18,4 +18,8 @@ declare global { const INTERNAL_CLOUDFLARE_ZONE_ID: string const TOKEN_CLOUDFLARE_API: string const WALLET_CONNECT_PROJECT_ID: string + + const STRIPE_API_SECRET: string + const STRIPE_WEBHOOK_SECRET: string + const STRIPE_PRO_PLAN_ID: string } diff --git a/apps/console/package.json b/apps/console/package.json index 906f65fc7a..d29adb08ef 100644 --- a/apps/console/package.json +++ b/apps/console/package.json @@ -46,6 +46,7 @@ "react-dom": "18.2.0", "react-helmet": "6.1.0", "react-icons": "4.8.0", + "stripe": "12.9.0", "tiny-invariant": "1.3.1", "viem": "1.0.0", "wagmi": "1.1.1" diff --git a/packages/design-system/src/atoms/pills/DangerPill.tsx b/packages/design-system/src/atoms/pills/DangerPill.tsx new file mode 100644 index 0000000000..b43b33456f --- /dev/null +++ b/packages/design-system/src/atoms/pills/DangerPill.tsx @@ -0,0 +1,17 @@ +import React from 'react' +import { Text } from '../text/Text' +import { Pill } from './Pill' +import { HiOutlineExclamationTriangle } from 'react-icons/hi2' + +export type DangerPillProps = { + text: string +} + +export const DangerPill = ({ text }: DangerPillProps) => ( + + + + {text} + + +) diff --git a/packages/design-system/src/atoms/toast/ToastInfo.tsx b/packages/design-system/src/atoms/toast/ToastInfo.tsx index dce7fc48e3..d84012d007 100644 --- a/packages/design-system/src/atoms/toast/ToastInfo.tsx +++ b/packages/design-system/src/atoms/toast/ToastInfo.tsx @@ -9,7 +9,7 @@ type ToastInfoProps = { export const ToastInfo = ({ message, remove, -}: ToastInfoProps & { remove: () => void }) => ( +}: ToastInfoProps & { remove?: () => void }) => ( } + +export type PaymentData = { + customerID: string + email: string + name: string + paymentMethodID?: string +} diff --git a/platform/account/src/jsonrpc/methods/getEntitlements.ts b/platform/account/src/jsonrpc/methods/getEntitlements.ts index 6a56518532..f1461043b7 100644 --- a/platform/account/src/jsonrpc/methods/getEntitlements.ts +++ b/platform/account/src/jsonrpc/methods/getEntitlements.ts @@ -4,13 +4,15 @@ import { Context } from '../../context' const PlanTypeEnum = z.nativeEnum(ServicePlanType) -export const GetEntitlementsOutputSchema = z.record( - PlanTypeEnum, - z.object({ - entitlements: z.number(), - pendingEntitlements: z.number(), - }) -) +export const GetEntitlementsOutputSchema = z.object({ + subscriptionID: z.string().optional(), + plans: z.record( + PlanTypeEnum, + z.object({ + entitlements: z.number(), + }) + ), +}) type GetEntitlementsOutput = z.infer export const getEntitlements = async ({ @@ -18,22 +20,12 @@ export const getEntitlements = async ({ }: { ctx: Context }): Promise => { - const result: GetEntitlementsOutput = {} + const result: GetEntitlementsOutput = { + plans: {}, + } const servicePlans = await ctx.account?.class.getServicePlans() - - const servicePlanOrders = await ctx.account?.class.getServicePlanOrders() - const servicePlanOrdersByType = servicePlanOrders - ? Object.keys(servicePlanOrders || {}) - .map((k) => servicePlanOrders[k]) - .reduce((acc, v) => { - if (!acc[v.type]) { - acc[v.type] = 0 - } - acc[v.type] += v.quantity - return acc - }, {} as Record) - : undefined + result.subscriptionID = servicePlans?.subscriptionID for (const key of Object.keys(ServicePlanType)) { const enumKey = PlanTypeEnum.parse(key) @@ -46,11 +38,7 @@ export const getEntitlements = async ({ resEntry.entitlements = servicePlans.plans[enumKey].entitlements } - if (servicePlanOrdersByType && servicePlanOrdersByType[enumKey]) { - resEntry.pendingEntitlements = servicePlanOrdersByType[enumKey] - } - - result[enumKey] = resEntry + result.plans[enumKey] = resEntry } return result diff --git a/platform/account/src/jsonrpc/methods/registerServicePlanOrder.ts b/platform/account/src/jsonrpc/methods/registerServicePlanOrder.ts deleted file mode 100644 index 5d98c9e4b2..0000000000 --- a/platform/account/src/jsonrpc/methods/registerServicePlanOrder.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { ServicePlanType } from '@proofzero/types/account' -import { z } from 'zod' -import { Context } from '../../context' - -export const RegisterServicePlanOrderInputSchema = z.object({ - planType: z.nativeEnum(ServicePlanType), - quantity: z.number(), - nonce: z.string(), -}) -export type RegisterServicePlanOrderInput = z.infer< - typeof RegisterServicePlanOrderInputSchema -> - -export const registerServicePlanOrder = async ({ - input, - ctx, -}: { - input: RegisterServicePlanOrderInput - ctx: Context -}): Promise => { - const { planType, quantity, nonce } = input - - await ctx.account?.class.registerServicePlanOrder(planType, quantity, nonce) -} diff --git a/platform/account/src/jsonrpc/methods/stripePaymentData.ts b/platform/account/src/jsonrpc/methods/stripePaymentData.ts new file mode 100644 index 0000000000..9702387be1 --- /dev/null +++ b/platform/account/src/jsonrpc/methods/stripePaymentData.ts @@ -0,0 +1,60 @@ +import { z } from 'zod' +import { Context } from '../../context' +import { AccountURNInput } from '@proofzero/platform-middleware/inputValidators' +import { initAccountNodeByName } from '../../nodes' + +export const GetStripPaymentDataInputSchema = z.object({ + accountURN: AccountURNInput, +}) +type GetStripPaymentDataInput = z.infer + +export const GetStripePaymentDataOutputSchema = z + .object({ + customerID: z.string(), + email: z.string(), + name: z.string(), + paymentMethodID: z.string().optional(), + }) + .optional() +type GetStripePaymentDataOutput = z.infer< + typeof GetStripePaymentDataOutputSchema +> + +export const getStripePaymentData = async ({ + ctx, + input, +}: { + ctx: Context + input: GetStripPaymentDataInput +}): Promise => { + const account = await initAccountNodeByName(input.accountURN, ctx.Account) + + return account.class.getStripePaymentData() +} + +export const SetStripePaymentDataInputSchema = z.object({ + customerID: z.string(), + paymentMethodID: z.string().optional(), + accountURN: AccountURNInput, + email: z.string(), + name: z.string(), +}) +type SetStripePaymentDataInput = z.infer + +export const setStripePaymentData = async ({ + ctx, + input, +}: { + ctx: Context + input: SetStripePaymentDataInput +}): Promise => { + const account = await initAccountNodeByName(input.accountURN, ctx.Account) + + const { customerID, paymentMethodID, email, name } = input + await account.class.setStripePaymentData({ + customerID, + paymentMethodID, + email, + name, + }) +} diff --git a/platform/account/src/jsonrpc/methods/updateEntitlements.ts b/platform/account/src/jsonrpc/methods/updateEntitlements.ts index d19b0bab94..1f849d4716 100644 --- a/platform/account/src/jsonrpc/methods/updateEntitlements.ts +++ b/platform/account/src/jsonrpc/methods/updateEntitlements.ts @@ -1,10 +1,14 @@ import { ServicePlanType } from '@proofzero/types/account' import { z } from 'zod' import { Context } from '../../context' +import { AccountURNInput } from '@proofzero/platform-middleware/inputValidators' +import { initAccountNodeByName } from '../../nodes' export const UpdateEntitlementsInputSchema = z.object({ - planType: z.nativeEnum(ServicePlanType), - delta: z.number(), + accountURN: AccountURNInput, + subscriptionID: z.string(), + type: z.nativeEnum(ServicePlanType), + quantity: z.number(), }) export type UpdateEntitlementsInput = z.infer< typeof UpdateEntitlementsInputSchema @@ -17,7 +21,10 @@ export const updateEntitlements = async ({ input: UpdateEntitlementsInput ctx: Context }): Promise => { - const { planType: type, delta } = input + const { type, quantity, subscriptionID, accountURN } = input - await ctx.account?.class.updateEntitlements(type, delta) + const account = await initAccountNodeByName(accountURN, ctx.Account) + await account.class.updateEntitlements(type, quantity, subscriptionID) + + // await ctx.account?.class.updateEntitlements(type, quantity, subscriptionID) } diff --git a/platform/account/src/jsonrpc/router.ts b/platform/account/src/jsonrpc/router.ts index 9b96d3658b..610167233a 100644 --- a/platform/account/src/jsonrpc/router.ts +++ b/platform/account/src/jsonrpc/router.ts @@ -44,9 +44,16 @@ import { getEntitlements, } from './methods/getEntitlements' import { - RegisterServicePlanOrderInputSchema, - registerServicePlanOrder, -} from './methods/registerServicePlanOrder' + UpdateEntitlementsInputSchema, + updateEntitlements, +} from './methods/updateEntitlements' +import { + GetStripPaymentDataInputSchema, + GetStripePaymentDataOutputSchema, + SetStripePaymentDataInputSchema, + getStripePaymentData, + setStripePaymentData, +} from './methods/stripePaymentData' const t = initTRPC.context().create({ errorFormatter }) @@ -140,14 +147,6 @@ export const appRouter = t.router({ .use(LogUsage) .input(DeleteAccountNodeInput) .mutation(deleteAccountNodeMethod), - registerServicePlanOrder: t.procedure - .use(AuthorizationTokenFromHeader) - .use(ValidateJWT) - .use(injectAccountNode) - .use(LogUsage) - .use(Analytics) - .input(RegisterServicePlanOrderInputSchema) - .mutation(registerServicePlanOrder), getEntitlements: t.procedure .use(AuthorizationTokenFromHeader) .use(ValidateJWT) @@ -156,4 +155,20 @@ export const appRouter = t.router({ .use(Analytics) .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), }) diff --git a/platform/account/src/nodes/account.ts b/platform/account/src/nodes/account.ts index 1755e3ca67..6f8803bd8f 100644 --- a/platform/account/src/nodes/account.ts +++ b/platform/account/src/nodes/account.ts @@ -1,7 +1,7 @@ import { DOProxy } from 'do-proxy' import type { Profile, AddressList } from '../types' import { - PendingServicePlans, + PaymentData, ServicePlanType, ServicePlans, } from '@proofzero/types/account' @@ -29,78 +29,14 @@ export default class Account extends DOProxy { return stored || null } - async getServicePlanOrders(): Promise { - return this.state.storage.get('pendingServicePlans') - } - - async registerServicePlanOrder( - type: ServicePlanType, - quantity: number, - nonce: string - ): Promise { - let psp = await this.state.storage.get( - 'pendingServicePlans' - ) - - if (!psp) { - psp = {} - } - psp[nonce] = { - type, - quantity, - } - - await this.state.storage.put('pendingServicePlans', psp) - - // This alarm is temporary, should be removed - // after we add the stripe flow - this.state.storage.setAlarm(Date.now() + 4000) - } - - // This alarm is temporary, should be removed - // after we add the stripe flow - async alarm() { - const psps = await this.state.storage.get( - 'pendingServicePlans' - ) - - // PSP key is nonce - for (const pspKey in psps) { - await this.fullfillServicePlanOrder(pspKey) - } - } - - async fullfillServicePlanOrder(nonce: string): Promise { - const psp = await this.state.storage.get( - 'pendingServicePlans' - ) - if (!psp) { - throw new RollupError({ - message: 'No pending service plans found', - }) - } - - const order = psp[nonce] - if (!order) { - throw new RollupError({ - message: 'No order found for nonce', - }) - } - - await this.updateEntitlements(order.type, order.quantity) - - delete psp[nonce] - - await this.state.storage.put('pendingServicePlans', psp) - } - async getServicePlans(): Promise { return this.state.storage.get('servicePlans') } async updateEntitlements( type: ServicePlanType, - delta: number + quantity: number, + subscriptionID: string ): Promise { let servicePlans = await this.state.storage.get( 'servicePlans' @@ -108,6 +44,17 @@ export default class Account extends DOProxy { 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 = {} } @@ -118,27 +65,26 @@ export default class Account extends DOProxy { // Non-null assertion operator is used // because of checks in previous lines - servicePlans.plans[type]!.entitlements += delta - - // Fix negative entitlements - if (servicePlans.plans[type]!.entitlements < 0) { - console.warn('Negative entitlements detected, resetting to 0') - servicePlans.plans[type]!.entitlements = 0 - } + servicePlans.plans[type]!.entitlements = quantity await this.state.storage.put('servicePlans', servicePlans) } - async setCustomerID(customerID: string): Promise { - const servicePlans = await this.state.storage.get( - 'servicePlans' + async getStripePaymentData(): Promise { + return this.state.storage.get('stripePaymentData') + } + + async setStripePaymentData(paymentData: PaymentData): Promise { + const stored = await this.state.storage.get( + 'stripePaymentData' ) - if (!servicePlans) { - await this.state.storage.put('servicePlans', { customerID }) - } else { - servicePlans.customerID = customerID - await this.state.storage.put('servicePlans', servicePlans) + if (stored && stored.customerID !== paymentData.customerID) { + throw new RollupError({ + message: 'Customer ID already set', + }) } + + await this.state.storage.put('stripePaymentData', paymentData) } } diff --git a/yarn.lock b/yarn.lock index 0574192602..75db7bcb50 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5792,6 +5792,7 @@ __metadata: react-dom: 18.2.0 react-helmet: 6.1.0 react-icons: 4.8.0 + stripe: 12.9.0 tailwindcss: 3.2.4 tiny-invariant: 1.3.1 typescript: 5.0.4 @@ -12101,6 +12102,13 @@ __metadata: languageName: node linkType: hard +"@types/node@npm:>=8.1.0": + version: 20.3.1 + resolution: "@types/node@npm:20.3.1" + checksum: 63a393ab6d947be17320817b35d7277ef03728e231558166ed07ee30b09fd7c08861be4d746f10fdc63ca7912e8cd023939d4eab887ff6580ff704ff24ed810c + languageName: node + linkType: hard + "@types/node@npm:^12.12.54": version: 12.20.55 resolution: "@types/node@npm:12.20.55" @@ -31991,6 +31999,15 @@ __metadata: languageName: node linkType: hard +"qs@npm:^6.11.0": + version: 6.11.2 + resolution: "qs@npm:6.11.2" + dependencies: + side-channel: ^1.0.4 + checksum: e812f3c590b2262548647d62f1637b6989cc56656dc960b893fe2098d96e1bd633f36576f4cd7564dfbff9db42e17775884db96d846bebe4f37420d073ecdc0b + languageName: node + linkType: hard + "qs@npm:~6.5.2": version: 6.5.3 resolution: "qs@npm:6.5.3" @@ -35608,6 +35625,16 @@ __metadata: languageName: node linkType: hard +"stripe@npm:12.9.0": + version: 12.9.0 + resolution: "stripe@npm:12.9.0" + dependencies: + "@types/node": ">=8.1.0" + qs: ^6.11.0 + checksum: a860736592ec96b4468697c3636591cee781e1483afa963d75be4c2e4d8d413983328129e4b15dab578494ffc5fd22a11eebc74bbdb3b675dd4a99c37c70ab76 + languageName: node + linkType: hard + "style-inject@npm:^0.3.0": version: 0.3.0 resolution: "style-inject@npm:0.3.0"