Skip to content

Commit 74dd139

Browse files
add billing details card
1 parent 201f0b2 commit 74dd139

9 files changed

Lines changed: 201 additions & 14 deletions

File tree

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
-- AlterTable
2+
ALTER TABLE "License" ADD COLUMN "currency" TEXT,
3+
ADD COLUMN "interval" TEXT,
4+
ADD COLUMN "intervalCount" INTEGER,
5+
ADD COLUMN "nextRenewalAmount" INTEGER,
6+
ADD COLUMN "nextRenewalAt" TIMESTAMP(3),
7+
ADD COLUMN "planName" TEXT,
8+
ADD COLUMN "unitAmount" INTEGER;

packages/db/prisma/schema.prisma

Lines changed: 17 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -296,16 +296,23 @@ model Org {
296296
}
297297

298298
model License {
299-
id String @id @default(cuid())
300-
orgId Int @unique
301-
org Org @relation(fields: [orgId], references: [id])
302-
activationCode String
303-
entitlements String[]
304-
seats Int?
305-
status String?
306-
lastSyncAt DateTime?
307-
createdAt DateTime @default(now())
308-
updatedAt DateTime @updatedAt
299+
id String @id @default(cuid())
300+
orgId Int @unique
301+
org Org @relation(fields: [orgId], references: [id])
302+
activationCode String
303+
entitlements String[]
304+
seats Int?
305+
status String? /// See LicenseStatus in packages/shared/src/types.ts
306+
planName String?
307+
unitAmount Int?
308+
currency String?
309+
interval String?
310+
intervalCount Int?
311+
nextRenewalAt DateTime?
312+
nextRenewalAmount Int?
313+
lastSyncAt DateTime?
314+
createdAt DateTime @default(now())
315+
updatedAt DateTime @updatedAt
309316
}
310317

