Skip to content

Commit 7f7d2a7

Browse files
committed
feat: add credit balance and recharge feature for billing
1 parent fe02e64 commit 7f7d2a7

File tree

11 files changed

+523
-20
lines changed

11 files changed

+523
-20
lines changed

pnpm-lock.yaml

Lines changed: 5 additions & 5 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import { useTranslation } from '@i18next-toolkit/react';
2+
import { LuCoins } from 'react-icons/lu';
3+
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
4+
import { Spinner } from '@/components/ui/spinner';
5+
import { TipIcon } from '@/components/TipIcon';
6+
import { useCurrentWorkspaceId } from '@/store/user';
7+
import { trpc } from '@/api/trpc';
8+
import React from 'react';
9+
10+
interface CreditBalanceCardProps {
11+
className?: string;
12+
refetchInterval?: number;
13+
}
14+
15+
export const CreditBalanceCard: React.FC<CreditBalanceCardProps> = React.memo(
16+
({ className, refetchInterval }: CreditBalanceCardProps) => {
17+
const { t } = useTranslation();
18+
const workspaceId = useCurrentWorkspaceId();
19+
20+
const { data, isLoading } = trpc.billing.credit.useQuery(
21+
{
22+
workspaceId,
23+
},
24+
{
25+
enabled: Boolean(workspaceId),
26+
refetchInterval,
27+
}
28+
);
29+
30+
return (
31+
<Card className={className}>
32+
<CardHeader className="flex flex-row items-center justify-between">
33+
<div className="flex items-center gap-2">
34+
<LuCoins className="h-4 w-4" />
35+
<CardTitle className="text-sm font-medium">
36+
{t('Available Credits')}
37+
</CardTitle>
38+
<TipIcon
39+
content={t(
40+
'Workspace credits can be consumed when using Tianji AI features.'
41+
)}
42+
/>
43+
</div>
44+
</CardHeader>
45+
<CardContent>
46+
{isLoading ? (
47+
<div className="text-muted-foreground flex items-center gap-2">
48+
<Spinner className="h-4 w-4" />
49+
{t('Loading...')}
50+
</div>
51+
) : (
52+
<div className="text-2xl font-semibold">{data?.credit ?? 0}</div>
53+
)}
54+
</CardContent>
55+
</Card>
56+
);
57+
}
58+
);
59+
CreditBalanceCard.displayName = 'CreditBalanceCard';
Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
import { useEffect, useState } from 'react';
2+
import { useTranslation } from '@i18next-toolkit/react';
3+
import { LuCoins, LuExternalLink, LuLoader } from 'react-icons/lu';
4+
5+
import { defaultErrorHandler, trpc } from '@/api/trpc';
6+
import { useEventWithLoading } from '@/hooks/useEvent';
7+
import { useCurrentWorkspaceId } from '@/store/user';
8+
import { Button } from '@/components/ui/button';
9+
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
10+
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
11+
import { Label } from '@/components/ui/label';
12+
import { appendUrlQueryParams } from '@/utils/url';
13+
import { cn } from '@/utils/style';
14+
import { toast } from 'sonner';
15+
import React from 'react';
16+
17+
interface CreditRechargeProps {
18+
onSuccess?: () => void;
19+
}
20+
21+
export const CreditRecharge: React.FC<CreditRechargeProps> = React.memo(
22+
({ onSuccess }: CreditRechargeProps) => {
23+
const workspaceId = useCurrentWorkspaceId();
24+
const { t } = useTranslation();
25+
const { data: packs, isLoading } = trpc.billing.creditPacks.useQuery({
26+
workspaceId,
27+
});
28+
29+
const [selectedPack, setSelectedPack] = useState<string | undefined>();
30+
31+
useEffect(() => {
32+
if (packs && packs.length > 0 && !selectedPack) {
33+
setSelectedPack(packs[0].id);
34+
}
35+
}, [packs, selectedPack]);
36+
37+
const checkoutMutation = trpc.billing.creditCheckout.useMutation({
38+
onError: defaultErrorHandler,
39+
});
40+
41+
const [handleRecharge, isSubmitting] = useEventWithLoading(async () => {
42+
if (!selectedPack) {
43+
toast.warning(t('Please select a credit pack'));
44+
return;
45+
}
46+
47+
const { url } = await checkoutMutation.mutateAsync({
48+
workspaceId,
49+
packId: selectedPack,
50+
redirectUrl: appendUrlQueryParams(location.href, {
51+
status: 'success',
52+
}),
53+
});
54+
55+
onSuccess?.();
56+
location.href = url;
57+
});
58+
59+
if (isLoading) {
60+
return (
61+
<Card>
62+
<CardHeader className="flex flex-row items-center gap-2">
63+
<LuCoins className="h-4 w-4" />
64+
<CardTitle>{t('Purchase Credits')}</CardTitle>
65+
</CardHeader>
66+
<CardContent className="text-muted-foreground flex items-center gap-2">
67+
<LuLoader className="h-4 w-4 animate-spin" />
68+
{t('Loading credit packs...')}
69+
</CardContent>
70+
</Card>
71+
);
72+
}
73+
74+
if (!packs || packs.length === 0) {
75+
return null;
76+
}
77+
78+
return (
79+
<Card>
80+
<CardHeader className="flex flex-row items-center gap-2">
81+
<LuCoins className="h-4 w-4" />
82+
<CardTitle>{t('Purchase Credits')}</CardTitle>
83+
</CardHeader>
84+
<CardContent className="space-y-4">
85+
<RadioGroup
86+
value={selectedPack}
87+
onValueChange={(value) => setSelectedPack(value)}
88+
className="flex flex-col gap-3"
89+
>
90+
{packs.map((pack) => (
91+
<Label
92+
key={pack.id}
93+
htmlFor={`credit-pack-${pack.id}`}
94+
className={cn(
95+
'flex cursor-pointer items-center justify-between rounded-md border p-4 transition-colors',
96+
selectedPack === pack.id
97+
? 'border-primary bg-primary/5'
98+
: 'border-border hover:bg-muted'
99+
)}
100+
>
101+
<div className="flex items-center gap-3">
102+
<RadioGroupItem
103+
value={pack.id}
104+
id={`credit-pack-${pack.id}`}
105+
/>
106+
<div>
107+
<div className="flex items-center gap-2">
108+
<span className="text-base font-medium">{pack.name}</span>
109+
<span className="text-muted-foreground text-sm">
110+
{pack.credit} {t('Credits')}
111+
</span>
112+
</div>
113+
</div>
114+
</div>
115+
<div className="text-right">
116+
<div className="text-lg font-semibold">
117+
{pack.currency} {pack.price.toFixed(2)}
118+
</div>
119+
</div>
120+
</Label>
121+
))}
122+
</RadioGroup>
123+
124+
<Button
125+
className="w-full"
126+
disabled={isSubmitting || checkoutMutation.isPending}
127+
loading={isSubmitting || checkoutMutation.isPending}
128+
onClick={handleRecharge}
129+
>
130+
{t('Proceed to Payment')}
131+
<LuExternalLink className="ml-2 h-4 w-4" />
132+
</Button>
133+
</CardContent>
134+
</Card>
135+
);
136+
}
137+
);
138+
CreditRecharge.displayName = 'CreditRecharge';

