Просмотр исходного кода

Show admin monthly revenue as a month, not a year (#320)

Plan prices are stored per billing interval and the plans sold today all
bill yearly, so summing them untouched labelled a year's income monthly.
Bernt Christian Egeland 4 недель назад
Родитель
Сommit
009281ec0b

+ 36 - 0
src/__tests__/features/admin/plan-pricing.test.ts

@@ -0,0 +1,36 @@
+/**
+ * The admin overview card is labelled monthly revenue, but plan prices are
+ * stored per billing interval and the plans sold today all bill yearly. Adding
+ * those prices up untouched showed a year's income as a month's.
+ */
+
+import { describe, it, expect } from 'vitest'
+import { monthlyPlanPrice } from '@/lib/plan-pricing'
+
+describe('monthlyPlanPrice', () => {
+  it('leaves a monthly price alone', () => {
+    expect(monthlyPlanPrice(99, 'month')).toBe(99)
+  })
+
+  it('spreads a yearly price across twelve months', () => {
+    expect(monthlyPlanPrice(140, 'year')).toBeCloseTo(11.67, 2)
+  })
+
+  it('treats a missing interval as monthly, matching the column default', () => {
+    expect(monthlyPlanPrice(99, null)).toBe(99)
+    expect(monthlyPlanPrice(99, undefined)).toBe(99)
+  })
+
+  it('ignores the casing an interval was written in', () => {
+    expect(monthlyPlanPrice(120, 'YEAR')).toBe(10)
+  })
+
+  it('handles quarterly and weekly billing', () => {
+    expect(monthlyPlanPrice(30, 'quarter')).toBe(10)
+    expect(monthlyPlanPrice(12, 'week')).toBe(52)
+  })
+
+  it('returns zero for a price that is not a number', () => {
+    expect(monthlyPlanPrice(Number.NaN, 'year')).toBe(0)
+  })
+})

+ 8 - 2
src/features/admin/Actions/getAdminStats.ts

@@ -2,6 +2,7 @@
 
 
 import { withSuperAdmin } from '@/lib/with-super-admin'
 import { withSuperAdmin } from '@/lib/with-super-admin'
 import { db } from '@/lib/db'
 import { db } from '@/lib/db'
+import { monthlyPlanPrice } from '@/lib/plan-pricing'
 
 
 export async function getAdminStats() {
 export async function getAdminStats() {
   return withSuperAdmin(async () => {
   return withSuperAdmin(async () => {
@@ -12,11 +13,16 @@ export async function getAdminStats() {
         db.subscription.count({ where: { status: 'active' } }),
         db.subscription.count({ where: { status: 'active' } }),
         db.subscription.findMany({
         db.subscription.findMany({
           where: { status: 'active' },
           where: { status: 'active' },
-          select: { plan: { select: { price: true } } },
+          select: { plan: { select: { price: true, interval: true } } },
         }),
         }),
       ])
       ])
 
 
-    const totalRevenue = activeSubscriptions.reduce((sum, sub) => sum + sub.plan.price, 0)
+    // Yearly plans are the common case, so their price has to come down to a
+    // month before it can be added to the monthly total.
+    const totalRevenue = activeSubscriptions.reduce(
+      (sum, sub) => sum + monthlyPlanPrice(sub.plan.price, sub.plan.interval),
+      0
+    )
 
 
     return {
     return {
       totalUsers,
       totalUsers,

+ 23 - 0
src/lib/plan-pricing.ts

@@ -0,0 +1,23 @@
+/**
+ * A plan's price is stored per billing interval, not per month. Most plans bill
+ * yearly, so summing prices straight out of the table and calling the result
+ * monthly revenue overstates it by a factor of twelve.
+ */
+export function monthlyPlanPrice(price: number, interval: string | null | undefined): number {
+  if (!Number.isFinite(price)) return 0
+
+  switch ((interval ?? 'month').toLowerCase()) {
+    case 'year':
+    case 'yearly':
+    case 'annual':
+      return price / 12
+    case 'quarter':
+    case 'quarterly':
+      return price / 3
+    case 'week':
+    case 'weekly':
+      return (price * 52) / 12
+    default:
+      return price
+  }
+}