From 372090546ce305bc67603472f29d6f4cbd2c4b10 Mon Sep 17 00:00:00 2001 From: Qing <7880675+devrsi0n@users.noreply.github.com> Date: Sat, 19 Oct 2024 15:43:01 +0800 Subject: [PATCH] fix: report usage to stripe error --- apps/main/package.json | 2 +- apps/main/src/pages/api/cron/usage.ts | 56 ++++++++++++++++--- apps/main/src/pages/api/stripe/webhook.ts | 1 + packages/trpc/README.md | 6 +- .../migration.sql | 2 + packages/trpc/prisma/schema.prisma | 13 +++-- packages/utils/src/analytics/tinybird.ts | 26 --------- packages/utils/src/date.ts | 1 + 8 files changed, 65 insertions(+), 42 deletions(-) create mode 100644 packages/trpc/prisma/migrations/20241019032740_add_stripe_subscription_item_id/migration.sql diff --git a/apps/main/package.json b/apps/main/package.json index 768579111..7c0f10803 100644 --- a/apps/main/package.json +++ b/apps/main/package.json @@ -8,7 +8,7 @@ "build:local": "dotenv -e .env.local -- next build", "debug": "NODE_OPTIONS='--inspect' next dev", "dev": "next dev", - "start": "next start", + "start": "dotenv -e .env.prod -- next start", "start:local": "dotenv -e .env.local -- next start", "test": "DEBUG_PRINT_LIMIT=999999 jest --silent=false", "test:coverage": "DEBUG_PRINT_LIMIT=999999 jest --silent=false --coverage" diff --git a/apps/main/src/pages/api/cron/usage.ts b/apps/main/src/pages/api/cron/usage.ts index 59679f99e..d925fe677 100644 --- a/apps/main/src/pages/api/cron/usage.ts +++ b/apps/main/src/pages/api/cron/usage.ts @@ -1,5 +1,5 @@ import { prisma, stripe } from '@chirpy-dev/trpc'; -import { cpDayjs, queryDailyPVUsage } from '@chirpy-dev/utils'; +import { cpDayjs, queryPipe, type QueryPipe } from '@chirpy-dev/utils'; import { NextApiRequest, NextApiResponse } from 'next'; import { log as axiomLog } from 'next-axiom'; @@ -25,12 +25,13 @@ export default async function updateUsage( plan: { in: ['PRO', 'ENTERPRISE'], }, - stripeSubscriptionId: { + stripeSubscriptionItemId: { not: null, }, }, select: { - stripeSubscriptionId: true, + stripeSubscriptionItemId: true, + billingCycleDay: true, projects: { select: { domain: true, @@ -39,19 +40,20 @@ export default async function updateUsage( }, }); const updateUserUsage = async (user: (typeof users)[number]) => { - const pv = await queryDailyPVUsage({ + const pv = await queryPVUsage({ domains: user.projects.map((p) => p.domain), + billingCycleDay: user.billingCycleDay, }); // To avoid duplicated reports - const idempotencyKey = `${user.stripeSubscriptionId}_${cpDayjs() + const idempotencyKey = `${user.stripeSubscriptionItemId}_${cpDayjs() .utc() .format('YYYY-MM-DD')}`; try { await stripe.subscriptionItems.createUsageRecord( - user.stripeSubscriptionId!, + user.stripeSubscriptionItemId!, { quantity: pv, - action: 'increment', + action: 'set', }, { idempotencyKey, @@ -59,14 +61,14 @@ export default async function updateUsage( ); } catch (error) { const msg = `Usage report failed for item ID ${ - user.stripeSubscriptionId + user.stripeSubscriptionItemId } with idempotency key ${idempotencyKey}: ${(error as Error).toString()}`; log.error(msg); throw new Error(msg); } }; try { - await Promise.allSettled(users.map((u) => updateUserUsage(u))); + await Promise.all(users.map((u) => updateUserUsage(u))); } catch { res.status(500).end('Create usage record failed'); } @@ -79,3 +81,39 @@ export const config = { bodyParser: false, }, }; + +const DATE_FORMAT = 'YYYY-MM-DD'; + +/** + * Get pageviews usage + */ +export async function queryPVUsage(params: { + domains: string[]; + billingCycleDay: number | null; +}): Promise { + const today = cpDayjs().utc(); + const billingCycleDay = params.billingCycleDay || 1; + + // For today is after billing cycle day, we query usage from this month to next month + let from = today.date(billingCycleDay); + let to = today.date(billingCycleDay).add(1, 'month'); + + // For today is before billing cycle day, we query usage from last month to this month + if (today.date() < billingCycleDay) { + from = from.subtract(1, 'month'); + to = to.subtract(1, 'month'); + } + const usage: QueryPipe<{ + pageviews: number; + href: string; + indices: number[]; + }> = await queryPipe('pv_by_domains', { + date_from: from.format(DATE_FORMAT), + date_to: to.format(DATE_FORMAT), + domains: params.domains.join(','), + }); + return usage.data.reduce((acc, { pageviews }) => { + acc += pageviews; + return acc; + }, 0); +} diff --git a/apps/main/src/pages/api/stripe/webhook.ts b/apps/main/src/pages/api/stripe/webhook.ts index d5ba209e5..e8fc76211 100644 --- a/apps/main/src/pages/api/stripe/webhook.ts +++ b/apps/main/src/pages/api/stripe/webhook.ts @@ -109,6 +109,7 @@ export default async function stripeWebhook( data: { plan: plan.type, stripeSubscriptionId: subscriptionId, + stripeSubscriptionItemId: subscription.items.data[0].id, }, select: { id: true, diff --git a/packages/trpc/README.md b/packages/trpc/README.md index c0fc28761..bf0555235 100644 --- a/packages/trpc/README.md +++ b/packages/trpc/README.md @@ -13,6 +13,10 @@ docker compose up -d ```sh # 1. prototyping pnpm prisma db push -# 2. generate migration history when ready + +# 2. generate migration history when ready. Will reset the db pnpm prisma migrate dev --name + +# 3. deploy +pnpm dotenv -e .env.prod -- pnpm prisma migrate deploy ``` diff --git a/packages/trpc/prisma/migrations/20241019032740_add_stripe_subscription_item_id/migration.sql b/packages/trpc/prisma/migrations/20241019032740_add_stripe_subscription_item_id/migration.sql new file mode 100644 index 000000000..dd12de1c1 --- /dev/null +++ b/packages/trpc/prisma/migrations/20241019032740_add_stripe_subscription_item_id/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "User" ADD COLUMN "stripeSubscriptionItemId" TEXT; diff --git a/packages/trpc/prisma/schema.prisma b/packages/trpc/prisma/schema.prisma index 15c6ed7cf..7beaf46a1 100644 --- a/packages/trpc/prisma/schema.prisma +++ b/packages/trpc/prisma/schema.prisma @@ -208,12 +208,15 @@ model User { settings Settings? - plan Plan? @default(HOBBY) + plan Plan? @default(HOBBY) // Billing cycle start day - billingCycleDay Int? - stripeCustomerId String? @unique - stripeSubscriptionId String? @unique - pages Page[] + billingCycleDay Int? + stripeCustomerId String? @unique + stripeSubscriptionId String? @unique + // For usage reporting + stripeSubscriptionItemId String? + + pages Page[] } enum Plan { diff --git a/packages/utils/src/analytics/tinybird.ts b/packages/utils/src/analytics/tinybird.ts index fc36438ca..ba9cc8351 100644 --- a/packages/utils/src/analytics/tinybird.ts +++ b/packages/utils/src/analytics/tinybird.ts @@ -1,5 +1,3 @@ -import { cpDayjs } from '../date'; - export class QueryError extends Error { status: number; constructor(message: string, status: number) { @@ -74,30 +72,6 @@ export function queryPipe( return client(`/pipes/${name}.json?${searchParams}`); } -const DATE_FORMAT = 'YYYY-MM-DD'; - -/** - * Get daily pageviews usage - */ -export async function queryDailyPVUsage(params: { - domains: string[]; -}): Promise { - const dateFrom = cpDayjs().subtract(1, 'day'); - const usage: QueryPipe<{ - pageviews: number; - href: string; - indices: number[]; - }> = await queryPipe('pv_by_domains', { - date_from: dateFrom.format(DATE_FORMAT), - date_to: cpDayjs().format(DATE_FORMAT), - domains: params.domains.join(','), - }); - return usage.data.reduce((acc, { pageviews }) => { - acc += pageviews; - return acc; - }, 0); -} - export type QuerySQL = { meta: Meta[]; data: T[]; diff --git a/packages/utils/src/date.ts b/packages/utils/src/date.ts index ef673e7d9..8ef2062ee 100644 --- a/packages/utils/src/date.ts +++ b/packages/utils/src/date.ts @@ -8,3 +8,4 @@ dayjs.extend(relativeTime); // eslint-disable-next-line unicorn/prefer-export-from export const cpDayjs = dayjs; +export type Dayjs = dayjs.Dayjs;