src/client/routes/settings/billing.tsx

Lines changed: 33 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,24 @@
1-
import { routeAuthBeforeLoad } from '@/utils/route';
1+
import { useMemo } from 'react';
2+
import { get } from 'lodash-es';
23
import { createFileRoute } from '@tanstack/react-router';
34
import { useTranslation } from '@i18next-toolkit/react';
5+
import { LuCheck, LuRefreshCw } from 'react-icons/lu';
6+
7+
import { routeAuthBeforeLoad } from '@/utils/route';
8+
import { getUrlQueryParams } from '@/utils/url';
9+
import { cn } from '@/utils/style';
410
import { CommonWrapper } from '@/components/CommonWrapper';
5-
import { ScrollArea } from '@/components/ui/scroll-area';
6-
import { trpc } from '../../api/trpc';
7-
import { useCurrentWorkspaceId } from '../../store/user';
811
import { CommonHeader } from '@/components/CommonHeader';
9-
import { Button } from '@/components/ui/button';
10-
import { useEventWithLoading } from '@/hooks/useEvent';
12+
import { CreditBalanceCard } from '@/components/billing/CreditBalanceCard';
13+
import { CreditRecharge } from '@/components/billing/CreditRecharge';
1114
import { SubscriptionSelection } from '@/components/billing/SubscriptionSelection';
12-
import { LuCheck, LuRefreshCw } from 'react-icons/lu';
13-
import { cn } from '@/utils/style';
14-
import { useMemo } from 'react';
15-
import { getUrlQueryParams } from '@/utils/url';
16-
import { get } from 'lodash-es';
1715
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
16+
import { Button } from '@/components/ui/button';
17+
import { ScrollArea } from '@/components/ui/scroll-area';
18+
import { useEventWithLoading } from '@/hooks/useEvent';
19+
import { useGlobalConfig } from '@/hooks/useConfig';
20+
import { trpc } from '../../api/trpc';
21+
import { useCurrentWorkspaceId } from '../../store/user';
1822

