Skip to content

Commit 4e1bae7

Browse files
committed
feat Customer payment page
1 parent 9bb72ba commit 4e1bae7

10 files changed

Lines changed: 989 additions & 5 deletions

File tree

.kiro/specs/workwork-ledger-mvp/tasks.md

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -185,19 +185,19 @@
185185
- 加密存储 API keys
186186
- _需求: 2.2, 2.5_
187187

188-
- [ ] 11. 客户支付页面
189-
- [ ] 11.1 实现公开支付页面路由
188+
- [x] 11. 客户支付页面
189+
- [x] 11.1 实现公开支付页面路由
190190
- /pay/[token] 路由
191191
- 验证 token 有效性
192192
- _需求: 6.1, 6.6_
193-
- [ ] 11.2 编写属性测试:支付链接有效性
193+
- [x] 11.2 编写属性测试:支付链接有效性
194194
- **属性 15: 支付链接有效性**
195195
- **验证: 需求 6.1**
196-
- [ ] 11.3 实现支付页面 UI
196+
- [x] 11.3 实现支付页面 UI
197197
- 显示发票详情
198198
- 支付方式选择
199199
- _需求: 6.1, 6.2, 6.3_
200-
- [ ] 11.4 实现 PSP Checkout 跳转
200+
- [x] 11.4 实现 PSP Checkout 跳转
201201
- 创建 Checkout Session 并重定向
202202
- _需求: 6.2_
203203

Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
import { NextRequest, NextResponse } from 'next/server';
2+
import Decimal from 'decimal.js';
3+
import { prisma } from '@/lib/prisma';
4+
import { validatePaymentToken } from '@/server/payment/payment-link';
5+
import { getPSPCredentials } from '@/server/payment/credentials';
6+
import { getPaymentGateway } from '@/server/payment/gateway-factory';
7+
import type { Currency } from '@prisma/client';
8+
9+
/**
10+
* POST /api/pay/[token]/checkout
11+
* Creates a Stripe Checkout session and returns the redirect URL
12+
* _需求: 6.2_
13+
*/
14+
export async function POST(
15+
request: NextRequest,
16+
{ params }: { params: Promise<{ token: string }> }
17+
) {
18+
try {
19+
const { token } = await params;
20+
const body = await request.json();
21+
const { method } = body;
22+
23+
if (!token) {
24+
return NextResponse.json(
25+
{ error: 'Invalid payment token' },
26+
{ status: 400 }
27+
);
28+
}
29+
30+
if (method !== 'card') {
31+
return NextResponse.json(
32+
{ error: 'Only card payment is currently supported' },
33+
{ status: 400 }
34+
);
35+
}
36+
37+
// Validate the payment token and get invoice
38+
const result = await validatePaymentToken(token);
39+
40+
if (!result.valid) {
41+
const errorMessages: Record<string, string> = {
42+
not_found: 'Invoice not found',
43+
expired: 'Payment link has expired',
44+
already_paid: 'Invoice has already been paid',
45+
cancelled: 'Invoice has been cancelled',
46+
};
47+
return NextResponse.json(
48+
{ error: errorMessages[result.error] || 'Invalid payment link' },
49+
{ status: 400 }
50+
);
51+
}
52+
53+
const { invoice } = result;
54+
55+
// Check if card payment is allowed
56+
if (!invoice.allowCardPayment) {
57+
return NextResponse.json(
58+
{ error: 'Card payment is not enabled for this invoice' },
59+
{ status: 400 }
60+
);
61+
}
62+
63+
// Get the invoice owner's user ID to fetch PSP credentials
64+
const invoiceRecord = await prisma.invoice.findFirst({
65+
where: { paymentToken: token },
66+
select: { userId: true },
67+
});
68+
69+
if (!invoiceRecord) {
70+
return NextResponse.json(
71+
{ error: 'Invoice not found' },
72+
{ status: 404 }
73+
);
74+
}
75+
76+
// Get PSP credentials for the invoice owner
77+
const credentials = await getPSPCredentials(invoiceRecord.userId);
78+
79+
if (!credentials) {
80+
return NextResponse.json(
81+
{ error: 'Payment is not configured for this merchant' },
82+
{ status: 400 }
83+
);
84+
}
85+
86+
// Create payment gateway
87+
const gateway = getPaymentGateway(credentials);
88+
89+
// Build success and cancel URLs
90+
const baseUrl = process.env.NEXT_PUBLIC_APP_URL || request.nextUrl.origin;
91+
const successUrl = `${baseUrl}/pay/${token}/success`;
92+
const cancelUrl = `${baseUrl}/pay/${token}`;
93+
94+
// Create checkout session
95+
const session = await gateway.createCheckoutSession({
96+
invoiceId: invoice.id,
97+
amount: new Decimal(invoice.total),
98+
currency: invoice.currency as Currency,
99+
customerEmail: invoice.client.email,
100+
successUrl,
101+
cancelUrl,
102+
metadata: {
103+
invoiceId: invoice.id,
104+
invoiceNumber: invoice.invoiceNumber,
105+
paymentToken: token,
106+
},
107+
});
108+
109+
// Store the checkout session ID for later verification
110+
await prisma.payment.upsert({
111+
where: { invoiceId: invoice.id },
112+
create: {
113+
invoiceId: invoice.id,
114+
type: 'fiat',
115+
amount: invoice.total,
116+
currency: invoice.currency as Currency,
117+
status: 'pending',
118+
metadata: { checkoutSessionId: session.id },
119+
fiatPayment: {
120+
create: {
121+
pspProvider: credentials.provider,
122+
checkoutSessionId: session.id,
123+
},
124+
},
125+
},
126+
update: {
127+
status: 'pending',
128+
metadata: { checkoutSessionId: session.id },
129+
fiatPayment: {
130+
upsert: {
131+
create: {
132+
pspProvider: credentials.provider,
133+
checkoutSessionId: session.id,
134+
},
135+
update: {
136+
checkoutSessionId: session.id,
137+
},
138+
},
139+
},
140+
},
141+
});
142+
143+
return NextResponse.json({ url: session.url });
144+
} catch (error) {
145+
console.error('Checkout error:', error);
146+
return NextResponse.json(
147+
{ error: 'Failed to create checkout session' },
148+
{ status: 500 }
149+
);
150+
}
151+
}
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import { NextRequest, NextResponse } from 'next/server';
2+
import { validatePaymentToken } from '@/server/payment/payment-link';
3+
4+
/**
5+
* GET /api/pay/[token]/validate
6+
* Validates a payment token and returns invoice data
7+
* _需求: 6.1, 6.6_
8+
*/
9+
export async function GET(
10+
request: NextRequest,
11+
{ params }: { params: Promise<{ token: string }> }
12+
) {
13+
try {
14+
const { token } = await params;
15+
16+
if (!token) {
17+
return NextResponse.json(
18+
{ valid: false, error: 'not_found' },
19+
{ status: 404 }
20+
);
21+
}
22+
23+
const result = await validatePaymentToken(token);
24+
25+
if (!result.valid) {
26+
const statusCode = result.error === 'not_found' ? 404 : 400;
27+
return NextResponse.json(result, { status: statusCode });
28+
}
29+
30+
return NextResponse.json(result);
31+
} catch (error) {
32+
console.error('Payment token validation error:', error);
33+
return NextResponse.json(
34+
{ valid: false, error: 'not_found' },
35+
{ status: 500 }
36+
);
37+
}
38+
}

