mirror of
https://github.com/umami-software/umami.git
synced 2026-05-30 06:47:25 +00:00
checkpoint
This commit is contained in:
Generated
+469
-6
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
@@ -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")
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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',
|
||||
};
|
||||
@@ -0,0 +1,10 @@
|
||||
import type { Metadata } from 'next';
|
||||
import { BillingsPage } from './BillingsPage';
|
||||
|
||||
export default function () {
|
||||
return <BillingsPage />;
|
||||
}
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Billing',
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
+26
-23
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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
@@ -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
@@ -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 };
|
||||
|
||||
@@ -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),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,4 +1,4 @@
|
||||
export * from './billingProvider';
|
||||
export * from './billing';
|
||||
export * from './board';
|
||||
export * from './link';
|
||||
export * from './pixel';
|
||||
|
||||
Reference in New Issue
Block a user