@cogito.ai/cli 0.4.3 → 0.4.4
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 +17 -37
- 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/[locale]/layout.tsx +1 -1
- 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/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/supabase/migrations/20250608_add_subscription_tables.sql +125 -0
- package/package.json +1 -1
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { wxpayRequest } from '@/lib/wechat-pay/client'
|
|
2
|
+
import type { WechatPayConfig } from '@/lib/wechat-pay/types'
|
|
3
|
+
import { decryptResource, verifySignature } from '@/lib/wechat-pay/crypto'
|
|
4
|
+
|
|
5
|
+
export function getWechatPayConfig(): WechatPayConfig {
|
|
6
|
+
const appId = process.env.WECHAT_PAY_APP_ID
|
|
7
|
+
const mchId = process.env.WECHAT_PAY_MCH_ID
|
|
8
|
+
const privateKey = process.env.WECHAT_PAY_PRIVATE_KEY
|
|
9
|
+
const serialNo = process.env.WECHAT_PAY_SERIAL_NO
|
|
10
|
+
const apiV3Key = process.env.WECHAT_PAY_API_V3_KEY
|
|
11
|
+
const platformPublicKey = process.env.WECHAT_PAY_PLATFORM_PUBLIC_KEY
|
|
12
|
+
const baseUrl = process.env.WECHAT_PAY_BASE_URL || 'https://api.mch.weixin.qq.com'
|
|
13
|
+
const notifyUrl = process.env.WECHAT_PAY_NOTIFY_URL
|
|
14
|
+
|
|
15
|
+
if (
|
|
16
|
+
!appId ||
|
|
17
|
+
!mchId ||
|
|
18
|
+
!privateKey ||
|
|
19
|
+
!serialNo ||
|
|
20
|
+
!apiV3Key ||
|
|
21
|
+
!platformPublicKey ||
|
|
22
|
+
!notifyUrl
|
|
23
|
+
) {
|
|
24
|
+
throw new Error(
|
|
25
|
+
'Missing WeChat Pay credentials. Ensure all WECHAT_PAY_* environment variables are set.',
|
|
26
|
+
)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
return {
|
|
30
|
+
appId,
|
|
31
|
+
mchId,
|
|
32
|
+
privateKey,
|
|
33
|
+
serialNo,
|
|
34
|
+
apiV3Key,
|
|
35
|
+
platformPublicKey,
|
|
36
|
+
baseUrl,
|
|
37
|
+
notifyUrl,
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
interface CreateWechatPayParams {
|
|
42
|
+
description: string
|
|
43
|
+
outTradeNo: string
|
|
44
|
+
notifyUrl: string
|
|
45
|
+
amount: { total: number; currency?: string }
|
|
46
|
+
sceneInfo?: object
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export async function createWechatNativePay(params: CreateWechatPayParams) {
|
|
50
|
+
const config = getWechatPayConfig()
|
|
51
|
+
const body = {
|
|
52
|
+
appid: config.appId,
|
|
53
|
+
mchid: config.mchId,
|
|
54
|
+
description: params.description,
|
|
55
|
+
out_trade_no: params.outTradeNo,
|
|
56
|
+
notify_url: params.notifyUrl,
|
|
57
|
+
amount: params.amount,
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return wxpayRequest<{ code_url: string }>({
|
|
61
|
+
config,
|
|
62
|
+
method: 'post',
|
|
63
|
+
url: '/v3/pay/transactions/native',
|
|
64
|
+
body,
|
|
65
|
+
})
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export async function createWechatH5Pay(params: CreateWechatPayParams) {
|
|
69
|
+
const config = getWechatPayConfig()
|
|
70
|
+
const body = {
|
|
71
|
+
appid: config.appId,
|
|
72
|
+
mchid: config.mchId,
|
|
73
|
+
description: params.description,
|
|
74
|
+
out_trade_no: params.outTradeNo,
|
|
75
|
+
notify_url: params.notifyUrl,
|
|
76
|
+
amount: params.amount,
|
|
77
|
+
scene_info: params.sceneInfo,
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return wxpayRequest<{ h5_url: string }>({
|
|
81
|
+
config,
|
|
82
|
+
method: 'post',
|
|
83
|
+
url: '/v3/pay/transactions/h5',
|
|
84
|
+
body,
|
|
85
|
+
})
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export async function queryWechatOrder(outTradeNo: string) {
|
|
89
|
+
const config = getWechatPayConfig()
|
|
90
|
+
return wxpayRequest<{ trade_state: string }>({
|
|
91
|
+
config,
|
|
92
|
+
method: 'get',
|
|
93
|
+
url: `/v3/pay/transactions/out-trade-no/${outTradeNo}`,
|
|
94
|
+
})
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export interface VerifyAndDecryptNotifyResult {
|
|
98
|
+
outTradeNo: string
|
|
99
|
+
tradeState: string
|
|
100
|
+
transactionId: string
|
|
101
|
+
amount: number
|
|
102
|
+
currency: string
|
|
103
|
+
appId: string
|
|
104
|
+
mchId: string
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export async function verifyAndDecryptNotify(
|
|
108
|
+
headers: Record<string, string | undefined>,
|
|
109
|
+
rawBody: string,
|
|
110
|
+
): Promise<VerifyAndDecryptNotifyResult> {
|
|
111
|
+
const config = getWechatPayConfig()
|
|
112
|
+
const timestamp = headers['wechatpay-timestamp'] || headers['Wechatpay-Timestamp'] || ''
|
|
113
|
+
const nonce = headers['wechatpay-nonce'] || headers['Wechatpay-Nonce'] || ''
|
|
114
|
+
const signature = headers['wechatpay-signature'] || headers['Wechatpay-Signature'] || ''
|
|
115
|
+
|
|
116
|
+
const isValid = verifySignature({
|
|
117
|
+
timestamp,
|
|
118
|
+
nonce,
|
|
119
|
+
body: rawBody,
|
|
120
|
+
signature,
|
|
121
|
+
platformPublicKey: config.platformPublicKey,
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
if (!isValid) {
|
|
125
|
+
throw new Error('Invalid WeChat Pay signature')
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const bodyData = JSON.parse(rawBody)
|
|
129
|
+
const resource = bodyData.resource
|
|
130
|
+
const decrypted = decryptResource({
|
|
131
|
+
ciphertext: resource.ciphertext,
|
|
132
|
+
associatedData: resource.associated_data,
|
|
133
|
+
nonce: resource.nonce,
|
|
134
|
+
apiV3Key: config.apiV3Key,
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
const transaction = JSON.parse(decrypted)
|
|
138
|
+
return {
|
|
139
|
+
outTradeNo: transaction.out_trade_no,
|
|
140
|
+
tradeState: transaction.trade_state,
|
|
141
|
+
transactionId: transaction.transaction_id,
|
|
142
|
+
amount: transaction.amount?.total,
|
|
143
|
+
currency: transaction.amount?.currency,
|
|
144
|
+
appId: transaction.appid,
|
|
145
|
+
mchId: transaction.mchid,
|
|
146
|
+
}
|
|
147
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { createClient } from '@supabase/supabase-js'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Creates a Supabase admin client using the service role key.
|
|
5
|
+
*
|
|
6
|
+
* ⚠️ WARNING: This client bypasses Row Level Security (RLS).
|
|
7
|
+
* Use ONLY in server-side contexts (Route Handlers, Server Actions).
|
|
8
|
+
* NEVER expose this client to the browser.
|
|
9
|
+
*/
|
|
10
|
+
export function createAdminClient() {
|
|
11
|
+
const url = process.env.NEXT_PUBLIC_SUPABASE_URL
|
|
12
|
+
const key = process.env.SUPABASE_SERVICE_ROLE_KEY
|
|
13
|
+
|
|
14
|
+
if (!url || !key) {
|
|
15
|
+
throw new Error(
|
|
16
|
+
'Missing Supabase admin credentials. Ensure NEXT_PUBLIC_SUPABASE_URL and SUPABASE_SERVICE_ROLE_KEY are set.',
|
|
17
|
+
)
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
return createClient(url, key, {
|
|
21
|
+
auth: { autoRefreshToken: false, persistSession: false },
|
|
22
|
+
})
|
|
23
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { buildAuthorizationHeader, generateNonce, signRequest } from './crypto'
|
|
2
|
+
import type { WechatPayConfig } from './types'
|
|
3
|
+
|
|
4
|
+
interface WxpayRequestParams<T = unknown> {
|
|
5
|
+
config: WechatPayConfig
|
|
6
|
+
method: string
|
|
7
|
+
url: string
|
|
8
|
+
body?: object
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export async function wxpayRequest<T>(params: WxpayRequestParams): Promise<T> {
|
|
12
|
+
const { config, method, url, body } = params
|
|
13
|
+
const timestamp = Math.floor(Date.now() / 1000).toString()
|
|
14
|
+
const nonce = generateNonce()
|
|
15
|
+
const bodyStr = body ? JSON.stringify(body) : ''
|
|
16
|
+
|
|
17
|
+
const signature = signRequest({
|
|
18
|
+
method,
|
|
19
|
+
url,
|
|
20
|
+
timestamp,
|
|
21
|
+
nonce,
|
|
22
|
+
body: bodyStr,
|
|
23
|
+
privateKey: config.privateKey,
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
const authorization = buildAuthorizationHeader({
|
|
27
|
+
mchId: config.mchId,
|
|
28
|
+
serialNo: config.serialNo,
|
|
29
|
+
timestamp,
|
|
30
|
+
nonce,
|
|
31
|
+
signature,
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
const headers: Record<string, string> = {
|
|
35
|
+
Accept: 'application/json',
|
|
36
|
+
Authorization: authorization,
|
|
37
|
+
'Content-Type': 'application/json',
|
|
38
|
+
'User-Agent': 'Mozilla/5.0 (compatible; WebNextjs/1.0)',
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const res = await fetch(`${config.baseUrl}${url}`, {
|
|
42
|
+
method: method.toUpperCase(),
|
|
43
|
+
headers,
|
|
44
|
+
body: bodyStr || null,
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
const data = (await res.json()) as T
|
|
48
|
+
|
|
49
|
+
// Check for WeChat Pay API errors
|
|
50
|
+
if (!res.ok) {
|
|
51
|
+
const errorData = data as Record<string, unknown>
|
|
52
|
+
const errorMessage =
|
|
53
|
+
typeof errorData.message === 'string'
|
|
54
|
+
? errorData.message
|
|
55
|
+
: `WeChat Pay API error: ${res.status}`
|
|
56
|
+
const error = new Error(errorMessage)
|
|
57
|
+
;(error as Error & { code?: string; status?: number; raw?: unknown }).code = String(
|
|
58
|
+
errorData.code || '',
|
|
59
|
+
)
|
|
60
|
+
;(error as Error & { code?: string; status?: number; raw?: unknown }).status = res.status
|
|
61
|
+
;(error as Error & { code?: string; status?: number; raw?: unknown }).raw = errorData
|
|
62
|
+
throw error
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return data
|
|
66
|
+
}
|