src/app/pay/[token]/page.tsx

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
'use client';
2+
3+
import { useEffect, useState } from 'react';
4+
import { useParams } from 'next/navigation';
5+
import { PaymentPageContent } from '@/components/payment/payment-page-content';
6+
import { PaymentPageError } from '@/components/payment/payment-page-error';
7+
import { PaymentPageLoading } from '@/components/payment/payment-page-loading';
8+
import type { PaymentPageInvoice } from '@/server/payment/payment-link';
9+
10+
type PageState =
11+
| { status: 'loading' }
12+
| { status: 'error'; error: 'not_found' | 'expired' | 'already_paid' | 'cancelled' }
13+
| { status: 'success'; invoice: PaymentPageInvoice };
14+
15+
/**
16+
* Public Payment Page
17+
* Allows customers to view and pay invoices without authentication
18+
* _需求: 6.1, 6.6_
19+
*/
20+
export default function PaymentPage() {
21+
const params = useParams();
22+
const token = params.token as string;
23+
const [state, setState] = useState<PageState>({ status: 'loading' });
24+
25+
useEffect(() => {
26+
async function validateToken() {
27+
if (!token) {
28+
setState({ status: 'error', error: 'not_found' });
29+
return;
30+
}
31+
32+
try {
33+
const response = await fetch(`/api/pay/${token}/validate`);
34+
const data = await response.json();
35+
36+
if (data.valid) {
37+
setState({ status: 'success', invoice: data.invoice });
38+
} else {
39+
setState({ status: 'error', error: data.error || 'not_found' });
40+
}
41+
} catch {
42+
setState({ status: 'error', error: 'not_found' });
43+
}
44+
}
45+
46+
validateToken();
47+
}, [token]);
48+
49+
if (state.status === 'loading') {
50+
return <PaymentPageLoading />;
51+
}
52+
53+
if (state.status === 'error') {
54+
return <PaymentPageError error={state.error} />;
55+
}
56+
57+
return <PaymentPageContent invoice={state.invoice} token={token} />;
58+
}
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
'use client';
2+
3+
import { useParams } from 'next/navigation';
4+
import Link from 'next/link';
5+
6+
/**
7+
* Payment Success Page
8+
* Displayed after successful payment completion
9+
* _需求: 6.4_
10+
*/
11+
export default function PaymentSuccessPage() {
12+
const params = useParams();
13+
const token = params.token as string;
14+
15+
return (
16+
<div className="min-h-screen bg-gray-50 flex items-center justify-center p-4">
17+
<div className="max-w-md w-full bg-white rounded-lg shadow-lg p-8 text-center">
18+
<div className="w-16 h-16 mx-auto mb-4 rounded-full bg-green-100 flex items-center justify-center">
19+
<svg
20+
className="w-8 h-8 text-green-600"
21+
fill="none"
22+
stroke="currentColor"
23+
viewBox="0 0 24 24"
24+
>
25+
<path
26+
strokeLinecap="round"
27+
strokeLinejoin="round"
28+
strokeWidth={2}
29+
d="M5 13l4 4L19 7"
30+
/>
31+
</svg>
32+
</div>
33+
<h1 className="text-2xl font-bold text-gray-900 mb-2">Payment Successful!</h1>
34+
<p className="text-gray-600 mb-6">
35+
Thank you for your payment. A confirmation email will be sent to you shortly.
36+
</p>
37+
<Link
38+
href={`/pay/${token}`}
39+
className="inline-block px-6 py-2 bg-emerald-600 text-white rounded-md hover:bg-emerald-700 transition-colors"
40+
>
41+
View Invoice
42+
</Link>
43+
</div>
44+
</div>
45+
);
46+
}

0 commit comments

Comments
 (0)