@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,128 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useEffect, useState } from 'react'
|
|
4
|
+
import { useRouter } from 'next/navigation'
|
|
5
|
+
import { useTranslations } from 'next-intl'
|
|
6
|
+
import { Button } from '@/components/ui/button'
|
|
7
|
+
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
|
8
|
+
import { Badge } from '@/components/ui/badge'
|
|
9
|
+
import { Progress } from '@/components/ui/progress'
|
|
10
|
+
import { useUserSubscription, useUserPayments } from '@/features/subscription/hooks'
|
|
11
|
+
import { createBrowserClient } from '@supabase/ssr'
|
|
12
|
+
import type { Payment } from '@/features/subscription/types'
|
|
13
|
+
|
|
14
|
+
export default function SubscriptionPage() {
|
|
15
|
+
const t = useTranslations('subscription')
|
|
16
|
+
const router = useRouter()
|
|
17
|
+
const [userId, setUserId] = useState<string | null>(null)
|
|
18
|
+
|
|
19
|
+
useEffect(() => {
|
|
20
|
+
const supabase = createBrowserClient(
|
|
21
|
+
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
|
22
|
+
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
|
|
23
|
+
)
|
|
24
|
+
supabase.auth.getUser().then(({ data }) => {
|
|
25
|
+
if (data.user) {
|
|
26
|
+
setUserId(data.user.id)
|
|
27
|
+
}
|
|
28
|
+
})
|
|
29
|
+
}, [])
|
|
30
|
+
|
|
31
|
+
const { data: subscription } = useUserSubscription(userId || '')
|
|
32
|
+
const { data: payments } = useUserPayments(userId || '')
|
|
33
|
+
|
|
34
|
+
const handleCancel = async () => {
|
|
35
|
+
if (!userId) return
|
|
36
|
+
const supabase = createBrowserClient(
|
|
37
|
+
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
|
38
|
+
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
|
|
39
|
+
)
|
|
40
|
+
await supabase
|
|
41
|
+
.from('user_subscriptions')
|
|
42
|
+
.update({ cancel_at_period_end: true })
|
|
43
|
+
.eq('user_id', userId)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return (
|
|
47
|
+
<div className="space-y-6">
|
|
48
|
+
<div>
|
|
49
|
+
<h1 className="text-2xl font-bold">{t('title')}</h1>
|
|
50
|
+
<p className="text-muted-foreground">{t('description')}</p>
|
|
51
|
+
</div>
|
|
52
|
+
|
|
53
|
+
<Card>
|
|
54
|
+
<CardHeader>
|
|
55
|
+
<CardTitle>{t('currentPlan')}</CardTitle>
|
|
56
|
+
</CardHeader>
|
|
57
|
+
<CardContent className="space-y-4">
|
|
58
|
+
{subscription ? (
|
|
59
|
+
<>
|
|
60
|
+
<div className="flex items-center justify-between">
|
|
61
|
+
<span className="font-medium">{t('status')}</span>
|
|
62
|
+
<Badge variant={subscription.status === 'active' ? 'default' : 'secondary'}>
|
|
63
|
+
{t(subscription.status)}
|
|
64
|
+
</Badge>
|
|
65
|
+
</div>
|
|
66
|
+
<div className="flex items-center justify-between">
|
|
67
|
+
<span className="font-medium">{t('billingCycle')}</span>
|
|
68
|
+
<span>{t(subscription.billing_cycle)}</span>
|
|
69
|
+
</div>
|
|
70
|
+
{subscription.current_period_end && (
|
|
71
|
+
<div className="flex items-center justify-between">
|
|
72
|
+
<span className="font-medium">{t('expiresOn')}</span>
|
|
73
|
+
<span>{new Date(subscription.current_period_end).toLocaleDateString()}</span>
|
|
74
|
+
</div>
|
|
75
|
+
)}
|
|
76
|
+
{!subscription.cancel_at_period_end && (
|
|
77
|
+
<Button variant="outline" onClick={handleCancel}>
|
|
78
|
+
{t('cancelRenewal')}
|
|
79
|
+
</Button>
|
|
80
|
+
)}
|
|
81
|
+
{subscription.cancel_at_period_end && (
|
|
82
|
+
<p className="text-sm text-muted-foreground">{t('cancelledAtPeriodEnd')}</p>
|
|
83
|
+
)}
|
|
84
|
+
</>
|
|
85
|
+
) : (
|
|
86
|
+
<div className="text-center py-6">
|
|
87
|
+
<p className="text-muted-foreground mb-4">{t('noActiveSubscription')}</p>
|
|
88
|
+
<Button onClick={() => router.push('/pricing')}>{t('upgradeNow')}</Button>
|
|
89
|
+
</div>
|
|
90
|
+
)}
|
|
91
|
+
</CardContent>
|
|
92
|
+
</Card>
|
|
93
|
+
|
|
94
|
+
<Card>
|
|
95
|
+
<CardHeader>
|
|
96
|
+
<CardTitle>{t('paymentHistory')}</CardTitle>
|
|
97
|
+
</CardHeader>
|
|
98
|
+
<CardContent>
|
|
99
|
+
{payments && payments.length > 0 ? (
|
|
100
|
+
<div className="space-y-3">
|
|
101
|
+
{payments.map((payment: Payment) => (
|
|
102
|
+
<div
|
|
103
|
+
key={payment.id}
|
|
104
|
+
className="flex items-center justify-between border-b py-3 last:border-0"
|
|
105
|
+
>
|
|
106
|
+
<div>
|
|
107
|
+
<p className="font-medium">{payment.order_no}</p>
|
|
108
|
+
<p className="text-sm text-muted-foreground">
|
|
109
|
+
{new Date(payment.created_at).toLocaleDateString()}
|
|
110
|
+
</p>
|
|
111
|
+
</div>
|
|
112
|
+
<div className="text-right">
|
|
113
|
+
<p className="font-medium">¥{(payment.amount / 100).toFixed(2)}</p>
|
|
114
|
+
<Badge variant={payment.status === 'paid' ? 'default' : 'secondary'}>
|
|
115
|
+
{t(payment.status)}
|
|
116
|
+
</Badge>
|
|
117
|
+
</div>
|
|
118
|
+
</div>
|
|
119
|
+
))}
|
|
120
|
+
</div>
|
|
121
|
+
) : (
|
|
122
|
+
<p className="text-muted-foreground text-center py-6">{t('noPayments')}</p>
|
|
123
|
+
)}
|
|
124
|
+
</CardContent>
|
|
125
|
+
</Card>
|
|
126
|
+
</div>
|
|
127
|
+
)
|
|
128
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { NextRequest, NextResponse } from 'next/server'
|
|
2
|
+
import { createAlipayPagePay, createAlipayWapPay } from '@/features/subscription/alipay/server'
|
|
3
|
+
import { getPaymentById } from '@/features/subscription/payment-service'
|
|
4
|
+
|
|
5
|
+
export async function POST(req: NextRequest) {
|
|
6
|
+
try {
|
|
7
|
+
const { paymentId } = (await req.json()) as { paymentId: number }
|
|
8
|
+
|
|
9
|
+
const payment = await getPaymentById(paymentId)
|
|
10
|
+
if (!payment) {
|
|
11
|
+
return NextResponse.json({ error: 'Payment not found' }, { status: 404 })
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const userAgent = req.headers.get('user-agent') || ''
|
|
15
|
+
const isMobile = /Mobile|Android|iPhone/i.test(userAgent)
|
|
16
|
+
|
|
17
|
+
const notifyUrl = `${process.env.NEXT_PUBLIC_APP_URL}/api/payments/alipay/notify`
|
|
18
|
+
const returnUrl = `${process.env.NEXT_PUBLIC_APP_URL}/payment/${payment.id}`
|
|
19
|
+
|
|
20
|
+
if (isMobile) {
|
|
21
|
+
const result = await createAlipayWapPay({
|
|
22
|
+
outTradeNo: payment.order_no,
|
|
23
|
+
totalAmount: String(payment.amount / 100),
|
|
24
|
+
subject: 'Subscription',
|
|
25
|
+
returnUrl,
|
|
26
|
+
notifyUrl,
|
|
27
|
+
})
|
|
28
|
+
return NextResponse.json({ redirectUrl: result.redirectUrl })
|
|
29
|
+
} else {
|
|
30
|
+
const result = await createAlipayPagePay({
|
|
31
|
+
outTradeNo: payment.order_no,
|
|
32
|
+
totalAmount: String(payment.amount / 100),
|
|
33
|
+
subject: 'Subscription',
|
|
34
|
+
returnUrl,
|
|
35
|
+
notifyUrl,
|
|
36
|
+
})
|
|
37
|
+
return NextResponse.json({ formHtml: result.formHtml })
|
|
38
|
+
}
|
|
39
|
+
} catch (error) {
|
|
40
|
+
console.error('Alipay create error:', error)
|
|
41
|
+
return NextResponse.json({ error: 'Failed to create Alipay payment' }, { status: 500 })
|
|
42
|
+
}
|
|
43
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { NextRequest, NextResponse } from 'next/server'
|
|
2
|
+
import { verifyAlipayNotify } from '@/features/subscription/alipay/server'
|
|
3
|
+
import { createAdminClient } from '@/lib/supabase/admin'
|
|
4
|
+
import { getPaymentByOrderNo } from '@/features/subscription/payment-service'
|
|
5
|
+
|
|
6
|
+
export async function POST(req: NextRequest) {
|
|
7
|
+
try {
|
|
8
|
+
const formData = await req.formData()
|
|
9
|
+
const data: Record<string, unknown> = {}
|
|
10
|
+
formData.forEach((value, key) => {
|
|
11
|
+
data[key] = value
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
const isValid = await verifyAlipayNotify(data)
|
|
15
|
+
if (!isValid) {
|
|
16
|
+
return new NextResponse('fail', { status: 400 })
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const outTradeNo = data.out_trade_no as string
|
|
20
|
+
const tradeStatus = data.trade_status as string
|
|
21
|
+
const tradeNo = data.trade_no as string
|
|
22
|
+
const totalAmount = data.total_amount as string
|
|
23
|
+
const appId = data.app_id as string
|
|
24
|
+
const sellerId = data.seller_id as string
|
|
25
|
+
|
|
26
|
+
// Validate critical business fields against the local order
|
|
27
|
+
const payment = await getPaymentByOrderNo(outTradeNo)
|
|
28
|
+
if (!payment) {
|
|
29
|
+
console.warn(`Alipay notify: payment not found for order ${outTradeNo}`)
|
|
30
|
+
return new NextResponse('success', { status: 200 })
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Verify app_id matches our configured app
|
|
34
|
+
if (appId !== process.env.ALIPAY_APP_ID) {
|
|
35
|
+
console.warn(
|
|
36
|
+
`Alipay notify: app_id mismatch. received=${appId}, expected=${process.env.ALIPAY_APP_ID}`,
|
|
37
|
+
)
|
|
38
|
+
return new NextResponse('fail', { status: 400 })
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Verify amount matches the local order (convert to cents for comparison)
|
|
42
|
+
const notifyAmountCents = Math.round(parseFloat(totalAmount) * 100)
|
|
43
|
+
if (notifyAmountCents !== payment.amount) {
|
|
44
|
+
console.warn(
|
|
45
|
+
`Alipay notify: amount mismatch. order=${outTradeNo}, expected=${payment.amount}, received=${notifyAmountCents}`,
|
|
46
|
+
)
|
|
47
|
+
return new NextResponse('fail', { status: 400 })
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Verify seller_id if configured
|
|
51
|
+
if (process.env.ALIPAY_SELLER_ID && sellerId !== process.env.ALIPAY_SELLER_ID) {
|
|
52
|
+
console.warn(`Alipay notify: seller_id mismatch. received=${sellerId}`)
|
|
53
|
+
return new NextResponse('fail', { status: 400 })
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (tradeStatus !== 'TRADE_SUCCESS') {
|
|
57
|
+
return new NextResponse('success', { status: 200 })
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Idempotency: already paid
|
|
61
|
+
if (payment.status === 'paid') {
|
|
62
|
+
return new NextResponse('success', { status: 200 })
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const supabase = createAdminClient()
|
|
66
|
+
|
|
67
|
+
// Update payment with provider_trade_no unique constraint check
|
|
68
|
+
const { error: updateError } = await supabase
|
|
69
|
+
.from('payments')
|
|
70
|
+
.update({
|
|
71
|
+
status: 'paid',
|
|
72
|
+
provider_trade_no: tradeNo,
|
|
73
|
+
paid_at: new Date().toISOString(),
|
|
74
|
+
provider_response: data as Record<string, unknown>,
|
|
75
|
+
})
|
|
76
|
+
.eq('order_no', outTradeNo)
|
|
77
|
+
.eq('status', 'pending')
|
|
78
|
+
|
|
79
|
+
if (updateError) {
|
|
80
|
+
console.error('Alipay notify: failed to update payment', updateError)
|
|
81
|
+
return new NextResponse('fail', { status: 500 })
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Update subscription
|
|
85
|
+
if (payment.subscription_id) {
|
|
86
|
+
const now = new Date()
|
|
87
|
+
const nextMonth = new Date(now)
|
|
88
|
+
nextMonth.setMonth(nextMonth.getMonth() + 1)
|
|
89
|
+
|
|
90
|
+
await supabase
|
|
91
|
+
.from('user_subscriptions')
|
|
92
|
+
.update({
|
|
93
|
+
status: 'active',
|
|
94
|
+
current_period_start: now.toISOString(),
|
|
95
|
+
current_period_end: nextMonth.toISOString(),
|
|
96
|
+
})
|
|
97
|
+
.eq('id', payment.subscription_id)
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return new NextResponse('success', { status: 200 })
|
|
101
|
+
} catch (error) {
|
|
102
|
+
console.error('Alipay notify error:', error)
|
|
103
|
+
return new NextResponse('fail', { status: 500 })
|
|
104
|
+
}
|
|
105
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { NextRequest, NextResponse } from 'next/server'
|
|
2
|
+
import { queryAlipayOrder } from '@/features/subscription/alipay/server'
|
|
3
|
+
import { getPaymentByOrderNo } from '@/features/subscription/payment-service'
|
|
4
|
+
import { getServerClient } from '@/infra/db/client'
|
|
5
|
+
|
|
6
|
+
function normalizeAlipayStatus(tradeStatus: string): string {
|
|
7
|
+
switch (tradeStatus) {
|
|
8
|
+
case 'TRADE_SUCCESS':
|
|
9
|
+
case 'TRADE_FINISHED':
|
|
10
|
+
return 'paid'
|
|
11
|
+
case 'TRADE_CLOSED':
|
|
12
|
+
return 'failed'
|
|
13
|
+
case 'WAIT_BUYER_PAY':
|
|
14
|
+
default:
|
|
15
|
+
return 'pending'
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export async function POST(req: NextRequest) {
|
|
20
|
+
try {
|
|
21
|
+
const supabase = await getServerClient()
|
|
22
|
+
const {
|
|
23
|
+
data: { user },
|
|
24
|
+
} = await supabase.auth.getUser()
|
|
25
|
+
|
|
26
|
+
if (!user) {
|
|
27
|
+
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const { outTradeNo } = (await req.json()) as { outTradeNo: string }
|
|
31
|
+
|
|
32
|
+
// Verify order ownership
|
|
33
|
+
const payment = await getPaymentByOrderNo(outTradeNo)
|
|
34
|
+
if (!payment) {
|
|
35
|
+
return NextResponse.json({ error: 'Payment not found' }, { status: 404 })
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if (payment.user_id !== user.id) {
|
|
39
|
+
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const result = await queryAlipayOrder(outTradeNo)
|
|
43
|
+
const normalizedStatus = normalizeAlipayStatus(result.tradeStatus)
|
|
44
|
+
|
|
45
|
+
return NextResponse.json({
|
|
46
|
+
status: normalizedStatus,
|
|
47
|
+
tradeStatus: result.tradeStatus,
|
|
48
|
+
code: result.code,
|
|
49
|
+
})
|
|
50
|
+
} catch (error) {
|
|
51
|
+
console.error('Alipay query error:', error)
|
|
52
|
+
return NextResponse.json({ error: 'Failed to query Alipay order' }, { status: 500 })
|
|
53
|
+
}
|
|
54
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { NextRequest, NextResponse } from 'next/server'
|
|
2
|
+
import { getPaymentByOrderNo } from '@/features/subscription/payment-service'
|
|
3
|
+
|
|
4
|
+
export async function GET(req: NextRequest) {
|
|
5
|
+
const url = new URL(req.url)
|
|
6
|
+
const outTradeNo = url.searchParams.get('out_trade_no')
|
|
7
|
+
|
|
8
|
+
if (!outTradeNo) {
|
|
9
|
+
return NextResponse.redirect(new URL('/pricing', req.url))
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
// Lookup payment by order_no to get the numeric ID for the status page
|
|
13
|
+
const payment = await getPaymentByOrderNo(outTradeNo)
|
|
14
|
+
if (!payment) {
|
|
15
|
+
return NextResponse.redirect(new URL('/pricing', req.url))
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// Redirect to payment status page using the numeric payment ID
|
|
19
|
+
return NextResponse.redirect(new URL(`/payment/${payment.id}`, req.url))
|
|
20
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { NextRequest, NextResponse } from 'next/server'
|
|
2
|
+
import { createPaymentRecord, getPaymentByOrderNo } from '@/features/subscription/payment-service'
|
|
3
|
+
import { getServerClient } from '@/infra/db/client'
|
|
4
|
+
import { generateOrderNumber } from '@/features/subscription/payment-helpers'
|
|
5
|
+
|
|
6
|
+
export async function POST(req: NextRequest) {
|
|
7
|
+
try {
|
|
8
|
+
const supabase = await getServerClient()
|
|
9
|
+
const {
|
|
10
|
+
data: { user },
|
|
11
|
+
} = await supabase.auth.getUser()
|
|
12
|
+
|
|
13
|
+
if (!user) {
|
|
14
|
+
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const body = (await req.json()) as {
|
|
18
|
+
planId: number
|
|
19
|
+
amount: number
|
|
20
|
+
paymentMethod: string
|
|
21
|
+
billingCycle: string
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
if (!body.planId || !body.amount || !body.paymentMethod || !body.billingCycle) {
|
|
25
|
+
return NextResponse.json({ error: 'Missing required fields' }, { status: 400 })
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const orderNo = generateOrderNumber()
|
|
29
|
+
|
|
30
|
+
const payment = await createPaymentRecord({
|
|
31
|
+
user_id: user.id,
|
|
32
|
+
order_no: orderNo,
|
|
33
|
+
amount: body.amount,
|
|
34
|
+
currency: 'CNY',
|
|
35
|
+
payment_method: body.paymentMethod as 'alipay' | 'wechat',
|
|
36
|
+
metadata: {
|
|
37
|
+
plan_id: body.planId,
|
|
38
|
+
billing_cycle: body.billingCycle,
|
|
39
|
+
},
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
return NextResponse.json({
|
|
43
|
+
id: payment.id,
|
|
44
|
+
orderNo: payment.order_no,
|
|
45
|
+
amount: payment.amount,
|
|
46
|
+
status: payment.status,
|
|
47
|
+
})
|
|
48
|
+
} catch (error) {
|
|
49
|
+
console.error('Payment creation error:', error)
|
|
50
|
+
return NextResponse.json({ error: 'Failed to create payment' }, { status: 500 })
|
|
51
|
+
}
|
|
52
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { NextRequest, NextResponse } from 'next/server'
|
|
2
|
+
import { createWechatNativePay, createWechatH5Pay } from '@/features/subscription/wechat/server'
|
|
3
|
+
import { getPaymentById } from '@/features/subscription/payment-service'
|
|
4
|
+
|
|
5
|
+
export async function POST(req: NextRequest) {
|
|
6
|
+
try {
|
|
7
|
+
const { paymentId } = (await req.json()) as { paymentId: number }
|
|
8
|
+
|
|
9
|
+
const payment = await getPaymentById(paymentId)
|
|
10
|
+
if (!payment) {
|
|
11
|
+
return NextResponse.json({ error: 'Payment not found' }, { status: 404 })
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const userAgent = req.headers.get('user-agent') || ''
|
|
15
|
+
const isMobile = /Mobile|Android|iPhone/i.test(userAgent)
|
|
16
|
+
|
|
17
|
+
// In development, always use native (qrcode) even on mobile (D3)
|
|
18
|
+
const forceNative = process.env.NODE_ENV === 'development' && isMobile
|
|
19
|
+
|
|
20
|
+
const notifyUrl = `${process.env.NEXT_PUBLIC_APP_URL}/api/payments/wechat/notify`
|
|
21
|
+
|
|
22
|
+
if (!isMobile || forceNative) {
|
|
23
|
+
// Native (PC) - return QR code URL
|
|
24
|
+
const result = await createWechatNativePay({
|
|
25
|
+
description: 'Subscription',
|
|
26
|
+
outTradeNo: payment.order_no,
|
|
27
|
+
notifyUrl,
|
|
28
|
+
amount: { total: payment.amount, currency: 'CNY' },
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
if (forceNative) {
|
|
32
|
+
return NextResponse.json({
|
|
33
|
+
type: 'qrcode',
|
|
34
|
+
codeUrl: result.code_url,
|
|
35
|
+
warning: 'H5 disabled in dev',
|
|
36
|
+
})
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return NextResponse.json({ type: 'qrcode', codeUrl: result.code_url })
|
|
40
|
+
} else {
|
|
41
|
+
// H5 (mobile)
|
|
42
|
+
const result = await createWechatH5Pay({
|
|
43
|
+
description: 'Subscription',
|
|
44
|
+
outTradeNo: payment.order_no,
|
|
45
|
+
notifyUrl,
|
|
46
|
+
amount: { total: payment.amount, currency: 'CNY' },
|
|
47
|
+
sceneInfo: {
|
|
48
|
+
payer_client_ip: req.headers.get('x-forwarded-for')?.split(',')[0]?.trim() || '127.0.0.1',
|
|
49
|
+
},
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
return NextResponse.json({ type: 'redirect', h5Url: result.h5_url })
|
|
53
|
+
}
|
|
54
|
+
} catch (error) {
|
|
55
|
+
console.error('WeChat create error:', error)
|
|
56
|
+
return NextResponse.json({ error: 'Failed to create WeChat payment' }, { status: 500 })
|
|
57
|
+
}
|
|
58
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { NextRequest, NextResponse } from 'next/server'
|
|
2
|
+
import { verifyAndDecryptNotify } from '@/features/subscription/wechat/server'
|
|
3
|
+
import { createAdminClient } from '@/lib/supabase/admin'
|
|
4
|
+
import { getPaymentByOrderNo } from '@/features/subscription/payment-service'
|
|
5
|
+
|
|
6
|
+
export async function POST(req: NextRequest) {
|
|
7
|
+
try {
|
|
8
|
+
const rawBody = await req.text()
|
|
9
|
+
const headers: Record<string, string | undefined> = {
|
|
10
|
+
'wechatpay-timestamp': req.headers.get('wechatpay-timestamp') || undefined,
|
|
11
|
+
'wechatpay-nonce': req.headers.get('wechatpay-nonce') || undefined,
|
|
12
|
+
'wechatpay-signature': req.headers.get('wechatpay-signature') || undefined,
|
|
13
|
+
'wechatpay-serial': req.headers.get('wechatpay-serial') || undefined,
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const result = await verifyAndDecryptNotify(headers, rawBody)
|
|
17
|
+
|
|
18
|
+
if (result.tradeState !== 'SUCCESS') {
|
|
19
|
+
return NextResponse.json({ code: 'SUCCESS', message: '成功' })
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const payment = await getPaymentByOrderNo(result.outTradeNo)
|
|
23
|
+
if (!payment) {
|
|
24
|
+
console.warn(`WeChat notify: payment not found for order ${result.outTradeNo}`)
|
|
25
|
+
return NextResponse.json({ code: 'SUCCESS', message: '成功' })
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Validate mch_id matches our configured merchant
|
|
29
|
+
if (result.mchId !== process.env.WECHAT_PAY_MCH_ID) {
|
|
30
|
+
console.warn(`WeChat notify: mch_id mismatch. received=${result.mchId}`)
|
|
31
|
+
return NextResponse.json({ code: 'FAIL', message: 'mch_id mismatch' }, { status: 400 })
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Validate appid matches our configured app
|
|
35
|
+
if (result.appId !== process.env.WECHAT_PAY_APP_ID) {
|
|
36
|
+
console.warn(`WeChat notify: appid mismatch. received=${result.appId}`)
|
|
37
|
+
return NextResponse.json({ code: 'FAIL', message: 'appid mismatch' }, { status: 400 })
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Validate amount matches the local order
|
|
41
|
+
if (result.amount !== payment.amount) {
|
|
42
|
+
console.warn(
|
|
43
|
+
`WeChat notify: amount mismatch. order=${result.outTradeNo}, expected=${payment.amount}, received=${result.amount}`,
|
|
44
|
+
)
|
|
45
|
+
return NextResponse.json({ code: 'FAIL', message: 'amount mismatch' }, { status: 400 })
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Idempotency
|
|
49
|
+
if (payment.status === 'paid') {
|
|
50
|
+
return NextResponse.json({ code: 'SUCCESS', message: '成功' })
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const supabase = createAdminClient()
|
|
54
|
+
|
|
55
|
+
// Update payment with provider_trade_no and conditional update
|
|
56
|
+
const { error: updateError } = await supabase
|
|
57
|
+
.from('payments')
|
|
58
|
+
.update({
|
|
59
|
+
status: 'paid',
|
|
60
|
+
provider_trade_no: result.transactionId,
|
|
61
|
+
paid_at: new Date().toISOString(),
|
|
62
|
+
})
|
|
63
|
+
.eq('order_no', result.outTradeNo)
|
|
64
|
+
.eq('status', 'pending')
|
|
65
|
+
|
|
66
|
+
if (updateError) {
|
|
67
|
+
console.error('WeChat notify: failed to update payment', updateError)
|
|
68
|
+
return NextResponse.json({ code: 'FAIL', message: 'update failed' }, { status: 500 })
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Update subscription
|
|
72
|
+
if (payment.subscription_id) {
|
|
73
|
+
const now = new Date()
|
|
74
|
+
const nextMonth = new Date(now)
|
|
75
|
+
nextMonth.setMonth(nextMonth.getMonth() + 1)
|
|
76
|
+
|
|
77
|
+
await supabase
|
|
78
|
+
.from('user_subscriptions')
|
|
79
|
+
.update({
|
|
80
|
+
status: 'active',
|
|
81
|
+
current_period_start: now.toISOString(),
|
|
82
|
+
current_period_end: nextMonth.toISOString(),
|
|
83
|
+
})
|
|
84
|
+
.eq('id', payment.subscription_id)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return NextResponse.json({ code: 'SUCCESS', message: '成功' })
|
|
88
|
+
} catch (error) {
|
|
89
|
+
console.error('WeChat notify error:', error)
|
|
90
|
+
return NextResponse.json({ code: 'FAIL', message: (error as Error).message }, { status: 500 })
|
|
91
|
+
}
|
|
92
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { NextRequest, NextResponse } from 'next/server'
|
|
2
|
+
import { queryWechatOrder } from '@/features/subscription/wechat/server'
|
|
3
|
+
import { getPaymentByOrderNo } from '@/features/subscription/payment-service'
|
|
4
|
+
import { getServerClient } from '@/infra/db/client'
|
|
5
|
+
|
|
6
|
+
function normalizeWechatStatus(tradeState: string): string {
|
|
7
|
+
switch (tradeState) {
|
|
8
|
+
case 'SUCCESS':
|
|
9
|
+
return 'paid'
|
|
10
|
+
case 'CLOSED':
|
|
11
|
+
case 'REVOKED':
|
|
12
|
+
return 'failed'
|
|
13
|
+
case 'NOTPAY':
|
|
14
|
+
case 'USERPAYING':
|
|
15
|
+
default:
|
|
16
|
+
return 'pending'
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export async function POST(req: NextRequest) {
|
|
21
|
+
try {
|
|
22
|
+
const supabase = await getServerClient()
|
|
23
|
+
const {
|
|
24
|
+
data: { user },
|
|
25
|
+
} = await supabase.auth.getUser()
|
|
26
|
+
|
|
27
|
+
if (!user) {
|
|
28
|
+
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const { outTradeNo } = (await req.json()) as { outTradeNo: string }
|
|
32
|
+
|
|
33
|
+
// Verify order ownership
|
|
34
|
+
const payment = await getPaymentByOrderNo(outTradeNo)
|
|
35
|
+
if (!payment) {
|
|
36
|
+
return NextResponse.json({ error: 'Payment not found' }, { status: 404 })
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
if (payment.user_id !== user.id) {
|
|
40
|
+
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const result = await queryWechatOrder(outTradeNo)
|
|
44
|
+
const normalizedStatus = normalizeWechatStatus(result.trade_state)
|
|
45
|
+
|
|
46
|
+
return NextResponse.json({
|
|
47
|
+
status: normalizedStatus,
|
|
48
|
+
tradeState: result.trade_state,
|
|
49
|
+
})
|
|
50
|
+
} catch (error) {
|
|
51
|
+
console.error('WeChat query error:', error)
|
|
52
|
+
return NextResponse.json({ error: 'Failed to query WeChat order' }, { status: 500 })
|
|
53
|
+
}
|
|
54
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
interface InitiateAlipayPaymentResult {
|
|
4
|
+
formHtml?: string
|
|
5
|
+
redirectUrl?: string
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export async function initiateAlipayPayment(paymentId: number, userId: string): Promise<void> {
|
|
9
|
+
const res = await fetch('/api/payments/alipay/create', {
|
|
10
|
+
method: 'POST',
|
|
11
|
+
headers: { 'Content-Type': 'application/json' },
|
|
12
|
+
body: JSON.stringify({ paymentId, userId }),
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
if (!res.ok) {
|
|
16
|
+
throw new Error('Failed to initiate Alipay payment')
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const data: InitiateAlipayPaymentResult = await res.json()
|
|
20
|
+
|
|
21
|
+
if (data.formHtml) {
|
|
22
|
+
// PC page pay: inject form HTML and auto-submit
|
|
23
|
+
const div = document.createElement('div')
|
|
24
|
+
div.innerHTML = data.formHtml
|
|
25
|
+
document.body.appendChild(div)
|
|
26
|
+
const form = div.querySelector('form')
|
|
27
|
+
if (form) {
|
|
28
|
+
form.submit()
|
|
29
|
+
}
|
|
30
|
+
} else if (data.redirectUrl) {
|
|
31
|
+
// H5 wap pay: redirect
|
|
32
|
+
window.location.href = data.redirectUrl
|
|
33
|
+
} else {
|
|
34
|
+
throw new Error('Unexpected Alipay response')
|
|
35
|
+
}
|
|
36
|
+
}
|