Skip to content

Commit 52eb2e2

Browse files
authored
Merge pull request #874 from SYMBAxx/feature/issues-762-763-764-765-upstream
feat: issues #762, #763, #764, #765 — geofencing, form validation, idempotency keys, reputation decay
2 parents 3422009 + fd173eb commit 52eb2e2

9 files changed

Lines changed: 679 additions & 21 deletions

File tree

Lines changed: 39 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,34 +1,64 @@
11
import { describe, expect, it } from 'vitest'
2-
import { applicationSubmitSchema } from '@/lib/validators'
2+
import { bountyApplicationSchema } from '@/lib/validations/bounty-application'
33

4-
describe('applicationSubmitSchema', () => {
4+
describe('bountyApplicationSchema', () => {
55
it('accepts valid proposal', () => {
6-
const r = applicationSubmitSchema.safeParse({
6+
const r = bountyApplicationSchema.safeParse({
77
bounty_id: 'bounty-1',
88
proposed_budget: 2500,
99
timeline: 14,
10-
proposal: 'a'.repeat(50),
10+
proposal: 'a'.repeat(100),
1111
})
1212
expect(r.success).toBe(true)
1313
})
1414

15-
it('rejects short proposal', () => {
16-
const r = applicationSubmitSchema.safeParse({
15+
it('rejects short proposal (under 100 chars)', () => {
16+
const r = bountyApplicationSchema.safeParse({
1717
bounty_id: 'bounty-1',
1818
proposed_budget: 100,
1919
timeline: 7,
20-
proposal: 'short',
20+
proposal: 'a'.repeat(99),
2121
})
2222
expect(r.success).toBe(false)
2323
})
2424

2525
it('rejects negative budget', () => {
26-
const r = applicationSubmitSchema.safeParse({
26+
const r = bountyApplicationSchema.safeParse({
2727
bounty_id: 'bounty-1',
2828
proposed_budget: -1,
2929
timeline: 7,
30-
proposal: 'a'.repeat(50),
30+
proposal: 'a'.repeat(100),
3131
})
3232
expect(r.success).toBe(false)
3333
})
34+
35+
it('rejects budget exceeding 1,000,000', () => {
36+
const r = bountyApplicationSchema.safeParse({
37+
bounty_id: 'bounty-1',
38+
proposed_budget: 1_000_001,
39+
timeline: 14,
40+
proposal: 'a'.repeat(100),
41+
})
42+
expect(r.success).toBe(false)
43+
})
44+
45+
it('rejects timeline exceeding 365 days', () => {
46+
const r = bountyApplicationSchema.safeParse({
47+
bounty_id: 'bounty-1',
48+
proposed_budget: 500,
49+
timeline: 366,
50+
proposal: 'a'.repeat(100),
51+
})
52+
expect(r.success).toBe(false)
53+
})
54+
55+
it('accepts boundary values', () => {
56+
const r = bountyApplicationSchema.safeParse({
57+
bounty_id: 'bounty-1',
58+
proposed_budget: 1_000_000,
59+
timeline: 365,
60+
proposal: 'a'.repeat(100),
61+
})
62+
expect(r.success).toBe(true)
63+
})
3464
})

app/api/payments/route.ts

Lines changed: 232 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,232 @@
1+
import { NextRequest, NextResponse } from 'next/server'
2+
import { getServerSession } from 'next-auth'
3+
import { authOptions } from '@/lib/auth'
4+
import { getStripe, isStripeConfigured } from '@/lib/stripe'
5+
import {
6+
createEscrow,
7+
attachPaymentIntent,
8+
getEscrow,
9+
listEscrowsForUser,
10+
markReleased,
11+
markRefunded,
12+
} from '@/lib/payments/escrow-service'
13+
import { paymentPostBodySchema } from '@/lib/payments/payment-validators'
14+
import { validateRequest, formatZodErrors } from '@/lib/utils/validators'
15+
import { getCachedResponse, cacheResponse } from '@/lib/payments/idempotency'
16+
17+
export const runtime = 'nodejs'
18+
19+
export async function GET() {
20+
const session = await getServerSession(authOptions)
21+
if (!session?.user?.id) {
22+
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
23+
}
24+
25+
const escrows = listEscrowsForUser(session.user.id)
26+
return NextResponse.json({
27+
escrows,
28+
stripeConfigured: isStripeConfigured(),
29+
})
30+
}
31+
32+
export async function POST(request: NextRequest) {
33+
const session = await getServerSession(authOptions)
34+
if (!session?.user?.id) {
35+
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
36+
}
37+
38+
const idempotencyKey = request.headers.get('Idempotency-Key')
39+
if (!idempotencyKey) {
40+
return NextResponse.json(
41+
{ error: 'Missing Idempotency-Key header' },
42+
{ status: 400 },
43+
)
44+
}
45+
46+
const cached = getCachedResponse(idempotencyKey)
47+
if (cached) {
48+
return NextResponse.json(cached.body, { status: cached.status })
49+
}
50+
51+
let body: unknown
52+
try {
53+
body = await request.json()
54+
} catch {
55+
return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 })
56+
}
57+
58+
const parsed = validateRequest(paymentPostBodySchema, body)
59+
if (!parsed.success) {
60+
return NextResponse.json(
61+
{ error: 'Validation failed', details: formatZodErrors(parsed.errors) },
62+
{ status: 400 },
63+
)
64+
}
65+
66+
const data = parsed.data
67+
68+
if (data.type === 'bounty_escrow') {
69+
if (session.user.role !== 'CLIENT' && session.user.role !== 'ADMIN') {
70+
return NextResponse.json({ error: 'Only clients can fund bounties' }, { status: 403 })
71+
}
72+
73+
if (!isStripeConfigured()) {
74+
return NextResponse.json(
75+
{ error: 'Payments are not configured (missing STRIPE_SECRET_KEY)' },
76+
{ status: 503 },
77+
)
78+
}
79+
80+
const escrow = createEscrow({
81+
bountyId: data.bountyId,
82+
clientUserId: session.user.id,
83+
amountCents: data.amountCents,
84+
currency: data.currency,
85+
})
86+
87+
const stripe = getStripe()
88+
const pi = await stripe.paymentIntents.create({
89+
amount: data.amountCents,
90+
currency: data.currency,
91+
capture_method: 'manual',
92+
automatic_payment_methods: { enabled: true },
93+
metadata: {
94+
escrowId: escrow.id,
95+
bountyId: data.bountyId,
96+
clientUserId: session.user.id,
97+
kind: 'bounty_escrow',
98+
},
99+
})
100+
101+
attachPaymentIntent(escrow.id, pi.id)
102+
103+
const responseBody = {
104+
escrowId: escrow.id,
105+
clientSecret: pi.client_secret,
106+
publishableKey: process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY ?? null,
107+
amountCents: data.amountCents,
108+
currency: data.currency,
109+
}
110+
cacheResponse(idempotencyKey, 200, responseBody)
111+
return NextResponse.json(responseBody)
112+
}
113+
114+
if (data.type === 'subscription') {
115+
if (!isStripeConfigured()) {
116+
return NextResponse.json({ error: 'Payments are not configured' }, { status: 503 })
117+
}
118+
119+
const base = process.env.NEXTAUTH_URL ?? request.nextUrl.origin
120+
const successUrl = data.successUrl ?? `${base}/dashboard/payments?session_id={CHECKOUT_SESSION_ID}`
121+
const cancelUrl = data.cancelUrl ?? `${base}/dashboard/payments?canceled=1`
122+
123+
const stripe = getStripe()
124+
const sessionCheckout = await stripe.checkout.sessions.create({
125+
mode: 'subscription',
126+
line_items: [{ price: data.priceId, quantity: 1 }],
127+
success_url: successUrl,
128+
cancel_url: cancelUrl,
129+
customer_email: session.user.email ?? undefined,
130+
metadata: { userId: session.user.id },
131+
})
132+
133+
const responseBody = { url: sessionCheckout.url }
134+
cacheResponse(idempotencyKey, 200, responseBody)
135+
return NextResponse.json(responseBody)
136+
}
137+
138+
if (data.type === 'escrow_release') {
139+
if (session.user.role !== 'CLIENT' && session.user.role !== 'ADMIN') {
140+
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
141+
}
142+
143+
const escrow = getEscrow(data.escrowId)
144+
if (!escrow) {
145+
return NextResponse.json({ error: 'Escrow not found' }, { status: 404 })
146+
}
147+
if (escrow.clientUserId !== session.user.id && session.user.role !== 'ADMIN') {
148+
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
149+
}
150+
if (escrow.status !== 'funded_authorized') {
151+
return NextResponse.json({ error: 'Escrow is not in a releasable state' }, { status: 400 })
152+
}
153+
if (!escrow.paymentIntentId) {
154+
return NextResponse.json({ error: 'Missing payment intent' }, { status: 400 })
155+
}
156+
157+
if (!isStripeConfigured()) {
158+
markReleased(escrow.id)
159+
const responseBody = { ok: true, escrow: getEscrow(escrow.id), mode: 'simulated' }
160+
cacheResponse(idempotencyKey, 200, responseBody)
161+
return NextResponse.json(responseBody)
162+
}
163+
164+
const stripe = getStripe()
165+
const captured = await stripe.paymentIntents.capture(escrow.paymentIntentId, {
166+
expand: ['latest_charge'],
167+
})
168+
const charge = captured.latest_charge
169+
const receiptUrl =
170+
typeof charge === 'object' && charge && !charge.deleted && 'receipt_url' in charge
171+
? (charge.receipt_url as string | null) ?? undefined
172+
: undefined
173+
markReleased(escrow.id, receiptUrl)
174+
175+
const responseBody = { ok: true, escrow: getEscrow(escrow.id), receiptUrl }
176+
cacheResponse(idempotencyKey, 200, responseBody)
177+
return NextResponse.json(responseBody)
178+
}
179+
180+
if (data.type === 'escrow_refund') {
181+
if (escrowRefundAllowed(session.user.role, session.user.id, data.escrowId) === false) {
182+
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
183+
}
184+
185+
const escrow = getEscrow(data.escrowId)
186+
if (!escrow) {
187+
return NextResponse.json({ error: 'Escrow not found' }, { status: 404 })
188+
}
189+
if (escrow.status !== 'funded_authorized' && escrow.status !== 'pending_funding') {
190+
return NextResponse.json({ error: 'Escrow cannot be refunded in this state' }, { status: 400 })
191+
}
192+
193+
if (!isStripeConfigured()) {
194+
markRefunded(escrow.id)
195+
const responseBody = { ok: true, escrow: getEscrow(escrow.id), mode: 'simulated' }
196+
cacheResponse(idempotencyKey, 200, responseBody)
197+
return NextResponse.json(responseBody)
198+
}
199+
200+
if (!escrow.paymentIntentId) {
201+
markRefunded(escrow.id)
202+
const responseBody = { ok: true, escrow: getEscrow(escrow.id) }
203+
cacheResponse(idempotencyKey, 200, responseBody)
204+
return NextResponse.json(responseBody)
205+
}
206+
207+
const stripe = getStripe()
208+
const pi = await stripe.paymentIntents.retrieve(escrow.paymentIntentId)
209+
if (pi.status === 'requires_capture') {
210+
await stripe.paymentIntents.cancel(escrow.paymentIntentId)
211+
} else if (pi.status === 'succeeded') {
212+
const chargeId = pi.latest_charge
213+
if (typeof chargeId === 'string') {
214+
await stripe.refunds.create({ charge: chargeId })
215+
}
216+
}
217+
218+
markRefunded(escrow.id)
219+
const responseBody = { ok: true, escrow: getEscrow(escrow.id) }
220+
cacheResponse(idempotencyKey, 200, responseBody)
221+
return NextResponse.json(responseBody)
222+
}
223+
224+
return NextResponse.json({ error: 'Unsupported' }, { status: 400 })
225+
}
226+
227+
function escrowRefundAllowed(role: string, userId: string, escrowId: string): boolean {
228+
const escrow = getEscrow(escrowId)
229+
if (!escrow) return false
230+
if (role === 'ADMIN') return true
231+
return escrow.clientUserId === userId
232+
}

0 commit comments

Comments
 (0)