|
| 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