checkpoint

This commit is contained in:
Brian Cao
2026-05-04 10:03:58 -07:00
parent 4918b9b848
commit a7f3215d4d
26 changed files with 1294 additions and 204 deletions
+469 -6
View File
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,64 @@
-- CreateTable
CREATE TABLE "billing_invoice" (
"line_id" VARCHAR(255) NOT NULL,
"provider_id" UUID NOT NULL,
"invoice_id" VARCHAR(255) NOT NULL,
"customer_id" VARCHAR(255) NOT NULL,
"invoice_status" VARCHAR(50) NOT NULL,
"invoice_period_end" TIMESTAMPTZ(6) NOT NULL,
"usage_type" VARCHAR(20) NOT NULL,
"amount_cents" INTEGER NOT NULL,
"period_start" TIMESTAMPTZ(6) NOT NULL,
"period_end" TIMESTAMPTZ(6) NOT NULL,
"period_months" INTEGER NOT NULL,
"mrr_cents" INTEGER NOT NULL,
CONSTRAINT "billing_invoice_pkey" PRIMARY KEY ("line_id")
);
-- CreateTable
CREATE TABLE "billing_provider" (
"billing_id" UUID NOT NULL,
"name" VARCHAR(100) NOT NULL,
"provider" VARCHAR(50) NOT NULL,
"user_id" UUID,
"team_id" UUID,
"api_key" TEXT NOT NULL,
"sync_cursor" VARCHAR(255),
"sync_status" VARCHAR(20) NOT NULL DEFAULT 'idle',
"last_run_at" TIMESTAMPTZ(6),
"created_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMPTZ(6),
CONSTRAINT "billing_provider_pkey" PRIMARY KEY ("billing_id")
);
-- CreateIndex
CREATE INDEX "billing_invoice_provider_id_idx" ON "billing_invoice"("provider_id");
-- CreateIndex
CREATE INDEX "billing_invoice_invoice_id_idx" ON "billing_invoice"("invoice_id");
-- CreateIndex
CREATE INDEX "billing_invoice_customer_id_idx" ON "billing_invoice"("customer_id");
-- CreateIndex
CREATE INDEX "billing_invoice_invoice_status_idx" ON "billing_invoice"("invoice_status");
-- CreateIndex
CREATE INDEX "billing_invoice_usage_type_idx" ON "billing_invoice"("usage_type");
-- CreateIndex
CREATE INDEX "billing_provider_user_id_idx" ON "billing_provider"("user_id");
-- CreateIndex
CREATE INDEX "billing_provider_team_id_idx" ON "billing_provider"("team_id");
-- CreateIndex
CREATE UNIQUE INDEX "billing_provider_name_user_id_key" ON "billing_provider"("name", "user_id");
-- CreateIndex
CREATE UNIQUE INDEX "billing_provider_name_team_id_key" ON "billing_provider"("name", "team_id");
-- CreateIndex
CREATE INDEX "session_replay_visit_id_idx" ON "session_replay"("visit_id");
+15 -13
View File
@@ -415,7 +415,7 @@ model BillingInvoice {
periodMonths Int @map("period_months") @db.Integer
mrrCents Int @map("mrr_cents") @db.Integer
billingProvider BillingProvider @relation(fields: [providerId], references: [id])
billingProvider Billing @relation(fields: [providerId], references: [id])
@@index([providerId])
@@index([invoiceId])
@@ -425,21 +425,23 @@ model BillingInvoice {
@@map("billing_invoice")
}
model BillingProvider {
id String @id @default(uuid()) @map("billing_provider_id") @db.Uuid
provider String @db.VarChar(50)
userId String? @map("user_id") @db.Uuid
teamId String? @map("team_id") @db.Uuid
apiKey String @map("api_key") @db.Text
syncCursor String? @map("sync_cursor") @db.VarChar(255)
syncStatus String @default("idle") @map("sync_status") @db.VarChar(20)
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(6)
model Billing {
id String @id @default(uuid()) @map("billing_id") @db.Uuid
name String @db.VarChar(100)
provider String @db.VarChar(50)
userId String? @map("user_id") @db.Uuid
teamId String? @map("team_id") @db.Uuid
apiKey String @map("api_key") @db.Text
syncCursor String? @map("sync_cursor") @db.VarChar(255)
syncStatus String @default("idle") @map("sync_status") @db.VarChar(20)
lastRunAt DateTime? @map("last_run_at") @db.Timestamptz(6)
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
updatedAt DateTime? @map("updated_at") @db.Timestamptz(6)
invoices BillingInvoice[]
@@unique([provider, userId])
@@unique([provider, teamId])
@@unique([name, userId])
@@unique([name, teamId])
@@index([userId])
@@index([teamId])
@@map("billing_provider")
+14 -7
View File
@@ -10,6 +10,7 @@ import {
LinkIcon,
PanelLeft,
PanelsLeftBottom,
BadgeDollarSign
} from '@/components/icons';
import { UserButton } from '@/components/input/UserButton';
import { Logo } from '@/components/svg';
@@ -34,13 +35,13 @@ export function SideNav(props: any) {
const links = [
...(!teamId
? [
{
id: 'dashboard',
label: t(labels.dashboard),
path: '/dashboard',
icon: <PanelsLeftBottom />,
},
]
{
id: 'dashboard',
label: t(labels.dashboard),
path: '/dashboard',
icon: <PanelsLeftBottom />,
},
]
: []),
{
id: 'boards',
@@ -66,6 +67,12 @@ export function SideNav(props: any) {
path: '/pixels',
icon: <Grid2x2 />,
},
{
id: 'billing',
label: t(labels.billing),
path: '/billing',
icon: <BadgeDollarSign />,
},
];
return (
@@ -0,0 +1,14 @@
import { useMessages } from '@/components/hooks';
import { Plus } from '@/components/icons';
import { DialogButton } from '@/components/input/DialogButton';
import { BillingsEditForm } from './BillingsEditForm';
export function BillingsAddButton() {
const { t, labels } = useMessages();
return (
<DialogButton icon={<Plus />} label={t(labels.addBillingProvider)} variant="primary" width="500px">
{({ close }) => <BillingsEditForm onClose={close} />}
</DialogButton>
);
}
@@ -0,0 +1,13 @@
import { DataGrid } from '@/components/common/DataGrid';
import { useBillingProvidersQuery } from '@/components/hooks';
import { BillingsTable } from './BillingsTable';
export function BillingsDataTable() {
const query = useBillingProvidersQuery();
return (
<DataGrid query={query} allowSearch={false} allowPaging={false}>
{({ data }) => <BillingsTable data={data} />}
</DataGrid>
);
}
@@ -0,0 +1,48 @@
import { ConfirmationForm } from '@/components/common/ConfirmationForm';
import { useDeleteQuery, useMessages } from '@/components/hooks';
import { Trash } from '@/components/icons';
import { DialogButton } from '@/components/input/DialogButton';
export function BillingsDeleteButton({
providerId,
providerName,
onSave,
}: {
providerId: string;
providerName: string;
onSave?: () => void;
}) {
const { t, labels, messages, getErrorMessage } = useMessages();
const { mutateAsync, isPending, error, touch } = useDeleteQuery(
`/billing/providers/${providerId}`,
);
const handleConfirm = async (close: () => void) => {
await mutateAsync(null, {
onSuccess: () => {
touch('billingProviders');
onSave?.();
close();
},
});
};
return (
<DialogButton icon={<Trash />} title={t(labels.confirm)} variant="quiet" width="400px">
{({ close }) => (
<ConfirmationForm
message={t.rich(messages.confirmRemove, {
target: providerName,
b: chunks => <b>{chunks}</b>,
})}
isLoading={isPending}
error={getErrorMessage(error)}
onConfirm={handleConfirm.bind(null, close)}
onClose={close}
buttonLabel={t(labels.delete)}
buttonVariant="danger"
/>
)}
</DialogButton>
);
}
@@ -0,0 +1,87 @@
import { Box, Button, Form, FormField, FormSubmitButton, ListItem, Row, Select, TextField } from '@umami/react-zen';
import { useLoginQuery, useMessages, useUpdateQuery } from '@/components/hooks';
import { BILLING_PROVIDER_TYPES } from '@/lib/constants';
interface BillingsFormValues {
name: string;
provider: string;
apiKey: string;
}
export function BillingsEditForm({
providerId,
providerName,
displayName,
onSave,
onClose,
}: {
providerId?: string;
providerName?: string;
displayName?: string;
onSave?: () => void;
onClose?: () => void;
}) {
const { t, labels, messages, getErrorMessage } = useMessages();
const { user } = useLoginQuery();
const { mutateAsync, error, isPending, touch, toast } = useUpdateQuery(
providerId ? `/billing/providers/${providerId}` : '/billing/providers',
providerId ? undefined : { userId: user?.id },
);
const handleSubmit = async (data: BillingsFormValues) => {
await mutateAsync({ name: data.name, provider: data.provider, apiKey: data.apiKey || undefined });
toast(t(messages.saved));
touch('billingProviders');
onSave?.();
onClose?.();
};
return (
<Form
onSubmit={handleSubmit}
error={getErrorMessage(error)}
values={{ name: displayName ?? '', provider: providerName ?? BILLING_PROVIDER_TYPES.stripe, apiKey: '' }}
>
{({ watch, setValue }) => {
const provider = watch('provider') as string;
return (
<>
<FormField name="name" label={t(labels.name)} rules={{ required: t(labels.required) }}>
<TextField autoComplete="off" autoFocus placeholder={t(labels.untitled)} />
</FormField>
<FormField name="provider" label={t(labels.provider)} rules={{ required: t(labels.required) }}>
<Box width="100%" maxWidth="360px">
<Select
value={provider}
onChange={value => setValue('provider', value, { shouldDirty: true })}
>
{Object.values(BILLING_PROVIDER_TYPES).map(type => (
<ListItem key={type} id={type}>
{type.charAt(0).toUpperCase() + type.slice(1)}
</ListItem>
))}
</Select>
</Box>
</FormField>
<FormField
name="apiKey"
label={t(labels.apiKey)}
rules={providerId ? undefined : { required: t(labels.required) }}
>
<TextField autoComplete="off" type="password" placeholder="sk_live_..." />
</FormField>
<Row justifyContent="flex-end" paddingTop="3" gap="3">
{onClose && (
<Button isDisabled={isPending} onPress={onClose}>
{t(labels.cancel)}
</Button>
)}
<FormSubmitButton isDisabled={isPending}>{t(labels.save)}</FormSubmitButton>
</Row>
</>
);
}}
</Form>
);
}
+25
View File
@@ -0,0 +1,25 @@
'use client';
import { Column } from '@umami/react-zen';
import { PageBody } from '@/components/common/PageBody';
import { PageHeader } from '@/components/common/PageHeader';
import { Panel } from '@/components/common/Panel';
import { useMessages } from '@/components/hooks';
import { BillingsAddButton } from './BillingsAddButton';
import { BillingsDataTable } from './BillingsDataTable';
export function BillingsPage() {
const { t, labels } = useMessages();
return (
<PageBody>
<Column margin="2">
<PageHeader title={t(labels.billing)}>
<BillingsAddButton />
</PageHeader>
<Panel>
<BillingsDataTable />
</Panel>
</Column>
</PageBody>
);
}
+34
View File
@@ -0,0 +1,34 @@
import { DataColumn, DataTable, type DataTableProps, Row } from '@umami/react-zen';
import { DateDistance } from '@/components/common/DateDistance';
import { useMessages } from '@/components/hooks';
import { Pencil } from '@/components/icons';
import { DialogButton } from '@/components/input/DialogButton';
import { BillingsDeleteButton } from './BillingsDeleteButton';
import { BillingsEditForm } from './BillingsEditForm';
export function BillingsTable(props: DataTableProps) {
const { t, labels } = useMessages();
return (
<DataTable {...props}>
<DataColumn id="name" label={t(labels.name)} />
<DataColumn id="provider" label={t(labels.provider)} />
<DataColumn id="syncStatus" label={t(labels.syncStatus)} />
<DataColumn id="lastRunAt" label={t(labels.lastRun)} width="200px">
{(row: any) => (row.lastRunAt ? <DateDistance date={new Date(row.lastRunAt)} /> : '—')}
</DataColumn>
<DataColumn id="action" align="end" width="80px">
{({ id, name, provider }: any) => (
<Row>
<DialogButton icon={<Pencil />} title={t(labels.edit)} variant="quiet" width="500px">
{({ close }) => (
<BillingsEditForm providerId={id} providerName={provider} displayName={name} onClose={close} />
)}
</DialogButton>
<BillingsDeleteButton providerId={id} providerName={name ?? provider} />
</Row>
)}
</DataColumn>
</DataTable>
);
}
@@ -0,0 +1,5 @@
'use client';
export function BillingsPage({ billingId }: { billingId: string }) {
return <div>{billingId}</div>;
}
@@ -0,0 +1,12 @@
import type { Metadata } from 'next';
import { BillingsPage } from './BillingsPage';
export default async function ({ params }: { params: Promise<{ billingId: string }> }) {
const { billingId } = await params;
return <BillingsPage billingId={billingId} />;
}
export const metadata: Metadata = {
title: 'Billing',
};
+10
View File
@@ -0,0 +1,10 @@
import type { Metadata } from 'next';
import { BillingsPage } from './BillingsPage';
export default function () {
return <BillingsPage />;
}
export const metadata: Metadata = {
title: 'Billing',
};
-7
View File
@@ -1,7 +0,0 @@
import { NextResponse } from 'next/server';
import { getBillingProviderSyncStatuses } from '@/queries/prisma';
export async function GET() {
const keys = await getBillingProviderSyncStatuses();
return NextResponse.json(keys);
}
+131
View File
@@ -0,0 +1,131 @@
import { z } from 'zod';
import { decrypt, encrypt, secret } from '@/lib/crypto';
import { parseRequest } from '@/lib/request';
import { json, notFound, ok, unauthorized } from '@/lib/response';
import { deleteBillingById, getBillingById, maskKey, updateBilling } from '@/queries/prisma';
function canAccess(
user: { id: string; isAdmin: boolean },
row: { userId: string | null; teamId: string | null },
) {
return user.isAdmin || row.userId === user.id;
}
export async function GET(
request: Request,
{ params }: { params: Promise<{ providerId: string }> },
) {
const { auth, error } = await parseRequest(request, z.object({}));
if (error) {
return error();
}
if (!auth.user) {
return unauthorized();
}
const { providerId } = await params;
const row = await getBillingById(providerId);
if (!row) {
return notFound();
}
if (!canAccess(auth.user, row)) {
return unauthorized();
}
const rawKey = decrypt(row.apiKey, secret());
return json({
id: row.id,
provider: row.provider,
userId: row.userId,
teamId: row.teamId,
keyPreview: maskKey(rawKey),
syncStatus: row.syncStatus,
syncCursor: row.syncCursor,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
});
}
export async function POST(
request: Request,
{ params }: { params: Promise<{ providerId: string }> },
) {
const schema = z.object({
name: z.string().max(100).optional(),
provider: z.string().max(50).optional(),
apiKey: z.string().min(1).optional(),
});
const { auth, body, error } = await parseRequest(request, schema);
if (error) {
return error();
}
if (!auth.user) {
return unauthorized();
}
const { providerId } = await params;
const existing = await getBillingById(providerId);
if (!existing) {
return notFound();
}
if (!canAccess(auth.user, existing)) {
return unauthorized();
}
const update: Record<string, any> = {};
if (body.name) update.name = body.name;
if (body.provider) update.provider = body.provider;
if (body.apiKey) update.apiKey = encrypt(body.apiKey, secret());
const row = await updateBilling(providerId, update);
const rawKey = decrypt(row.apiKey, secret());
return json({
id: row.id,
provider: row.provider,
userId: row.userId,
teamId: row.teamId,
keyPreview: maskKey(rawKey),
syncStatus: row.syncStatus,
updatedAt: row.updatedAt,
});
}
export async function DELETE(
request: Request,
{ params }: { params: Promise<{ providerId: string }> },
) {
const { auth, error } = await parseRequest(request, z.object({}));
if (error) {
return error();
}
if (!auth.user) {
return unauthorized();
}
const { providerId } = await params;
const row = await getBillingById(providerId);
if (!row) {
return notFound();
}
if (!canAccess(auth.user, row)) {
return unauthorized();
}
await deleteBillingById(providerId);
return ok();
}
@@ -1,24 +1,20 @@
import { NextResponse } from 'next/server';
import Stripe from 'stripe';
import { decrypt, secret } from '@/lib/crypto';
import { fetchInvoicePageBackfill, fetchInvoicePageIncremental } from '@/lib/stripe';
import { getBillingProviderById, updateBillingProviderSync } from '@/queries/prisma';
import { STALE_RUNNING_MS, upsertInvoiceBatch } from '@/queries/prisma/billing';
import { getBillingById, updateBillingSync } from '@/queries/prisma';
import { STALE_RUNNING_MS, upsertInvoiceBatch } from '@/queries/prisma/billingInvoice';
import { NextResponse } from 'next/server';
import Stripe from 'stripe';
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
export async function POST(
request: Request,
{ params }: { params: Promise<{ billingId: string }> },
) {
const { billingId } = await params;
const { mode: rawMode } = await request.json();
const mode = rawMode === 'full' ? 'full' : 'batch';
// keyId identifies which BillingProvider (i.e. which owner) to sync.
// mode=full — fetch all pages in one request (self-hosted, no timeout concern)
// mode=batch — fetch one page and return; caller re-invokes (Vercel / default)
const keyId = searchParams.get('keyId');
const mode = searchParams.get('mode') === 'full' ? 'full' : 'batch';
const keyRow = await getBillingById(billingId);
if (!keyId) {
return NextResponse.json({ error: 'keyId is required' }, { status: 400 });
}
const keyRow = await getBillingProviderById(keyId);
if (!keyRow) {
return NextResponse.json({ error: 'API key not found' }, { status: 404 });
}
@@ -31,7 +27,7 @@ export async function GET(request: Request) {
return NextResponse.json({ skipped: true, reason: 'already running' }, { status: 409 });
}
await updateBillingProviderSync(keyId, { syncStatus: 'running' });
await updateBillingSync(billingId, { syncStatus: 'running' });
// Decrypt the API key and create a scoped Stripe client
const rawApiKey = decrypt(keyRow.apiKey, secret());
@@ -42,7 +38,6 @@ export async function GET(request: Request) {
let processed = 0;
let lastCursor: string | null = keyRow.syncCursor ?? null;
// If backfilling, always fetch in batch mode to avoid partial syncs. Otherwise, follow the requested mode.
if (mode === 'full') {
let hasMore = true;
while (hasMore) {
@@ -50,7 +45,7 @@ export async function GET(request: Request) {
? await fetchInvoicePageBackfill(stripe, lastCursor)
: await fetchInvoicePageIncremental(stripe);
await upsertInvoiceBatch(page.data, keyId);
await upsertInvoiceBatch(page.data, billingId);
processed += page.data.length;
hasMore = page.has_more;
@@ -59,7 +54,11 @@ export async function GET(request: Request) {
if (!isBackfilling) break;
}
await updateBillingProviderSync(keyId, { syncStatus: 'idle', syncCursor: null });
await updateBillingSync(billingId, {
syncStatus: 'idle',
syncCursor: null,
lastRunAt: new Date(),
});
return NextResponse.json({ processed, hasMore: false, cursor: null, status: 'idle' });
} else {
@@ -67,13 +66,17 @@ export async function GET(request: Request) {
? await fetchInvoicePageBackfill(stripe, lastCursor)
: await fetchInvoicePageIncremental(stripe);
await upsertInvoiceBatch(page.data, keyId);
await upsertInvoiceBatch(page.data, billingId);
const nextCursor =
page.has_more && page.data.length > 0 ? page.data[page.data.length - 1].id : null;
const nextStatus = page.has_more ? 'backfilling' : 'idle';
await updateBillingProviderSync(keyId, { syncStatus: nextStatus, syncCursor: nextCursor });
await updateBillingSync(billingId, {
syncStatus: nextStatus,
syncCursor: nextCursor,
...(nextStatus === 'idle' && { lastRunAt: new Date() }),
});
return NextResponse.json({
processed: page.data.length,
@@ -83,7 +86,7 @@ export async function GET(request: Request) {
});
}
} catch (err) {
await updateBillingProviderSync(keyId, { syncStatus: 'idle' });
await updateBillingSync(billingId, { syncStatus: 'idle' });
throw err;
}
}
+108
View File
@@ -0,0 +1,108 @@
import { z } from 'zod';
import { decrypt, encrypt, secret } from '@/lib/crypto';
import { parseRequest } from '@/lib/request';
import { json, unauthorized } from '@/lib/response';
import { pagingParams, searchParams } from '@/lib/schema';
import {
getBillingsPage,
maskKey,
upsertBillingForTeam,
upsertBillingForUser,
} from '@/queries/prisma';
export async function GET(request: Request) {
const schema = z.object({
userId: z.string().uuid().optional(),
teamId: z.string().uuid().optional(),
...pagingParams,
...searchParams,
});
const { auth, query, error } = await parseRequest(request, schema);
if (error) {
return error();
}
if (!auth.user) {
return unauthorized();
}
const { userId, teamId, ...filters } = query;
let where: Record<string, any> = {};
if (teamId) {
if (!auth.user.isAdmin) {
return unauthorized();
}
where = { teamId };
} else {
const ownerId = userId ?? auth.user.id;
if (!auth.user.isAdmin && ownerId !== auth.user.id) {
return unauthorized();
}
where = auth.user.isAdmin && !userId ? {} : { userId: ownerId };
}
const result = await getBillingsPage(where, filters);
return json(result);
}
export async function POST(request: Request) {
const schema = z.object({
name: z.string().max(100),
provider: z.string().max(50),
apiKey: z.string().min(1),
userId: z.string().uuid().optional(),
teamId: z.string().uuid().optional(),
});
const { auth, body, error } = await parseRequest(request, schema);
if (error) {
return error();
}
if (!auth.user) {
return unauthorized();
}
const { name, provider, apiKey, userId, teamId } = body;
if (!userId && !teamId) {
return new Response(JSON.stringify({ error: 'userId or teamId is required' }), {
status: 400,
headers: { 'Content-Type': 'application/json' },
});
}
if (!auth.user.isAdmin) {
if (userId && userId !== auth.user.id) {
return unauthorized();
}
if (teamId) {
return unauthorized();
}
}
const encryptedKey = encrypt(apiKey, secret());
const row = userId
? await upsertBillingForUser(userId, provider, name, encryptedKey)
: await upsertBillingForTeam(teamId, provider, name, encryptedKey);
const rawKey = decrypt(row.apiKey, secret());
return json({
id: row.id,
provider: row.provider,
userId: row.userId,
teamId: row.teamId,
keyPreview: maskKey(rawKey),
syncStatus: row.syncStatus,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
});
}
+1
View File
@@ -11,6 +11,7 @@ export * from './context/useWebsite';
// Query hooks
export * from './queries/useActiveUsersQuery';
export * from './queries/useBillingProvidersQuery';
export * from './queries/useBoardQuery';
export * from './queries/useBoardSharesQuery';
export * from './queries/useBoardsQuery';
@@ -0,0 +1,18 @@
import type { ReactQueryOptions } from '@/lib/types';
import { useApi } from '../useApi';
import { useModified } from '../useModified';
import { usePagedQuery } from '../usePagedQuery';
export function useBillingProvidersQuery(
params?: Record<string, any>,
options?: ReactQueryOptions,
) {
const { get } = useApi();
const { modified } = useModified('billingProviders');
return usePagedQuery({
queryKey: ['billingProviders', { modified, ...params }],
queryFn: pageParams => get('/billing/providers', { ...pageParams, ...params }),
...options,
});
}
+8
View File
@@ -314,6 +314,14 @@ export const labels: Record<string, string> = {
paidVideo: 'label.paid-video',
grouped: 'label.grouped',
other: 'label.other',
billings: 'label.billings',
billing: 'label.billing',
addBillingProvider: 'label.add-billing-provider',
provider: 'label.provider',
apiKey: 'label.api-key',
syncStatus: 'label.sync-status',
syncCursor: 'label.sync-cursor',
lastRun: 'label.last-run',
boards: 'label.boards',
apply: 'label.apply',
match: 'label.match',
+4
View File
@@ -216,6 +216,10 @@ export const ROLE_PERMISSIONS = {
[ROLES.teamViewOnly]: [],
} as const;
export const BILLING_PROVIDER_TYPES = {
stripe: 'stripe',
};
export const THEME_COLORS = {
light: {
primary: '#2680eb',
+8 -5
View File
@@ -475,9 +475,9 @@ function getSearchParameters(query: string, filters: Record<string, any>[]) {
[key]:
typeof value === 'string'
? {
[value]: query,
mode: 'insensitive',
}
[value]: query,
mode: 'insensitive',
}
: parseFilter(value),
};
};
@@ -518,7 +518,10 @@ function getClient() {
const schema = getSchema();
const baseAdapter = new PrismaPg({ connectionString: url }, { schema });
console.log(schema, replicaUrl, url, 'replica adapter');
const baseAdapter = new PrismaPg({ connectionString: url }, schema ? { schema } : {});
const baseClient = new PrismaClient({
adapter: baseAdapter,
@@ -536,7 +539,7 @@ function getClient() {
return baseClient;
}
const replicaAdapter = new PrismaPg({ connectionString: replicaUrl }, { schema });
const replicaAdapter = new PrismaPg({ connectionString: replicaUrl }, schema ? { schema } : {});
const replicaClient = new PrismaClient({
adapter: replicaAdapter,
+117 -58
View File
@@ -1,62 +1,121 @@
import type Stripe from 'stripe';
import { uuid } from '@/lib/crypto';
import prisma from '@/lib/prisma';
export const STALE_RUNNING_MS = 2 * 60 * 1000;
const db = () => (prisma.client as any).billing;
// Upsert a batch of invoice line items into billing_invoice.
export async function upsertInvoiceBatch(
invoices: Stripe.Invoice[],
providerId: string,
): Promise<void> {
const db = prisma.client as any;
for (const invoice of invoices) {
const customerId = invoice.customer as string;
const invoiceStatus = invoice.status ?? 'unknown';
const invoicePeriodEnd = new Date(invoice.period_end * 1000);
for (const line of (invoice.lines as any).data) {
const usageType = line.pricing?.price_details?.price?.recurring?.usage_type;
const lineType: string | null =
usageType === 'licensed'
? 'licensed'
: usageType === 'metered'
? 'metered'
: usageType == null && line.amount != null
? 'one_time'
: null;
if (!lineType) continue;
const periodMonths = Math.max(
1,
Math.round((line.period.end - line.period.start) / (86400 * 30.44)),
);
await db.billingInvoice.upsert({
where: { id: line.id },
create: {
id: line.id,
providerId,
invoiceId: invoice.id,
customerId,
invoiceStatus,
invoicePeriodEnd,
usageType: lineType,
amountCents: line.amount,
periodStart: new Date(line.period.start * 1000),
periodEnd: new Date(line.period.end * 1000),
periodMonths,
mrrCents: Math.round(line.amount / periodMonths),
},
update: {
invoiceStatus,
invoicePeriodEnd,
amountCents: line.amount,
usageType: lineType,
mrrCents: Math.round(line.amount / periodMonths),
},
});
}
}
function maskKey(apiKey: string): string {
// Show last 4 chars: sk_live_****abcd
return apiKey.length > 4 ? `****${apiKey.slice(-4)}` : '****';
}
export async function getBillingByUser(userId: string, provider: string) {
return db().findUnique({
where: { provider_userId: { provider, userId } },
});
}
export async function getBillingByTeam(teamId: string, provider: string) {
return db().findUnique({
where: { provider_teamId: { provider, teamId } },
});
}
export async function upsertBillingForUser(
userId: string,
provider: string,
name: string,
encryptedKey: string,
) {
return db().upsert({
where: { provider_userId: { provider, userId } },
create: { id: uuid(), name, provider, userId, apiKey: encryptedKey, updatedAt: new Date() },
update: { name, apiKey: encryptedKey, updatedAt: new Date() },
});
}
export async function upsertBillingForTeam(
teamId: string,
provider: string,
name: string,
encryptedKey: string,
) {
return db().upsert({
where: { provider_teamId: { provider, teamId } },
create: { id: uuid(), name, provider, teamId, apiKey: encryptedKey, updatedAt: new Date() },
update: { name, apiKey: encryptedKey, updatedAt: new Date() },
});
}
export async function deleteBillingByUser(userId: string, provider: string) {
return db().delete({
where: { provider_userId: { provider, userId } },
});
}
export async function deleteBillingByTeam(teamId: string, provider: string) {
return db().delete({
where: { provider_teamId: { provider, teamId } },
});
}
export async function getBillingById(id: string) {
return db().findUnique({ where: { id } });
}
export async function updateBillingSync(
id: string,
data: { syncStatus: string; syncCursor?: string | null; lastRunAt?: Date | null },
) {
return db().update({ where: { id }, data });
}
export async function getBillingSyncStatuses() {
return db().findMany({
select: {
id: true,
provider: true,
userId: true,
teamId: true,
syncStatus: true,
syncCursor: true,
updatedAt: true,
},
});
}
const providerSelect = {
id: true,
name: true,
provider: true,
userId: true,
teamId: true,
syncStatus: true,
syncCursor: true,
lastRunAt: true,
createdAt: true,
updatedAt: true,
};
export async function getBillingsPage(
where: Record<string, any> = {},
filters?: Record<string, any>,
) {
return prisma.pagedQuery(
'billing',
{ where, select: providerSelect },
{ orderBy: 'createdAt', sortDescending: true, ...filters },
);
}
export async function updateBilling(
id: string,
data: { name?: string; apiKey?: string; provider?: string },
) {
return db().update({ where: { id }, data: { ...data, updatedAt: new Date() } });
}
export async function deleteBillingById(id: string) {
return db().delete({ where: { id } });
}
export { maskKey };
+62
View File
@@ -0,0 +1,62 @@
import type Stripe from 'stripe';
import prisma from '@/lib/prisma';
export const STALE_RUNNING_MS = 2 * 60 * 1000;
// Upsert a batch of invoice line items into billing_invoice.
export async function upsertInvoiceBatch(
invoices: Stripe.Invoice[],
providerId: string,
): Promise<void> {
const db = prisma.client as any;
for (const invoice of invoices) {
const customerId = invoice.customer as string;
const invoiceStatus = invoice.status ?? 'unknown';
const invoicePeriodEnd = new Date(invoice.period_end * 1000);
for (const line of (invoice.lines as any).data) {
const usageType = line.pricing?.price_details?.price?.recurring?.usage_type;
const lineType: string | null =
usageType === 'licensed'
? 'licensed'
: usageType === 'metered'
? 'metered'
: usageType == null && line.amount != null
? 'one_time'
: null;
if (!lineType) continue;
const periodMonths = Math.max(
1,
Math.round((line.period.end - line.period.start) / (86400 * 30.44)),
);
await db.billingInvoice.upsert({
where: { id: line.id },
create: {
id: line.id,
providerId,
invoiceId: invoice.id,
customerId,
invoiceStatus,
invoicePeriodEnd,
usageType: lineType,
amountCents: line.amount,
periodStart: new Date(line.period.start * 1000),
periodEnd: new Date(line.period.end * 1000),
periodMonths,
mrrCents: Math.round(line.amount / periodMonths),
},
update: {
invoiceStatus,
invoicePeriodEnd,
amountCents: line.amount,
usageType: lineType,
mrrCents: Math.round(line.amount / periodMonths),
},
});
}
}
}
-84
View File
@@ -1,84 +0,0 @@
import { uuid } from '@/lib/crypto';
import prisma from '@/lib/prisma';
const db = () => (prisma.client as any).billingProvider;
function maskKey(apiKey: string): string {
// Show last 4 chars: sk_live_****abcd
return apiKey.length > 4 ? `****${apiKey.slice(-4)}` : '****';
}
export async function getBillingProviderByUser(userId: string, provider: string) {
return db().findUnique({
where: { provider_userId: { provider, userId } },
});
}
export async function getBillingProviderByTeam(teamId: string, provider: string) {
return db().findUnique({
where: { provider_teamId: { provider, teamId } },
});
}
export async function upsertBillingProviderForUser(
userId: string,
provider: string,
encryptedKey: string,
) {
return db().upsert({
where: { provider_userId: { provider, userId } },
create: { id: uuid(), provider, userId, apiKey: encryptedKey },
update: { apiKey: encryptedKey },
});
}
export async function upsertBillingProviderForTeam(
teamId: string,
provider: string,
encryptedKey: string,
) {
return db().upsert({
where: { provider_teamId: { provider, teamId } },
create: { id: uuid(), provider, teamId, apiKey: encryptedKey },
update: { apiKey: encryptedKey },
});
}
export async function deleteBillingProviderByUser(userId: string, provider: string) {
return db().delete({
where: { provider_userId: { provider, userId } },
});
}
export async function deleteBillingProviderByTeam(teamId: string, provider: string) {
return db().delete({
where: { provider_teamId: { provider, teamId } },
});
}
export async function getBillingProviderById(id: string) {
return db().findUnique({ where: { id } });
}
export async function updateBillingProviderSync(
id: string,
data: { syncStatus: string; syncCursor?: string | null },
) {
return db().update({ where: { id }, data });
}
export async function getBillingProviderSyncStatuses() {
return db().findMany({
select: {
id: true,
provider: true,
userId: true,
teamId: true,
syncStatus: true,
syncCursor: true,
updatedAt: true,
},
});
}
export { maskKey };
+1 -1
View File
@@ -1,4 +1,4 @@
export * from './billingProvider';
export * from './billing';
export * from './board';
export * from './link';
export * from './pixel';