@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.
Files changed (45) hide show
  1. package/dist/index.js +1 -1
  2. package/dist/templates/web-nextjs/apps/docs/.source/browser.ts +8 -18
  3. package/dist/templates/web-nextjs/apps/docs/.source/dynamic.ts +5 -11
  4. package/dist/templates/web-nextjs/apps/docs/.source/server.ts +17 -37
  5. package/dist/templates/web-nextjs/apps/web/.env.example +35 -0
  6. package/dist/templates/web-nextjs/apps/web/messages/en.json +70 -0
  7. package/dist/templates/web-nextjs/apps/web/messages/zh.json +71 -1
  8. package/dist/templates/web-nextjs/apps/web/next-env.d.ts +1 -1
  9. package/dist/templates/web-nextjs/apps/web/package.json +4 -0
  10. package/dist/templates/web-nextjs/apps/web/src/app/[locale]/(auth)/forgot-password/page.tsx +4 -10
  11. package/dist/templates/web-nextjs/apps/web/src/app/[locale]/(protected)/payment/[paymentId]/page.tsx +88 -0
  12. package/dist/templates/web-nextjs/apps/web/src/app/[locale]/(protected)/payment/checkout/page.tsx +170 -0
  13. package/dist/templates/web-nextjs/apps/web/src/app/[locale]/(protected)/pricing/page.tsx +120 -0
  14. package/dist/templates/web-nextjs/apps/web/src/app/[locale]/(protected)/settings/layout.tsx +48 -1
  15. package/dist/templates/web-nextjs/apps/web/src/app/[locale]/(protected)/settings/subscription/page.tsx +128 -0
  16. package/dist/templates/web-nextjs/apps/web/src/app/[locale]/layout.tsx +1 -1
  17. package/dist/templates/web-nextjs/apps/web/src/app/api/payments/alipay/create/route.ts +43 -0
  18. package/dist/templates/web-nextjs/apps/web/src/app/api/payments/alipay/notify/route.ts +105 -0
  19. package/dist/templates/web-nextjs/apps/web/src/app/api/payments/alipay/query/route.ts +54 -0
  20. package/dist/templates/web-nextjs/apps/web/src/app/api/payments/alipay/return/route.ts +20 -0
  21. package/dist/templates/web-nextjs/apps/web/src/app/api/payments/create/route.ts +52 -0
  22. package/dist/templates/web-nextjs/apps/web/src/app/api/payments/wechat/create/route.ts +58 -0
  23. package/dist/templates/web-nextjs/apps/web/src/app/api/payments/wechat/notify/route.ts +92 -0
  24. package/dist/templates/web-nextjs/apps/web/src/app/api/payments/wechat/query/route.ts +54 -0
  25. package/dist/templates/web-nextjs/apps/web/src/features/subscription/alipay/client.ts +36 -0
  26. package/dist/templates/web-nextjs/apps/web/src/features/subscription/alipay/server.ts +83 -0
  27. package/dist/templates/web-nextjs/apps/web/src/features/subscription/components/index.ts +4 -0
  28. package/dist/templates/web-nextjs/apps/web/src/features/subscription/components/pro-badge.tsx +9 -0
  29. package/dist/templates/web-nextjs/apps/web/src/features/subscription/components/pro-feature-comparison.tsx +70 -0
  30. package/dist/templates/web-nextjs/apps/web/src/features/subscription/components/quota-warning-banner.tsx +37 -0
  31. package/dist/templates/web-nextjs/apps/web/src/features/subscription/components/upgrade-button.tsx +42 -0
  32. package/dist/templates/web-nextjs/apps/web/src/features/subscription/hooks.ts +141 -0
  33. package/dist/templates/web-nextjs/apps/web/src/features/subscription/payment-helpers.ts +27 -0
  34. package/dist/templates/web-nextjs/apps/web/src/features/subscription/payment-service.ts +56 -0
  35. package/dist/templates/web-nextjs/apps/web/src/features/subscription/service.ts +55 -0
  36. package/dist/templates/web-nextjs/apps/web/src/features/subscription/types.ts +73 -0
  37. package/dist/templates/web-nextjs/apps/web/src/features/subscription/wechat/client.ts +33 -0
  38. package/dist/templates/web-nextjs/apps/web/src/features/subscription/wechat/server.ts +147 -0
  39. package/dist/templates/web-nextjs/apps/web/src/lib/supabase/admin.ts +23 -0
  40. package/dist/templates/web-nextjs/apps/web/src/lib/wechat-pay/client.ts +66 -0
  41. package/dist/templates/web-nextjs/apps/web/src/lib/wechat-pay/crypto.ts +99 -0
  42. package/dist/templates/web-nextjs/apps/web/src/lib/wechat-pay/types.ts +10 -0
  43. package/dist/templates/web-nextjs/pnpm-lock.yaml +319 -0
  44. package/dist/templates/web-nextjs/supabase/migrations/20250608_add_subscription_tables.sql +125 -0
  45. package/package.json +1 -1
