From 73a88342bddaec7e10652a1d9a435142e2963eed Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Wed, 21 Oct 2026 12:54:45 +0000 Subject: [PATCH] feat(bounties): fund bounties through CoinPay checkout sessions (stack 0.5.0) Bounty funding now creates a hosted checkout session (any wallet coin or PayPal) with the payer email, the DID as account_id and property c0upons.com. Email comes from the CoinPay login profile, else the form. The webhook uses verifyAndReadCoinPayWebhook and its paid flag. Co-Authored-By: Claude Opus 5.5 --- apps/web/app/api/auth/me/route.ts | 11 ++++- apps/web/app/api/bounties/route.ts | 51 ++++++++++++++-------- apps/web/app/api/webhooks/coinpay/route.ts | 47 +++++++++----------- apps/web/app/bounties/[id]/page.tsx | 2 +- apps/web/app/bounties/new/page.tsx | 18 +++++++- apps/web/package.json | 2 +- bun.lock | 6 ++- 7 files changed, 85 insertions(+), 52 deletions(-) diff --git a/apps/web/app/api/auth/me/route.ts b/apps/web/app/api/auth/me/route.ts index f0325a5..4715980 100644 --- a/apps/web/app/api/auth/me/route.ts +++ b/apps/web/app/api/auth/me/route.ts @@ -1,7 +1,16 @@ import { NextResponse } from 'next/server'; import { getSessionDid } from '@/lib/auth'; +import { memberByDid } from '@/lib/members'; export async function GET() { const did = await getSessionDid(); - return NextResponse.json({ did: did ?? null }); + let email: string | null = null; + if (did) { + try { + email = (await memberByDid(did))?.email ?? null; + } catch { + email = null; + } + } + return NextResponse.json({ did: did ?? null, email }); } diff --git a/apps/web/app/api/bounties/route.ts b/apps/web/app/api/bounties/route.ts index dfa01f2..9ab1b28 100644 --- a/apps/web/app/api/bounties/route.ts +++ b/apps/web/app/api/bounties/route.ts @@ -4,10 +4,12 @@ import { getDb } from '@/lib/db'; import { dbErrorResponse } from '@/lib/api-error'; import { getSessionDid } from '@/lib/auth'; import { generatePublicId } from '@/lib/id'; +import { memberByDid } from '@/lib/members'; + +const EMAIL = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; const COINPAY_BASE = 'https://coinpayportal.com'; const API_KEY = process.env.COINPAY_API_KEY!; -const MERCHANT_ID = process.env.COINPAY_MERCHANT_ID!; const APP_URL = process.env.NEXT_PUBLIC_BASE_URL!; export async function GET() { @@ -32,13 +34,24 @@ export async function POST(req: NextRequest) { const did = await getSessionDid(); if (!did) return NextResponse.json({ error: 'Authentication required' }, { status: 401 }); - const { title, description, url, reward_usd, store_id, store_name } = await req.json(); + const { title, description, url, reward_usd, store_id, store_name, email: bodyEmail } = await req.json(); if (!title?.trim()) return NextResponse.json({ error: 'Title is required' }, { status: 400 }); const reward = parseFloat(reward_usd); if (isNaN(reward) || reward < 0.10) return NextResponse.json({ error: 'Minimum reward is $0.10' }, { status: 400 }); if (!store_id && !store_name?.trim()) return NextResponse.json({ error: 'Store is required' }, { status: 400 }); + // CoinPay checkout sessions need the payer's email: the one on file from + // the CoinPay login, else the one typed on the form. + let email = ''; + try { + email = (await memberByDid(did))?.email?.trim().toLowerCase() ?? ''; + } catch { /* members table unavailable: fall back to the form */ } + if (!email && typeof bodyEmail === 'string') email = bodyEmail.trim().toLowerCase(); + if (!EMAIL.test(email) || email.length > 320) { + return NextResponse.json({ error: 'A valid email is required to fund the bounty' }, { status: 400 }); + } + try { const db = getDb(); const publicId = generatePublicId(); @@ -59,36 +72,36 @@ export async function POST(req: NextRequest) { ) `; - // Create a CoinPay payment for the creator to fund the bounty - // Docs: POST /api/payments/create - let paymentAddress: string | null = null; - let paymentId: string | null = null; + // Hosted CoinPay checkout session: the creator funds the bounty with any + // coin the business has a wallet for, or PayPal. payment_id holds the + // cs_ session id until the webhook replaces it with the paid payment id. + let payUrl: string | null = null; + let sessionId: string | null = null; try { const coinpay = createCoinPayClient({ apiKey: API_KEY, baseUrl: COINPAY_BASE }); const checkout = await coinpay.createCheckout({ amountUsd: reward, - currency: 'usdc_pol', // default to USDC on Polygon — low fees - // Keep the API's implicit default ('crypto'); 'both' would require Stripe Connect. - paymentMethod: 'crypto', - description: `Coupon bounty: ${title.trim()}`, - redirectUrl: `${APP_URL}/bounties/${publicId}?funded=1`, + customerEmail: email, + accountId: did, + property: 'c0upons.com', + description: `Coupon bounty: ${title.trim()}`.slice(0, 500), + successUrl: `${APP_URL}/bounties/${publicId}?funded=1`, + cancelUrl: `${APP_URL}/bounties/${publicId}`, metadata: { type: 'bounty_fund', bounty_id: publicId, creator_did: did }, - businessId: MERCHANT_ID, }); - paymentId = checkout.paymentId; - paymentAddress = checkout.payment.payment_address ?? null; - await db.sql`UPDATE bounties SET payment_id = ${paymentId} WHERE public_id = ${publicId}`; + sessionId = checkout.sessionId; + payUrl = checkout.checkoutUrl; + await db.sql`UPDATE bounties SET payment_id = ${sessionId} WHERE public_id = ${publicId}`; } catch (e) { - console.error('CoinPay payment create failed:', e); + console.error('CoinPay checkout session create failed:', e); } return NextResponse.json({ id: publicId, public_id: publicId, - payment_id: paymentId, - payment_address: paymentAddress, - pay_url: paymentId ? `${COINPAY_BASE}/pay/${paymentId}` : null, + checkout_session_id: sessionId, + pay_url: payUrl, }, { status: 201 }); } catch (e) { console.error('Failed to create bounty:', e); diff --git a/apps/web/app/api/webhooks/coinpay/route.ts b/apps/web/app/api/webhooks/coinpay/route.ts index 73f92c3..aff8607 100644 --- a/apps/web/app/api/webhooks/coinpay/route.ts +++ b/apps/web/app/api/webhooks/coinpay/route.ts @@ -1,40 +1,35 @@ import { NextRequest, NextResponse } from 'next/server'; -import { verifyCoinPayWebhook } from '@profullstack/stack/coinpay'; +import { verifyAndReadCoinPayWebhook } from '@profullstack/stack/coinpay'; const SECRET = process.env.COINPAY_WEBHOOK_SECRET!; export async function POST(req: NextRequest) { const raw = await req.text(); - const sigHeader = req.headers.get('x-coinpay-signature'); - - if (!verifyCoinPayWebhook({ signature: sigHeader, rawBody: raw, secret: SECRET })) { - console.error('CoinPay webhook: invalid signature'); + const hook = verifyAndReadCoinPayWebhook({ + signature: req.headers.get('x-coinpay-signature'), + rawBody: raw, + secret: SECRET, + }); + if (!hook) { + console.error('CoinPay webhook: invalid signature or payload'); return NextResponse.json({ error: 'invalid signature' }, { status: 401 }); } - const event = JSON.parse(raw); - console.log('CoinPay webhook:', event.type, event.id); + console.log('CoinPay webhook:', hook.type, hook.paymentId, hook.checkoutSessionId ?? '-'); - switch (event.type) { - case 'test.webhook': - break; - case 'payment.completed': - case 'payment.confirmed': { - const meta = event.data?.metadata ?? event.metadata ?? {}; - if (meta.type === 'bounty_fund' && meta.bounty_id) { - const db = (await import('@/lib/db')).getDb(); - await db.sql` - UPDATE bounties - SET status = 'funded', payment_id = ${event.data?.payment_id ?? event.id}, - updated_at = ${new Date().toISOString()} - WHERE public_id = ${meta.bounty_id} AND status = 'open' - `; - console.log('Bounty funded:', meta.bounty_id); - } - break; + if (hook.paid) { + const meta = hook.metadata; + const bountyId = typeof meta.bounty_id === 'string' ? meta.bounty_id : null; + if (meta.type === 'bounty_fund' && bountyId) { + const db = (await import('@/lib/db')).getDb(); + await db.sql` + UPDATE bounties + SET status = 'funded', payment_id = ${hook.paymentId ?? hook.checkoutSessionId}, + updated_at = ${new Date().toISOString()} + WHERE public_id = ${bountyId} AND status = 'open' + `; + console.log('Bounty funded:', bountyId, hook.rail); } - case 'payment.failed': - break; } return NextResponse.json({ received: true }); diff --git a/apps/web/app/bounties/[id]/page.tsx b/apps/web/app/bounties/[id]/page.tsx index 65bd6b6..2d7ae05 100644 --- a/apps/web/app/bounties/[id]/page.tsx +++ b/apps/web/app/bounties/[id]/page.tsx @@ -137,7 +137,7 @@ export default async function BountyPage({ params }: { params: Promise<{ id: str {/* Actions */} {bounty.status === 'open' && isCreator && bounty.payment_id && ( (null); + const [emailOnFile, setEmailOnFile] = useState(null); const [stores, setStores] = useState([]); const [loading, setLoading] = useState(false); const [error, setError] = useState(''); @@ -20,10 +21,11 @@ export default function NewBountyPage() { reward_usd: '0.10', store_id: '', store_name: '', + email: '', }); useEffect(() => { - fetch('/api/auth/me').then(r => r.json()).then(d => setDid(d.did ?? null)).catch(() => {}); + fetch('/api/auth/me').then(r => r.json()).then(d => { setDid(d.did ?? null); setEmailOnFile(d.email ?? null); }).catch(() => {}); fetch('/api/stores').then(r => r.json()).then(d => setStores(Array.isArray(d) ? d : [])).catch(() => {}); }, []); @@ -36,6 +38,7 @@ export default function NewBountyPage() { if (isNaN(reward) || reward < 0.10) { setError('Minimum reward is $0.10'); return; } if (!form.store_id && !form.store_name.trim()) { setError('Please select or name a store'); return; } if (!form.title.trim()) { setError('Title is required'); return; } + if (!emailOnFile && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(form.email.trim())) { setError('Enter your email for the payment receipt'); return; } setLoading(true); try { const res = await fetch('/api/bounties', { @@ -48,6 +51,7 @@ export default function NewBountyPage() { reward_usd: reward, store_id: form.store_id ? parseInt(form.store_id) : null, store_name: !form.store_id ? form.store_name.trim() : null, + email: emailOnFile ? undefined : form.email.trim(), }), }); const data = await res.json(); @@ -161,8 +165,18 @@ export default function NewBountyPage() { required /> + {!emailOnFile && ( + set('email', e.target.value)} + className="mt-3 w-full border border-gray-200 rounded-lg px-3 py-2 text-sm text-gray-900 focus:outline-none focus:ring-2 focus:ring-orange-400" + required + /> + )}

- You will be redirected to CoinPay to fund this bounty. The reward is held until a valid coupon is submitted. + You will be redirected to CoinPay to fund this bounty with any supported coin or PayPal. The reward is held until a valid coupon is submitted.

diff --git a/apps/web/package.json b/apps/web/package.json index e0fe4e4..33dbfb1 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -18,7 +18,7 @@ "@profullstack/bridges": "^0.1.0", "@profullstack/footer": "latest", "@profullstack/libsql-pg": "^0.1.4", - "@profullstack/stack": "^0.1.3", + "@profullstack/stack": "0.5.0", "@profullstack/x402-gateway": "^0.11.0", "@serwist/next": "^9.5.0", "next": "16.3.8", diff --git a/bun.lock b/bun.lock index 6817071..387d07b 100644 --- a/bun.lock +++ b/bun.lock @@ -36,7 +36,7 @@ "@profullstack/bridges": "^0.1.0", "@profullstack/footer": "latest", "@profullstack/libsql-pg": "^0.1.4", - "@profullstack/stack": "^0.1.3", + "@profullstack/stack": "0.5.0", "@profullstack/x402-gateway": "^0.11.0", "@serwist/next": "^9.5.0", "next": "16.3.8", @@ -323,13 +323,15 @@ "@profullstack/footer": ["@profullstack/footer@0.2.5", "", { "peerDependencies": { "react": ">=18" }, "optionalPeers": ["react"], "bin": { "profullstack-footer": "bin/profullstack-footer.js" } }, "sha512-/CKwFP3m+qM0ZFTC9Rj11fgClutJzeYliX44Qp4qwrv2JgA/1k4tVOB3mhkPhaeckUhnfg5UyESDI1uoWr9c2w=="], + "@profullstack/form-guard": ["@profullstack/form-guard@0.1.1", "", {}, "sha512-RGLGfQhjq40f+JqEhSmsJnS0I5sFWjYdrYx4VYtkUqz8xvioN2yyR+6uFK0mnDAStKrYytlngg3meUsrChm/OQ=="], + "@profullstack/keys": ["@profullstack/keys@0.1.0", "", {}, "sha512-Vys47LvS5ivhdZu4SYOxh9Le3DEmWhjP0+CtrU45RhXAbY0ZFG/2Dk9V1bIIUGDp8UWMJqctvUD21JrNjfh04w=="], "@profullstack/libsql-pg": ["@profullstack/libsql-pg@0.1.4", "", { "dependencies": { "pg": "8.23.0" }, "optionalDependencies": { "@libsql/client": "0.15.15" }, "bin": { "libsql-pg": "bin/libsql-pg.js" } }, "sha512-FB+rGGmM9Ua5ZqcrZQvjhagjDDIXZRj9mTC2wLsluD4MTk7/XcjHbOsHb6uMh5gEzhmH2yZz/yGpKCsMyVLujw=="], "@profullstack/referrals": ["@profullstack/referrals@0.1.0", "", { "peerDependencies": { "react": ">=18" }, "optionalPeers": ["react"] }, "sha512-u66SdBVpsv3kc0N+NWISPoYD5vjCERyv5wfD07iSkZwQeC2IA+ihX5jNA4e7Xr+Y4AUvxLycG+3b4VaROqzgRg=="], - "@profullstack/stack": ["@profullstack/stack@0.1.3", "", { "dependencies": { "@profullstack/emailer": "1.0.1", "@profullstack/referrals": "0.1.0" }, "peerDependencies": { "@supabase/ssr": ">=0.5.0", "next": ">=13.0.0", "react": ">=18.0.0" }, "optionalPeers": ["@supabase/ssr", "next", "react"] }, "sha512-NOseYE5cWMwH75/luUE46kJLyAd1xD+DSgGgwhxvgNMoJYfZ9W9vKmqdcGA/4Lgahzf7hWfjNvmLtVIzAqwhJw=="], + "@profullstack/stack": ["@profullstack/stack@0.5.0", "", { "dependencies": { "@profullstack/emailer": "^1.0.1", "@profullstack/form-guard": "^0.1.1", "@profullstack/referrals": "^0.1.0" }, "peerDependencies": { "@supabase/ssr": ">=0.5.0", "next": ">=13.0.0", "react": ">=18.0.0" }, "optionalPeers": ["@supabase/ssr", "next", "react"] }, "sha512-LbXovIhyzLEq+a+y3q8KWC+jN1tJjvSoUiFEhpiEBisQn5U+iutIGHDgg16Lul74kQaFZeFlbp7CMPAVJ8KL6g=="], "@profullstack/x402-gateway": ["@profullstack/x402-gateway@0.11.0", "", { "dependencies": { "@profullstack/keys": "^0.1.0" } }, "sha512-Lfrz+SRTwQZfcoCaVtaaMNPJx74Lp/5oyS7vJJAEuJmH1TjBzhE7dWquISkj1p42oc+VW5I9xH9p7YhEKqmr4Q=="],