diff --git a/.yarn/cache/@stripe-stripe-js-npm-1.54.1-5949142f7a-eb54054ede.zip b/.yarn/cache/@stripe-stripe-js-npm-1.54.1-5949142f7a-eb54054ede.zip new file mode 100644 index 0000000000..97dfd08840 Binary files /dev/null and b/.yarn/cache/@stripe-stripe-js-npm-1.54.1-5949142f7a-eb54054ede.zip differ diff --git a/apps/console/app/entry.server.tsx b/apps/console/app/entry.server.tsx index 0edc23c979..2f425856b4 100644 --- a/apps/console/app/entry.server.tsx +++ b/apps/console/app/entry.server.tsx @@ -42,6 +42,7 @@ export default function handleRequest( SELF, 'https://verify.walletconnect.com', 'form.typeform.com', + 'https://*.stripe.com', ], 'connect-src': [ SELF, @@ -50,9 +51,16 @@ export default function handleRequest( 'https://*.g.alchemy.com', 'https://upload.imagedelivery.net', 'https://analytics.rollup.id', + 'https://maps.googleapis.com', + 'https://api.stripe.com', // Used for Remix WebSocket Live Reaload ...(dev ? ['ws://localhost:*/socket'] : []), ], + 'frame-src': [ + SELF, + 'https://js.stripe.com', + 'https://hooks.stripe.com', + ], 'script-src': [SELF, `'nonce-${nonce}' ${STRICT_DYNAMIC}`], 'style-src': [ SELF, diff --git a/apps/console/app/root.tsx b/apps/console/app/root.tsx index c11857d67d..afdeded787 100644 --- a/apps/console/app/root.tsx +++ b/apps/console/app/root.tsx @@ -53,6 +53,7 @@ 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' export const links: LinksFunction = () => { return [ @@ -85,6 +86,7 @@ export type LoaderData = { avatarUrl: string PASSPORT_URL: string displayName: string + hasUnpaidInvoices: boolean ENV: { POSTHOG_API_KEY: string POSTHOG_PROXY_HOST: string @@ -144,9 +146,27 @@ export const loader: LoaderFunction = getRollupReqFunctionErrorWrapper( WALLET_CONNECT_PROJECT_ID, } = context.env + const spd = await coreClient.account.getStripePaymentData.query({ + accountURN, + }) + + // might be quite heavy object + // for that reason I don't put it in outlet context + const invoices = await getCurrentAndUpcomingInvoices( + spd, + context.env.SECRET_STRIPE_API_KEY + ) + + const hasUnpaidInvoices = invoices.some((invoice) => { + if (invoice.status) + return ['uncollectible', 'open'].includes(invoice.status) + return false + }) + return json({ apps: reshapedApps, avatarUrl, + hasUnpaidInvoices, PASSPORT_URL, ENV: { POSTHOG_API_KEY, @@ -180,7 +200,14 @@ export default function App() { const remixDevPort = loaderData.ENV.REMIX_DEV_SERVER_WS_PORT useTreeshakeHack(remixDevPort) - const { apps, avatarUrl, PASSPORT_URL, displayName, accountURN } = loaderData + const { + apps, + avatarUrl, + PASSPORT_URL, + displayName, + accountURN, + hasUnpaidInvoices, + } = loaderData useEffect(() => { if (GATag) { @@ -246,6 +273,7 @@ export default function App() { PASSPORT_URL, displayName, accountURN, + hasUnpaidInvoices, }} /> @@ -257,6 +285,7 @@ export default function App() { PASSPORT_URL, displayName, accountURN, + hasUnpaidInvoices, }} /> )} diff --git a/apps/console/app/routes/__layout.tsx b/apps/console/app/routes/__layout.tsx index c4c0795d3c..643f981ea7 100644 --- a/apps/console/app/routes/__layout.tsx +++ b/apps/console/app/routes/__layout.tsx @@ -10,13 +10,15 @@ import SiteHeader from '~/components/SiteHeader' import { Popover } from '@headlessui/react' import type { LoaderData as OutletContextData } from '~/root' +import { ToastWithLink } from '@proofzero/design-system/src/atoms/toast/ToastWithLink' // Component // ----------------------------------------------------------------------------- export default function DashboardIndexPage() { const context = useOutletContext() - const { apps, avatarUrl, displayName, PASSPORT_URL } = context + const { apps, avatarUrl, displayName, PASSPORT_URL, hasUnpaidInvoices } = + context return ( @@ -31,6 +33,14 @@ export default function DashboardIndexPage() { />
+ {hasUnpaidInvoices && ( + + )}
{ + await requireJWT(request, context.env) + + const fd = await request.formData() + + const invoiceId = fd.get('invoice_id') as string + + const headers = request.headers + let returnURL = headers.get('Referer') as string + + const flashSession = await getFlashSession(request, context.env) + + try { + await voidInvoice(invoiceId, context.env.SECRET_STRIPE_API_KEY) + flashSession.flash( + 'toastNotification', + JSON.stringify({ + type: ToastType.Success, + message: 'Invoice successfully cancelled.', + }) + ) + } catch (e) { + console.error(e) + flashSession.flash( + 'toastNotification', + JSON.stringify({ + type: ToastType.Error, + message: 'Invoice cancellation failed.', + }) + ) + } + + return redirect(returnURL, { + headers: { + 'Set-Cookie': await commitFlashSession(flashSession, context.env), + }, + }) + } +) diff --git a/apps/console/app/routes/__layout/billing/details.tsx b/apps/console/app/routes/__layout/billing/details.tsx index d620c687d3..62a4abc34d 100644 --- a/apps/console/app/routes/__layout/billing/details.tsx +++ b/apps/console/app/routes/__layout/billing/details.tsx @@ -14,6 +14,7 @@ import { import { createCustomer, updateCustomer } from '~/services/billing/stripe' import { AccountURN } from '@proofzero/urns/account' import { AddressURN } from '@proofzero/urns/address' +import { ToastType } from '@proofzero/design-system/src/atoms/toast' export const action: ActionFunction = getRollupReqFunctionErrorWrapper( async ({ request, context }) => { @@ -81,7 +82,13 @@ export const action: ActionFunction = getRollupReqFunctionErrorWrapper( }) const flashSession = await getFlashSession(request, context.env) - flashSession.flash('success_toast', 'Payment data updated') + flashSession.flash( + 'toast_notification', + JSON.stringify({ + type: ToastType.Success, + message: 'Payment data updated', + }) + ) return redirect('/billing', { headers: { diff --git a/apps/console/app/routes/__layout/billing/index.tsx b/apps/console/app/routes/__layout/billing/index.tsx index 2e75185fd9..41d8313283 100644 --- a/apps/console/app/routes/__layout/billing/index.tsx +++ b/apps/console/app/routes/__layout/billing/index.tsx @@ -29,6 +29,7 @@ import { import { Link, NavLink, + useActionData, useLoaderData, useOutletContext, useSubmit, @@ -64,33 +65,30 @@ import { 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, - getInvoices, - reconcileAppSubscriptions, - updateSubscription, -} from '~/services/billing/stripe' +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' - -type StripeInvoice = { - amount: number - timestamp: number - status: string | null - url?: string -} +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' type LoaderData = { + STRIPE_PUBLISHABLE_KEY: string paymentData?: PaymentData entitlements: { [ServicePlanType.PRO]: number } - toastNotification?: { - message: string - type: ToastType - } + toastNotification?: ToastNotification connectedEmails: DropdownSelectListItem[] invoices: StripeInvoice[] } @@ -108,15 +106,14 @@ export const loader: LoaderFunction = getRollupReqFunctionErrorWrapper( ...traceHeader, }) - const { plans, subscriptionID } = - await coreClient.account.getEntitlements.query({ - accountURN, - }) + const { plans } = await coreClient.account.getEntitlements.query({ + accountURN, + }) const flashSession = await getFlashSession(request, context.env) let toastNotification = undefined - const toastStr = flashSession.get('toastNotification') + const toastStr = flashSession.get('toast_notification') if (toastStr) { toastNotification = JSON.parse(toastStr) } @@ -150,34 +147,14 @@ export const loader: LoaderFunction = getRollupReqFunctionErrorWrapper( spd.addressURN = targetAddressURN } - let invoices: StripeInvoice[] = [] - if (subscriptionID) { - const stripeInvoices = await getInvoices( - { - customerID: spd.customerID, - }, - context.env - ) - - invoices = stripeInvoices.invoices.data.map((i) => ({ - amount: i.total / 100, - timestamp: i.created * 1000, - status: i.status, - url: i.hosted_invoice_url ?? undefined, - })) - - if (stripeInvoices.upcomingInvoices) { - invoices = invoices.concat({ - amount: stripeInvoices.upcomingInvoices.lines.data[0].amount / 100, - timestamp: - stripeInvoices.upcomingInvoices.lines.data[0].period.start * 1000, - status: 'scheduled', - }) - } - } + const invoices = await getCurrentAndUpcomingInvoices( + spd, + context.env.SECRET_STRIPE_API_KEY + ) return json( { + STRIPE_PUBLISHABLE_KEY: context.env.STRIPE_PUBLISHABLE_KEY, paymentData: spd, entitlements: { [ServicePlanType.PRO]: @@ -209,6 +186,23 @@ export const action: ActionFunction = getRollupReqFunctionErrorWrapper( ...traceHeader, }) + const spd = await coreClient.account.getStripePaymentData.query({ + accountURN, + }) + + 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 @@ -230,33 +224,24 @@ export const action: ActionFunction = getRollupReqFunctionErrorWrapper( }) } + if ((quantity < 1 && txType === 'buy') || quantity < 0) { + throw new BadRequestError({ + message: `Invalid quantity. Please enter a valid number of entitlements.`, + }) + } + const entitlements = await coreClient.account.getEntitlements.query({ accountURN, }) - let sub - if (!entitlements.subscriptionID) { - sub = await createSubscription( - { - customerID: customerID, - planID: context.env.SECRET_STRIPE_PRO_PLAN_ID, - quantity: +quantity, - accountURN, - handled: true, - }, - context.env - ) - } else { - sub = await updateSubscription( - { - subscriptionID: entitlements.subscriptionID, - planID: context.env.SECRET_STRIPE_PRO_PLAN_ID, - quantity: +quantity, - handled: true, - }, - context.env - ) - } + 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, + accountURN, + }) if ( (txType === 'buy' && @@ -275,30 +260,15 @@ export const action: ActionFunction = getRollupReqFunctionErrorWrapper( ) } - const flashSession = await getFlashSession(request, context.env) if (txType === 'buy') { - // https://stripe.com/docs/billing/subscriptions/overview#subscription-statuses - if (sub.status === 'active' || sub.status === 'trialing') { - flashSession.flash( - 'toastNotification', - JSON.stringify({ - type: ToastType.Success, - message: 'Entitlement(s) successfully bought', - }) - ) - } else { - flashSession.flash( - 'toastNotification', - JSON.stringify({ - type: ToastType.Error, - message: 'Payment failed - check your card details', - }) - ) - } + setPurchaseToastNotification({ + sub, + flashSession, + }) } if (txType === 'remove') { flashSession.flash( - 'toastNotification', + 'toast_notification', JSON.stringify({ type: ToastType.Success, message: 'Entitlement(s) successfully removed', @@ -306,11 +276,32 @@ export const action: ActionFunction = getRollupReqFunctionErrorWrapper( ) } - return new Response(null, { - headers: { - 'Set-Cookie': await commitFlashSession(flashSession, context.env), + 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, + quantity, + subId: sub.id, }, - }) + { + headers: { + 'Set-Cookie': await commitFlashSession(flashSession, context.env), + }, + } + ) } ) @@ -459,7 +450,7 @@ const PurchaseProModal = ({ onClick={() => { setProEntitlementDelta((prev) => prev - 1) }} - disabled={proEntitlementDelta < 1} + disabled={proEntitlementDelta <= 1} > @@ -510,7 +501,6 @@ const PurchaseProModal = ({ onClick={() => { setIsOpen(false) setProEntitlementDelta(1) - submit( { payload: JSON.stringify({ @@ -789,11 +779,13 @@ const PlanCard = ({ apps, paymentData, submit, + hasUnpaidInvoices = false, }: { plan: PlanDetails entitlements: number apps: AppLoaderData[] paymentData?: PaymentData + hasUnpaidInvoices: boolean submit: (data: any, options: any) => void }) => { const [purchaseProModalOpen, setPurchaseProModalOpen] = useState(false) @@ -855,43 +847,49 @@ const PlanCard = ({ - - + + Purchase Entitlement(s) +
- - + + Remove Entitlement(s) +
@@ -1000,6 +998,7 @@ const PlanCard = ({ export default () => { const { + STRIPE_PUBLISHABLE_KEY, entitlements, toastNotification, paymentData, @@ -1007,20 +1006,32 @@ export default () => { invoices, } = useLoaderData() - const { apps, PASSPORT_URL } = useOutletContext() + const { apps, PASSPORT_URL, hasUnpaidInvoices } = + useOutletContext() + + const actionData = useActionData() + const submit = useSubmit() + + useEffect(() => { + if (actionData) { + const { status, client_secret, payment_method, subId } = actionData + process3DSecureCard({ + STRIPE_PUBLISHABLE_KEY, + status, + subId, + client_secret, + payment_method, + submit, + redirectUrl: '/billing', + }) + } + }, [actionData]) useEffect(() => { if (toastNotification) { - if (toastNotification.type === ToastType.Success) { - toast(ToastType.Success, { - message: toastNotification.message, - }) - } - if (toastNotification.type === ToastType.Error) { - toast(ToastType.Error, { - message: toastNotification.message, - }) - } + toast(toastNotification.type, { + message: toastNotification.message, + }) } }, [toastNotification]) @@ -1052,7 +1063,6 @@ export default () => { paymentData?.name ) - const submit = useSubmit() const hydrated = useHydrated() const [invoiceSort, setInvoiceSort] = useState<'asc' | 'desc'>('desc') @@ -1223,6 +1233,7 @@ export default () => { paymentData={paymentData} submit={submit} apps={apps.filter((a) => a.appPlan === ServicePlanType.PRO)} + hasUnpaidInvoices={hasUnpaidInvoices} /> @@ -1314,16 +1325,32 @@ export default () => { {hydrated && ( - - {new Date(invoice.timestamp).toLocaleString( - 'default', - { - day: '2-digit', - month: 'short', - year: 'numeric', - } +
+ + {hydrated && + new Date(invoice.timestamp).toLocaleString( + 'default', + { + day: '2-digit', + month: 'short', + year: 'numeric', + } + )} + + + {(invoice.status === 'open' || + invoice.status === 'uncollectible') && ( +
+ + + Payment Error + +
)} - +
)} @@ -1341,12 +1368,48 @@ export default () => { {invoice.status && _.startCase(invoice.status)}
{invoice.status === 'paid' && ( - + View Invoice )} + {(invoice.status === 'open' || + invoice.status === 'uncollectible') && ( +
+ + + Update Payment + + + +
+ )} ))} diff --git a/apps/console/app/routes/__layout/billing/update.tsx b/apps/console/app/routes/__layout/billing/update.tsx new file mode 100644 index 0000000000..1231f3149b --- /dev/null +++ b/apps/console/app/routes/__layout/billing/update.tsx @@ -0,0 +1,81 @@ +import { generateTraceContextHeaders } from '@proofzero/platform-middleware/trace' +import { getRollupReqFunctionErrorWrapper } from '@proofzero/utils/errors' +import { redirect, type ActionFunction } from '@remix-run/cloudflare' +import { + commitFlashSession, + getFlashSession, + requireJWT, +} from '~/utilities/session.server' +import createCoreClient from '@proofzero/platform-clients/core' +import { + getAuthzHeaderConditionallyFromToken, + parseJwt, +} from '@proofzero/utils' +import { reconcileAppSubscriptions } from '~/services/billing/stripe' +import { type AccountURN } from '@proofzero/urns/account' +import { ToastType } from '@proofzero/design-system/src/atoms/toast' + +/** + * WARNING: Here be dragons, and not the cute, cuddly kind! This code runs twice in certain scenarios because when the user + * is doing this interactively, we first run it synchronously, followed by an asynchronous invocation that updated the + * object idemptotently with the same data. + * + * We're doing this because we need to keep the front-end updated with the latest subscription info. + * Yes, it's as fun as a porcupine at a balloon party, but until we find a better solution, this is the mess we're in. + * So if you're about to change something here, make sure you update the other instance as well. Or else you'll be the one + * explaining to the boss why our app is acting like a drunk squirrel. Good luck, you're gonna need it! + */ + +export const action: ActionFunction = getRollupReqFunctionErrorWrapper( + async ({ request, context }) => { + const jwt = await requireJWT(request, context.env) + const parsedJwt = parseJwt(jwt!) + const accountURN = parsedJwt.sub as AccountURN + + const traceHeader = generateTraceContextHeaders(context.traceSpan) + const fd = await request.formData() + + const subId = fd.get('subId') as string + const redirectUrl = fd.get('redirectUrl') as string + + const coreClient = createCoreClient(context.env.Core, { + ...getAuthzHeaderConditionallyFromToken(jwt), + ...traceHeader, + }) + const flashSession = await getFlashSession(request, context.env) + + try { + await reconcileAppSubscriptions( + { + subscriptionID: subId, + accountURN, + coreClient, + billingURL: `${context.env.CONSOLE_URL}/billing`, + settingsURL: `${context.env.CONSOLE_URL}`, + }, + context.env + ) + flashSession.flash( + 'toast_notification', + JSON.stringify({ + type: ToastType.Success, + message: 'Successfully purchased entitlement(s)', + }) + ) + } catch (ex) { + flashSession.flash( + 'toast_notification', + JSON.stringify({ + type: ToastType.Error, + message: 'Something went wrong. Please try again', + }) + ) + } + + return redirect(`${redirectUrl}`, { + headers: { + 'Set-Cookie': await commitFlashSession(flashSession, context.env), + }, + }) + } +) diff --git a/apps/console/app/routes/__layout/billing/webhook.tsx b/apps/console/app/routes/__layout/billing/webhook.tsx index be63db62b5..eeda5f3b25 100644 --- a/apps/console/app/routes/__layout/billing/webhook.tsx +++ b/apps/console/app/routes/__layout/billing/webhook.tsx @@ -7,15 +7,15 @@ import createCoreClient from '@proofzero/platform-clients/core' import { type AccountURN } from '@proofzero/urns/account' import { getAuthzHeaderConditionallyFromToken } from '@proofzero/utils' -import { - reconcileAppSubscriptions, - updateSubscriptionMetadata, -} from '~/services/billing/stripe' +import { reconcileAppSubscriptions } from '~/services/billing/stripe' import { InternalServerError, RollupError } from '@proofzero/errors' -import { AddressURN } from '@proofzero/urns/address' +import { type AddressURN } from '@proofzero/urns/address' type StripeInvoicePayload = { + id: string + subscription: string customer: string + payment_intent: string lines: { data: Array<{ price: { product: string } @@ -23,6 +23,7 @@ type StripeInvoicePayload = { quantity: number }> } + metadata: any } export const action: ActionFunction = getRollupReqFunctionErrorWrapper( @@ -51,61 +52,28 @@ export const action: ActionFunction = getRollupReqFunctionErrorWrapper( switch (event.type) { case 'customer.subscription.created': case 'customer.subscription.updated': - const { id, metadata: subMeta } = event.data.object as { + const { + id, + metadata: subMeta, + status: subStatus, + latest_invoice: latestInvoice, + } = event.data.object as { id: string + latest_invoice: string metadata: { accountURN: AccountURN - handled?: string | null - } - } - - if (event.data.previous_attributes) { - let metadataUpdateEvent = false - - const { metadata } = event.data.previous_attributes as { - metadata?: { - handled?: string - } - } - - // If previous attributes had a handled flag and the current - // event does not, then the webhook is handling only the - // handled removal so we shouldn't move further - if ( - !subMeta.handled && - metadata?.handled && - JSON.parse(metadata.handled) - ) { - console.info( - `Cleared Subscription ${id} - ${event.type} handled flag` - ) - metadataUpdateEvent = true - } - - if (metadataUpdateEvent) { - return null } + status: string } - // When synchronously handling subscription update effects - // a flag is set to prevent the webhook from handling it again - // when it is received asynchronously - // This call clears the flag - if (subMeta.handled) { - console.info( - `Subscription ${id} - ${event.type} already handled synchronously` - ) - - subMeta.handled = null - - await updateSubscriptionMetadata( - { - id, - metadata: subMeta, - }, - context.env - ) + const invoice = await stripeClient.invoices.retrieve(latestInvoice) + // We don't want to do anything with subscription + // if payment for it failed + if ( + (subStatus !== 'active' && subStatus !== 'trialing') || + invoice.status !== 'paid' + ) { return null } @@ -160,7 +128,7 @@ export const action: ActionFunction = getRollupReqFunctionErrorWrapper( let inferredAddressURN if (paymentData && !paymentData.addressURN) { inferredAddressURN = - await addressClient.getAddressURNForEmail.query( + await coreClient.address.getAddressURNForEmail.query( email.toLowerCase() ) @@ -246,13 +214,22 @@ export const action: ActionFunction = getRollupReqFunctionErrorWrapper( } break + case 'invoice.payment_failed': - const { customer: customerFail } = event.data - .object as StripeInvoicePayload + const { customer: customerFail, payment_intent: paymentIntentFail } = + event.data.object as StripeInvoicePayload const customerDataFail = await stripeClient.customers.retrieve( customerFail ) - if (!customerDataFail.deleted && customerDataFail.email) { + const paymentIntentInfo = await stripeClient.paymentIntents.retrieve( + paymentIntentFail + ) + + if ( + !customerDataFail.deleted && + customerDataFail.email && + paymentIntentInfo.status !== 'requires_action' + ) { const { email, name } = customerDataFail await coreClient.address.sendFailedPaymentNotification.mutate({ diff --git a/apps/console/app/routes/apps/$clientId.tsx b/apps/console/app/routes/apps/$clientId.tsx index a143c6cb5f..85b93f9f92 100644 --- a/apps/console/app/routes/apps/$clientId.tsx +++ b/apps/console/app/routes/apps/$clientId.tsx @@ -27,6 +27,7 @@ import { getRollupReqFunctionErrorWrapper } from '@proofzero/utils/errors' import { PlatformAddressURNHeader } from '@proofzero/types/headers' import { getToastsAndFlashSession } from '~/utils/toast.server' import { useEffect } from 'react' +import { ToastWithLink } from '@proofzero/design-system/src/atoms/toast/ToastWithLink' type LoaderData = { appDetails: appDetailsProps @@ -143,8 +144,14 @@ export const loader: LoaderFunction = getRollupReqFunctionErrorWrapper( export default function AppDetailIndexPage() { const loaderData = useLoaderData() - const { apps, avatarUrl, PASSPORT_URL, displayName, accountURN } = - useOutletContext() + const { + apps, + avatarUrl, + PASSPORT_URL, + displayName, + accountURN, + hasUnpaidInvoices, + } = useOutletContext() const { appDetails, rotationResult, @@ -193,16 +200,25 @@ export default function AppDetailIndexPage() { />
+ {hasUnpaidInvoices && ( + + )}
diff --git a/apps/console/app/routes/apps/$clientId/billing.tsx b/apps/console/app/routes/apps/$clientId/billing.tsx index 6cf010d60f..7d2665d8dc 100644 --- a/apps/console/app/routes/apps/$clientId/billing.tsx +++ b/apps/console/app/routes/apps/$clientId/billing.tsx @@ -1,14 +1,14 @@ import { Text } from '@proofzero/design-system/src/atoms/text/Text' -import plans, { PlanDetails } from '~/routes/__layout/billing/plans' +import plans, { type PlanDetails } from '~/routes/__layout/billing/plans' import { PlanFeatures } from '~/routes/__layout/billing' -import { PaymentData, ServicePlanType } from '@proofzero/types/account' +import { type PaymentData, ServicePlanType } from '@proofzero/types/account' import { Button } from '@proofzero/design-system' import { StatusPill } from '@proofzero/design-system/src/atoms/pills/StatusPill' import { - ActionFunction, - LoaderFunction, - Session, - SessionData, + type ActionFunction, + type LoaderFunction, + type Session, + type SessionData, json, } from '@remix-run/cloudflare' import { getRollupReqFunctionErrorWrapper } from '@proofzero/utils/errors' @@ -23,16 +23,18 @@ import { getAuthzHeaderConditionallyFromToken, parseJwt, } from '@proofzero/utils' -import { useLoaderData, useOutletContext, useSubmit } from '@remix-run/react' -import { GetEntitlementsOutput } from '@proofzero/platform/account/src/jsonrpc/methods/getEntitlements' -import { AccountURN } from '@proofzero/urns/account' -import { BadRequestError } from '@proofzero/errors' -import type { appDetailsProps } from '~/types' -import { AppLoaderData } from '~/root' import { - createSubscription, - updateSubscription, -} from '~/services/billing/stripe' + useActionData, + useLoaderData, + useOutletContext, + useSubmit, +} from '@remix-run/react' +import { type GetEntitlementsOutput } from '@proofzero/platform/account/src/jsonrpc/methods/getEntitlements' +import { type AccountURN } from '@proofzero/urns/account' +import { BadRequestError } from '@proofzero/errors' +import type { ToastNotification, appDetailsProps } from '~/types' +import { type AppLoaderData } from '~/root' + import { Modal } from '@proofzero/design-system/src/molecules/modal/Modal' import { ToastWithLink } from '@proofzero/design-system/src/atoms/toast/ToastWithLink' import { useEffect, useMemo, useState } from 'react' @@ -42,8 +44,16 @@ import { Toaster, toast, } from '@proofzero/design-system/src/atoms/toast' -import { Env } from 'bindings' import dangerVector from '~/images/danger.svg' +import { type Env } from 'bindings' +import { + getCurrentAndUpcomingInvoices, + createOrUpdateSubscription, + process3DSecureCard, + UnpaidInvoiceNotification, +} from '~/utils/billing' +import { setPurchaseToastNotification } from '~/utils' +import type Stripe from 'stripe' export const loader: LoaderFunction = getRollupReqFunctionErrorWrapper( async ({ request, context }) => { @@ -65,15 +75,18 @@ export const loader: LoaderFunction = getRollupReqFunctionErrorWrapper( }) const flashSession = await getFlashSession(request, context.env) - const successToast = flashSession.get('success_toast') - const errorToast = flashSession.get('error_toast') + let toastNotification: ToastNotification | undefined = undefined + const toastStr = flashSession.get('toast_notification') + if (toastStr) { + toastNotification = JSON.parse(toastStr) + } return json( { entitlements, paymentData, - successToast, - errorToast, + toastNotification, + STRIPE_PUBLISHABLE_KEY: context.env.STRIPE_PUBLISHABLE_KEY, }, { headers: { @@ -136,8 +149,13 @@ const processUpdateOp = async ( }) } } - - flashSession.flash('success_toast', `${plans[plan].title} assigned.`) + flashSession.flash( + 'toast_notification', + JSON.stringify({ + type: ToastType.Success, + message: `${plans[plan].title} assigned.`, + }) + ) } const processPurchaseOp = async ( @@ -170,66 +188,41 @@ const processPurchaseOp = async ( } const { customerID } = paymentData - let sub - let quantity - try { - if (!entitlements.subscriptionID) { - quantity = 1 - sub = await createSubscription( - { - customerID: customerID, - planID: env.SECRET_STRIPE_PRO_PLAN_ID, - quantity, - accountURN, - handled: true, - }, - env - ) - } else { - quantity = entitlements.plans[plan]?.entitlements - ? entitlements.plans[plan]?.entitlements! + 1 - : 1 - - sub = await updateSubscription( - { - subscriptionID: entitlements.subscriptionID, - planID: env.SECRET_STRIPE_PRO_PLAN_ID, - quantity, - handled: true, - }, - env - ) - } - } catch (e) { - flashSession.flash( - 'error_toast', - 'Transaction failed. You were not charged.' - ) - - return new Response(null, { - headers: { - 'Set-Cookie': await commitFlashSession(flashSession, env), - }, - }) - } - - await coreClient.account.updateEntitlements.mutate({ - accountURN: accountURN, - subscriptionID: sub.id, - quantity: quantity, - type: plan, + const quantity = entitlements.subscriptionID + ? entitlements.plans[plan]?.entitlements + ? entitlements.plans[plan]?.entitlements! + 1 + : 1 + : 1 + + const sub = await createOrUpdateSubscription({ + customerID, + SECRET_STRIPE_PRO_PLAN_ID: env.SECRET_STRIPE_PRO_PLAN_ID, + SECRET_STRIPE_API_KEY: env.SECRET_STRIPE_API_KEY, + quantity, + subscriptionID: entitlements.subscriptionID, + accountURN, }) - await coreClient.starbase.setAppPlan.mutate({ - accountURN, - clientId, - plan, + setPurchaseToastNotification({ + sub, + flashSession, }) + if (sub.status === 'active' || sub.status === 'trialing') { + await coreClient.account.updateEntitlements.mutate({ + accountURN: accountURN, + subscriptionID: sub.id, + quantity: quantity, + type: plan, + }) - flashSession.flash( - 'success_toast', - `${plans[plan].title} purchased and assigned.` - ) + await coreClient.starbase.setAppPlan.mutate({ + accountURN, + clientId, + plan, + }) + } + + return sub } export const action: ActionFunction = getRollupReqFunctionErrorWrapper( @@ -241,19 +234,42 @@ export const action: ActionFunction = getRollupReqFunctionErrorWrapper( }) } + const parsedJwt = parseJwt(jwt!) + const accountURN = parsedJwt.sub as AccountURN + const { clientId } = params if (!clientId) throw new BadRequestError({ message: 'Missing Client ID' }) const traceHeader = generateTraceContextHeaders(context.traceSpan) + const coreClient = createCoreClient(context.env.Core, { + ...getAuthzHeaderConditionallyFromToken(jwt), + ...traceHeader, + }) + + const spd = await coreClient.account.getStripePaymentData.query({ + accountURN, + }) + + 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 op = fd.get('op') as 'update' | 'purchase' const { plan } = JSON.parse(fd.get('payload') as string) as { plan: ServicePlanType } - const flashSession = await getFlashSession(request, context.env) - switch (op) { case 'update': { await processUpdateOp( @@ -268,7 +284,7 @@ export const action: ActionFunction = getRollupReqFunctionErrorWrapper( } case 'purchase': { - await processPurchaseOp( + const sub = await processPurchaseOp( jwt, plan, clientId, @@ -276,7 +292,32 @@ export const action: ActionFunction = getRollupReqFunctionErrorWrapper( context.env, traceHeader ) - break + + 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( + { + subId: sub.id, + status, + client_secret, + payment_method, + }, + { + headers: { + 'Set-Cookie': await commitFlashSession(flashSession, context.env), + }, + } + ) } } @@ -301,6 +342,7 @@ const PlanCard = ({ usedEntitlements, paymentData, featuresColor, + hasUnpaidInvoices, }: { planType: ServicePlanType currentPlan: ServicePlanType @@ -308,6 +350,7 @@ const PlanCard = ({ usedEntitlements?: number paymentData: PaymentData featuresColor: 'text-gray-500' | 'text-indigo-500' + hasUnpaidInvoices: boolean }) => { const plan = plans[planType] const active = planType === currentPlan @@ -340,6 +383,7 @@ const PlanCard = ({ {!active && ( void plan: PlanDetails paymentData?: PaymentData + hasUnpaidInvoices: boolean }) => { const submit = useSubmit() @@ -414,7 +460,7 @@ const PurchaseConfirmationModal = ({ )} -
+
{plan.title} @@ -473,7 +519,7 @@ const PurchaseConfirmationModal = ({