@@ -0,0 +1,170 @@
1
+ 'use client'
2
+
3
+ import { useState } from 'react'
4
+ import { useRouter, useSearchParams } 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 { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'
9
+ import { Label } from '@/components/ui/label'
10
+ import { Input } from '@/components/ui/input'
11
+ import { useSubscriptionPlan } from '@/features/subscription/hooks'
12
+ import { createBrowserClient } from '@supabase/ssr'
13
+
14
+ export default function CheckoutPage() {
15
+ const t = useTranslations('payment')
16
+ const router = useRouter()
17
+ const searchParams = useSearchParams()
18
+ const planCode = searchParams.get('plan') || ''
19
+ const cycle = (searchParams.get('cycle') as 'monthly' | 'yearly') || 'monthly'
20
+
21
+ const { data: plan, isLoading } = useSubscriptionPlan(planCode)
22
+ const [paymentMethod, setPaymentMethod] = useState<'alipay' | 'wechat'>('alipay')
23
+ const [coupon, setCoupon] = useState('')
24
+ const [couponError, setCouponError] = useState('')
25
+ const [isProcessing, setIsProcessing] = useState(false)
26
+
27
+ if (!planCode) {
28
+ router.push('/pricing')
29
+ return null
30
+ }
31
+
32
+ if (isLoading) {
33
+ return (
34
+ <div className="container mx-auto max-w-2xl px-4 py-10">
35
+ <div className="flex justify-center py-20">
36
+ <div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent" />
37
+ </div>
38
+ </div>
39
+ )
40
+ }
41
+
42
+ const amount = cycle === 'monthly' ? plan?.price_monthly : plan?.price_yearly
43
+
44
+ const handleSubmit = async () => {
45
+ setIsProcessing(true)
46
+ try {
47
+ // Validate coupon (stub)
48
+ if (coupon && coupon !== 'DISCOUNT10') {
49
+ setCouponError(t('invalidCoupon'))
50
+ setIsProcessing(false)
51
+ return
52
+ }
53
+ setCouponError('')
54
+
55
+ const supabase = createBrowserClient(
56
+ process.env.NEXT_PUBLIC_SUPABASE_URL!,
57
+ process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
58
+ )
59
+ const {
60
+ data: { user },
61
+ } = await supabase.auth.getUser()
62
+ if (!user) {
63
+ router.push('/login')
64
+ return
65
+ }
66
+
67
+ // Create payment record
68
+ const res = await fetch('/api/payments/create', {
69
+ method: 'POST',
70
+ headers: { 'Content-Type': 'application/json' },
71
+ body: JSON.stringify({
72
+ userId: user.id,
73
+ planId: plan?.id,
74
+ amount,
75
+ paymentMethod,
76
+ billingCycle: cycle,
77
+ }),
78
+ })
79
+
80
+ if (!res.ok) throw new Error('Failed to create payment')
81
+ const payment = await res.json()
82
+
83
+ // Initiate payment
84
+ if (paymentMethod === 'alipay') {
85
+ const { initiateAlipayPayment } = await import('@/features/subscription/alipay/client')
86
+ await initiateAlipayPayment(payment.id, user.id)
87
+ } else {
88
+ const { initiateWechatPayment } = await import('@/features/subscription/wechat/client')
89
+ const result = await initiateWechatPayment(payment.id, user.id)
90
+ if (result.type === 'qrcode') {
91
+ router.push(`/payment/${payment.id}`)
92
+ }
93
+ }
94
+ } catch (error) {
95
+ setCouponError(t('paymentError'))
96
+ } finally {
97
+ setIsProcessing(false)
98
+ }
99
+ }
100
+
101
+ return (
102
+ <div className="container mx-auto max-w-2xl px-4 py-10">
103
+ <h1 className="text-2xl font-bold mb-6">{t('checkoutTitle')}</h1>
104
+
105
+ <Card className="mb-6">
106
+ <CardHeader>
107
+ <CardTitle>{t('orderSummary')}</CardTitle>
108
+ </CardHeader>
109
+ <CardContent className="space-y-4">
110
+ <div className="flex justify-between">
111
+ <span>{t('plan')}</span>
112
+ <span className="font-medium">{plan?.plan_name}</span>
113
+ </div>
114
+ <div className="flex justify-between">
115
+ <span>{t('billingCycle')}</span>
116
+ <span className="font-medium">{cycle === 'monthly' ? t('monthly') : t('yearly')}</span>
117
+ </div>
118
+ <div className="flex justify-between text-lg font-bold">
119
+ <span>{t('total')}</span>
120
+ <span>¥{((amount || 0) / 100).toFixed(2)}</span>
121
+ </div>
122
+ </CardContent>
123
+ </Card>
124
+
125
+ <Card className="mb-6">
126
+ <CardHeader>
127
+ <CardTitle>{t('paymentMethod')}</CardTitle>
128
+ </CardHeader>
129
+ <CardContent>
130
+ <RadioGroup
131
+ value={paymentMethod}
132
+ onValueChange={(v) => setPaymentMethod(v as 'alipay' | 'wechat')}
133
+ className="space-y-3"
134
+ >
135
+ <div className="flex items-center space-x-2 border rounded-lg p-4">
136
+ <RadioGroupItem value="alipay" id="alipay" />
137
+ <Label htmlFor="alipay" className="flex-1 cursor-pointer">
138
+ {t('alipay')}
139
+ </Label>
140
+ </div>
141
+ <div className="flex items-center space-x-2 border rounded-lg p-4">
142
+ <RadioGroupItem value="wechat" id="wechat" />
143
+ <Label htmlFor="wechat" className="flex-1 cursor-pointer">
144
+ {t('wechatPay')}
145
+ </Label>
146
+ </div>
147
+ </RadioGroup>
148
+ </CardContent>
149
+ </Card>
150
+
151
+ <Card className="mb-6">
152
+ <CardHeader>
153
+ <CardTitle>{t('coupon')}</CardTitle>
154
+ </CardHeader>
155
+ <CardContent>
156
+ <Input
157
+ placeholder={t('couponPlaceholder')}
158
+ value={coupon}
159
+ onChange={(e) => setCoupon(e.target.value)}
160
+ />
161
+ {couponError && <p className="text-sm text-destructive mt-2">{couponError}</p>}
162
+ </CardContent>
163
+ </Card>
164
+
165
+ <Button className="w-full" size="lg" onClick={handleSubmit} disabled={isProcessing}>
166
+ {isProcessing ? t('processing') : t('confirmPayment')}
167
+ </Button>
168
+ </div>
169
+ )
170
+ }
@@ -0,0 +1,120 @@
1
+ 'use client'
2
+
3
+ import { useState } from 'react'
4
+ import { useRouter } from 'next/navigation'
5
+ import { useTranslations } from 'next-intl'
6
+ import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'
7
+ import { Button } from '@/components/ui/button'
8
+ import {
9
+ Card,
10
+ CardContent,
11
+ CardHeader,
12
+ CardTitle,
13
+ CardDescription,
14
+ CardFooter,
15
+ } from '@/components/ui/card'
16
+ import { Badge } from '@/components/ui/badge'
17
+ import { useSubscriptionPlans, useUserSubscription } from '@/features/subscription/hooks'
18
+ import { createBrowserClient } from '@supabase/ssr'
19
+
20
+ export default function PricingPage() {
21
+ const t = useTranslations('pricing')
22
+ const router = useRouter()
23
+ const [billingCycle, setBillingCycle] = useState<'monthly' | 'yearly'>('monthly')
24
+
25
+ const { data: plans, isLoading: plansLoading } = useSubscriptionPlans()
26
+ const [user, setUser] = useState<{ id: string } | null>(null)
27
+
28
+ // Fetch current user
29
+ useState(() => {
30
+ const supabase = createBrowserClient(
31
+ process.env.NEXT_PUBLIC_SUPABASE_URL!,
32
+ process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
33
+ )
34
+ supabase.auth.getUser().then(({ data }) => {
35
+ if (data.user) {
36
+ setUser({ id: data.user.id })
37
+ }
38
+ })
39
+ })
40
+
41
+ const { data: currentSubscription } = useUserSubscription(user?.id || '')
42
+
43
+ const handleSelectPlan = (planCode: string) => {
44
+ router.push(`/payment/checkout?plan=${planCode}&cycle=${billingCycle}`)
45
+ }
46
+
47
+ if (plansLoading) {
48
+ return (
49
+ <div className="container mx-auto max-w-6xl px-4 py-10">
50
+ <div className="flex justify-center py-20">
51
+ <div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent" />
52
+ </div>
53
+ </div>
54
+ )
55
+ }
56
+
57
+ return (
58
+ <div className="container mx-auto max-w-6xl px-4 py-10">
59
+ <div className="text-center mb-10">
60
+ <h1 className="text-3xl font-bold mb-4">{t('title')}</h1>
61
+ <p className="text-muted-foreground">{t('description')}</p>
62
+ </div>
63
+
64
+ <Tabs
65
+ value={billingCycle}
66
+ onValueChange={(v) => setBillingCycle(v as 'monthly' | 'yearly')}
67
+ className="w-full max-w-md mx-auto mb-10"
68
+ >
69
+ <TabsList className="grid w-full grid-cols-2">
70
+ <TabsTrigger value="monthly">{t('monthly')}</TabsTrigger>
71
+ <TabsTrigger value="yearly">{t('yearly')}</TabsTrigger>
72
+ </TabsList>
73
+ </Tabs>
74
+
75
+ <div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
76
+ {plans?.map((plan) => {
77
+ const price = billingCycle === 'monthly' ? plan.price_monthly : plan.price_yearly
78
+ const isCurrentPlan = currentSubscription?.plan_id === plan.id
79
+
80
+ return (
81
+ <Card
82
+ key={plan.id}
83
+ className={isCurrentPlan ? 'border-primary ring-1 ring-primary' : ''}
84
+ >
85
+ <CardHeader>
86
+ <div className="flex items-center justify-between">
87
+ <CardTitle>{plan.plan_name}</CardTitle>
88
+ {isCurrentPlan && <Badge>{t('currentPlan')}</Badge>}
89
+ {plan.is_featured && <Badge variant="secondary">{t('popular')}</Badge>}
90
+ </div>
91
+ <CardDescription>{plan.description}</CardDescription>
92
+ </CardHeader>
93
+ <CardContent>
94
+ <div className="text-3xl font-bold">
95
+ {price === 0 ? t('free') : `¥${(price / 100).toFixed(2)}`}
96
+ {price > 0 && (
97
+ <span className="text-sm font-normal text-muted-foreground ml-1">
98
+ /{billingCycle === 'monthly' ? t('perMonth') : t('perYear')}
99
+ </span>
100
+ )}
101
+ </div>
102
+ </CardContent>
103
+ <CardFooter>
104
+ {isCurrentPlan ? (
105
+ <Button variant="outline" className="w-full" disabled>
106
+ {t('currentPlan')}
107
+ </Button>
108
+ ) : (
109
+ <Button className="w-full" onClick={() => handleSelectPlan(plan.plan_code)}>
110
+ {price === 0 ? t('getStarted') : t('upgrade')}
111
+ </Button>
112
+ )}
113
+ </CardFooter>
114
+ </Card>
115
+ )
116
+ })}
117
+ </div>
118
+ </div>
119
+ )
120
+ }
@@ -1,5 +1,52 @@
1
+ 'use client'
2
+
1
3
  import { ReactNode } from 'react'
4
+ import Link from 'next/link'
5
+ import { usePathname } from 'next/navigation'
6
+ import { useTranslations } from 'next-intl'
7
+ import { User, CreditCard } from 'lucide-react'
8
+ import { cn } from '@/lib/utils'
2
9
 
3
10
  export default function SettingsLayout({ children }: { children: ReactNode }) {
4
- return <div className="container mx-auto max-w-4xl px-4 py-10">{children}</div>
11
+ const t = useTranslations('settings')
12
+ const pathname = usePathname()
13
+
14
+ const navItems = [
15
+ { href: '/settings/profile', label: t('profileTitle'), icon: User },
16
+ { href: '/settings/subscription', label: t('subscriptionTitle'), icon: CreditCard },
17
+ ]
18
+
19
+ return (
20
+ <div className="container mx-auto max-w-6xl px-4 py-10">
21
+ <div className="flex flex-col gap-8 md:flex-row">
22
+ {/* Sidebar Navigation */}
23
+ <aside className="w-full md:w-64 flex-shrink-0">
24
+ <nav className="space-y-1">
25
+ {navItems.map((item) => {
26
+ const isActive = pathname === item.href || pathname.startsWith(item.href + '/')
27
+ const Icon = item.icon
28
+ return (
29
+ <Link
30
+ key={item.href}
31
+ href={item.href}
32
+ className={cn(
33
+ 'flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition-colors',
34
+ isActive
35
+ ? 'bg-primary text-primary-foreground'
36
+ : 'text-muted-foreground hover:bg-muted hover:text-foreground',
37
+ )}
38
+ >
39
+ <Icon className="h-4 w-4" />
40
+ {item.label}
41
+ </Link>
42
+ )
43
+ })}
44
+ </nav>
45
+ </aside>
46
+
47
+ {/* Content */}
48
+ <main className="flex-1 min-w-0">{children}</main>
49
+ </div>
50
+ </div>
51
+ )
5
52
  }
@@ -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
+ }
@@ -4,7 +4,7 @@ import { NextIntlClientProvider } from 'next-intl'
4
4
  import { getMessages } from 'next-intl/server'
5
5
  import { notFound } from 'next/navigation'
6
6
  import { Toaster } from '@/components/ui/sonner'
7
- import { ThemeProvider } from '@/components/providers/theme-provider'
7
+ import { ThemeProvider } from 'next-themes'
8
8
  import { isLocale } from '@/i18n/config'
9
9
  import './globals.css'
10
10
 
@@ -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
+ }