311318
enum OrgRole {

packages/shared/src/entitlements.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { createLogger } from "./logger.js";
44
import { env } from "./env.server.js";
55
import { verifySignature } from "./crypto.js";
66
import { License } from "@sourcebot/db";
7+
import { LicenseStatus } from "./types.js";
78

89
const logger = createLogger('entitlements');
910

@@ -18,7 +19,11 @@ const offlineLicensePayloadSchema = z.object({
1819

1920
type getValidOfflineLicense = z.infer<typeof offlineLicensePayloadSchema>;
2021

21-
const ACTIVE_ONLINE_LICENSE_STATUSES = ['active', 'trialing', 'past_due'] as const;
22+
const ACTIVE_ONLINE_LICENSE_STATUSES: LicenseStatus[] = [
23+
'active',
24+
'trialing',
25+
'past_due',
26+
];
2227

2328
// eslint-disable-next-line @typescript-eslint/no-unused-vars
2429
const ALL_ENTITLEMENTS = [
@@ -83,7 +88,7 @@ const getValidOnlineLicense = (_license: License | null): License | null => {
8388
if (
8489
_license &&
8590
_license.status &&
86-
ACTIVE_ONLINE_LICENSE_STATUSES.includes(_license.status as typeof ACTIVE_ONLINE_LICENSE_STATUSES[number])
91+
ACTIVE_ONLINE_LICENSE_STATUSES.includes(_license.status as LicenseStatus)
8792
) {
8893
return _license;
8994
}

packages/shared/src/index.server.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ export type {
1414
RepoMetadata,
1515
RepoIndexingJobMetadata,
1616
IdentityProviderType,
17+
LicenseStatus,
1718
} from "./types.js";
1819
export {
1920
repoMetadataSchema,

packages/shared/src/types.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,4 +64,15 @@ export const repoIndexingJobMetadataSchema = z.object({
6464

6565
export type RepoIndexingJobMetadata = z.infer<typeof repoIndexingJobMetadataSchema>;
6666

67-
export type IdentityProviderType = IdentityProviderConfig['provider'];
67+
export type IdentityProviderType = IdentityProviderConfig['provider'];
68+
69+
// @see: https://docs.stripe.com/api/subscriptions/object#subscription_object-status
70+
export type LicenseStatus =
71+
'active' |
72+
'trialing' |
73+
'past_due' |
74+
'unpaid' |
75+
'canceled' |
76+
'incomplete' |
77+
'incomplete_expired' |
78+
'paused';
Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
import { License } from "@sourcebot/db";
2+
import { Badge } from "@/components/ui/badge";
3+
import { SettingsCard } from "../components/settingsCard";
4+
5+
interface CurrentPlanCardProps {
6+
license: License;
7+
}
8+
9+
export function CurrentPlanCard({ license }: CurrentPlanCardProps) {
10+
if (
11+
license.status !== 'active'
12+
&& license.status !== 'trialing'
13+
&& license.status !== 'past_due'
14+
) {
15+
return null;
16+
}
17+
18+
const {
19+
planName,
20+
unitAmount,
21+
currency,
22+
interval,
23+
intervalCount,
24+
seats,
25+
nextRenewalAt,
26+
nextRenewalAmount,
27+
} = license;
28+
29+
if (
30+
!planName
31+
|| unitAmount === null
32+
|| !currency
33+
|| !interval
34+
|| intervalCount === null
35+
|| !nextRenewalAt
36+
) {
37+
return null;
38+
}
39+
40+
const monthlyPerSeat = normalizeToMonthly(unitAmount, interval, intervalCount);
41+
42+
return (
43+
<SettingsCard>
44+
<div className="flex items-center justify-between gap-6">
45+
<div className="flex flex-col gap-1">
46+
<div className="flex items-center gap-2">
47+
<p className="font-medium">{planName} plan</p>
48+
<Badge variant="outline" className="border-primary/30 text-primary">
49+
Current
50+
</Badge>
51+
</div>
52+
{monthlyPerSeat !== null ? (
53+
<p className="text-sm text-muted-foreground">
54+
{formatCurrency(monthlyPerSeat, currency)} per user/mo, billed {formatCadence(interval, intervalCount)}
55+
</p>
56+
) : (
57+
<p className="text-sm text-muted-foreground">
58+
{formatCurrency(unitAmount, currency)} per user, billed {formatCadence(interval, intervalCount)}
59+
</p>
60+
)}
61+
</div>
62+
<div className="flex items-start gap-12">
63+
<div className="flex flex-col items-end">
64+
<p className="text-xs text-muted-foreground">Users</p>
65+
<p className="text-sm">{seats ?? 0}</p>
66+
</div>
67+
<div className="flex flex-col items-end">
68+
<p className="text-xs text-muted-foreground">Next renewal</p>
69+
<p className="text-sm">
70+
{formatCurrency(nextRenewalAmount ?? 0, currency)} on {formatDate(nextRenewalAt)}
71+
</p>
72+
</div>
73+
</div>
74+
</div>
75+
</SettingsCard>
76+
);
77+
}
78+
79+
function normalizeToMonthly(unitAmount: number, interval: string, intervalCount: number): number | null {
80+
if (interval === 'year') {
81+
return unitAmount / (12 * intervalCount);
82+
}
83+
if (interval === 'month') {
84+
return unitAmount / intervalCount;
85+
}
86+
return null;
87+
}
88+
89+
function formatCurrency(amountCents: number, currency: string): string {
90+
return new Intl.NumberFormat('en-US', {
91+
style: 'currency',
92+
currency: currency.toUpperCase(),
93+
minimumFractionDigits: 0,
94+
maximumFractionDigits: 2,
95+
}).format(amountCents / 100);
96+
}
97+
98+
function formatCadence(interval: string, intervalCount: number): string {
99+
if (intervalCount === 1) {
100+
if (interval === 'year') {
101+
return 'annually';
102+
}
103+
if (interval === 'month') {
104+
return 'monthly';
105+
}
106+
if (interval === 'week') {
107+
return 'weekly';
108+
}
109+
if (interval === 'day') {
110+
return 'daily';
111+
}
112+
}
113+
if (interval === 'month' && intervalCount === 3) {
114+
return 'quarterly';
115+
}
116+
if (interval === 'month' && intervalCount === 6) {
117+
return 'semi-annually';
118+
}
119+
return `every ${intervalCount} ${interval}s`;
120+
}
121+
122+
function formatDate(date: Date): string {
123+
return new Date(date).toLocaleDateString('en-US', {
124+
month: 'short',
125+
day: 'numeric',
126+
year: 'numeric',
127+
});
128+
}

packages/web/src/app/(app)/settings/license/page.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { authenticatedPage } from "@/middleware/authenticatedPage";
22
import { OrgRole } from "@sourcebot/db";
33
import { ActivationCodeCard } from "./activationCodeCard";
4+
import { CurrentPlanCard } from "./currentPlanCard";
45
import { PurchaseButton } from "./purchaseButton";
56
import { ManageSubscriptionButton } from "./manageSubscriptionButton";
67
import { RefreshLicenseButton } from "./refreshLicenseButton";
@@ -20,6 +21,7 @@ export default authenticatedPage(async ({ prisma, org }) => {
2021
<h3 className="text-lg font-medium">License</h3>
2122
<p className="text-sm text-muted-foreground">Manage your license.</p>
2223
</div>
24+
{license && <CurrentPlanCard license={license} />}
2325
<SettingsCard>
2426
<span className="text-sm font-medium">{entitlements.join(", ")}</span>
2527
</SettingsCard>

packages/web/src/ee/features/lighthouse/servicePing.ts

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,18 @@ export const syncWithLighthouse = async (orgId: number) => {
4242

4343
// If we have a license and Lighthouse returned license data, sync it
4444
if (license && response.license) {
45-
const { entitlements, seats, status } = response.license;
45+
const {
46+
entitlements,
47+
seats,
48+
status,
49+
planName,
50+
unitAmount,
51+
currency,
52+
interval,
53+
intervalCount,
54+
nextRenewalAt,
55+
nextRenewalAmount,
56+
} = response.license;
4657

4758
await __unsafePrisma.license.update({
4859
where: {
@@ -52,6 +63,13 @@ export const syncWithLighthouse = async (orgId: number) => {
5263
entitlements,
5364
seats,
5465
status,
66+
planName,
67+
unitAmount,
68+
currency,
69+
interval,
70+
intervalCount,
71+
nextRenewalAt: new Date(nextRenewalAt),
72+
nextRenewalAmount,
5573
lastSyncAt: new Date(),
5674
},
5775
});

packages/web/src/ee/features/lighthouse/types.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,13 @@ export const servicePingResponseSchema = z.object({
1313
entitlements: z.string().array(),
1414
seats: z.number(),
1515
status: z.string(),
16+
planName: z.string(),
17+
unitAmount: z.number().int(),
18+
currency: z.string(),
19+
interval: z.string(),
20+
intervalCount: z.number().int(),
21+
nextRenewalAt: z.string().datetime(),
22+
nextRenewalAmount: z.number().int(),
1623
}).optional(),
1724
});
1825
export type ServicePingResponse = z.infer<typeof servicePingResponseSchema>;

0 commit comments

Comments
 (0)