diff --git a/apps/console/app/routes/__layout/billing/index.tsx b/apps/console/app/routes/__layout/billing/index.tsx index e2e79e0617..eaa15a0c44 100644 --- a/apps/console/app/routes/__layout/billing/index.tsx +++ b/apps/console/app/routes/__layout/billing/index.tsx @@ -74,11 +74,12 @@ import { createOrUpdateSubscription, getCurrentAndUpcomingInvoices, process3DSecureCard, - setPurchaseToastNotification, + UnpaidInvoiceNotification, type StripeInvoice, } from '~/utils/stripe' import { IoWarningOutline } from 'react-icons/io5' import { type ToastNotification } from '~/types' +import { setPurchaseToastNotification } from '~/utils' type LoaderData = { STRIPE_PUBLISHABLE_KEY: string @@ -195,26 +196,11 @@ export const action: ActionFunction = getRollupReqFunctionErrorWrapper( const flashSession = await getFlashSession(request, context.env) - for (const invoice of invoices) { - // We are not creating and/or updating subscriptions - // until we resolve our unpaid invoices - if (invoice?.status) { - if (['open', 'uncollectible'].includes(invoice.status)) { - flashSession.flash( - 'toast_notification', - JSON.stringify({ - type: ToastType.Error, - message: 'Payment failed - check your card details', - }) - ) - return new Response(null, { - headers: { - 'Set-Cookie': await commitFlashSession(flashSession, context.env), - }, - }) - } - } - } + await UnpaidInvoiceNotification({ + invoices, + flashSession, + env: context.env, + }) const fd = await request.formData() const { customerID, quantity, txType } = JSON.parse( @@ -289,8 +275,8 @@ export const action: ActionFunction = getRollupReqFunctionErrorWrapper( ) } - return new Response( - JSON.stringify({ + return json( + { status: (sub.latest_invoice as unknown as StripeInvoice)?.payment_intent ?.status, client_secret: (sub.latest_invoice as unknown as StripeInvoice) @@ -299,7 +285,7 @@ export const action: ActionFunction = getRollupReqFunctionErrorWrapper( .payment_intent?.payment_method, quantity, subId: sub.id, - }), + }, { headers: { 'Set-Cookie': await commitFlashSession(flashSession, context.env), @@ -981,18 +967,15 @@ export default () => { useEffect(() => { if (actionData) { - const { status, client_secret, payment_method, subId } = - JSON.parse(actionData) - ;(async () => { - await process3DSecureCard({ - STRIPE_PUBLISHABLE_KEY, - status, - subId, - client_secret, - payment_method, - submit, - }) - })() + const { status, client_secret, payment_method, subId } = actionData + process3DSecureCard({ + STRIPE_PUBLISHABLE_KEY, + status, + subId, + client_secret, + payment_method, + submit, + }) } }, [actionData]) @@ -1296,14 +1279,15 @@ 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' || @@ -1360,7 +1344,7 @@ export default () => {
diff --git a/apps/console/app/routes/__layout/billing/webhook.tsx b/apps/console/app/routes/__layout/billing/webhook.tsx index c3366293dd..eeda5f3b25 100644 --- a/apps/console/app/routes/__layout/billing/webhook.tsx +++ b/apps/console/app/routes/__layout/billing/webhook.tsx @@ -7,11 +7,8 @@ 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 { InternalServerError } from '@proofzero/errors' +import { reconcileAppSubscriptions } from '~/services/billing/stripe' +import { InternalServerError, RollupError } from '@proofzero/errors' import { type AddressURN } from '@proofzero/urns/address' type StripeInvoicePayload = { @@ -80,6 +77,15 @@ export const action: ActionFunction = getRollupReqFunctionErrorWrapper( return null } + const entitlements = await coreClient.account.getEntitlements.query({ + accountURN: subMeta.accountURN, + }) + if (entitlements?.subscriptionID !== id) { + throw new RollupError({ + message: `Subscription ID ${id} does not match entitlements subscription ID ${entitlements?.subscriptionID}`, + }) + } + await reconcileAppSubscriptions( { subscriptionID: id, diff --git a/apps/console/app/routes/apps/$clientId/billing.tsx b/apps/console/app/routes/apps/$clientId/billing.tsx index 87e5c1c29b..f2b7986eed 100644 --- a/apps/console/app/routes/apps/$clientId/billing.tsx +++ b/apps/console/app/routes/apps/$clientId/billing.tsx @@ -50,10 +50,11 @@ import { type Env } from 'bindings' import { type StripeInvoice, getCurrentAndUpcomingInvoices, - setPurchaseToastNotification, createOrUpdateSubscription, process3DSecureCard, + UnpaidInvoiceNotification, } from '~/utils/stripe' +import { setPurchaseToastNotification } from '~/utils' export const loader: LoaderFunction = getRollupReqFunctionErrorWrapper( async ({ request, context }) => { @@ -195,16 +196,14 @@ const processPurchaseOp = async ( : 1 : 1 - 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, - }) - ).sub + 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, + }) setPurchaseToastNotification({ sub, @@ -261,26 +260,11 @@ export const action: ActionFunction = getRollupReqFunctionErrorWrapper( const flashSession = await getFlashSession(request, context.env) - for (const invoice of invoices) { - // We are not creating and/or updating subscriptions - // until we resolve our unpaid invoices - if (invoice?.status) { - if (['open', 'uncollectible'].includes(invoice.status)) { - flashSession.flash( - 'toast_notification', - JSON.stringify({ - type: ToastType.Error, - message: 'Payment failed - check your card details', - }) - ) - return new Response(null, { - headers: { - 'Set-Cookie': await commitFlashSession(flashSession, context.env), - }, - }) - } - } - } + await UnpaidInvoiceNotification({ + invoices, + flashSession, + env: context.env, + }) const fd = await request.formData() const op = fd.get('op') as 'update' | 'purchase' @@ -311,15 +295,15 @@ export const action: ActionFunction = getRollupReqFunctionErrorWrapper( traceHeader ) - return new Response( - JSON.stringify({ + return json( + { status: (sub?.latest_invoice as unknown as StripeInvoice) ?.payment_intent?.status, client_secret: (sub?.latest_invoice as unknown as StripeInvoice) .payment_intent?.client_secret, payment_method: (sub?.latest_invoice as unknown as StripeInvoice) .payment_intent?.payment_method, - }), + }, { headers: { 'Set-Cookie': await commitFlashSession(flashSession, context.env), @@ -747,9 +731,11 @@ export default () => { hasUnpaidInvoices: boolean }>() + const navigate = useNavigate() + useEffect(() => { if (actionData) { - const { status, client_secret, payment_method } = JSON.parse(actionData) + const { status, client_secret, payment_method } = actionData ;(async () => { await process3DSecureCard({ STRIPE_PUBLISHABLE_KEY, @@ -757,6 +743,7 @@ export default () => { client_secret, payment_method, }) + navigate('.', { replace: true }) })() } }, [actionData]) diff --git a/apps/console/app/services/billing/stripe.ts b/apps/console/app/services/billing/stripe.ts index 4c92d21ee2..bc884fa512 100644 --- a/apps/console/app/services/billing/stripe.ts +++ b/apps/console/app/services/billing/stripe.ts @@ -1,4 +1,5 @@ import { InternalServerError } from '@proofzero/errors' +import { type CoreClientType } from '@proofzero/platform-clients/core' import { ReconcileAppsSubscriptionsOutput } from '@proofzero/platform/starbase/src/jsonrpc/methods/reconcileAppSubscriptions' import { ServicePlanType } from '@proofzero/types/account' import { AccountURN } from '@proofzero/urns/account' @@ -264,7 +265,7 @@ export const reconcileAppSubscriptions = async ( }: { subscriptionID: string accountURN: AccountURN - coreClient: any + coreClient: CoreClientType billingURL: string settingsURL: string }, diff --git a/apps/console/app/utils.ts b/apps/console/app/utils.ts index a4212bb341..7dd0ac1b86 100644 --- a/apps/console/app/utils.ts +++ b/apps/console/app/utils.ts @@ -2,10 +2,13 @@ * @file app/utils.ts */ -import { useMatches } from "@remix-run/react"; -import { useMemo } from "react"; +import { ToastType } from '@proofzero/design-system/src/atoms/toast' +import { useMatches } from '@remix-run/react' +import { useMemo } from 'react' +import { type StripeInvoice } from './utils/stripe' +import type Stripe from 'stripe' -const DEFAULT_REDIRECT = "/"; +const DEFAULT_REDIRECT = '/' // safeRedirect // ----------------------------------------------------------------------------- @@ -21,15 +24,15 @@ export function safeRedirect( to: FormDataEntryValue | string | null | undefined, defaultRedirect: string = DEFAULT_REDIRECT ) { - if (!to || typeof to !== "string") { - return defaultRedirect; + if (!to || typeof to !== 'string') { + return defaultRedirect } - if (!to.startsWith("/") || to.startsWith("//")) { - return defaultRedirect; + if (!to.startsWith('/') || to.startsWith('//')) { + return defaultRedirect } - return to; + return to } // useMatchesData @@ -44,10 +47,53 @@ export function safeRedirect( export function useMatchesData( id: string ): Record | undefined { - const matchingRoutes = useMatches(); + const matchingRoutes = useMatches() const route = useMemo( () => matchingRoutes.find((route) => route.id === id), [matchingRoutes, id] - ); - return route?.data; + ) + return route?.data +} + +export const setPurchaseToastNotification = ({ + sub, + flashSession, +}: { + sub: Stripe.Subscription + flashSession: any +}) => { + // https://stripe.com/docs/billing/subscriptions/overview#subscription-statuses + if ( + (sub.status === 'active' || sub.status === 'trialing') && + sub.latest_invoice?.status === 'paid' + ) { + flashSession.flash( + 'toast_notification', + JSON.stringify({ + type: ToastType.Success, + message: 'Entitlement(s) successfully bought', + }) + ) + } else { + if ( + (sub.latest_invoice as unknown as StripeInvoice)?.payment_intent + ?.status === 'requires_action' + ) { + flashSession.flash( + 'toast_notification', + JSON.stringify({ + type: ToastType.Warning, + message: 'Payment requires additional action', + }) + ) + } else { + flashSession.flash( + 'toast_notification', + JSON.stringify({ + type: ToastType.Error, + message: 'Payment failed - check your card details', + }) + ) + } + } } diff --git a/apps/console/app/utils/stripe.ts b/apps/console/app/utils/stripe.ts index e72a84ee06..bbbb851b06 100644 --- a/apps/console/app/utils/stripe.ts +++ b/apps/console/app/utils/stripe.ts @@ -5,11 +5,13 @@ import { updateSubscription, } from '~/services/billing/stripe' import type { StripePaymentData } from '@proofzero/platform/account/src/types' -import type Stripe from 'stripe' import { ToastType, toast } from '@proofzero/design-system/src/atoms/toast' import { type AccountURN } from '@proofzero/urns/account' 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' export type StripeInvoice = { id: string @@ -43,7 +45,7 @@ export const getCurrentAndUpcomingInvoices = async ( invoices = currentInvoices.data.map((i) => ({ id: i.id, - amount: i.total / 100, + amount: i.amount_due / 100, timestamp: i.created * 1000, status: i.status, url: i.hosted_invoice_url ?? undefined, @@ -118,49 +120,6 @@ export const createOrUpdateSubscription = async ({ return sub } -export const setPurchaseToastNotification = ({ - sub, - flashSession, -}: { - sub: Stripe.Subscription - flashSession: any -}) => { - // https://stripe.com/docs/billing/subscriptions/overview#subscription-statuses - if ( - (sub.status === 'active' || sub.status === 'trialing') && - sub.latest_invoice?.status === 'paid' - ) { - flashSession.flash( - 'toast_notification', - JSON.stringify({ - type: ToastType.Success, - message: 'Entitlement(s) successfully bought', - }) - ) - } else { - if ( - (sub.latest_invoice as unknown as StripeInvoice)?.payment_intent - ?.status === 'requires_action' - ) { - flashSession.flash( - 'toast_notification', - JSON.stringify({ - type: ToastType.Warning, - message: 'Payment requires additional action', - }) - ) - } else { - flashSession.flash( - 'toast_notification', - JSON.stringify({ - type: ToastType.Error, - message: 'Payment failed - check your card details', - }) - ) - } - } -} - export const process3DSecureCard = async ({ STRIPE_PUBLISHABLE_KEY, status, @@ -205,3 +164,33 @@ export const process3DSecureCard = async ({ } } } + +export const UnpaidInvoiceNotification = async ({ + invoices, + flashSession, + env, +}: { + invoices: StripeInvoice[] + flashSession: Session + env: Env +}) => { + if ( + invoices.some( + (invoice) => + invoice?.status && ['open', 'uncollectible'].includes(invoice.status) + ) + ) { + flashSession.flash( + 'toast_notification', + JSON.stringify({ + type: ToastType.Error, + message: 'Payment failed - check your card details', + }) + ) + throw new Response(null, { + headers: { + 'Set-Cookie': await commitFlashSession(flashSession, env), + }, + }) + } +} diff --git a/packages/platform-clients/core.ts b/packages/platform-clients/core.ts index 4e7a374b30..b32c449c35 100644 --- a/packages/platform-clients/core.ts +++ b/packages/platform-clients/core.ts @@ -4,7 +4,7 @@ import type { CoreRouter } from '@proofzero/platform.core/src/router' import { trpcClientLoggerGenerator } from './utils' import { PlatformHeaders } from './base' -export default (fetcher: Fetcher, headers: PlatformHeaders) => +const createCoreClient = (fetcher: Fetcher, headers: PlatformHeaders) => createTRPCProxyClient({ links: [ loggerLink({ @@ -19,3 +19,7 @@ export default (fetcher: Fetcher, headers: PlatformHeaders) => }), ], }) + +export type CoreClientType = ReturnType + +export default createCoreClient