Skip to content

Commit

Permalink
feat(console): App storage billing integration (#2837)
Browse files Browse the repository at this point in the history
  • Loading branch information
Cosmin-Parvulescu authored Feb 20, 2024
1 parent e0751e4 commit 9245b65
Show file tree
Hide file tree
Showing 15 changed files with 307 additions and 82 deletions.
2 changes: 2 additions & 0 deletions .github/workflows/main-console.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ jobs:
SECRET_STRIPE_WEBHOOK_SECRET
SECRET_STRIPE_PRO_PLAN_ID
SECRET_STRIPE_GROUP_SEAT_PLAN_ID
SECRET_STRIPE_APP_DATA_STORAGE_PRICE_IDS
env:
NODE_ENV: 'development'
# A secret used for session encryption.
Expand All @@ -66,6 +67,7 @@ jobs:
SECRET_STRIPE_WEBHOOK_SECRET: ${{ secrets.SECRET_STRIPE_WEBHOOK_SECRET_DEV }}
SECRET_STRIPE_PRO_PLAN_ID: ${{ secrets.SECRET_STRIPE_PRO_PLAN_ID_DEV }}
SECRET_STRIPE_GROUP_SEAT_PLAN_ID: ${{ secrets.SECRET_STRIPE_GROUP_SEAT_PLAN_ID_DEV }}
SECRET_STRIPE_APP_DATA_STORAGE_PRICE_IDS: ${{ secrets.SECRET_STRIPE_APP_DATA_STORAGE_PRICE_IDS_DEV }}

- name: Setup Playwright
working-directory: 'apps/console'
Expand Down
2 changes: 2 additions & 0 deletions .github/workflows/next-console.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ jobs:
SECRET_STRIPE_WEBHOOK_SECRET
SECRET_STRIPE_PRO_PLAN_ID
SECRET_STRIPE_GROUP_SEAT_PLAN_ID
SECRET_STRIPE_APP_DATA_STORAGE_PRICE_IDS
env:
# A secret used for session encryption.
SECRET_SESSION_KEY: ${{ secrets.SECRET_SESSION_KEY_TEST }}
Expand All @@ -64,3 +65,4 @@ jobs:
SECRET_STRIPE_WEBHOOK_SECRET: ${{ secrets.SECRET_STRIPE_WEBHOOK_SECRET_TEST }}
SECRET_STRIPE_PRO_PLAN_ID: ${{ secrets.SECRET_STRIPE_PRO_PLAN_ID_TEST }}
SECRET_STRIPE_GROUP_SEAT_PLAN_ID: ${{ secrets.SECRET_STRIPE_GROUP_SEAT_PLAN_ID_TEST }}
SECRET_STRIPE_APP_DATA_STORAGE_PRICE_IDS: ${{ secrets.SECRET_STRIPE_APP_DATA_STORAGE_PRICE_IDS_TEST }}
2 changes: 2 additions & 0 deletions .github/workflows/release-console.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ jobs:
SECRET_STRIPE_WEBHOOK_SECRET
SECRET_STRIPE_PRO_PLAN_ID
SECRET_STRIPE_GROUP_SEAT_PLAN_ID
SECRET_STRIPE_APP_DATA_STORAGE_PRICE_IDS
env:
# A secret used for session encryption.
SECRET_SESSION_KEY: ${{ secrets.SECRET_SESSION_KEY_PROD }}
Expand All @@ -59,3 +60,4 @@ jobs:
SECRET_STRIPE_WEBHOOK_SECRET: ${{ secrets.SECRET_STRIPE_WEBHOOK_SECRET_PROD }}
SECRET_STRIPE_PRO_PLAN_ID: ${{ secrets.SECRET_STRIPE_PRO_PLAN_ID_PROD }}
SECRET_STRIPE_GROUP_SEAT_PLAN_ID: ${{ secrets.SECRET_STRIPE_GROUP_SEAT_PLAN_ID_PROD }}
SECRET_STRIPE_APP_DATA_STORAGE_PRICE_IDS: ${{ secrets.SECRET_STRIPE_APP_DATA_STORAGE_PRICE_IDS_PROD }}
1 change: 1 addition & 0 deletions apps/console/.dev.vars.example
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,4 @@ SECRET_STRIPE_API_KEY = ""
SECRET_STRIPE_WEBHOOK_SECRET = ""
SECRET_STRIPE_PRO_PLAN_ID = ""
SECRET_STRIPE_GROUP_SEAT_PLAN_ID = ""
SECRET_STRIPE_APP_DATA_STORAGE_PRICE_IDS = ""
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ type AppDataStorageModalProps = {
subscriptionFetcher: FetcherWithComponents<any>
currentPackage?: ExternalAppDataPackageType
topUp?: boolean
currentPrice?: number
clientID: string
}

Expand All @@ -24,6 +25,7 @@ const AppDataStorageModal: React.FC<AppDataStorageModalProps> = ({
subscriptionFetcher,
currentPackage,
topUp = false,
currentPrice = 0,
clientID,
}) => {
const [selectedPackage, setSelectedPackage] =
Expand Down Expand Up @@ -161,6 +163,32 @@ const AppDataStorageModal: React.FC<AppDataStorageModalProps> = ({
</Text>

<div className="flex flex-col gap-2">
<div>
<Text size="sm" weight="medium" className="text-gray-500">
<Text
type="span"
size="lg"
weight="semibold"
className="text-gray-900"
>
{currentPrice
? ExternalAppDataPackages[selectedPackage].price -
currentPrice >=
0
? `+$${
ExternalAppDataPackages[selectedPackage].price -
currentPrice
}`
: `-$${Math.abs(
ExternalAppDataPackages[selectedPackage].price -
currentPrice
)}`
: `+$${ExternalAppDataPackages[selectedPackage].price}`}
</Text>{' '}
per month
</Text>
</div>

<div className="flex flex-row justify-between items-center">
<span>
{autoTopUp ? (
Expand Down
134 changes: 71 additions & 63 deletions apps/console/app/routes/__layout/billing/webhook.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import createCoreClient from '@proofzero/platform-clients/core'

import { getAuthzHeaderConditionallyFromToken } from '@proofzero/utils'
import { reconcileSubscriptions } from '~/services/billing/stripe'
import { InternalServerError, RollupError } from '@proofzero/errors'
import { InternalServerError } from '@proofzero/errors'
import { type AccountURN } from '@proofzero/urns/account'
import { createAnalyticsEvent } from '@proofzero/utils/analytics'
import { ServicePlanType } from '@proofzero/types/billing'
Expand All @@ -17,23 +17,12 @@ import {
IdentityGroupURNSpace,
} from '@proofzero/urns/identity-group'

type StripeInvoicePayload = {
id: string
subscription: string
customer: string
payment_intent: string
lines: {
data: Array<{
price: { product: string }
amount: number
quantity: number
}>
}
metadata: any
}

export const action: ActionFunction = getRollupReqFunctionErrorWrapper(
async ({ request, context }) => {
const notificationEnabledPriceIDSet = new Set([
context.env.SECRET_STRIPE_PRO_PLAN_ID,
])

const traceHeader = generateTraceContextHeaders(context.traceSpan)

const coreClient = createCoreClient(context.env.Core, {
Expand Down Expand Up @@ -136,32 +125,36 @@ export const action: ActionFunction = getRollupReqFunctionErrorWrapper(

break
case 'invoice.payment_succeeded':
const {
customer: customerSuccess,
lines: linesSuccess,
metadata: metaSuccess,
} = event.data.object as StripeInvoicePayload
const { customer: customerSuccess, lines: linesSuccess } = event.data
.object as Stripe.Invoice
const customerDataSuccess = await stripeClient.customers.retrieve(
customerSuccess
customerSuccess as string
)

const updatedItems = {} as {
[key: string]: { amount: number; quantity: number }
[key: string]: { amount: number; quantity: number; productID: string }
}

linesSuccess.data.forEach((line) => {
if (updatedItems[line.price.product]) {
updatedItems[line.price.product] = {
amount: updatedItems[line.price.product].amount + line.amount,
quantity:
updatedItems[line.price.product].quantity + line.quantity,
}
} else {
updatedItems[line.price.product] = {
// this amount is negative when we cancel or update subsription,
// but this event is being fired anyway
amount: line.amount,
quantity: line.amount > 0 ? line.quantity : -line.quantity,
if (line.price) {
const priceID = line.price.id
const productID = line.price.product as string

if (updatedItems[priceID]) {
updatedItems[priceID] = {
amount: updatedItems[priceID].amount + line.amount,
quantity: updatedItems[priceID].quantity + (line.quantity ?? 0),
productID,
}
} else {
updatedItems[priceID] = {
// this amount is negative when we cancel or update subsription,
// but this event is being fired anyway
amount: line.amount,
quantity:
line.amount > 0 ? line.quantity ?? 0 : -(line.quantity ?? 0),
productID,
}
}
}
})
Expand All @@ -173,9 +166,10 @@ export const action: ActionFunction = getRollupReqFunctionErrorWrapper(
})
.map((key) => {
return {
productID: key,
priceID: key,
amount: updatedItems[key].amount,
quantity: updatedItems[key].quantity,
productID: updatedItems[key].productID,
}
})
if (
Expand All @@ -190,42 +184,49 @@ export const action: ActionFunction = getRollupReqFunctionErrorWrapper(
})
)

await createAnalyticsEvent({
eventName: 'identity_purchased_entitlement',
apiKey: context.env.POSTHOG_API_KEY,
distinctId: customerDataSuccess.metadata.URN,
properties: {
plans: purchasedItems.map((item) => ({
const proEntitlements = purchasedItems.filter(
(item) => item.priceID === context.env.SECRET_STRIPE_PRO_PLAN_ID
)

if (proEntitlements.length > 0) {
await createAnalyticsEvent({
eventName: 'identity_purchased_entitlement',
apiKey: context.env.POSTHOG_API_KEY,
distinctId: customerDataSuccess.metadata.URN,
properties: {
plans: proEntitlements.map((item) => ({
quantity: item.quantity,
name: products.find(
(product) => product?.id === item?.productID
)!.name,
type: ServicePlanType.PRO,
})),
},
})

await coreClient.account.sendSuccessfulPaymentNotification.mutate({
email,
name: name || 'Client',
plans: proEntitlements.map((item) => ({
quantity: item.quantity,
name: products.find(
(product) => product?.id === item?.productID
)!.name,
type: ServicePlanType.PRO,
})),
},
})

await coreClient.account.sendSuccessfulPaymentNotification.mutate({
email,
name: name || 'Client',
plans: purchasedItems.map((item) => ({
quantity: item.quantity,
name: products.find((product) => product?.id === item?.productID)!
.name,
})),
})
})
}
}

break

case 'invoice.payment_failed':
const { customer: customerFail, payment_intent: paymentIntentFail } =
event.data.object as StripeInvoicePayload
event.data.object as Stripe.Invoice
const customerDataFail = await stripeClient.customers.retrieve(
customerFail
customerFail as string
)
const paymentIntentInfo = await stripeClient.paymentIntents.retrieve(
paymentIntentFail
paymentIntentFail as string
)

if (
Expand Down Expand Up @@ -262,14 +263,21 @@ export const action: ActionFunction = getRollupReqFunctionErrorWrapper(

URN = metaDel.URN as IdentityRefURN

const delSub = event.data.object as Stripe.Subscription
await Promise.all(
delSub.items.data.map(async (item) => {
if (notificationEnabledPriceIDSet.has(item.price.id)) {
await coreClient.account.sendBillingNotification.mutate({
email,
name: name || 'Client',
})
}
})
)

await Promise.all([
coreClient.account.sendBillingNotification.mutate({
email,
name: name || 'Client',
}),
coreClient.billing.cancelServicePlans.mutate({
URN,
subscriptionID: subIdDel,
deletePaymentData: event.type === 'customer.deleted',
}),
coreClient.starbase.deleteSubscriptionPlans.mutate({
Expand Down
Loading

0 comments on commit 9245b65

Please sign in to comment.