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