From d551fabf027548d30c70e3ec7715f887a7fb1a08 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 13 May 2026 19:03:54 +0000 Subject: [PATCH 1/2] docs: add NowPayments integration plan Captures the locked-in v1 scope (crypto top-ups alongside Stripe), DB provider column strategy, IPN webhook handling, and deferred v2 subscription work. --- docs/specs/nowpayments-integration.md | 241 ++++++++++++++++++++++++++ 1 file changed, 241 insertions(+) create mode 100644 docs/specs/nowpayments-integration.md diff --git a/docs/specs/nowpayments-integration.md b/docs/specs/nowpayments-integration.md new file mode 100644 index 000000000..f777b0bf4 --- /dev/null +++ b/docs/specs/nowpayments-integration.md @@ -0,0 +1,241 @@ +# NowPayments Integration Plan + +Status: Approved (decisions locked-in 2026-05-13) +Branch: `claude/add-nowpayments-integration-HyvHg` +API reference: https://documenter.getpostman.com/view/7907941/2s93JusNJt + +## Goal + +Add NowPayments (crypto) as a second payment provider alongside the existing +Stripe integration, supporting one-time credit top-ups in v1 and crypto +subscriptions in v2. + +## Context recap + +What we parallel: + +- **Monorepo:** Next.js 15 app at `apps/web` (app router, Node 24, pnpm). +- **Pricing config:** `apps/web/lib/stripe/pricing.ts` — 3 tiers ($5/$10/$75) for + both top-up and subscription; subscription adds a 15% credit bonus. +- **Stripe server action:** `apps/web/app/[lang]/actions/stripe.ts` → creates + Checkout Session. +- **Stripe webhook:** `apps/web/app/api/stripe/webhook/route.ts` → on + `checkout.session.completed` / `invoice.payment_succeeded`, awards credits + via `insertTopupCreditTransaction` / `insertSubscriptionCreditTransaction`. +- **DB:** `credit_transactions` (enum `purchase|usage|freemium|topup|refund`, + plus `reference_id`, `subscription_id`, `metadata JSONB`); `profiles.stripe_id` + links the Supabase user to a Stripe customer. +- **Idempotency key:** `reference_id` (Stripe `payment_intent`). +- **UI:** `apps/web/app/[lang]/(dashboard)/dashboard/credits/credit-topup.tsx` + calls the server action. + +## How NowPayments differs from Stripe (important constraints) + +1. **No card-on-file.** Crypto can't auto-charge. NowPayments "subscriptions" + are really *recurring email-invoices*: each cycle they email the customer a + payment link. The customer must click & pay every period. Worth telling + users explicitly. +2. **Hosted invoice ≈ Stripe Checkout.** `POST /v1/invoice` → returns + `invoice_url`; we redirect. +3. **IPN webhook** is HMAC-SHA512 over the *sorted JSON body* using the IPN + secret, header `x-nowpayments-sig`. Must verify before trusting. +4. **Status state-machine:** + `waiting → confirming → confirmed → sending → finished`, plus + `partially_paid`, `failed`, `refunded`, `expired`. Credits are only granted + on `finished` (and possibly `partially_paid` if amount ≥ expected). +5. **Idempotency:** the same `payment_id`/`invoice_id` will receive multiple + IPN calls as it transitions — must dedupe by `(provider, reference_id)`. +6. **Subscriptions need JWT.** `/v1/subscriptions/*` endpoints require Bearer + auth from `/v1/auth` (email/password), not just the API key. We'll cache + the JWT. (v2 scope.) + +## Locked-in decisions + +| Decision | Choice | +| --- | --- | +| Coexistence in `credit_transactions` | Add a `provider` column (`'stripe' \| 'nowpayments'`) | +| v1 scope | Top-ups only; crypto subscriptions deferred to v2 | +| UI placement | Tabs *Card (Stripe)* / *Crypto (NowPayments)* on `/dashboard/credits` | +| Accepted coins | Curated list: BTC, ETH, USDT, USDC, SOL, LTC | + +## Architecture (v1) + +### 1. DB migrations (`apps/web/supabase/migrations/`) + +**`_add_provider_to_credit_transactions.sql`** + +- Add `provider TEXT NOT NULL DEFAULT 'stripe'` to `credit_transactions` with + `CHECK (provider IN ('stripe','nowpayments'))`. +- Drop existing `credit_transactions_reference_id_idx`. +- Add composite uniqueness: + `UNIQUE (provider, reference_id) WHERE reference_id IS NOT NULL`. +- Backfill not needed (default takes care of existing rows). + +### 2. Pricing config + +Reuse `apps/web/lib/stripe/pricing.ts` as-is. NowPayments invoices take a USD +amount directly — no price IDs needed for top-ups. If we later add crypto +subscriptions (v2), we'll create matching NowPayments plans and store their +IDs as env vars. + +### 3. New files + +``` +apps/web/lib/nowpayments/ + client.ts # fetch wrapper, base URL toggle (sandbox) + invoice.ts # createInvoice({ priceAmountUsd, orderId, ... }) + ipn.ts # verifySignature(rawBody, sigHeader) — HMAC-SHA512 sorted-keys + types.ts # IPN payload + invoice response types + +apps/web/app/[lang]/actions/nowpayments.ts + createCryptoTopupCheckout(formData, packageId) + → returns { url } (the NowPayments invoice_url) + +apps/web/app/api/nowpayments/webhook/route.ts + POST handler: verify sig → switch on payment_status → award credits + (mirrors stripe/webhook/route.ts structure) + +apps/web/app/[lang]/(dashboard)/dashboard/credits/crypto-topup.tsx + Mirrors credit-topup.tsx but calls createCryptoTopupCheckout +``` + +### 4. Top-up flow + +1. User clicks **Pay with crypto** on the Crypto tab of `/dashboard/credits`. +2. Server action: + - Authenticate user via Supabase. + - Look up the selected package from `getTopupPackages('en')`. + - Generate `order_id = "topup_${userId}_${nanoid()}"`. + - Insert a *pending* row in `credit_transactions`: + `{ provider: 'nowpayments', reference_id: orderId, amount: 0, + type: 'topup', metadata: { status: 'pending', packageId, credits, + dollarAmount } }`. + - Call `POST /v1/invoice` with: + - `price_amount: package.dollarAmount`, `price_currency: 'usd'` + - `order_id: orderId` + - `order_description: " credits top-up"` + - `ipn_callback_url: ${SITE_URL}/api/nowpayments/webhook` + - `success_url`, `cancel_url` mirroring Stripe's + - `pay_currency` whitelist (optional, hosted page lets user pick from + enabled coins) + - Update the pending row's metadata with the returned `invoiceId`. + - Return `invoice_url`. +3. Client redirects to `invoice_url`. +4. IPN webhook: + - Read raw body, verify HMAC-SHA512, parse JSON. + - Switch on `payment_status`: + - `finished` → atomically look up the pending row by `(provider='nowpayments', + reference_id=order_id)`, set `amount = credits` and + `metadata.status = 'finished'`, then call `increment_user_credits` RPC. + - `partially_paid` → update metadata, Sentry breadcrumb, no credit grant. + - `failed` / `expired` / `refunded` → update metadata, Sentry breadcrumb, + no credit grant. + - Return 200 (400 only on invalid signature). + +We persist the pending row up-front because the IPN payload echoes only +`order_id` and amount — without it we'd have to retain user → invoice mapping +elsewhere (Redis or otherwise) to handle race conditions where the IPN beats +the user back to the success page. + +### 5. UI changes + +- `apps/web/app/[lang]/(dashboard)/dashboard/credits/page.tsx` — wrap the + top-up section in ``: + - Tab 1: *Card* — existing `credit-topup.tsx`. + - Tab 2: *Crypto* — new `crypto-topup.tsx`, with a one-line disclaimer + about crypto confirmation times. +- `crypto-topup.tsx` mirrors `credit-topup.tsx`: same cards, same pricing, + same `Buy Credits` CTA wired to `createCryptoTopupCheckout`. +- `credit-history.tsx` — render a small provider icon (card / coin) per row, + reading from the new `provider` column. + +### 6. Webhook security + +- Body must be read as raw text *before* `JSON.parse` (same pattern as + `stripe/webhook/route.ts` line 18). +- `verifySignature`: + - Recursively sort all object keys (NowPayments quirk — applies to nested + objects and arrays-of-objects). + - `crypto.createHmac('sha512', IPN_SECRET).update(sortedJson).digest('hex')`. + - Compare to `x-nowpayments-sig` via `crypto.timingSafeEqual`. +- Reject non-matching with 400; no Sentry noise on signature failures + (they're typically scanners). + +### 7. Idempotency / safety + +- Composite unique `(provider, reference_id)` blocks double-credit at DB + level. +- `reference_id` semantics: + - Top-ups: our generated `order_id` (echoed back by NowPayments in IPN). + - Subscription payments (v2): NowPayments `payment_id` (unique per cycle). +- All credit grants go through the existing `increment_user_credits` RPC; no + new mutation path. + +### 8. Environment variables (`.env.example`) + +```env +NOWPAYMENTS_API_KEY= +NOWPAYMENTS_IPN_SECRET= +NOWPAYMENTS_SANDBOX=false +NOWPAYMENTS_PAY_CURRENCIES=btc,eth,usdttrc20,usdcmatic,sol,ltc +``` + +For v2 (subscriptions), add: + +```env +NOWPAYMENTS_EMAIL= +NOWPAYMENTS_PASSWORD= +NOWPAYMENTS_SUB_5_PLAN_ID= +NOWPAYMENTS_SUB_10_PLAN_ID= +NOWPAYMENTS_SUB_99_PLAN_ID= +``` + +### 9. Tests + +- Unit: `verifySignature` happy path with a known-good fixture from + NowPayments docs + negative case (tampered body, wrong secret). +- Unit: key-sorter handles nested objects, arrays of objects, and primitive + values. +- Integration (manual, sandbox): full top-up flow against + `api-sandbox.nowpayments.io`. + +## Implementation order + +1. Migration + queries refactor (thread `provider` through the existing + `insertTopupCreditTransaction`; default `'stripe'` for backwards compat). +2. `lib/nowpayments/*` (client, invoice, ipn, types) + HMAC unit tests. +3. Server action `actions/nowpayments.ts` + webhook route (top-up happy path). +4. UI: Tabs on `/dashboard/credits`, new `crypto-topup.tsx`, provider icon + in `credit-history.tsx`. +5. End-to-end sandbox test against `api-sandbox.nowpayments.io`. +6. Polish: `partially_paid`, `expired`, `refunded` handling; Sentry tags + `section: 'nowpayments_webhook'`; admin recovery script for stuck + pending invoices. + +## v2 scope (not in this PR) + +- `crypto_subscriptions` table to mirror what Redis caches for Stripe. +- JWT auth (`/v1/auth`) caching in Redis with 5-min TTL. +- `lib/nowpayments/subscriptions.ts`: createSubscriber, deleteSubscriber, + listPayments. +- Plans bootstrap script in `scripts/` to create the 3 plans and print their + IDs for env config. +- IPN handling for recurring `purchase`-type credits, applying the same 15% + bonus multiplier as Stripe subscriptions. +- Cancel flow in dashboard. +- User-facing disclaimer: *"Crypto subscriptions send you an email reminder + each cycle — you'll need to confirm payment manually."* + +## Open risks + +- **Sandbox vs production parity.** NowPayments' sandbox occasionally behaves + differently around IPN timing; budget for a production smoke test with a + $1 invoice before announcing. +- **Stuck pending invoices.** A user could create an invoice and never pay; + we'll accumulate `metadata.status='pending'` rows with `amount=0`. These + don't affect credit balance, but a periodic cleanup job (or filter in + `credit-history.tsx`) keeps the history clean. +- **Refunds.** NowPayments refunds are manual. If we see `payment_status: + refunded` after we've credited, we need to insert a compensating + `refund`-type row. The enum already supports `refund` — handler stub is + cheap to add now even if rare. From 622eb4a9ccda62eaeaa7e65ee75017d213954a09 Mon Sep 17 00:00:00 2001 From: gianpaj Date: Thu, 2 Jul 2026 23:54:16 +0200 Subject: [PATCH 2/2] stash --- .../2026-04-26-coinbase-crypto-payments.md | 217 +++++++++++ ...-04-22-coinbase-business-crypto-payment.md | 220 +++++++++++ .../specs/nowpayments-implementation-guide.md | 341 ++++++++++++++++++ docs/specs/nowpayments-integration.md | 102 ++++-- 4 files changed, 858 insertions(+), 22 deletions(-) create mode 100644 apps/web/docs/plans/2026-04-26-coinbase-crypto-payments.md create mode 100644 docs/plans/2026-04-22-coinbase-business-crypto-payment.md create mode 100644 docs/specs/nowpayments-implementation-guide.md diff --git a/apps/web/docs/plans/2026-04-26-coinbase-crypto-payments.md b/apps/web/docs/plans/2026-04-26-coinbase-crypto-payments.md new file mode 100644 index 000000000..f72925186 --- /dev/null +++ b/apps/web/docs/plans/2026-04-26-coinbase-crypto-payments.md @@ -0,0 +1,217 @@ +# Plan: Coinbase Business Crypto Payments Integration + +**Date:** 2026-04-26 +**Branch target:** `feat/coinbase-crypto-payments` + +--- + +## Context + +SexyVoice accepts fiat payments via Stripe (one-time topups + subscriptions). This adds **crypto payment** as an alternative for one-time credit topups ($5/$10/$75), using the **Coinbase Business Checkouts API** (the current replacement for Coinbase Commerce, shut down March 31, 2026). + +Subscriptions stay Stripe-only. Crypto covers `topup` transactions only. + +--- + +## Flow + +``` +User clicks "Pay with Crypto" + └── PlanCard: second useActionState → createCoinbaseCheckoutSession(formData, packageId) + └── POST https://business.coinbase.com/api/v1/checkouts (JWT auth) + └── Store coinbase:checkout:{id} → {userId, packageId, credits, dollarAmount} in Redis (TTL 2h) + └── Return { url: hosted_url } + └── window.location.assign(hosted_url) + +User pays on Coinbase-hosted page + └── Coinbase fires POST /api/coinbase/webhook + └── Verify X-Hook0-Signature (HMAC-SHA256, node:crypto) + └── On charge:confirmed → Redis GET coinbase:checkout:{checkoutId} + └── insertTopupCreditTransaction(userId, checkoutId, credits, dollarAmount, packageId) + └── Deduplication via reference_id = checkoutId (same logic as Stripe) + └── Return 200 { received: true } +``` + +--- + +## Dependency: `jose` + +`jose` is **not** in `apps/web/package.json`. It must be added. It is needed to sign Ed25519 JWTs for the Coinbase CDP API authentication (2-minute TTL bearer tokens). Using `jose` over manual `crypto.subtle` avoids hand-rolling Base64URL encoding and JWT structure. + +Add to `apps/web/package.json` dependencies: +``` +"jose": "^5.x" +``` + +--- + +## Files to Create + +### `apps/web/lib/coinbase/coinbase-admin.ts` + +Three exports: + +1. **`getCoinbaseJWT(method, path)`** — builds a short-lived Ed25519 JWT: + - Header: `{ alg: "EdDSA", kid: COINBASE_API_KEY_ID, typ: "JWT" }` + - Claims: `{ iss: "cdp", sub: COINBASE_API_KEY_ID, nbf: now, exp: now+120, uri: " business.coinbase.com" }` + - Signs with `COINBASE_API_KEY_SECRET` (PEM) via `jose`'s `SignJWT` + +2. **`createCoinbaseCheckout({ packageId, credits, dollarAmount, successUrl, cancelUrl })`** — calls `POST https://business.coinbase.com/api/v1/checkouts`, returns `{ id, hosted_url }`. + +3. **`verifyCoinbaseWebhookSignature(rawBody, signatureHeader, secret)`** — HMAC-SHA256 via `node:crypto`'s `createHmac` + `timingSafeEqual`. ⚠️ Exact header name and signed-payload format must be validated (see Open Questions). + +### `apps/web/app/[lang]/actions/coinbase.ts` + +```typescript +'use server'; + +export async function createCoinbaseCheckoutSession( + data: FormData, + packageId: PackageType, +): Promise<{ url: string | null }> +``` + +- Gets authenticated user (same `supabase.auth.getUser()` pattern as `stripe.ts`) +- Looks up package from `getTopupPackages('en')` in `lib/stripe/pricing.ts` (provider-agnostic despite the path) +- Calls `createCoinbaseCheckout()` from `coinbase-admin.ts` +- Stores pending metadata in Redis via new helper +- Returns `{ url }` on success, catches + Sentry-reports on failure + +### `apps/web/app/api/coinbase/webhook/route.ts` + +Mirror structure of `app/api/stripe/webhook/route.ts`: + +- `POST` handler reads raw body via `req.text()` (not `req.json()`) +- Reads `X-Hook0-Signature` header (⚠️ validate name) +- Calls `verifyCoinbaseWebhookSignature()`; returns 400 if invalid +- Handles `charge:confirmed` event (⚠️ validate exact name): looks up `coinbase:checkout:{checkoutId}` from Redis, calls `insertTopupCreditTransaction()` +- Always returns `NextResponse.json({ received: true })` with 200 +- Sentry tags: `section: 'coinbase_webhook'` + +--- + +## Files to Modify + +### `apps/web/lib/redis/queries.ts` + +Add two helpers alongside the existing `setCustomerData`/`getCustomerData` pattern: + +```typescript +const COINBASE_CHECKOUT_TTL = 60 * 60 * 2; // 2 hours + +export interface CoinbaseCheckoutData { + userId: string; + packageId: string; + credits: number; + dollarAmount: number; +} + +export function setCoinbaseCheckoutData(checkoutId: string, data: CoinbaseCheckoutData) { + return getRedisClient().set( + `coinbase:checkout:${checkoutId}`, + JSON.stringify(data), + { ex: COINBASE_CHECKOUT_TTL }, + ); +} + +export async function getCoinbaseCheckoutData( + checkoutId: string, +): Promise { + const result = await getRedisClient().get(`coinbase:checkout:${checkoutId}`); + if (!result) return null; + if (typeof result === 'string') return JSON.parse(result); + return result as CoinbaseCheckoutData; +} +``` + +### `apps/web/app/[lang]/(dashboard)/dashboard/credits/credit-topup.tsx` + +Add a second `useActionState` + `
` inside `PlanCard`, below the existing Stripe button: + +```tsx +const [cryptoState, cryptoFormAction, cryptoPending] = useActionState( + async (_prev: ActionState, formData: FormData): Promise => { + const packageId = formData.get('packageId') as PackageType; + const { url } = await createCoinbaseCheckoutSession(formData, packageId); + if (url) { window.location.assign(url); return { error: null, success: true }; } + return { error: creditsDict.status.checkoutError, success: false }; + }, + initialState, +); +``` + +```tsx + + + + +``` + +Shown on all 3 plans (Starter/Standard/Pro). Label hardcoded English for now. + +### `apps/web/.env.example` + +Add after the Stripe block: +```bash +# Coinbase CDP (server-side only) +COINBASE_API_KEY_ID= # e.g. organizations/xxx/apiKeys/yyy +COINBASE_API_KEY_SECRET= # Ed25519 private key (PEM, multiline) +COINBASE_WEBHOOK_SECRET= # Webhook shared secret from Coinbase Business dashboard +``` + +--- + +## Functions Reused (no changes) + +| Function | File | Notes | +|---|---|---| +| `insertTopupCreditTransaction()` | `lib/supabase/queries.ts:400` | Pass `checkoutId` as `paymentIntentId`; dedup via `reference_id` works unchanged | +| `getTopupPackages()` | `lib/stripe/pricing.ts` | Returns credits + dollarAmount per packageId | +| `createAdminClient()` | `lib/supabase/server.ts` | Used inside `insertTopupCreditTransaction` already | +| `createHmac`, `timingSafeEqual` | `node:crypto` | Standard Node.js, no new deps | + +--- + +## Open Questions — Validate Before Implementing Webhook + +| # | Question | Impact | How to validate | +|---|---|---|---| +| 1 | Exact webhook header name (`X-Hook0-Signature` vs `X-CC-Webhook-Signature`)? | Breaks all webhooks if wrong | Check Business dashboard webhook setup page | +| 2 | Signed-payload format for HMAC (`t=.h=.v1=` or Stripe-style `t=,v1=`)? | Wrong HMAC = reject all events | Send test webhook from dashboard, inspect raw header | +| 3 | Exact event name for completed payment (`charge:confirmed`? `charge.confirmed`?)? | Credits never awarded | Check Business webhook event list; fire test event | +| 4 | Does Checkouts API support a `metadata` field? | If yes, skip Redis — embed metadata directly | Check `POST /api/v1/checkouts` request schema | +| 5 | CDP API key algorithm: ES256 (ECDSA P-256) or EdDSA (Ed25519)? | Determines JWT `alg` + jose signing method | Check key type in CDP dashboard | +| 6 | Does checkout response include `hosted_url`? | Determines redirect field name | Create a test checkout, inspect response | +| 7 | Sandbox/testnet available? | Needed for local e2e testing | Check CDP dashboard for test mode | + +**Recommendation:** Create a Coinbase Business account and fire a test checkout before writing the webhook handler. The admin module + server action can be written first (they only depend on the checkout response shape). + +--- + +## Implementation Order + +1. `pnpm add jose` in `apps/web/` +2. `lib/coinbase/coinbase-admin.ts` — JWT auth + checkout creation + signature verification stub +3. `lib/redis/queries.ts` — add `setCoinbaseCheckoutData` / `getCoinbaseCheckoutData` +4. `app/[lang]/actions/coinbase.ts` — server action +5. `app/api/coinbase/webhook/route.ts` — webhook handler (fill event name + sig format from sandbox) +6. `app/[lang]/(dashboard)/dashboard/credits/credit-topup.tsx` — add second form + button +7. `.env.example` — add 3 env vars +8. `tests/coinbase-webhook.test.ts` — mirror `stripe-webhook.test.ts` structure + +--- + +## Verification + +1. `pnpm test` — confirm no regressions before starting +2. Set `COINBASE_API_KEY_ID`, `COINBASE_API_KEY_SECRET`, `COINBASE_WEBHOOK_SECRET` in `.env.local` +3. Trigger server action via UI — confirm `hosted_url` returned and redirect fires +4. Complete test payment in Coinbase sandbox → confirm webhook fires +5. Check Supabase `credit_transactions`: new row with `type='topup'`, `reference_id=`, correct `amount` +6. Replay same webhook → confirm no duplicate row (dedup check) +7. Confirm credits balance updates in UI +8. Complete a Stripe topup → confirm Stripe flow still works (no regression) diff --git a/docs/plans/2026-04-22-coinbase-business-crypto-payment.md b/docs/plans/2026-04-22-coinbase-business-crypto-payment.md new file mode 100644 index 000000000..6c5d5c64b --- /dev/null +++ b/docs/plans/2026-04-22-coinbase-business-crypto-payment.md @@ -0,0 +1,220 @@ +# Plan: Coinbase Business Crypto Payments Integration + +**Date:** 2026-04-22 +**Branch target:** `feat/coinbase-crypto-payments` + +--- + +## Context + +SexyVoice accepts fiat payments via Stripe (one-time topups + subscriptions). This adds **crypto payment** as an alternative for one-time credit topups ($5/$10/$75), using the **Coinbase Business Checkouts API** (the current replacement for Coinbase Commerce, shut down March 31, 2026). + +Subscriptions stay Stripe-only. Crypto covers `topup` transactions only. + +--- + +## Flow + +``` +User clicks "Pay with Crypto" + └── PlanCard: second useActionState → createCoinbaseCheckoutSession(formData, packageId) + └── POST https://business.coinbase.com/api/v1/checkouts (JWT auth) + └── Store coinbase:checkout:{id} → {userId, packageId, credits, dollarAmount} in Redis (TTL 2h) + └── Return { url: hosted_url } + └── window.location.assign(hosted_url) + +User pays on Coinbase-hosted page + └── Coinbase fires POST /api/coinbase/webhook + └── Verify X-Hook0-Signature (HMAC-SHA256, node:crypto) + └── On charge:confirmed → Redis GET coinbase:checkout:{checkoutId} + └── insertTopupCreditTransaction(userId, checkoutId, credits, dollarAmount, packageId) + └── Deduplication via reference_id = checkoutId (same logic as Stripe) + └── Return 200 { received: true } +``` + +--- + +## Dependency: `jose` + +`jose` is **not** in `apps/web/package.json`. It must be added. It is needed to sign Ed25519 JWTs for the Coinbase CDP API authentication (2-minute TTL bearer tokens). Using `jose` over manual `crypto.subtle` avoids hand-rolling Base64URL encoding and JWT structure. + +Add to `apps/web/package.json` dependencies: +``` +"jose": "^5.x" +``` + +--- + +## Files to Create + +### `apps/web/lib/coinbase/coinbase-admin.ts` + +Three exports: + +1. **`getCoinbaseJWT(method, path)`** — builds a short-lived Ed25519 JWT: + - Header: `{ alg: "EdDSA", kid: COINBASE_API_KEY_ID, typ: "JWT" }` + - Claims: `{ iss: "cdp", sub: COINBASE_API_KEY_ID, nbf: now, exp: now+120, uri: " business.coinbase.com" }` + - Signs with `COINBASE_API_KEY_SECRET` (PEM) via `jose`'s `SignJWT` + +2. **`createCoinbaseCheckout({ packageId, credits, dollarAmount, successUrl, cancelUrl })`** — calls `POST https://business.coinbase.com/api/v1/checkouts`, returns `{ id, hosted_url }`. + +3. **`verifyCoinbaseWebhookSignature(rawBody, signatureHeader, secret)`** — HMAC-SHA256 via `node:crypto`'s `createHmac` + `timingSafeEqual` (same pattern as `lib/supabase/oauth-callback-marker.ts`). ⚠️ Exact header name and signed-payload format must be validated (see Open Questions). + +### `apps/web/app/[lang]/actions/coinbase.ts` + +```typescript +'use server'; + +export async function createCoinbaseCheckoutSession( + data: FormData, + packageId: PackageType, +): Promise<{ url: string | null }> +``` + +- Gets authenticated user (same `supabase.auth.getUser()` pattern as `stripe.ts`) +- Looks up package from `getTopupPackages('en')` in `lib/stripe/pricing.ts` (provider-agnostic, despite the path) +- Calls `createCoinbaseCheckout()` from `coinbase-admin.ts` +- Stores pending metadata in Redis via new helper (see below) +- Returns `{ url }` on success, catches + Sentry-reports on failure + +### `apps/web/app/api/coinbase/webhook/route.ts` + +Mirror structure of `app/api/stripe/webhook/route.ts`: + +- `POST` handler reads raw body via `req.text()` (not `req.json()`) +- Reads `X-Hook0-Signature` header (⚠️ validate name) +- Calls `verifyCoinbaseWebhookSignature()`; returns 400 if invalid +- Handles `charge:confirmed` event (⚠️ validate exact name): looks up `coinbase:checkout:{checkoutId}` from Redis, calls `insertTopupCreditTransaction()` +- Always returns `NextResponse.json({ received: true })` with 200 +- Sentry tags: `section: 'coinbase_webhook'` + +--- + +## Files to Modify + +### `apps/web/lib/redis/queries.ts` + +Add two helpers alongside the existing `setCustomerData`/`getCustomerData` pattern. Use the same `getRedisClient()` accessor: + +```typescript +const COINBASE_CHECKOUT_TTL = 60 * 60 * 2; // 2 hours + +export interface CoinbaseCheckoutData { + userId: string; + packageId: string; + credits: number; + dollarAmount: number; +} + +export function setCoinbaseCheckoutData(checkoutId: string, data: CoinbaseCheckoutData) { + return getRedisClient().set( + `coinbase:checkout:${checkoutId}`, + JSON.stringify(data), + { ex: COINBASE_CHECKOUT_TTL }, + ); +} + +export async function getCoinbaseCheckoutData( + checkoutId: string, +): Promise { + const result = await getRedisClient().get(`coinbase:checkout:${checkoutId}`); + if (!result) return null; + if (typeof result === 'string') return JSON.parse(result); + return result as CoinbaseCheckoutData; +} +``` + +Note: `@upstash/redis` `set()` accepts `{ ex: seconds }` for TTL. `ioredis` (test client) accepts `setex`. Make the helper work for both: use `getRedisClient().set(key, value, 'EX', ttl)` via raw string args which both clients support, or keep the upstash syntax and accept that TTL is skipped in tests (same approach the existing helpers take — they don't set TTLs either). + +### `apps/web/app/[lang]/(dashboard)/dashboard/credits/credit-topup.tsx` + +Add a second `useActionState` to `PlanCard` for the Coinbase action, plus a second `
`: + +```tsx +const [cryptoState, cryptoFormAction, cryptoPending] = useActionState( + async (_prev: ActionState, formData: FormData): Promise => { + const packageId = formData.get('packageId') as PackageType; + const { url } = await createCoinbaseCheckoutSession(formData, packageId); + if (url) { window.location.assign(url); return { error: null, success: true }; } + return { error: creditsDict.status.checkoutError, success: false }; + }, + initialState, +); +``` + +Add below the existing Stripe ``: +```tsx + + + + +``` + +The "Pay with Crypto" label is hardcoded English for now (no i18n key needed initially). The button appears for all 3 plans; Base network fees (~$0.01) make the $5 starter viable. + +### `apps/web/.env.example` + +Add after the Stripe block: +```bash +# Coinbase CDP (server-side only) +COINBASE_API_KEY_ID= # e.g. organizations/xxx/apiKeys/yyy +COINBASE_API_KEY_SECRET= # Ed25519 private key (PEM, multiline) +COINBASE_WEBHOOK_SECRET= # Webhook shared secret from Coinbase Business dashboard +``` + +--- + +## Functions Reused (no changes) + +| Function | File | Notes | +|---|---|---| +| `insertTopupCreditTransaction()` | `lib/supabase/queries.ts:400` | Pass `checkoutId` as `paymentIntentId`; deduplication via `reference_id` works unchanged | +| `getTopupPackages()` | `lib/stripe/pricing.ts` | Provider-agnostic despite path; returns credits + dollarAmount per packageId | +| `createAdminClient()` | `lib/supabase/server.ts` | Used inside `insertTopupCreditTransaction` already | +| `createHmac`, `timingSafeEqual` | `node:crypto` | Same pattern as `lib/supabase/oauth-callback-marker.ts` | + +--- + +## Open Questions — Must Validate Before Implementing Webhook + +These require a Coinbase Business account, CDP API key, and sandbox: + +| # | Question | Impact | Validate by | +|---|---|---|---| +| 1 | Exact webhook header name (`X-Hook0-Signature` vs `X-CC-Webhook-Signature`)? | Breaks all webhooks if wrong | Check Business dashboard webhook setup page | +| 2 | Signed-payload format for HMAC (`t=.h=.v1=` scheme from draft, or Stripe-style `t=,v1=`)? | Wrong HMAC = reject all events | Send test webhook from dashboard, inspect raw header | +| 3 | Exact event name for completed payment (`charge:confirmed`? `charge.confirmed`?)? | Credits never awarded | Check Business webhook event list; fire test event | +| 4 | Does the Checkouts API support a `metadata` field in the POST body? | If yes, skip Redis and embed metadata directly (simpler, matches Stripe pattern) | Check `POST /api/v1/checkouts` request schema in Business API docs | +| 5 | Is the CDP API key algorithm ES256 (ECDSA P-256) or EdDSA (Ed25519)? | Determines JWT `alg` header and jose signing method | Check the key type shown in CDP dashboard when creating the key | +| 6 | Does checkout response include `hosted_url`? | Determines redirect field name | Create a test checkout, inspect response | +| 7 | Sandbox/testnet available? | Needed for end-to-end testing without real crypto | Check CDP dashboard for test mode | + +**Recommendation:** Create the Coinbase Business account and a test checkout before writing the webhook handler, so you can fill in the exact event name and signature format. The server action and admin module can be written first since they only depend on the checkout creation response shape. + +--- + +## Implementation Order + +1. `npm install jose` in `apps/web/` +2. `apps/web/lib/coinbase/coinbase-admin.ts` — JWT auth + checkout creation + signature verification stub +3. `apps/web/lib/redis/queries.ts` — add `setCoinbaseCheckoutData` / `getCoinbaseCheckoutData` +4. `apps/web/app/[lang]/actions/coinbase.ts` — server action +5. `apps/web/app/api/coinbase/webhook/route.ts` — webhook handler (fill in event name + signature format from sandbox testing) +6. `apps/web/app/[lang]/(dashboard)/dashboard/credits/credit-topup.tsx` — add second form + button +7. `apps/web/.env.example` — add 3 env vars +8. `apps/web/tests/coinbase-webhook.test.ts` — mirror `stripe-webhook.test.ts` structure + +--- + +## Verification + +1. Run existing tests to confirm no regressions: `pnpm test` +2. Set `COINBASE_API_KEY_ID`, `COINBASE_API_KEY_SECRET`, `COINBASE_WEBHOOK_SECRET` in `.env.local` +3. Hit the server action directly or via the UI — confirm `hosted_url` is returned and redirect fires +4. Complete a test payment in Coinbase sandbox → confirm webhook fires +5. Check Supabase `credit_transactions` table: new row with `type='topup'`, `reference_id=`, correct `amount` +6. Replay the same webhook → confirm duplicate row is NOT inserted (dedup check) +7. Confirm credits balance updates in dashboard UI +8. Complete a Stripe topup → confirm Stripe flow still works (no regression) \ No newline at end of file diff --git a/docs/specs/nowpayments-implementation-guide.md b/docs/specs/nowpayments-implementation-guide.md new file mode 100644 index 000000000..8760c5f2d --- /dev/null +++ b/docs/specs/nowpayments-implementation-guide.md @@ -0,0 +1,341 @@ +# NowPayments Integration — Implementation Guide + +Companion to [`nowpayments-integration.md`](./nowpayments-integration.md). That +document is the *what/why*; this is the *how*, step by step, with the exact +files, signatures, and patterns to follow from the existing Stripe code. + +Branch: `claude/add-nowpayments-integration-HyvHg` +Scope: v1 only (one-time crypto top-ups). Crypto subscriptions are v2. + +## Prerequisites + +- NowPayments account with API key + IPN secret (sandbox + production). +- Read the Stripe equivalents first — they are the templates: + - `apps/web/app/[lang]/actions/stripe.ts` + - `apps/web/app/api/stripe/webhook/route.ts` + - `apps/web/lib/supabase/queries.ts` (`insertTopupCreditTransaction`, + `updateUserCredits`, `getUserIdByStripeCustomerId`) + +--- + +## Step 1 — DB migration + queries refactor + +### 1a. Migration file + +Create `apps/web/supabase/migrations/_add_provider_to_credit_transactions.sql` +(timestamp format `YYYYMMDDHHMMSS`, matching existing files). + +```sql +-- Add provider column +ALTER TABLE credit_transactions + ADD COLUMN provider TEXT NOT NULL DEFAULT 'stripe' + CHECK (provider IN ('stripe', 'nowpayments')); + +-- Drop all existing reference_id uniqueness structures +DROP INDEX IF EXISTS public.credit_transactions_reference_id_idx; +DROP INDEX IF EXISTS public.unique_reference_topup_idx; +DROP INDEX IF EXISTS public.unique_reference_purchase_idx; +ALTER TABLE credit_transactions + DROP CONSTRAINT IF EXISTS reference_id_required_for_payments; + +-- Provider-aware composite uniqueness (per-provider dedupe) +CREATE UNIQUE INDEX unique_provider_reference_id_idx + ON credit_transactions USING btree (provider, reference_id) + WHERE reference_id IS NOT NULL; + +-- Recreate the payment-type reference_id requirement +ALTER TABLE credit_transactions + ADD CONSTRAINT reference_id_required_for_payments + CHECK ( + (type IN ('purchase', 'topup') AND reference_id IS NOT NULL) + OR (type NOT IN ('purchase', 'topup')) + ); + +COMMENT ON COLUMN credit_transactions.provider IS + 'Payment provider for this transaction: stripe or nowpayments.'; +``` + +Verify against the current schema first — the live indexes come from +`20260105154300_remove_usage_from_credit_transaction_type.sql` (recreates +`unique_reference_topup_idx` / `unique_reference_purchase_idx` and the check +constraint) and `20250401150000` (`credit_transactions_reference_id_idx`). + +After the migration, regenerate the DB types so `Tables<'credit_transactions'>` +includes `provider` (whatever command the repo uses — check `package.json` +scripts for `supabase gen types`). + +### 1b. Queries refactor + +In `apps/web/lib/supabase/queries.ts`: + +- `insertTopupCreditTransaction` — add a `provider` parameter + (default `'stripe'`). Thread it into the `.insert({ ... })` call. +- **Critical:** update the duplicate-check SELECT (line ~412) to filter on + provider, otherwise a Stripe `payment_intent` and a NowPayments `order_id` + with the same string value collide: + ```ts + .eq('user_id', userId) + .eq('provider', provider) // add this + .eq('reference_id', paymentIntentId) + ``` +- Do the same in `insertSubscriptionCreditTransaction` (line ~339) for + consistency, even though v1 doesn't use it for NowPayments — pass + `provider: 'stripe'` explicitly. +- The Stripe callers in `stripe/webhook/route.ts` don't pass `provider`, so + the default keeps them working unchanged. + +--- + +## Step 2 — `lib/nowpayments/*` + HMAC unit tests + +Create `apps/web/lib/nowpayments/`: + +### `client.ts` + +Thin `fetch` wrapper. Base URL toggles on `NOWPAYMENTS_SANDBOX`: +- prod: `https://api.nowpayments.io/v1` +- sandbox: `https://api-sandbox.nowpayments.io/v1` + +Sends `x-api-key: NOWPAYMENTS_API_KEY` header. Throws on non-2xx with the +response body in the error for Sentry. + +### `types.ts` + +Type the `POST /v1/invoice` response (`id`, `invoice_url`, `order_id`, …) and +the IPN payload (`payment_id`, `payment_status`, `order_id`, `price_amount`, +`pay_amount`, `actually_paid`, `outcome_amount`, …). Define a +`PaymentStatus` union: `'waiting' | 'confirming' | 'confirmed' | 'sending' | +'finished' | 'partially_paid' | 'failed' | 'refunded' | 'expired'`. + +### `invoice.ts` + +```ts +createInvoice({ priceAmountUsd, orderId, orderDescription, payCurrencies }) + → POST /v1/invoice → returns the parsed invoice response +``` + +Body fields: `price_amount`, `price_currency: 'usd'`, `order_id`, +`order_description`, `ipn_callback_url`, `success_url`, `cancel_url`, +`pay_currencies` (array, from `NOWPAYMENTS_PAY_CURRENCIES.split(',')`). + +### `ipn.ts` — `verifySignature(rawBody: string, sigHeader: string | null)` + +This is the security-critical piece. Follow the spec §6 exactly: + +1. `JSON.parse(rawBody)`, then **recursively sort all object keys** (applies to + nested objects and arrays-of-objects). +2. Serialize with **no whitespace**: `JSON.stringify(sorted)` (default — no + spacer argument). +3. `crypto.createHmac('sha512', process.env.NOWPAYMENTS_IPN_SECRET!).update(sortedJson, 'utf8').digest('hex')`. +4. Compare to `sigHeader` with `crypto.timingSafeEqual`: + ```ts + const expected = Buffer.from(computedHex, 'hex'); + const received = Buffer.from(sigHeader ?? '', 'hex'); + if (expected.length !== received.length) return false; // avoid throw + return crypto.timingSafeEqual(expected, received); + ``` + +### Unit tests — `lib/nowpayments/__tests__/ipn.test.ts` + +- `verifySignature` happy path with a known-good fixture (compute a fixture + with a test secret). +- Negative cases: tampered body, wrong secret, malformed/short header. +- Key-sorter: nested objects, arrays of objects, primitive values. + +Use whatever test runner the repo uses — check for `vitest`/`jest` config. + +--- + +## Step 3 — Server action + webhook route + +### 3a. `apps/web/app/[lang]/actions/nowpayments.ts` + +Mirror `actions/stripe.ts`. `'use server'` at top. + +```ts +export async function createCryptoTopupCheckout( + data: FormData, + packageId: CheckoutPackageId, // reuse the Exclude type +): Promise<{ url: string }> +``` + +Flow (spec §4): +1. Validate `packageId` against `getTopupPackages('en')` keys excluding `free` + (copy the `isCheckoutPackageId` guard from `stripe.ts`). +2. `createClient()` → `supabase.auth.getUser()`. Throw if no user. +3. `const pkg = getTopupPackages('en')[packageId]`. +4. `const orderId = "topup_" + user.id + "_" + nanoid()` — JS variable + `orderId`; it becomes the NowPayments `order_id` field and the DB + `reference_id` column. +5. Insert pending row via a new query helper (see 3c) — `createAdminClient()`, + `{ provider: 'nowpayments', reference_id: orderId, amount: 0, type: + 'topup', user_id, description, metadata: { status: 'pending', packageId, + credits: pkg.credits, dollarAmount: pkg.dollarAmount } }`. +6. `createInvoice({ priceAmountUsd: pkg.dollarAmount, orderId, orderDescription: + \`${pkg.credits} credits top-up\`, payCurrencies: + process.env.NOWPAYMENTS_PAY_CURRENCIES!.split(',') })`. The + `ipn_callback_url` is `\`${process.env.NEXT_PUBLIC_SITE_URL}/api/nowpayments/webhook\``. +7. Update the pending row's `metadata` with the returned `invoiceId`. +8. `return { url: invoice.invoice_url }`. + +Wrap in try/catch with `captureException`, tags `section: 'nowpayments_actions'`. + +### 3b. `apps/web/app/api/nowpayments/webhook/route.ts` + +Mirror `stripe/webhook/route.ts` structure. + +```ts +export async function POST(req: Request) { + const body = await req.text(); // raw, before JSON.parse + const sig = req.headers.get('x-nowpayments-sig'); + if (!verifySignature(body, sig)) { + return NextResponse.json({}, { status: 400 }); // no Sentry — scanners + } + const payload = JSON.parse(body) as IpnPayload; + try { + await processIpn(payload); + } catch (error) { + Sentry.captureException(error, { + tags: { section: 'nowpayments_webhook', event_type: payload.payment_status }, + extra: { order_id: payload.order_id, payment_id: payload.payment_id }, + }); + } + return NextResponse.json({ received: true }); // always 200 past sig check +} +``` + +`processIpn` switches on `payment_status`: +- **`finished`** → call the atomic RPC from 3c. The RPC itself does the + idempotency guard + record update + credit increment in one transaction. +- **`partially_paid`** → update `metadata.status`, `Sentry.addBreadcrumb`, no + credit grant. (Policy: full payment required in v1.) +- **`failed` / `expired`** → update `metadata.status`, breadcrumb, no grant. +- **`refunded`** → call the refund RPC from 3c. +- default (`waiting`/`confirming`/`confirmed`/`sending`) → update + `metadata.status` only. + +### 3c. New DB function + query helpers + +Supabase's JS client can't span multiple statements in one transaction, so the +spec's "single DB transaction" requirement means **a Postgres function**. Add a +second migration (or fold into Step 1's): + +```sql +-- Atomic: idempotency guard + record update + credit grant +CREATE OR REPLACE FUNCTION grant_nowpayments_topup( + order_id_var TEXT, + credit_amount_var INTEGER +) RETURNS VOID AS $$ +DECLARE + tx credit_transactions%ROWTYPE; +BEGIN + SELECT * INTO tx FROM credit_transactions + WHERE provider = 'nowpayments' AND reference_id = order_id_var + FOR UPDATE; + IF NOT FOUND THEN + RAISE EXCEPTION 'nowpayments tx not found: %', order_id_var; + END IF; + IF tx.metadata->>'status' = 'finished' THEN + RETURN; -- idempotency guard: already granted + END IF; + UPDATE credit_transactions + SET amount = credit_amount_var, + metadata = jsonb_set(metadata, '{status}', '"finished"') + WHERE id = tx.id; + PERFORM increment_user_credits(tx.user_id, credit_amount_var); +END; +$$ LANGUAGE plpgsql SECURITY DEFINER; +``` + +Add a `refund_nowpayments_topup` counterpart: guard on +`metadata.status = 'finished'`, insert a `refund`-type row, `PERFORM +decrement_user_credits(...)`. `decrement_user_credits` already exists (used in +`queries.ts:178` and `:594`). + +Then add thin wrappers in `queries.ts`: +`insertPendingCryptoTopup(...)`, `grantNowpaymentsTopup(orderId, credits)`, +`refundNowpaymentsTopup(orderId, credits)` — each calls `.rpc(...)` with +`createAdminClient()`. + +--- + +## Step 4 — UI + +### `apps/web/app/[lang]/(dashboard)/dashboard/credits/page.tsx` + +- Add `provider` to the transactions select: + `.select('id, created_at, description, type, amount, provider')`. +- Wrap the top-up section in `` (component at `@/components/ui/tabs`): + - Tab *Card* → existing ``. + - Tab *Crypto* → new `` + one-line disclaimer about crypto + confirmation times. + +### `apps/web/app/[lang]/(dashboard)/dashboard/credits/crypto-topup.tsx` + +Copy `credit-topup.tsx`. Changes: +- Import and call `createCryptoTopupCheckout` instead of `createCheckoutSession`. +- The action returns `{ url }` only (no `client_secret`) — simplify + `formAction` accordingly; drop the `uiMode` hidden input. +- Render only the three purchasable tiers (`starter`, `standard`, `pro`) — + skip `free` (`dollarAmount === 0`). The `plans` array already only lists + those three, so just keep it as-is; do not add `free`. + +### `apps/web/app/[lang]/(dashboard)/dashboard/credits/credit-history.tsx` + +- Add `'provider'` to the `Pick, ...>` prop type. +- Render a small icon per row from `transaction.provider` (card icon for + `stripe`, coin icon for `nowpayments` — `lucide-react` has `CreditCard` and + `Bitcoin`/`Coins`). + +--- + +## Step 5 — End-to-end sandbox test + +- Set `NOWPAYMENTS_SANDBOX=true` + sandbox API key/IPN secret locally. +- Use a tunnel (e.g. the repo's existing dev tunnel setup) so the sandbox can + reach `/api/nowpayments/webhook`. +- Run the full flow: Crypto tab → pick a tier → pay on the hosted invoice → + confirm the IPN transitions land and credits are granted exactly once. +- Re-send the same `finished` IPN manually to confirm the idempotency guard + holds (no double credit). + +--- + +## Step 6 — Polish + +- `partially_paid` / `expired` / `refunded` handler branches fully wired. +- Sentry tags `section: 'nowpayments_webhook'` on all captures. +- Admin recovery script in `scripts/` for stuck `metadata.status='pending'` + rows (list invoices older than N hours still pending; optionally re-query + NowPayments `GET /v1/payment/{id}` to reconcile). +- Production smoke test with a real $1 invoice before announcing (spec "Open + risks"). + +--- + +## Environment variables + +Add to `apps/web/.env.example`: + +```env +NOWPAYMENTS_API_KEY= +NOWPAYMENTS_IPN_SECRET= +NOWPAYMENTS_SANDBOX=false +NOWPAYMENTS_PAY_CURRENCIES=btc,eth,usdttrc20,usdcmatic,sol,ltc +``` + +Set the real values in Vercel project settings for preview + production. + +## Done-when checklist + +- [ ] Migration applied; `provider` column + `unique_provider_reference_id_idx` + + recreated check constraint present; DB types regenerated. +- [ ] `insertTopupCreditTransaction` / `insertSubscriptionCreditTransaction` + duplicate-check queries filter on `provider`. +- [ ] `verifySignature` unit tests pass (happy + tampered + wrong-secret + + key-sorter cases). +- [ ] `grant_nowpayments_topup` is idempotent under repeated `finished` IPNs. +- [ ] Crypto tab renders three tiers, redirects to the hosted invoice. +- [ ] `credit-history.tsx` shows a provider icon per row. +- [ ] Full sandbox top-up flow grants credits exactly once. +- [ ] Stripe top-up flow still works unchanged (regression check). diff --git a/docs/specs/nowpayments-integration.md b/docs/specs/nowpayments-integration.md index f777b0bf4..5a44fefce 100644 --- a/docs/specs/nowpayments-integration.md +++ b/docs/specs/nowpayments-integration.md @@ -22,9 +22,10 @@ What we parallel: - **Stripe webhook:** `apps/web/app/api/stripe/webhook/route.ts` → on `checkout.session.completed` / `invoice.payment_succeeded`, awards credits via `insertTopupCreditTransaction` / `insertSubscriptionCreditTransaction`. -- **DB:** `credit_transactions` (enum `purchase|usage|freemium|topup|refund`, - plus `reference_id`, `subscription_id`, `metadata JSONB`); `profiles.stripe_id` - links the Supabase user to a Stripe customer. +- **DB:** `credit_transactions` (enum `purchase|freemium|topup|refund` — `usage` + was removed in `20260105154300`; consumption is tracked in `usage_events` + instead; plus `reference_id`, `subscription_id`, `metadata JSONB`); + `profiles.stripe_id` links the Supabase user to a Stripe customer. - **Idempotency key:** `reference_id` (Stripe `payment_intent`). - **UI:** `apps/web/app/[lang]/(dashboard)/dashboard/credits/credit-topup.tsx` calls the server action. @@ -42,7 +43,8 @@ What we parallel: 4. **Status state-machine:** `waiting → confirming → confirmed → sending → finished`, plus `partially_paid`, `failed`, `refunded`, `expired`. Credits are only granted - on `finished` (and possibly `partially_paid` if amount ≥ expected). + on `finished`. `partially_paid` is logged but never grants credits in v1 + (see webhook handler for the authoritative policy). 5. **Idempotency:** the same `payment_id`/`invoice_id` will receive multiple IPN calls as it transitions — must dedupe by `(provider, reference_id)`. 6. **Subscriptions need JWT.** `/v1/subscriptions/*` endpoints require Bearer @@ -66,10 +68,25 @@ What we parallel: - Add `provider TEXT NOT NULL DEFAULT 'stripe'` to `credit_transactions` with `CHECK (provider IN ('stripe','nowpayments'))`. -- Drop existing `credit_transactions_reference_id_idx`. -- Add composite uniqueness: - `UNIQUE (provider, reference_id) WHERE reference_id IS NOT NULL`. - Backfill not needed (default takes care of existing rows). +- Drop **all** existing reference_id uniqueness structures before replacing: + - `credit_transactions_reference_id_idx` (non-partial, created in + `20250401150000`) + - `unique_reference_topup_idx` (partial on `type='topup'`, from + `20260105154300`) + - `unique_reference_purchase_idx` (partial on `type='purchase'`, from + `20260105154300`) + - `reference_id_required_for_payments` check constraint (from + `20260105154300`) +- Add provider-aware composite uniqueness so each provider's reference IDs + are deduplicated independently: + `CREATE UNIQUE INDEX … ON credit_transactions (provider, reference_id) + WHERE reference_id IS NOT NULL`. +- Recreate check constraint to require `reference_id` for payment types: + `CHECK ((type IN ('purchase','topup') AND reference_id IS NOT NULL) + OR (type NOT IN ('purchase','topup')))`. +- The composite index allows Stripe and NowPayments to share the same + `reference_id` value without conflict (different providers). ### 2. Pricing config @@ -95,6 +112,10 @@ apps/web/app/api/nowpayments/webhook/route.ts POST handler: verify sig → switch on payment_status → award credits (mirrors stripe/webhook/route.ts structure) +apps/web/supabase/migrations/_add_nowpayments_credit_functions.sql + grant_nowpayments_topup(order_id, credit_amount) — atomic grant + idempotency + refund_nowpayments_topup(order_id, credit_amount) — atomic compensating refund + apps/web/app/[lang]/(dashboard)/dashboard/credits/crypto-topup.tsx Mirrors credit-topup.tsx but calls createCryptoTopupCheckout ``` @@ -105,7 +126,8 @@ apps/web/app/[lang]/(dashboard)/dashboard/credits/crypto-topup.tsx 2. Server action: - Authenticate user via Supabase. - Look up the selected package from `getTopupPackages('en')`. - - Generate `order_id = "topup_${userId}_${nanoid()}"`. + - Generate JS variable `orderId = "topup_${userId}_${nanoid()}"` (maps to + the NowPayments API field `order_id`; stored as `reference_id` in DB). - Insert a *pending* row in `credit_transactions`: `{ provider: 'nowpayments', reference_id: orderId, amount: 0, type: 'topup', metadata: { status: 'pending', packageId, credits, @@ -114,22 +136,39 @@ apps/web/app/[lang]/(dashboard)/dashboard/credits/crypto-topup.tsx - `price_amount: package.dollarAmount`, `price_currency: 'usd'` - `order_id: orderId` - `order_description: " credits top-up"` - - `ipn_callback_url: ${SITE_URL}/api/nowpayments/webhook` + - `ipn_callback_url: ${process.env.NEXT_PUBLIC_SITE_URL}/api/nowpayments/webhook` - `success_url`, `cancel_url` mirroring Stripe's - - `pay_currency` whitelist (optional, hosted page lets user pick from - enabled coins) + - `pay_currencies: NOWPAYMENTS_PAY_CURRENCIES.split(',')` — the accepted + coin whitelist from env (see §8); omit the field to let the hosted page + show all enabled coins, but always populate it from the env var so we + control the list centrally. - Update the pending row's metadata with the returned `invoiceId`. - Return `invoice_url`. 3. Client redirects to `invoice_url`. 4. IPN webhook: - Read raw body, verify HMAC-SHA512, parse JSON. - Switch on `payment_status`: - - `finished` → atomically look up the pending row by `(provider='nowpayments', - reference_id=order_id)`, set `amount = credits` and - `metadata.status = 'finished'`, then call `increment_user_credits` RPC. - - `partially_paid` → update metadata, Sentry breadcrumb, no credit grant. - - `failed` / `expired` / `refunded` → update metadata, Sentry breadcrumb, + - `finished`: handled by a single **Postgres function** + `grant_nowpayments_topup(order_id, credit_amount)` that, in one + transaction: + 1. Looks up the row by `(provider='nowpayments', reference_id=order_id)` + with `FOR UPDATE`. + 2. **Idempotency guard:** if `metadata.status` is already `'finished'`, + returns early — credits were already granted. + 3. Updates `amount = credits` and `metadata.status = 'finished'`, then + `PERFORM increment_user_credits(...)`. Both succeed or both roll + back — never update the record without granting credits, and never + grant credits without updating the record. + + This must be a DB function, not client-side logic: the Supabase JS + client cannot span multiple statements in a single transaction, so the + atomicity requirement can only be met inside Postgres. + - `partially_paid` → update `metadata.status`, Sentry breadcrumb, no + credit grant. (Policy: always require full payment; never grant partial + credits in v1.) + - `failed` / `expired` → update `metadata.status`, Sentry breadcrumb, no credit grant. + - `refunded` → see refund handling below. - Return 200 (400 only on invalid signature). We persist the pending row up-front because the IPN payload echoes only @@ -145,7 +184,8 @@ the user back to the success page. - Tab 2: *Crypto* — new `crypto-topup.tsx`, with a one-line disclaimer about crypto confirmation times. - `crypto-topup.tsx` mirrors `credit-topup.tsx`: same cards, same pricing, - same `Buy Credits` CTA wired to `createCryptoTopupCheckout`. + same `Buy Credits` CTA wired to `createCryptoTopupCheckout`. Only render + the three purchasable tiers (`dollarAmount > 0`); skip the `free` tier. - `credit-history.tsx` — render a small provider icon (card / coin) per row, reading from the new `provider` column. @@ -156,8 +196,15 @@ the user back to the success page. - `verifySignature`: - Recursively sort all object keys (NowPayments quirk — applies to nested objects and arrays-of-objects). - - `crypto.createHmac('sha512', IPN_SECRET).update(sortedJson).digest('hex')`. - - Compare to `x-nowpayments-sig` via `crypto.timingSafeEqual`. + - Serialize to JSON with **no whitespace** (`JSON.stringify(sorted)` — + no spaces or newlines), encoded as **UTF-8**. The exact byte sequence must + match what NowPayments signs; any extra whitespace will break the hash. + - `crypto.createHmac('sha512', IPN_SECRET).update(sortedJson, 'utf8').digest('hex')`. + - Compare via `crypto.timingSafeEqual`: both arguments must be `Buffer` + objects of equal byte length. Convert hex strings with + `Buffer.from(hexString, 'hex')`. If the lengths differ (truncated or + malformed header), reject immediately rather than letting + `timingSafeEqual` throw. - Reject non-matching with 400; no Sentry noise on signature failures (they're typically scanners). @@ -203,6 +250,11 @@ NOWPAYMENTS_SUB_99_PLAN_ID= 1. Migration + queries refactor (thread `provider` through the existing `insertTopupCreditTransaction`; default `'stripe'` for backwards compat). + Also add `.eq('provider', 'stripe')` to the duplicate-check SELECT inside + both `insertTopupCreditTransaction` and `insertSubscriptionCreditTransaction` + — without it, a Stripe `payment_intent` and a NowPayments `order_id` with + the same string value would incorrectly collide after the composite index is + in place. 2. `lib/nowpayments/*` (client, invoice, ipn, types) + HMAC unit tests. 3. Server action `actions/nowpayments.ts` + webhook route (top-up happy path). 4. UI: Tabs on `/dashboard/credits`, new `crypto-topup.tsx`, provider icon @@ -236,6 +288,12 @@ NOWPAYMENTS_SUB_99_PLAN_ID= don't affect credit balance, but a periodic cleanup job (or filter in `credit-history.tsx`) keeps the history clean. - **Refunds.** NowPayments refunds are manual. If we see `payment_status: - refunded` after we've credited, we need to insert a compensating - `refund`-type row. The enum already supports `refund` — handler stub is - cheap to add now even if rare. + refunded` after we've credited, a `refund_nowpayments_topup` Postgres + function must, in one transaction: insert a compensating `refund`-type row + in `credit_transactions` **and** `PERFORM decrement_user_credits(...)`. + Updating the transaction log alone will not adjust the user's balance in the + `credits` table. Like `grant_nowpayments_topup`, this is a DB function for + atomicity (the JS client can't span statements transactionally). The enum + already supports `refund` — the function stub is cheap to add now even if + rare. Skip the decrement if `metadata.status` is not yet `'finished'` + (nothing was credited).