@cogito.ai/cli 0.4.3 → 0.4.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.js +1 -1
- package/dist/templates/web-nextjs/apps/docs/.source/browser.ts +8 -18
- package/dist/templates/web-nextjs/apps/docs/.source/dynamic.ts +5 -11
- package/dist/templates/web-nextjs/apps/docs/.source/server.ts +26 -37
- package/dist/templates/web-nextjs/apps/docs/content/docs/meta.json +1 -1
- package/dist/templates/web-nextjs/apps/docs/content/docs/template/alipay.mdx +276 -0
- package/dist/templates/web-nextjs/apps/docs/content/docs/template/deployment.mdx +32 -0
- package/dist/templates/web-nextjs/apps/docs/content/docs/template/drizzle.mdx +18 -0
- package/dist/templates/web-nextjs/apps/docs/content/docs/template/getting-started.mdx +98 -0
- package/dist/templates/web-nextjs/apps/docs/content/docs/template/meta.json +13 -0
- package/dist/templates/web-nextjs/apps/docs/content/docs/template/stripe.mdx +18 -0
- package/dist/templates/web-nextjs/apps/docs/content/docs/template/supabase.mdx +130 -0
- package/dist/templates/web-nextjs/apps/docs/content/docs/template/troubleshooting.mdx +87 -0
- package/dist/templates/web-nextjs/apps/docs/content/docs/template/wechat-pay.mdx +318 -0
- package/dist/templates/web-nextjs/apps/web/.env.example +35 -0
- package/dist/templates/web-nextjs/apps/web/messages/en.json +70 -0
- package/dist/templates/web-nextjs/apps/web/messages/zh.json +71 -1
- package/dist/templates/web-nextjs/apps/web/next-env.d.ts +1 -1
- package/dist/templates/web-nextjs/apps/web/package.json +4 -0
- package/dist/templates/web-nextjs/apps/web/src/app/[locale]/(auth)/forgot-password/page.tsx +4 -10
- package/dist/templates/web-nextjs/apps/web/src/app/[locale]/(protected)/payment/[paymentId]/page.tsx +88 -0
- package/dist/templates/web-nextjs/apps/web/src/app/[locale]/(protected)/payment/checkout/page.tsx +170 -0
- package/dist/templates/web-nextjs/apps/web/src/app/[locale]/(protected)/pricing/page.tsx +120 -0
- package/dist/templates/web-nextjs/apps/web/src/app/[locale]/(protected)/settings/layout.tsx +48 -1
- package/dist/templates/web-nextjs/apps/web/src/app/[locale]/(protected)/settings/subscription/page.tsx +128 -0
- package/dist/templates/web-nextjs/apps/web/src/app/api/payments/alipay/create/route.ts +43 -0
- package/dist/templates/web-nextjs/apps/web/src/app/api/payments/alipay/notify/route.ts +105 -0
- package/dist/templates/web-nextjs/apps/web/src/app/api/payments/alipay/query/route.ts +54 -0
- package/dist/templates/web-nextjs/apps/web/src/app/api/payments/alipay/return/route.ts +20 -0
- package/dist/templates/web-nextjs/apps/web/src/app/api/payments/create/route.ts +52 -0
- package/dist/templates/web-nextjs/apps/web/src/app/api/payments/wechat/create/route.ts +58 -0
- package/dist/templates/web-nextjs/apps/web/src/app/api/payments/wechat/notify/route.ts +92 -0
- package/dist/templates/web-nextjs/apps/web/src/app/api/payments/wechat/query/route.ts +54 -0
- package/dist/templates/web-nextjs/apps/web/src/app/docs/page.tsx +5 -0
- package/dist/templates/web-nextjs/apps/web/src/features/subscription/alipay/client.ts +36 -0
- package/dist/templates/web-nextjs/apps/web/src/features/subscription/alipay/server.ts +83 -0
- package/dist/templates/web-nextjs/apps/web/src/features/subscription/components/index.ts +4 -0
- package/dist/templates/web-nextjs/apps/web/src/features/subscription/components/pro-badge.tsx +9 -0
- package/dist/templates/web-nextjs/apps/web/src/features/subscription/components/pro-feature-comparison.tsx +70 -0
- package/dist/templates/web-nextjs/apps/web/src/features/subscription/components/quota-warning-banner.tsx +37 -0
- package/dist/templates/web-nextjs/apps/web/src/features/subscription/components/upgrade-button.tsx +42 -0
- package/dist/templates/web-nextjs/apps/web/src/features/subscription/hooks.ts +141 -0
- package/dist/templates/web-nextjs/apps/web/src/features/subscription/payment-helpers.ts +27 -0
- package/dist/templates/web-nextjs/apps/web/src/features/subscription/payment-service.ts +56 -0
- package/dist/templates/web-nextjs/apps/web/src/features/subscription/service.ts +55 -0
- package/dist/templates/web-nextjs/apps/web/src/features/subscription/types.ts +73 -0
- package/dist/templates/web-nextjs/apps/web/src/features/subscription/wechat/client.ts +33 -0
- package/dist/templates/web-nextjs/apps/web/src/features/subscription/wechat/server.ts +147 -0
- package/dist/templates/web-nextjs/apps/web/src/lib/supabase/admin.ts +23 -0
- package/dist/templates/web-nextjs/apps/web/src/lib/wechat-pay/client.ts +66 -0
- package/dist/templates/web-nextjs/apps/web/src/lib/wechat-pay/crypto.ts +99 -0
- package/dist/templates/web-nextjs/apps/web/src/lib/wechat-pay/types.ts +10 -0
- package/dist/templates/web-nextjs/pnpm-lock.yaml +319 -0
- package/dist/templates/web-nextjs/pnpm-workspace.yaml +7 -8
- package/dist/templates/web-nextjs/supabase/migrations/20250608_add_subscription_tables.sql +125 -0
- package/package.json +1 -1
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { AlipaySdk } from 'alipay-sdk'
|
|
2
|
+
|
|
3
|
+
const ALIPAY_APP_ID = process.env.ALIPAY_APP_ID
|
|
4
|
+
const ALIPAY_PRIVATE_KEY = process.env.ALIPAY_PRIVATE_KEY
|
|
5
|
+
const ALIPAY_PUBLIC_KEY = process.env.ALIPAY_PUBLIC_KEY
|
|
6
|
+
const ALIPAY_GATEWAY = process.env.ALIPAY_GATEWAY || 'https://openapi.alipay.com/gateway.do'
|
|
7
|
+
|
|
8
|
+
export interface AlipayPayParams {
|
|
9
|
+
outTradeNo: string
|
|
10
|
+
totalAmount: string
|
|
11
|
+
subject: string
|
|
12
|
+
body?: string
|
|
13
|
+
returnUrl: string
|
|
14
|
+
notifyUrl: string
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function getAlipaySDK(): AlipaySdk {
|
|
18
|
+
if (!ALIPAY_APP_ID || !ALIPAY_PRIVATE_KEY || !ALIPAY_PUBLIC_KEY) {
|
|
19
|
+
throw new Error(
|
|
20
|
+
'Missing Alipay credentials. Ensure ALIPAY_APP_ID, ALIPAY_PRIVATE_KEY, and ALIPAY_PUBLIC_KEY are set.',
|
|
21
|
+
)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
return new AlipaySdk({
|
|
25
|
+
appId: ALIPAY_APP_ID,
|
|
26
|
+
privateKey: ALIPAY_PRIVATE_KEY,
|
|
27
|
+
signType: 'RSA2',
|
|
28
|
+
alipayPublicKey: ALIPAY_PUBLIC_KEY,
|
|
29
|
+
gateway: ALIPAY_GATEWAY,
|
|
30
|
+
camelcase: true,
|
|
31
|
+
})
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export async function createAlipayPagePay(params: AlipayPayParams) {
|
|
35
|
+
const alipay = getAlipaySDK()
|
|
36
|
+
const result = await alipay.exec('alipay.trade.page.pay', {
|
|
37
|
+
notify_url: params.notifyUrl,
|
|
38
|
+
return_url: params.returnUrl,
|
|
39
|
+
bizContent: {
|
|
40
|
+
out_trade_no: params.outTradeNo,
|
|
41
|
+
total_amount: params.totalAmount,
|
|
42
|
+
subject: params.subject,
|
|
43
|
+
body: params.body,
|
|
44
|
+
product_code: 'FAST_INSTANT_TRADE_PAY',
|
|
45
|
+
},
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
return { formHtml: result }
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export async function createAlipayWapPay(params: AlipayPayParams) {
|
|
52
|
+
const alipay = getAlipaySDK()
|
|
53
|
+
const result = await alipay.exec('alipay.trade.wap.pay', {
|
|
54
|
+
notify_url: params.notifyUrl,
|
|
55
|
+
return_url: params.returnUrl,
|
|
56
|
+
bizContent: {
|
|
57
|
+
out_trade_no: params.outTradeNo,
|
|
58
|
+
total_amount: params.totalAmount,
|
|
59
|
+
subject: params.subject,
|
|
60
|
+
body: params.body,
|
|
61
|
+
product_code: 'QUICK_WAP_WAY',
|
|
62
|
+
},
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
return { redirectUrl: (result as unknown as { url: string }).url }
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export async function queryAlipayOrder(outTradeNo: string) {
|
|
69
|
+
const alipay = getAlipaySDK()
|
|
70
|
+
const result = await alipay.exec('alipay.trade.query', {
|
|
71
|
+
bizContent: { out_trade_no: outTradeNo },
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
return result as unknown as {
|
|
75
|
+
tradeStatus: string
|
|
76
|
+
code: string
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export async function verifyAlipayNotify(formData: Record<string, unknown>) {
|
|
81
|
+
const alipay = getAlipaySDK()
|
|
82
|
+
return alipay.checkNotifySign(formData)
|
|
83
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useTranslations } from 'next-intl'
|
|
4
|
+
import { Check, X } from 'lucide-react'
|
|
5
|
+
|
|
6
|
+
interface Feature {
|
|
7
|
+
name: string
|
|
8
|
+
free: boolean | string
|
|
9
|
+
pro: boolean | string
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
interface ProFeatureComparisonProps {
|
|
13
|
+
features?: Feature[]
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function ProFeatureComparison({ features }: ProFeatureComparisonProps) {
|
|
17
|
+
const t = useTranslations('subscription')
|
|
18
|
+
|
|
19
|
+
const defaultFeatures: Feature[] = [
|
|
20
|
+
{ name: t('featureProjects'), free: '3', pro: '50' },
|
|
21
|
+
{ name: t('featureApiCalls'), free: '1,000', pro: '100,000' },
|
|
22
|
+
{ name: t('featureSupport'), free: false, pro: true },
|
|
23
|
+
{ name: t('featureAnalytics'), free: false, pro: true },
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
const displayFeatures = features || defaultFeatures
|
|
27
|
+
|
|
28
|
+
return (
|
|
29
|
+
<div className="w-full overflow-hidden rounded-lg border">
|
|
30
|
+
<table className="w-full text-sm">
|
|
31
|
+
<thead>
|
|
32
|
+
<tr className="border-b bg-muted/50">
|
|
33
|
+
<th className="px-4 py-3 text-left font-medium">{t('feature')}</th>
|
|
34
|
+
<th className="px-4 py-3 text-center font-medium">{t('free')}</th>
|
|
35
|
+
<th className="px-4 py-3 text-center font-medium">{t('pro')}</th>
|
|
36
|
+
</tr>
|
|
37
|
+
</thead>
|
|
38
|
+
<tbody>
|
|
39
|
+
{displayFeatures.map((feat, index) => (
|
|
40
|
+
<tr key={index} className="border-b last:border-0">
|
|
41
|
+
<td className="px-4 py-3 font-medium">{feat.name}</td>
|
|
42
|
+
<td className="px-4 py-3 text-center">
|
|
43
|
+
{typeof feat.free === 'boolean' ? (
|
|
44
|
+
feat.free ? (
|
|
45
|
+
<Check className="mx-auto h-4 w-4 text-green-500" />
|
|
46
|
+
) : (
|
|
47
|
+
<X className="mx-auto h-4 w-4 text-muted-foreground" />
|
|
48
|
+
)
|
|
49
|
+
) : (
|
|
50
|
+
<span>{feat.free}</span>
|
|
51
|
+
)}
|
|
52
|
+
</td>
|
|
53
|
+
<td className="px-4 py-3 text-center">
|
|
54
|
+
{typeof feat.pro === 'boolean' ? (
|
|
55
|
+
feat.pro ? (
|
|
56
|
+
<Check className="mx-auto h-4 w-4 text-green-500" />
|
|
57
|
+
) : (
|
|
58
|
+
<X className="mx-auto h-4 w-4 text-muted-foreground" />
|
|
59
|
+
)
|
|
60
|
+
) : (
|
|
61
|
+
<span className="font-medium">{feat.pro}</span>
|
|
62
|
+
)}
|
|
63
|
+
</td>
|
|
64
|
+
</tr>
|
|
65
|
+
))}
|
|
66
|
+
</tbody>
|
|
67
|
+
</table>
|
|
68
|
+
</div>
|
|
69
|
+
)
|
|
70
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useRouter } from 'next/navigation'
|
|
4
|
+
import { AlertTriangle } from 'lucide-react'
|
|
5
|
+
import { Button } from '@/components/ui/button'
|
|
6
|
+
|
|
7
|
+
interface QuotaWarningBannerProps {
|
|
8
|
+
quotaName?: string
|
|
9
|
+
currentUsage?: number
|
|
10
|
+
maxQuota?: number
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function QuotaWarningBanner({
|
|
14
|
+
quotaName = 'API calls',
|
|
15
|
+
currentUsage = 0,
|
|
16
|
+
maxQuota = 1000,
|
|
17
|
+
}: QuotaWarningBannerProps) {
|
|
18
|
+
const router = useRouter()
|
|
19
|
+
const percentage = (currentUsage / maxQuota) * 100
|
|
20
|
+
const isNearLimit = percentage >= 80
|
|
21
|
+
|
|
22
|
+
if (!isNearLimit) return null
|
|
23
|
+
|
|
24
|
+
return (
|
|
25
|
+
<div className="flex items-center gap-3 rounded-lg border border-amber-200 bg-amber-50 p-4 dark:border-amber-900 dark:bg-amber-950">
|
|
26
|
+
<AlertTriangle className="h-5 w-5 flex-shrink-0 text-amber-600 dark:text-amber-400" />
|
|
27
|
+
<div className="flex-1">
|
|
28
|
+
<p className="text-sm font-medium text-amber-900 dark:text-amber-200">
|
|
29
|
+
You are using {currentUsage} of {maxQuota} {quotaName} ({percentage.toFixed(0)}%)
|
|
30
|
+
</p>
|
|
31
|
+
</div>
|
|
32
|
+
<Button variant="outline" size="sm" onClick={() => router.push('/pricing')}>
|
|
33
|
+
Upgrade
|
|
34
|
+
</Button>
|
|
35
|
+
</div>
|
|
36
|
+
)
|
|
37
|
+
}
|
package/dist/templates/web-nextjs/apps/web/src/features/subscription/components/upgrade-button.tsx
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useRouter } from 'next/navigation'
|
|
4
|
+
import { Button } from '@/components/ui/button'
|
|
5
|
+
import { Zap } from 'lucide-react'
|
|
6
|
+
|
|
7
|
+
interface UpgradeButtonProps {
|
|
8
|
+
source?: string
|
|
9
|
+
feature?: string
|
|
10
|
+
trigger?: string
|
|
11
|
+
icon?: boolean
|
|
12
|
+
text?: string
|
|
13
|
+
highlight?: boolean
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function UpgradeButton({
|
|
17
|
+
source = 'default',
|
|
18
|
+
feature,
|
|
19
|
+
trigger,
|
|
20
|
+
icon = true,
|
|
21
|
+
text = 'Upgrade',
|
|
22
|
+
highlight = false,
|
|
23
|
+
}: UpgradeButtonProps) {
|
|
24
|
+
const router = useRouter()
|
|
25
|
+
|
|
26
|
+
const handleClick = () => {
|
|
27
|
+
const params = new URLSearchParams()
|
|
28
|
+
if (source) params.set('source', source)
|
|
29
|
+
if (feature) params.set('feature', feature)
|
|
30
|
+
if (trigger) params.set('trigger', trigger)
|
|
31
|
+
|
|
32
|
+
const queryString = params.toString()
|
|
33
|
+
router.push(`/pricing${queryString ? `?${queryString}` : ''}`)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return (
|
|
37
|
+
<Button onClick={handleClick} variant={highlight ? 'default' : 'outline'} size="sm">
|
|
38
|
+
{icon && <Zap className="mr-1 h-4 w-4" />}
|
|
39
|
+
{text}
|
|
40
|
+
</Button>
|
|
41
|
+
)
|
|
42
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
|
4
|
+
import type { Payment, SubscriptionPlan, UserSubscription } from './types'
|
|
5
|
+
|
|
6
|
+
function createSupabaseClient() {
|
|
7
|
+
return import('@supabase/ssr').then(({ createBrowserClient }) =>
|
|
8
|
+
createBrowserClient(
|
|
9
|
+
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
|
10
|
+
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
|
|
11
|
+
),
|
|
12
|
+
)
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function useSubscriptionPlans() {
|
|
16
|
+
return useQuery({
|
|
17
|
+
queryKey: ['subscription-plans'],
|
|
18
|
+
queryFn: async (): Promise<SubscriptionPlan[]> => {
|
|
19
|
+
const supabase = await createSupabaseClient()
|
|
20
|
+
const { data, error } = await supabase
|
|
21
|
+
.from('subscription_plans')
|
|
22
|
+
.select('*')
|
|
23
|
+
.eq('is_active', true)
|
|
24
|
+
.order('display_order', { ascending: true })
|
|
25
|
+
|
|
26
|
+
if (error) throw error
|
|
27
|
+
return (data as SubscriptionPlan[]) ?? []
|
|
28
|
+
},
|
|
29
|
+
})
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function useSubscriptionPlan(planCode: string) {
|
|
33
|
+
return useQuery({
|
|
34
|
+
queryKey: ['subscription-plan', planCode],
|
|
35
|
+
queryFn: async (): Promise<SubscriptionPlan | null> => {
|
|
36
|
+
const supabase = await createSupabaseClient()
|
|
37
|
+
const { data, error } = await supabase
|
|
38
|
+
.from('subscription_plans')
|
|
39
|
+
.select('*')
|
|
40
|
+
.eq('plan_code', planCode)
|
|
41
|
+
.single()
|
|
42
|
+
|
|
43
|
+
if (error && error.code !== 'PGRST116') throw error
|
|
44
|
+
return (data as SubscriptionPlan) ?? null
|
|
45
|
+
},
|
|
46
|
+
})
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function useUserSubscription(userId: string) {
|
|
50
|
+
return useQuery({
|
|
51
|
+
queryKey: ['user-subscription', userId],
|
|
52
|
+
queryFn: async (): Promise<UserSubscription | null> => {
|
|
53
|
+
const supabase = await createSupabaseClient()
|
|
54
|
+
const { data, error } = await supabase
|
|
55
|
+
.from('user_subscriptions')
|
|
56
|
+
.select('*')
|
|
57
|
+
.eq('user_id', userId)
|
|
58
|
+
.in('status', ['trial', 'active'])
|
|
59
|
+
.order('created_at', { ascending: false })
|
|
60
|
+
.limit(1)
|
|
61
|
+
.single()
|
|
62
|
+
|
|
63
|
+
if (error && error.code !== 'PGRST116') throw error
|
|
64
|
+
return (data as UserSubscription) ?? null
|
|
65
|
+
},
|
|
66
|
+
staleTime: 2 * 60 * 1000, // 2 minutes
|
|
67
|
+
enabled: !!userId,
|
|
68
|
+
})
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function useUserPayments(userId: string) {
|
|
72
|
+
return useQuery({
|
|
73
|
+
queryKey: ['user-payments', userId],
|
|
74
|
+
queryFn: async (): Promise<Payment[]> => {
|
|
75
|
+
const supabase = await createSupabaseClient()
|
|
76
|
+
const { data, error } = await supabase
|
|
77
|
+
.from('payments')
|
|
78
|
+
.select('*')
|
|
79
|
+
.eq('user_id', userId)
|
|
80
|
+
.order('created_at', { ascending: false })
|
|
81
|
+
|
|
82
|
+
if (error) throw error
|
|
83
|
+
return (data as Payment[]) ?? []
|
|
84
|
+
},
|
|
85
|
+
enabled: !!userId,
|
|
86
|
+
})
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
interface PaymentStatusResult {
|
|
90
|
+
status: string
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function usePaymentStatus(
|
|
94
|
+
orderNo: string,
|
|
95
|
+
paymentMethod: string,
|
|
96
|
+
options?: { enabled?: boolean },
|
|
97
|
+
) {
|
|
98
|
+
return useQuery({
|
|
99
|
+
queryKey: ['payment-status', orderNo],
|
|
100
|
+
queryFn: async (): Promise<PaymentStatusResult> => {
|
|
101
|
+
const endpoint =
|
|
102
|
+
paymentMethod === 'alipay' ? '/api/payments/alipay/query' : '/api/payments/wechat/query'
|
|
103
|
+
const res = await fetch(endpoint, {
|
|
104
|
+
method: 'POST',
|
|
105
|
+
headers: { 'Content-Type': 'application/json' },
|
|
106
|
+
body: JSON.stringify({ outTradeNo: orderNo }),
|
|
107
|
+
})
|
|
108
|
+
if (!res.ok) throw new Error('Failed to query payment status')
|
|
109
|
+
return res.json() as Promise<PaymentStatusResult>
|
|
110
|
+
},
|
|
111
|
+
refetchInterval: 3000,
|
|
112
|
+
enabled: options?.enabled ?? !!orderNo,
|
|
113
|
+
})
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
interface CreatePaymentParams {
|
|
117
|
+
userId: string
|
|
118
|
+
planId: number
|
|
119
|
+
amount: number
|
|
120
|
+
paymentMethod: string
|
|
121
|
+
billingCycle: string
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export function useCreatePayment() {
|
|
125
|
+
const queryClient = useQueryClient()
|
|
126
|
+
|
|
127
|
+
return useMutation({
|
|
128
|
+
mutationFn: async (params: CreatePaymentParams) => {
|
|
129
|
+
const res = await fetch('/api/payments/create', {
|
|
130
|
+
method: 'POST',
|
|
131
|
+
headers: { 'Content-Type': 'application/json' },
|
|
132
|
+
body: JSON.stringify(params),
|
|
133
|
+
})
|
|
134
|
+
if (!res.ok) throw new Error('Failed to create payment')
|
|
135
|
+
return res.json()
|
|
136
|
+
},
|
|
137
|
+
onSuccess: () => {
|
|
138
|
+
queryClient.invalidateQueries({ queryKey: ['user-payments'] })
|
|
139
|
+
},
|
|
140
|
+
})
|
|
141
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import crypto from 'crypto'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Generates a unique order number in the format WN{timestamp}{4-digit-random}
|
|
5
|
+
* WN = WebNextjs prefix to avoid conflicts with other projects
|
|
6
|
+
*/
|
|
7
|
+
export function generateOrderNumber(): string {
|
|
8
|
+
const timestamp = Date.now()
|
|
9
|
+
const random = Math.floor(Math.random() * 10000)
|
|
10
|
+
.toString()
|
|
11
|
+
.padStart(4, '0')
|
|
12
|
+
return `WN${timestamp}${random}`
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Formats a price in cents to a human-readable currency string (CNY)
|
|
17
|
+
*/
|
|
18
|
+
export function formatPriceInCents(cents: number): string {
|
|
19
|
+
return `¥${(cents / 100).toFixed(2)}`
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Checks if a subscription is currently active
|
|
24
|
+
*/
|
|
25
|
+
export function isSubscriptionActive(status: string): boolean {
|
|
26
|
+
return status === 'trial' || status === 'active'
|
|
27
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { getServerClient } from '@/infra/db/client'
|
|
2
|
+
import type { Payment, CreatePaymentParams } from './types'
|
|
3
|
+
|
|
4
|
+
export async function createPaymentRecord(params: CreatePaymentParams): Promise<Payment> {
|
|
5
|
+
const supabase = await getServerClient()
|
|
6
|
+
const { data, error } = await supabase
|
|
7
|
+
.from('payments')
|
|
8
|
+
.insert({
|
|
9
|
+
user_id: params.user_id,
|
|
10
|
+
subscription_id: params.subscription_id ?? null,
|
|
11
|
+
order_no: params.order_no,
|
|
12
|
+
amount: params.amount,
|
|
13
|
+
currency: params.currency ?? 'CNY',
|
|
14
|
+
payment_method: params.payment_method,
|
|
15
|
+
payment_channel: params.payment_channel ?? null,
|
|
16
|
+
status: 'pending',
|
|
17
|
+
metadata: params.metadata ?? {},
|
|
18
|
+
})
|
|
19
|
+
.select()
|
|
20
|
+
.single()
|
|
21
|
+
|
|
22
|
+
if (error) throw error
|
|
23
|
+
return data as Payment
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export async function getPaymentByOrderNo(orderNo: string): Promise<Payment | null> {
|
|
27
|
+
const supabase = await getServerClient()
|
|
28
|
+
const { data, error } = await supabase
|
|
29
|
+
.from('payments')
|
|
30
|
+
.select('*')
|
|
31
|
+
.eq('order_no', orderNo)
|
|
32
|
+
.single()
|
|
33
|
+
|
|
34
|
+
if (error && error.code !== 'PGRST116') throw error
|
|
35
|
+
return (data as Payment) ?? null
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export async function getPaymentById(id: number): Promise<Payment | null> {
|
|
39
|
+
const supabase = await getServerClient()
|
|
40
|
+
const { data, error } = await supabase.from('payments').select('*').eq('id', id).single()
|
|
41
|
+
|
|
42
|
+
if (error && error.code !== 'PGRST116') throw error
|
|
43
|
+
return (data as Payment) ?? null
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export async function getUserPayments(userId: string): Promise<Payment[]> {
|
|
47
|
+
const supabase = await getServerClient()
|
|
48
|
+
const { data, error } = await supabase
|
|
49
|
+
.from('payments')
|
|
50
|
+
.select('*')
|
|
51
|
+
.eq('user_id', userId)
|
|
52
|
+
.order('created_at', { ascending: false })
|
|
53
|
+
|
|
54
|
+
if (error) throw error
|
|
55
|
+
return (data as Payment[]) ?? []
|
|
56
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { getBrowserClient, getServerClient } from '@/infra/db/client'
|
|
2
|
+
import type { SubscriptionPlan, UserSubscription } from './types'
|
|
3
|
+
|
|
4
|
+
export async function getActiveSubscriptionPlans(): Promise<SubscriptionPlan[]> {
|
|
5
|
+
const supabase = await getServerClient()
|
|
6
|
+
const { data, error } = await supabase
|
|
7
|
+
.from('subscription_plans')
|
|
8
|
+
.select('*')
|
|
9
|
+
.eq('is_active', true)
|
|
10
|
+
.order('display_order', { ascending: true })
|
|
11
|
+
|
|
12
|
+
if (error) throw error
|
|
13
|
+
return (data as SubscriptionPlan[]) ?? []
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export async function getSubscriptionPlanByCode(
|
|
17
|
+
planCode: string,
|
|
18
|
+
): Promise<SubscriptionPlan | null> {
|
|
19
|
+
const supabase = await getServerClient()
|
|
20
|
+
const { data, error } = await supabase
|
|
21
|
+
.from('subscription_plans')
|
|
22
|
+
.select('*')
|
|
23
|
+
.eq('plan_code', planCode)
|
|
24
|
+
.single()
|
|
25
|
+
|
|
26
|
+
if (error && error.code !== 'PGRST116') throw error
|
|
27
|
+
return (data as SubscriptionPlan) ?? null
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export async function getUserActiveSubscription(userId: string): Promise<UserSubscription | null> {
|
|
31
|
+
const supabase = await getServerClient()
|
|
32
|
+
const { data, error } = await supabase
|
|
33
|
+
.from('user_subscriptions')
|
|
34
|
+
.select('*')
|
|
35
|
+
.eq('user_id', userId)
|
|
36
|
+
.in('status', ['trial', 'active'])
|
|
37
|
+
.order('created_at', { ascending: false })
|
|
38
|
+
.limit(1)
|
|
39
|
+
.single()
|
|
40
|
+
|
|
41
|
+
if (error && error.code !== 'PGRST116') throw error
|
|
42
|
+
return (data as UserSubscription) ?? null
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export async function getUserSubscriptionHistory(userId: string): Promise<UserSubscription[]> {
|
|
46
|
+
const supabase = await getServerClient()
|
|
47
|
+
const { data, error } = await supabase
|
|
48
|
+
.from('user_subscriptions')
|
|
49
|
+
.select('*')
|
|
50
|
+
.eq('user_id', userId)
|
|
51
|
+
.order('created_at', { ascending: false })
|
|
52
|
+
|
|
53
|
+
if (error) throw error
|
|
54
|
+
return (data as UserSubscription[]) ?? []
|
|
55
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
export type PlanCode = 'free' | 'pro' | 'enterprise'
|
|
2
|
+
|
|
3
|
+
export type BillingCycle = 'monthly' | 'yearly'
|
|
4
|
+
|
|
5
|
+
export type PaymentMethod = 'alipay' | 'wechat' | 'stripe'
|
|
6
|
+
|
|
7
|
+
export type PaymentStatus = 'pending' | 'paid' | 'failed' | 'refunded' | 'cancelled'
|
|
8
|
+
|
|
9
|
+
export type SubscriptionStatus = 'trial' | 'active' | 'expired' | 'cancelled' | 'pending'
|
|
10
|
+
|
|
11
|
+
export type PaymentChannel = 'alipay_page' | 'alipay_wap' | 'wechat_native' | 'wechat_h5'
|
|
12
|
+
|
|
13
|
+
export interface SubscriptionPlan {
|
|
14
|
+
id: number
|
|
15
|
+
plan_code: PlanCode
|
|
16
|
+
plan_name: string
|
|
17
|
+
description: string | null
|
|
18
|
+
price_monthly: number
|
|
19
|
+
price_yearly: number
|
|
20
|
+
features: Record<string, unknown>
|
|
21
|
+
display_order: number
|
|
22
|
+
is_active: boolean
|
|
23
|
+
is_featured: boolean
|
|
24
|
+
created_at: string
|
|
25
|
+
updated_at: string
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface UserSubscription {
|
|
29
|
+
id: number
|
|
30
|
+
user_id: string
|
|
31
|
+
plan_id: number
|
|
32
|
+
status: SubscriptionStatus
|
|
33
|
+
billing_cycle: BillingCycle
|
|
34
|
+
current_period_start: string | null
|
|
35
|
+
current_period_end: string | null
|
|
36
|
+
cancelled_at: string | null
|
|
37
|
+
cancel_at_period_end: boolean
|
|
38
|
+
last_payment_id: number | null
|
|
39
|
+
next_billing_date: string | null
|
|
40
|
+
metadata: Record<string, unknown> | null
|
|
41
|
+
created_at: string
|
|
42
|
+
updated_at: string
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface Payment {
|
|
46
|
+
id: number
|
|
47
|
+
user_id: string
|
|
48
|
+
subscription_id: number | null
|
|
49
|
+
order_no: string
|
|
50
|
+
amount: number
|
|
51
|
+
currency: string
|
|
52
|
+
payment_method: PaymentMethod
|
|
53
|
+
payment_channel: PaymentChannel | null
|
|
54
|
+
status: PaymentStatus
|
|
55
|
+
provider_trade_no: string | null
|
|
56
|
+
provider_response: Record<string, unknown> | null
|
|
57
|
+
paid_at: string | null
|
|
58
|
+
refunded_at: string | null
|
|
59
|
+
metadata: Record<string, unknown> | null
|
|
60
|
+
created_at: string
|
|
61
|
+
updated_at: string
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface CreatePaymentParams {
|
|
65
|
+
user_id: string
|
|
66
|
+
subscription_id?: number
|
|
67
|
+
order_no: string
|
|
68
|
+
amount: number
|
|
69
|
+
currency?: string
|
|
70
|
+
payment_method: PaymentMethod
|
|
71
|
+
payment_channel?: PaymentChannel
|
|
72
|
+
metadata?: Record<string, unknown>
|
|
73
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
interface WechatPayResult {
|
|
4
|
+
type: 'qrcode' | 'redirect'
|
|
5
|
+
codeUrl?: string
|
|
6
|
+
h5Url?: string
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export async function initiateWechatPayment(
|
|
10
|
+
paymentId: number,
|
|
11
|
+
userId: string,
|
|
12
|
+
): Promise<WechatPayResult> {
|
|
13
|
+
const res = await fetch('/api/payments/wechat/create', {
|
|
14
|
+
method: 'POST',
|
|
15
|
+
headers: { 'Content-Type': 'application/json' },
|
|
16
|
+
body: JSON.stringify({ paymentId, userId }),
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
if (!res.ok) {
|
|
20
|
+
throw new Error('Failed to initiate WeChat payment')
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const data = (await res.json()) as WechatPayResult
|
|
24
|
+
|
|
25
|
+
if (data.type === 'qrcode' && data.codeUrl) {
|
|
26
|
+
return { type: 'qrcode', codeUrl: data.codeUrl }
|
|
27
|
+
} else if (data.type === 'redirect' && data.h5Url) {
|
|
28
|
+
window.location.href = data.h5Url
|
|
29
|
+
return { type: 'redirect', h5Url: data.h5Url }
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
throw new Error('Unexpected WeChat payment response')
|
|
33
|
+
}
|