From f318d00b621846d7d3b921f076e175bc68c5a65d Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Wed, 21 Oct 2026 13:42:16 +0000 Subject: [PATCH] fix(payments): settle VOD, seedbox rental and IPTV pass payments from the one CoinPay webhook CoinPay delivers every event for a business to its single webhook_url (https://bittorrented.com/api/webhooks/coinpayportal). The VOD, seedbox rental and IPTV resale pass checkouts sent a per-payment webhook_url (/vod, /share, /iptv-share) that CoinPay never delivers to, so a paid pass reached the main route, matched no payment_history row, and was never granted. The main route now dispatches on metadata.type (vod, seedbox_share, iptv_share) to the existing grant handlers, after the same signature check (HMAC over t.rawBody, 300 s tolerance), before the translation, session and subscription paths. An unknown grant is a 200 no-op; a handler error is a 500 so CoinPay redelivers. Idempotency: VOD and seedbox markGrantPaid are now conditional (pending or expired -> paid, returning whether it changed), as IPTV's already was, so a redelivery or a confirmed-then-forwarded pair credits provider/share earnings once. The three old routes are thin forwards (re-export of the main POST/GET), and the checkouts now name the main webhook. Tests: grant-routing.test.ts posts really signed, nested CoinPay payloads per kind through the real verifier (paid once, forwarded/redelivery no-op, expired, unknown payment, bad signature 401, old addresses forward, subscription payments still reach the subscription handler). Co-Authored-By: Claude Opus 5.5 --- .../coinpayportal/grant-routing.test.ts | 170 ++++++++++++++++++ .../coinpayportal/iptv-share/route.ts | 118 +----------- src/app/api/webhooks/coinpayportal/route.ts | 41 +++++ .../api/webhooks/coinpayportal/share/route.ts | 135 +------------- .../api/webhooks/coinpayportal/vod/route.ts | 112 +----------- src/lib/coinpayportal/grant-webhooks.ts | 59 ++++++ src/lib/iptv/shares/service.ts | 2 +- src/lib/seedbox/shares/repository.ts | 13 +- src/lib/seedbox/shares/service.ts | 5 +- src/lib/vod/repository.ts | 13 +- src/lib/vod/service.ts | 5 +- 11 files changed, 319 insertions(+), 354 deletions(-) create mode 100644 src/app/api/webhooks/coinpayportal/grant-routing.test.ts create mode 100644 src/lib/coinpayportal/grant-webhooks.ts diff --git a/src/app/api/webhooks/coinpayportal/grant-routing.test.ts b/src/app/api/webhooks/coinpayportal/grant-routing.test.ts new file mode 100644 index 00000000..f9678dee --- /dev/null +++ b/src/app/api/webhooks/coinpayportal/grant-routing.test.ts @@ -0,0 +1,170 @@ +/** + * Pass payments (VOD, seedbox rental, IPTV resale pass) arrive at the ONE + * business webhook, /api/webhooks/coinpayportal: CoinPay never delivers to + * the per-kind routes. These post real-shaped, really signed (HMAC over + * "t.rawBody") nested CoinPay payloads and check each kind reaches its grant, + * flips it once, and that the old per-kind addresses forward to the same handler. + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { NextRequest } from 'next/server'; +import crypto from 'crypto'; + +const { SECRET, handleWebhook, adoptSessionPayment, grants, earnings, byPayment, flip } = vi.hoisted(() => { + type Grant = { id: string; status: string; paymentId: string; amountUsd: number }; + /** One grant per kind, in memory, behind each repository's webhook functions. */ + const grants: Record = {}; + const earnings: Record = { vod: 0, seedbox_share: 0, iptv_share: 0 }; + const byPayment = (paymentId: string) => { + const g = Object.values(grants).find((one) => one.paymentId === paymentId); + return g ? { ...g, coinpayportalPaymentId: g.paymentId, providerId: 'prov', shareId: 'share', grantKind: 'weekly', accessMode: 'stream' } : null; + }; + const flip = (id: string): boolean => { + const g = Object.values(grants).find((one) => one.id === id)!; + if (g.status === 'paid' || g.status === 'refunded') return false; + g.status = 'paid'; + return true; + }; + return { SECRET: 'whsec_grant_routing_test', handleWebhook: vi.fn(), adoptSessionPayment: vi.fn(), grants, earnings, byPayment, flip }; +}); + +vi.mock('@/lib/coinpayportal/client', async () => { + const actual = await vi.importActual('@/lib/coinpayportal/client'); + const client = new actual.CoinPayPortalClient({ apiKey: 'cp_live_test', businessId: 'biz', webhookSecret: SECRET }); + return { ...actual, getCoinPayPortalClient: () => client }; +}); + +// The subscription path must never see a pass payment. +vi.mock('@/lib/coinpayportal/webhook-handler', () => ({ getWebhookHandler: () => ({ handleWebhook }) })); +vi.mock('@/lib/coinpayportal/session-payments', () => ({ adoptSessionPayment: (...a: unknown[]) => adoptSessionPayment(...a) })); +vi.mock('@/lib/argontv/webhook', () => ({ processIptvWebhook: vi.fn() })); +vi.mock('@/lib/supabase/client', () => ({ getServerClient: vi.fn(() => ({})) })); + +vi.mock('@/lib/vod/repository', () => ({ + getGrantByPaymentId: async (p: string) => byPayment(p), + getProviderById: async () => ({ passWindowMinutes: 10080 }), + markGrantPaid: async (id: string) => flip(id), + markGrantStatus: async (id: string, status: string) => { Object.values(grants).find((g) => g.id === id)!.status = status; }, + incrementProviderEarnings: async (_: string, usd: number) => { earnings.vod += usd; }, +})); +vi.mock('@/lib/seedbox/shares/repository', () => ({ + getGrantByPaymentId: async (p: string) => byPayment(p), + getShareById: async () => ({ passWindowMinutes: 1440 }), + markGrantPaid: async (id: string) => flip(id), + markGrantStatus: async (id: string, status: string) => { Object.values(grants).find((g) => g.id === id)!.status = status; }, + incrementShareEarnings: async (_: string, usd: number) => { earnings.seedbox_share += usd; }, +})); +vi.mock('@/lib/iptv/shares/repository', () => ({ + getGrantByPaymentId: async (p: string) => byPayment(p), + getShareById: async () => ({ passWindowMinutes: 240 }), + markGrantPaid: async (id: string) => (flip(id) ? { id } : null), + markGrantStatus: async (id: string, status: string) => { Object.values(grants).find((g) => g.id === id)!.status = status; }, + bumpShareCounters: async (_: string, c: { earningsUsd: number }) => { earnings.iptv_share += c.earningsUsd; }, +})); + +import { POST } from './route'; +import { POST as VOD_POST } from './vod/route'; +import { POST as SHARE_POST } from './share/route'; +import { POST as IPTV_SHARE_POST } from './iptv-share/route'; + +const KINDS = [ + { kind: 'vod', payment: '0b7f3c1e-1111-4a2b-9c3d-000000000001', grant: 'g-vod', extra: { providerId: 'prov' } }, + { kind: 'seedbox_share', payment: '0b7f3c1e-2222-4a2b-9c3d-000000000002', grant: 'g-seedbox', extra: { shareId: 'share' } }, + { kind: 'iptv_share', payment: '0b7f3c1e-3333-4a2b-9c3d-000000000003', grant: 'g-iptv', extra: { shareId: 'share' } }, +] as const; + +/** The body CoinPay's sendPaymentWebhook signs: nested under data, top-level id is the event. */ +function coinpayBody(type: string, paymentId: string, metadata: Record) { + const ts = Math.floor(Date.now() / 1000); + return JSON.stringify({ + id: `evt_${paymentId}_${ts}`, + type, + data: { + payment_id: paymentId, + status: type.split('.')[1], + amount: 5, + amount_usd: '5.00', + amount_crypto: '5.000000', + currency: 'USDC_POL', + tx_hash: '0xabc', + metadata, + }, + created_at: new Date().toISOString(), + business_id: '00000000-0000-4000-8000-00000000b170', + }); +} + +function signed(url: string, raw: string, secret = SECRET): NextRequest { + const t = Math.floor(Date.now() / 1000); + const v1 = crypto.createHmac('sha256', secret).update(`${t}.${raw}`).digest('hex'); + return new NextRequest(url, { method: 'POST', headers: { 'content-type': 'application/json', 'x-coinpay-signature': `t=${t},v1=${v1}` }, body: raw }); +} + +const MAIN = 'https://bittorrented.com/api/webhooks/coinpayportal'; + +describe('pass payments through the one CoinPay webhook', () => { + beforeEach(() => { + handleWebhook.mockReset(); + adoptSessionPayment.mockReset(); + for (const k of KINDS) grants[k.kind] = { id: k.grant, status: 'pending', paymentId: k.payment, amountUsd: 5 }; + for (const key of Object.keys(earnings)) earnings[key] = 0; + }); + + for (const k of KINDS) { + it(`${k.kind}: confirmed pays the grant once; forwarded and a redelivery are no-ops`, async () => { + const meta = { type: k.kind, grantId: k.grant, ...k.extra }; + const confirmed = await POST(signed(MAIN, coinpayBody('payment.confirmed', k.payment, meta))); + expect(confirmed.status).toBe(200); + expect(await confirmed.json()).toMatchObject({ success: true, kind: k.kind, action: 'paid', grantId: k.grant }); + expect(grants[k.kind]!.status).toBe('paid'); + + const forwarded = await POST(signed(MAIN, coinpayBody('payment.forwarded', k.payment, meta))); + expect(await forwarded.json()).toMatchObject({ success: true, action: 'already_paid' }); + const again = await POST(signed(MAIN, coinpayBody('payment.confirmed', k.payment, meta))); + expect(await again.json()).toMatchObject({ success: true, action: 'already_paid' }); + + expect(earnings[k.kind]).toBe(5); + expect(handleWebhook).not.toHaveBeenCalled(); + expect(adoptSessionPayment).not.toHaveBeenCalled(); + }); + + it(`${k.kind}: expired marks a pending grant expired`, async () => { + const res = await POST(signed(MAIN, coinpayBody('payment.expired', k.payment, { type: k.kind, grantId: k.grant }))); + expect(await res.json()).toMatchObject({ success: true, action: 'expired' }); + expect(grants[k.kind]!.status).toBe('expired'); + }); + + it(`${k.kind}: an unknown payment is a 200 no-op (nothing to retry)`, async () => { + const res = await POST(signed(MAIN, coinpayBody('payment.confirmed', '0b7f3c1e-9999-4a2b-9c3d-999999999999', { type: k.kind }))); + expect(res.status).toBe(200); + expect(await res.json()).toMatchObject({ success: false, kind: k.kind, action: 'ignored:grant_not_found' }); + expect(earnings[k.kind]).toBe(0); + }); + } + + it('refuses a bad signature before touching any grant', async () => { + const raw = coinpayBody('payment.confirmed', KINDS[0].payment, { type: 'vod', grantId: 'g-vod' }); + const res = await POST(signed(MAIN, raw, 'not-the-secret')); + expect(res.status).toBe(401); + expect(grants.vod!.status).toBe('pending'); + }); + + it('the old per-kind addresses forward to the same verified handler', async () => { + const routes = [VOD_POST, SHARE_POST, IPTV_SHARE_POST]; + for (const [at, k] of KINDS.entries()) { + const url = `${MAIN}/${['vod', 'share', 'iptv-share'][at]}`; + const raw = coinpayBody('payment.confirmed', k.payment, { type: k.kind, grantId: k.grant }); + expect((await routes[at]!(signed(url, raw, 'wrong'))).status).toBe(401); + const res = await routes[at]!(signed(url, raw)); + expect(await res.json()).toMatchObject({ success: true, kind: k.kind, action: 'paid' }); + } + }); + + it('a payment without a pass kind still goes to the subscription handler', async () => { + adoptSessionPayment.mockResolvedValue({ table: null, rekeyed: false, abandonedAttempt: false }); + handleWebhook.mockResolvedValue({ success: true, action: 'payment_confirmed', paymentId: 'p', userId: 'u' }); + const res = await POST(signed(MAIN, coinpayBody('payment.confirmed', '0b7f3c1e-4444-4a2b-9c3d-000000000004', { type: 'subscription', user_id: 'u' }))); + expect(res.status).toBe(200); + expect(handleWebhook).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/app/api/webhooks/coinpayportal/iptv-share/route.ts b/src/app/api/webhooks/coinpayportal/iptv-share/route.ts index 074d0150..e192aeda 100644 --- a/src/app/api/webhooks/coinpayportal/iptv-share/route.ts +++ b/src/app/api/webhooks/coinpayportal/iptv-share/route.ts @@ -1,116 +1,14 @@ /** - * CoinPayPortal webhook — IPTV resale passes. + * Former per-kind CoinPay webhook (/api/webhooks/coinpayportal/iptv-share). * - * Mirrors the seedbox-rental webhook at ../share/route.ts: verify the signature, - * then dispatch to the IPTV handler, which is idempotent on the CoinPay payment id. + * CoinPay delivers every event for the business to its ONE webhook_url + * (/api/webhooks/coinpayportal), so this address never received deliveries. + * It stays as a thin forward to that route, which verifies the signature and + * dispatches pass payments on metadata.type (src/lib/coinpayportal/grant-webhooks.ts), + * in case anything still posts here. * * @route POST /api/webhooks/coinpayportal/iptv-share + * @route GET /api/webhooks/coinpayportal/iptv-share */ -import { NextRequest, NextResponse } from 'next/server'; - -import { getCoinPayPortalClient } from '@/lib/coinpayportal/client'; -import { handleIptvShareWebhook } from '@/lib/iptv/shares'; - -const LOG_PREFIX = '[Webhook:IptvResale]'; - -const VALID_TYPES = new Set([ - 'payment.detected', - 'payment.confirmed', - 'payment.forwarded', - 'payment.failed', - 'payment.expired', - 'test.webhook', -]); - -interface FlatWebhookBody { - event?: string; - type?: string; - payment_id?: string; - amount_crypto?: string; - currency?: string; - blockchain?: string; - tx_hash?: string; - metadata?: Record; - data?: { - payment_id?: string; - amount_crypto?: string; - currency?: string; - blockchain?: string; - tx_hash?: string; - metadata?: Record; - }; -} - -export async function POST(request: NextRequest): Promise { - let rawBody: string; - try { - rawBody = await request.text(); - } catch { - return NextResponse.json({ error: 'Failed to read body' }, { status: 400 }); - } - - const signature = request.headers.get('x-coinpay-signature'); - if (!signature) return NextResponse.json({ error: 'Missing signature' }, { status: 401 }); - try { - // Verified over the RAW bytes; re-serialising the parsed JSON changes them and - // the signature stops matching. - if (!getCoinPayPortalClient().verifyWebhookSignature(rawBody, signature)) { - return NextResponse.json({ error: 'Invalid signature' }, { status: 401 }); - } - } catch { - return NextResponse.json({ error: 'Signature verification failed' }, { status: 401 }); - } - - let body: FlatWebhookBody; - try { - body = JSON.parse(rawBody) as FlatWebhookBody; - } catch { - return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 }); - } - - const type = body.event || body.type; - const paymentId = body.payment_id || body.data?.payment_id; - const metadata = body.metadata || body.data?.metadata; - - if (!type || !VALID_TYPES.has(type)) { - return NextResponse.json({ error: `Invalid webhook type: ${type ?? 'none'}` }, { status: 400 }); - } - if (type === 'test.webhook') { - return NextResponse.json({ success: true, action: 'test' }, { status: 200 }); - } - if (!paymentId) return NextResponse.json({ error: 'Missing payment_id' }, { status: 400 }); - - // Defence in depth: the endpoint is dedicated, but a misrouted event must not - // settle a pass it has nothing to do with. - if (metadata?.type && metadata.type !== 'iptv_share') { - return NextResponse.json({ success: true, action: 'ignored:not_iptv' }, { status: 200 }); - } - - try { - const outcome = await handleIptvShareWebhook({ - type, - paymentId, - amountCrypto: body.amount_crypto || body.data?.amount_crypto || null, - currency: body.currency || body.data?.currency || null, - blockchain: body.blockchain || body.data?.blockchain || null, - txHash: body.tx_hash || body.data?.tx_hash || null, - }); - if (!outcome.handled) { - console.warn(`${LOG_PREFIX} unhandled`, { type, paymentId, action: outcome.action }); - return NextResponse.json({ success: false, action: outcome.action }, { status: 422 }); - } - return NextResponse.json( - { success: true, action: outcome.action, grantId: outcome.grantId }, - { status: 200 } - ); - } catch (error) { - const detail = error instanceof Error ? error.message : String(error); - console.error(`${LOG_PREFIX} error`, { type, paymentId, error: detail }); - return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); - } -} - -export async function GET(): Promise { - return NextResponse.json({ endpoint: 'coinpayportal/iptv-share', status: 'ready' }); -} +export { POST, GET } from '../route'; diff --git a/src/app/api/webhooks/coinpayportal/route.ts b/src/app/api/webhooks/coinpayportal/route.ts index f88eb908..62c35a7b 100644 --- a/src/app/api/webhooks/coinpayportal/route.ts +++ b/src/app/api/webhooks/coinpayportal/route.ts @@ -16,6 +16,7 @@ import { getPasses, isTranslationPayment } from '@/lib/translate'; import { adoptSessionPayment } from '@/lib/coinpayportal/session-payments'; import { processIptvWebhook } from '@/lib/argontv/webhook'; import { getServerClient } from '@/lib/supabase/client'; +import { dispatchGrantWebhook, grantKindOf } from '@/lib/coinpayportal/grant-webhooks'; // ============================================================================ // Logging Utilities @@ -394,6 +395,46 @@ export async function POST(request: NextRequest): Promise { }); } + // ------------------------------------------------------------------------- + // Pass payments (VOD, seedbox rentals, IPTV resale passes): CoinPay sends + // every event to this one business webhook, so they are dispatched on the + // metadata.type their checkout set (src/lib/coinpayportal/grant-webhooks.ts). + // Each handler flips its grant once, so redeliveries are no-ops. + // ------------------------------------------------------------------------- + const grantKind = payload.type === 'test.webhook' ? null : grantKindOf(payload.data.metadata); + if (grantKind) { + try { + const outcome = await dispatchGrantWebhook(grantKind, { + type: payload.type, + paymentId: payload.data.payment_id, + amountCrypto: payload.data.amount_crypto || null, + currency: payload.data.currency || null, + txHash: payload.data.tx_hash || payload.data.merchant_tx_hash || null, + }); + log(requestId, outcome.handled ? 'INFO' : 'WARN', 'Pass payment webhook', { + kind: grantKind, + paymentId: payload.data.payment_id, + action: outcome.action, + grantId: outcome.grantId, + }); + // An unknown grant is answered 200: delivering it again cannot make it known. + return NextResponse.json({ + success: outcome.handled, + kind: grantKind, + action: outcome.handled ? outcome.action : `ignored:${outcome.action}`, + grantId: outcome.grantId, + requestId, + }); + } catch (error) { + // A 5xx makes CoinPay deliver it again, which a database blip needs. + log(requestId, 'ERROR', 'Pass payment webhook could not settle', { + kind: grantKind, + error: error instanceof Error ? error.message : String(error), + }); + return NextResponse.json({ error: 'could not settle; retry', requestId }, { status: 500 }); + } + } + // ------------------------------------------------------------------------- // Translated audio passes: settled by asking CoinPay (src/lib/translate/passes.ts) // ------------------------------------------------------------------------- diff --git a/src/app/api/webhooks/coinpayportal/share/route.ts b/src/app/api/webhooks/coinpayportal/share/route.ts index 1ad765c2..9fecbdfb 100644 --- a/src/app/api/webhooks/coinpayportal/share/route.ts +++ b/src/app/api/webhooks/coinpayportal/share/route.ts @@ -1,135 +1,14 @@ /** - * CoinPayPortal webhook — seedbox rentals. + * Former per-kind CoinPay webhook (/api/webhooks/coinpayportal/share). * - * Receives payment notifications for seedbox-rental session passes (the - * `webhookUrl` set at checkout points here). Verifies the signature, then flips - * the matching grant to `paid` (setting its expiry from the rental's window) or - * `expired`. Idempotent on the CoinPay payment id. - * - * Mirrors the signature-verifying pattern of the subscription webhook at - * ../route.ts, but dispatches to the seedbox-rental handler. + * CoinPay delivers every event for the business to its ONE webhook_url + * (/api/webhooks/coinpayportal), so this address never received deliveries. + * It stays as a thin forward to that route, which verifies the signature and + * dispatches pass payments on metadata.type (src/lib/coinpayportal/grant-webhooks.ts), + * in case anything still posts here. * * @route POST /api/webhooks/coinpayportal/share * @route GET /api/webhooks/coinpayportal/share */ -import { NextRequest, NextResponse } from 'next/server'; - -import { getCoinPayPortalClient } from '@/lib/coinpayportal/client'; -import { handleShareWebhook } from '@/lib/seedbox/shares'; - -const LOG_PREFIX = '[Webhook:SeedboxRental]'; - -interface FlatWebhookBody { - event?: string; - type?: string; - payment_id?: string; - amount_crypto?: string; - currency?: string; - blockchain?: string; - tx_hash?: string; - metadata?: Record; - data?: { - payment_id?: string; - amount_crypto?: string; - currency?: string; - blockchain?: string; - tx_hash?: string; - metadata?: Record; - }; -} - -const VALID_TYPES = new Set([ - 'payment.detected', - 'payment.confirmed', - 'payment.forwarded', - 'payment.failed', - 'payment.expired', - 'test.webhook', -]); - -export async function POST(request: NextRequest): Promise { - const startedAt = Date.now(); - - let rawBody: string; - try { - rawBody = await request.text(); - } catch { - return NextResponse.json({ error: 'Failed to read body' }, { status: 400 }); - } - - const signature = request.headers.get('x-coinpay-signature'); - if (!signature) { - return NextResponse.json({ error: 'Missing signature' }, { status: 401 }); - } - try { - if (!getCoinPayPortalClient().verifyWebhookSignature(rawBody, signature)) { - return NextResponse.json({ error: 'Invalid signature' }, { status: 401 }); - } - } catch { - return NextResponse.json({ error: 'Signature verification failed' }, { status: 401 }); - } - - let body: FlatWebhookBody; - try { - body = JSON.parse(rawBody) as FlatWebhookBody; - } catch { - return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 }); - } - - const type = body.event || body.type; - const paymentId = body.payment_id || body.data?.payment_id; - const metadata = body.metadata || body.data?.metadata; - - if (!type || !VALID_TYPES.has(type)) { - return NextResponse.json({ error: `Invalid webhook type: ${type ?? 'none'}` }, { status: 400 }); - } - if (type === 'test.webhook') { - return NextResponse.json({ success: true, action: 'test' }, { status: 200 }); - } - if (!paymentId) { - return NextResponse.json({ error: 'Missing payment_id' }, { status: 400 }); - } - // Defense in depth: only handle rental payments (the endpoint is dedicated, - // but guard against misrouted events). - if (metadata && metadata.type && metadata.type !== 'seedbox_share') { - return NextResponse.json({ success: true, action: 'ignored:not_rental' }, { status: 200 }); - } - - try { - const outcome = await handleShareWebhook({ - type, - paymentId, - amountCrypto: body.amount_crypto || body.data?.amount_crypto || null, - currency: body.currency || body.data?.currency || null, - blockchain: body.blockchain || body.data?.blockchain || body.currency || null, - txHash: body.tx_hash || body.data?.tx_hash || null, - }); - - if (!outcome.handled) { - console.warn(`${LOG_PREFIX} unhandled`, { type, paymentId, action: outcome.action }); - return NextResponse.json({ success: false, action: outcome.action }, { status: 422 }); - } - console.log(`${LOG_PREFIX} ${outcome.action}`, { - paymentId, - grantId: outcome.grantId, - ms: Date.now() - startedAt, - }); - return NextResponse.json( - { success: true, action: outcome.action, grantId: outcome.grantId }, - { status: 200 } - ); - } catch (error) { - const detail = error instanceof Error ? error.message : String(error); - console.error(`${LOG_PREFIX} error`, { type, paymentId, error: detail }); - return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); - } -} - -export async function GET(): Promise { - return NextResponse.json({ - status: 'ok', - endpoint: '/api/webhooks/coinpayportal/share', - message: 'Seedbox-rental webhook endpoint is reachable.', - }); -} +export { POST, GET } from '../route'; diff --git a/src/app/api/webhooks/coinpayportal/vod/route.ts b/src/app/api/webhooks/coinpayportal/vod/route.ts index bfb8ffe7..ec9b0b32 100644 --- a/src/app/api/webhooks/coinpayportal/vod/route.ts +++ b/src/app/api/webhooks/coinpayportal/vod/route.ts @@ -1,112 +1,14 @@ /** - * CoinPayPortal webhook — VOD monetization. + * Former per-kind CoinPay webhook (/api/webhooks/coinpayportal/vod). * - * Verifies the signature, then flips the matching VOD grant to `paid` (weekly - * pass / per-title stream get a windowed expiry; a per-title *download* is - * permanent) or `expired`. Idempotent on the CoinPay payment id. Mirrors the - * signature-verifying pattern of the subscription webhook. + * CoinPay delivers every event for the business to its ONE webhook_url + * (/api/webhooks/coinpayportal), so this address never received deliveries. + * It stays as a thin forward to that route, which verifies the signature and + * dispatches pass payments on metadata.type (src/lib/coinpayportal/grant-webhooks.ts), + * in case anything still posts here. * * @route POST /api/webhooks/coinpayportal/vod * @route GET /api/webhooks/coinpayportal/vod */ -import { NextRequest, NextResponse } from 'next/server'; - -import { getCoinPayPortalClient } from '@/lib/coinpayportal/client'; -import { handleVodWebhook } from '@/lib/vod'; - -const LOG_PREFIX = '[Webhook:VOD]'; - -interface FlatWebhookBody { - event?: string; - type?: string; - payment_id?: string; - amount_crypto?: string; - currency?: string; - blockchain?: string; - tx_hash?: string; - metadata?: Record; - data?: { - payment_id?: string; - amount_crypto?: string; - currency?: string; - blockchain?: string; - tx_hash?: string; - metadata?: Record; - }; -} - -const VALID_TYPES = new Set([ - 'payment.detected', - 'payment.confirmed', - 'payment.forwarded', - 'payment.failed', - 'payment.expired', - 'test.webhook', -]); - -export async function POST(request: NextRequest): Promise { - let rawBody: string; - try { - rawBody = await request.text(); - } catch { - return NextResponse.json({ error: 'Failed to read body' }, { status: 400 }); - } - - const signature = request.headers.get('x-coinpay-signature'); - if (!signature) return NextResponse.json({ error: 'Missing signature' }, { status: 401 }); - try { - if (!getCoinPayPortalClient().verifyWebhookSignature(rawBody, signature)) { - return NextResponse.json({ error: 'Invalid signature' }, { status: 401 }); - } - } catch { - return NextResponse.json({ error: 'Signature verification failed' }, { status: 401 }); - } - - let body: FlatWebhookBody; - try { - body = JSON.parse(rawBody) as FlatWebhookBody; - } catch { - return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 }); - } - - const type = body.event || body.type; - const paymentId = body.payment_id || body.data?.payment_id; - const metadata = body.metadata || body.data?.metadata; - - if (!type || !VALID_TYPES.has(type)) { - return NextResponse.json({ error: `Invalid webhook type: ${type ?? 'none'}` }, { status: 400 }); - } - if (type === 'test.webhook') { - return NextResponse.json({ success: true, action: 'test' }, { status: 200 }); - } - if (!paymentId) return NextResponse.json({ error: 'Missing payment_id' }, { status: 400 }); - if (metadata && metadata.type && metadata.type !== 'vod') { - return NextResponse.json({ success: true, action: 'ignored:not_vod' }, { status: 200 }); - } - - try { - const outcome = await handleVodWebhook({ - type, - paymentId, - amountCrypto: body.amount_crypto || body.data?.amount_crypto || null, - currency: body.currency || body.data?.currency || null, - blockchain: body.blockchain || body.data?.blockchain || body.currency || null, - txHash: body.tx_hash || body.data?.tx_hash || null, - }); - if (!outcome.handled) { - console.warn(`${LOG_PREFIX} unhandled`, { type, paymentId, action: outcome.action }); - return NextResponse.json({ success: false, action: outcome.action }, { status: 422 }); - } - console.log(`${LOG_PREFIX} ${outcome.action}`, { paymentId, grantId: outcome.grantId }); - return NextResponse.json({ success: true, action: outcome.action, grantId: outcome.grantId }, { status: 200 }); - } catch (error) { - const detail = error instanceof Error ? error.message : String(error); - console.error(`${LOG_PREFIX} error`, { type, paymentId, error: detail }); - return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); - } -} - -export async function GET(): Promise { - return NextResponse.json({ status: 'ok', endpoint: '/api/webhooks/coinpayportal/vod' }); -} +export { POST, GET } from '../route'; diff --git a/src/lib/coinpayportal/grant-webhooks.ts b/src/lib/coinpayportal/grant-webhooks.ts new file mode 100644 index 00000000..27984895 --- /dev/null +++ b/src/lib/coinpayportal/grant-webhooks.ts @@ -0,0 +1,59 @@ +/** + * Pass/grant payments (VOD, seedbox rentals, IPTV resale passes) settled from + * the ONE CoinPay webhook. + * + * CoinPay delivers every event for a business to that business's single + * webhook_url (https://bittorrented.com/api/webhooks/coinpayportal); the + * per-payment `webhook_url` these checkouts used to send is not where events + * go. So the main route reads `metadata.type` and hands the event to the + * matching grant handler here. Each handler is idempotent on the CoinPay + * payment id: a grant flips to paid once (a conditional update), so a + * redelivery or confirmed-then-forwarded pair credits the provider once. + */ + +import { handleVodWebhook } from '@/lib/vod'; +import { handleShareWebhook } from '@/lib/seedbox/shares'; +import { handleIptvShareWebhook } from '@/lib/iptv/shares'; + +/** The `metadata.type` each grant checkout sets, as stored on the CoinPay payment. */ +export const GRANT_KINDS = ['vod', 'seedbox_share', 'iptv_share'] as const; +export type GrantKind = (typeof GRANT_KINDS)[number]; + +export function grantKindOf(metadata: Record | null | undefined): GrantKind | null { + const type = metadata?.type; + return typeof type === 'string' && (GRANT_KINDS as readonly string[]).includes(type) ? (type as GrantKind) : null; +} + +export interface GrantWebhookInput { + type: string; + paymentId: string; + amountCrypto?: string | null; + currency?: string | null; + blockchain?: string | null; + txHash?: string | null; +} + +export interface GrantWebhookOutcome { + handled: boolean; + action: string; + grantId?: string; +} + +export async function dispatchGrantWebhook(kind: GrantKind, input: GrantWebhookInput): Promise { + const payload = { + type: input.type, + paymentId: input.paymentId, + amountCrypto: input.amountCrypto ?? null, + currency: input.currency ?? null, + blockchain: input.blockchain ?? input.currency ?? null, + txHash: input.txHash ?? null, + }; + switch (kind) { + case 'vod': + return handleVodWebhook(payload); + case 'seedbox_share': + return handleShareWebhook(payload); + case 'iptv_share': + return handleIptvShareWebhook(payload); + } +} diff --git a/src/lib/iptv/shares/service.ts b/src/lib/iptv/shares/service.ts index 9da0633c..d6bf9fa1 100644 --- a/src/lib/iptv/shares/service.ts +++ b/src/lib/iptv/shares/service.ts @@ -294,7 +294,7 @@ export async function createCheckout( blockchain, description: `IPTV pass: ${share.title}`.slice(0, 140), metadata: { type: 'iptv_share', shareId: share.id, grantId: grant.id }, - webhookUrl: base ? `${base}/api/webhooks/coinpayportal/iptv-share` : undefined, + webhookUrl: base ? `${base}/api/webhooks/coinpayportal` : undefined, redirectUrl: base ? `${base}/watch/${share.slug}?grant=${grant.id}` : undefined, merchantWalletAddress: share.payoutWalletAddress ?? undefined, }); diff --git a/src/lib/seedbox/shares/repository.ts b/src/lib/seedbox/shares/repository.ts index 1430894f..c02aa21c 100644 --- a/src/lib/seedbox/shares/repository.ts +++ b/src/lib/seedbox/shares/repository.ts @@ -323,6 +323,7 @@ export async function getGrantByPaymentId(paymentId: string): Promise { - const { error } = await db() +): Promise { + const { data, error } = await db() .from('seedbox_share_grants') .update({ status: 'paid', @@ -347,8 +348,14 @@ export async function markGrantPaid( webhook_event_type: fields.webhookEventType ?? null, webhook_received_at: new Date().toISOString(), }) - .eq('id', grantId); + .eq('id', grantId) + // Once only: a redelivery, or confirmed then forwarded, finds it paid + // already, so the earnings are counted once per payment. + .in('status', ['pending', 'expired']) + .select('id') + .maybeSingle(); if (error) throw new Error(`Failed to mark session pass paid: ${error.message}`); + return Boolean(data); } export async function updateGrantTokenHash(grantId: string, grantTokenHash: string): Promise { diff --git a/src/lib/seedbox/shares/service.ts b/src/lib/seedbox/shares/service.ts index f1b69d25..9fb3c4d9 100644 --- a/src/lib/seedbox/shares/service.ts +++ b/src/lib/seedbox/shares/service.ts @@ -270,7 +270,7 @@ export async function createCheckout( blockchain, description: `Seedbox rental: ${share.title}`.slice(0, 140), metadata: { type: 'seedbox_share', shareId: share.id, grantId: grant.id }, - webhookUrl: base ? `${base}/api/webhooks/coinpayportal/share` : undefined, + webhookUrl: base ? `${base}/api/webhooks/coinpayportal` : undefined, redirectUrl: base ? `${base}/rent/${share.slug}?grant=${grant.id}` : undefined, merchantWalletAddress: share.payoutWalletAddress ?? undefined, }); @@ -703,7 +703,7 @@ export async function handleShareWebhook(payload: { const share = await repo.getShareById(grant.shareId); const windowMinutes = share?.passWindowMinutes ?? 1440; const expiresAt = new Date(Date.now() + windowMinutes * 60_000).toISOString(); - await repo.markGrantPaid(grant.id, { + const changed = await repo.markGrantPaid(grant.id, { expiresAt, amountCrypto: payload.amountCrypto ?? null, cryptoCurrency: payload.currency ?? null, @@ -711,6 +711,7 @@ export async function handleShareWebhook(payload: { txHash: payload.txHash ?? null, webhookEventType: payload.type, }); + if (!changed) return { handled: true, action: 'already_paid', grantId: grant.id }; await repo.incrementShareEarnings(grant.shareId, grant.amountUsd); return { handled: true, action: 'paid', grantId: grant.id }; } diff --git a/src/lib/vod/repository.ts b/src/lib/vod/repository.ts index bb6112bb..2d42e6d8 100644 --- a/src/lib/vod/repository.ts +++ b/src/lib/vod/repository.ts @@ -363,6 +363,7 @@ export async function getGrantByPaymentId(paymentId: string): Promise { - const { error } = await db() +): Promise { + const { data, error } = await db() .from('vod_grants') .update({ status: 'paid', @@ -387,8 +388,14 @@ export async function markGrantPaid( webhook_event_type: fields.webhookEventType ?? null, webhook_received_at: new Date().toISOString(), }) - .eq('id', grantId); + .eq('id', grantId) + // Once only: a redelivery, or confirmed then forwarded, finds it paid + // already, so the earnings are counted once per payment. + .in('status', ['pending', 'expired']) + .select('id') + .maybeSingle(); if (error) throw new Error(`Failed to mark grant paid: ${error.message}`); + return Boolean(data); } export async function markGrantStatus(grantId: string, status: GrantStatus): Promise { diff --git a/src/lib/vod/service.ts b/src/lib/vod/service.ts index 83d569a6..f0ff2f5f 100644 --- a/src/lib/vod/service.ts +++ b/src/lib/vod/service.ts @@ -292,7 +292,7 @@ export async function createCheckout( blockchain, description: `${provider.title}: ${opts.kind === 'weekly' ? 'weekly pass' : 'title'}`.slice(0, 140), metadata: { type: 'vod', providerId: provider.id, grantId: grant.id }, - webhookUrl: base ? `${base}/api/webhooks/coinpayportal/vod` : undefined, + webhookUrl: base ? `${base}/api/webhooks/coinpayportal` : undefined, redirectUrl: base ? `${base}/vod/${provider.slug}?grant=${grant.id}` : undefined, merchantWalletAddress: provider.payoutWalletAddress ?? undefined, }); @@ -486,7 +486,7 @@ export async function handleVodWebhook(payload: { const permanent = grant.grantKind === 'title' && grant.accessMode === 'download'; const windowMin = provider?.passWindowMinutes ?? 10080; const expiresAt = permanent ? null : new Date(Date.now() + windowMin * 60_000).toISOString(); - await repo.markGrantPaid(grant.id, { + const changed = await repo.markGrantPaid(grant.id, { expiresAt, amountCrypto: payload.amountCrypto ?? null, cryptoCurrency: payload.currency ?? null, @@ -494,6 +494,7 @@ export async function handleVodWebhook(payload: { txHash: payload.txHash ?? null, webhookEventType: payload.type, }); + if (!changed) return { handled: true, action: 'already_paid', grantId: grant.id }; await repo.incrementProviderEarnings(grant.providerId, grant.amountUsd); return { handled: true, action: 'paid', grantId: grant.id }; }