1923
export const Route = createFileRoute('/settings/billing')({
2024
beforeLoad: routeAuthBeforeLoad,
@@ -39,6 +43,8 @@ function PageComponent() {
3943
}
4044
);
4145

46+
const { enableAI } = useGlobalConfig();
47+
4248
const {
4349
data,
4450
refetch: refetchCurrendSubscription,
@@ -47,18 +53,23 @@ function PageComponent() {
4753
workspaceId,
4854
});
4955

56+
const creditRefetchInterval = isRechargeCallback ? 5000 : undefined;
57+
const trpcUtils = trpc.useUtils();
58+
5059
const [handleRefresh, isRefreshing] = useEventWithLoading(async () => {
5160
await Promise.all([
5261
// refresh all info
5362
refetchCurrendSubscription(),
5463
refetchCurrendTier(),
64+
trpcUtils.billing.credit.invalidate({ workspaceId }),
5565
]);
5666

5767
setTimeout(() => {
5868
Promise.all([
5969
// refresh all info
6070
refetchCurrendSubscription(),
6171
refetchCurrendTier(),
72+
trpcUtils.billing.credit.invalidate({ workspaceId }),
6273
]);
6374
}, 5000);
6475
});
@@ -89,13 +100,22 @@ function PageComponent() {
89100
<LuCheck className="h-4 w-4" />
90101
<AlertTitle>{t('Subscription Recharge Successful')}</AlertTitle>
91102
<AlertDescription>
92-
{t('It will take effect in a few minutes.')}
103+
{t('It maybe take effect in a few minutes.')}
93104
</AlertDescription>
94105
</Alert>
95106
)}
96107

108+
{enableAI && (
109+
<CreditBalanceCard
110+
className="mb-2"
111+
refetchInterval={creditRefetchInterval}
112+
/>
113+
)}
114+
97115
{isInitialLoading === false && (
98-
<div className="flex gap-2">
116+
<div className="flex flex-col gap-6">
117+
{enableAI && <CreditRecharge onSuccess={handleRefresh} />}
118+
99119
<SubscriptionSelection
100120
currentTier={currentTier}
101121
alreadySubscribed={

src/server/model/billing/credit.ts

Lines changed: 50 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,19 @@ export const tokenCreditFactor = 1.5;
66

77
export type CreditType = 'ai' | 'recharge' | 'bouns';
88

9+
export async function getWorkspaceCredit(workspaceId: string): Promise<number> {
10+
const workspace = await prisma.workspace.findUnique({
11+
where: {
12+
id: workspaceId,
13+
},
14+
select: {
15+
credit: true,
16+
},
17+
});
18+
19+
return workspace?.credit ?? 0;
20+
}
21+
922
export async function checkCredit(workspaceId: string) {
1023
const res = await prisma.workspace.findFirst({
1124
where: {
@@ -30,7 +43,7 @@ export async function checkCredit(workspaceId: string) {
3043
export async function costCredit(
3144
workspaceId: string,
3245
credit: number,
33-
type: string,
46+
type: CreditType,
3447
meta?: Record<string, any>
3548
): Promise<number> {
3649
return retry(
@@ -66,3 +79,39 @@ export async function costCredit(
6679
}
6780
);
6881
}
82+
83+
export async function addCredit(
84+
workspaceId: string,
85+
credit: number,
86+
meta?: Record<string, any>
87+
) {
88+
if (credit <= 0) {
89+
throw new Error('Credit should be greater than zero');
90+
}
91+
92+
const [res] = await prisma.$transaction([
93+
prisma.workspace.update({
94+
where: {
95+
id: workspaceId,
96+
},
97+
data: {
98+
credit: {
99+
increment: credit,
100+
},
101+
},
102+
select: {
103+
credit: true,
104+
},
105+
}),
106+
prisma.workspaceBill.create({
107+
data: {
108+
workspaceId,
109+
type: 'recharge',
110+
amount: credit,
111+
meta,
112+
},
113+
}),
114+
]);
115+
116+
return res.credit;
117+
}

0 commit comments

Comments
 (0)