Skip to content

Pricing from one source, CoinPay webhook settlement fixes, free post cap, fee disclosure - #596

Merged
ralyodio merged 4 commits into
masterfrom
fix/pricing-and-payment-webhooks
Oct 6, 2026
Merged

ralyodio merged 4 commits into
masterfrom
fix/pricing-and-payment-webhooks

Conversation

@ralyodio

@ralyodio ralyodio commented Oct 6, 2026 •

Copy link
Copy Markdown
Contributor

Implements PRD 05 (payment reliability), PRD 06 (pricing and plans) and the fee-disclosure part of PRD 07, using the decisions already made.

Pricing (PRD 06)

  • src/lib/plans.ts is now the only place prices and perks are defined. It holds the prices (Pro $9/month, $90/year, Lifetime $100), plan names, perks, FREE_MONTHLY_GIG_POSTS = 10, isPaidPlan and hasPaidAccess. Every page, doc, the OpenAPI spec, openaccess.json and the CoinPay create route read from it.
  • Crypto only, no card checkout. CoinPay is the only payment rail for monthly, annual and Lifetime.
    • POST /api/subscriptions/checkout now returns 410 and points at /api/payments/coinpayportal/create. Prod has no Stripe-billed subscriptions (checked read-only).
    • The Stripe webhook, portal and cancel routes stay only so a legacy subscriber could still be managed.
    • Removed: the card button on the subscription page, the unused Stripe browser client, the createCheckout API helper, and the checkout/portal entries in openapi.json.
    • Funding: the card option is gone from the UI and /api/funding/create-invoice refuses currency: "card".
    • No copy mentions cards or Stripe. docs/deployment.md marks Stripe as retired.
  • Lifetime gets the same perks as Pro everywhere:
    • marketplace seller fee for skills, MCP and prompts;
    • the gig post cap;
    • dashboard usage display.
  • Perks. The only Pro perks listed now are unlimited gig posts and the 2% seller fee (instead of 5%).
    • Removed: featured badge/placement, priority support, advanced analytics, "Post unlimited gigs for free", the funding Supporter/Founder badges, premium placement and API access.
    • The only funding perk left is $50+ = Lifetime.
  • Subscription page.
    • No longer imports the Stripe SDK into a client component.
    • Crypto checkout for monthly, annual and Lifetime.
    • Crypto-paid Pro shows "Paid through" plus an "Add a year" button. The Manage/Downgrade buttons only appear for a legacy Stripe-billed row.
    • Lifetime members no longer see "Downgrade".
    • Shows a confirmation when CoinPay sends the buyer back with ?payment=success. /settings/billing now keeps that query string when it redirects.

Free 10 posts/month cap (now actually enforced)

  • Bug: POST /api/gigs upserted posts_count: 1 and then incremented, so every free account sat at 2 and never hit the cap.
  • Fix: the usage row is now created with ignoreDuplicates, and the RPC does the increment.
  • Same check and counter everywhere a gig becomes active: POST /api/gigs, PATCH /api/gigs/[id]/status, and PUT /api/gigs/[id] when it changes the status. Hiring gigs and for-hire ads both count. Shared code is in src/lib/gig-usage.ts.
  • Migration 20261006131000_gig_usage_atomic_increment.sql:
    • increment_gig_usage becomes an atomic insert-or-increment.
    • A signed-in caller can only increment their own usage.
    • Drops the user INSERT/UPDATE policies on gig_usage. Until now any user could reset their own counter through PostgREST.
    • The code works the same whether or not the migration has been applied.

CoinPay webhook and status (PRD 05)

Settlement code shared by the webhook and the status poll is in src/lib/payments/checkout-settlement.ts.

  1. $50+ funding payments now grant Lifetime. This happens inside the funding path. It is idempotent and writes funding_rewards_log once per funding payment. Anonymous contributions are recorded but grant nothing.
  2. forwarded with no confirmed before it now activates the purchase, exactly once. A guarded status transition makes sure of that. Metadata is merged, not replaced. A confirmed that arrives after forwarded no longer moves the status back.
  3. The plan is now carried from checkout to webhook.
    • The create route stores plan on the local row.
    • The webhook falls back to the plan in the payload metadata.
    • Annual adds 12 months, extending from the current end date if the user pays early.
    • Lifetime sets plan=lifetime with no end date.
    • A Pro payment never downgrades a Lifetime member.
  4. payment.failed now marks the payment failed. A late expired or failed event never un-pays a settled row (checkout or funding).
  5. The status poll can now write. It uses the service client, maps provider statuses onto the enum, and settles through the same code as the webhook. If the poll sees the payment first, the plan is still activated, once. It also accepts API-key auth.
  6. New cron routes, both with CRON_SECRET auth and ?dry_run=1:
    • POST /api/cron/expire-payments: pending payments rows past metadata.expires_at (or 24h after creation) become expired.
    • POST /api/cron/expire-subscriptions: CoinPay Pro more than 3 days past current_period_end drops to Free, with an in-app notification. It never touches Lifetime or Stripe-managed rows (stripe_subscription_id set).
  7. CLI: ugig payments status <id> now calls /api/payments/coinpayportal/status?payment_id=. The --plan help lists lifetime.

Fee disclosure before payment (Terms §5)

Rates come from src/lib/constants.ts. I added ESCROW_FEE_RATE and fixed the comment that claimed a 2% withdrawal fee. The preview code (src/lib/fees.ts) does the same arithmetic as the server.

  • EscrowPaymentButton: shows the 5% fee and what the worker receives before the escrow is created. Sats gigs now show sats, not "$".
  • Zap buttons: show the 2% fee and what the recipient gets.
  • Skill, MCP and prompt listing forms (and repo import): show the seller fee and their net. They show both the Free and Pro/Lifetime rates, because the forms don't know the seller's plan.
  • Purchase buttons: show the exact price the buyer pays, with no buyer fee.
  • Escrow is still a product.

Migration (not applied)

  • supabase/migrations/20261006131000_gig_usage_atomic_increment.sql

Cron schedules to add

  • POST /api/cron/expire-payments: hourly
  • POST /api/cron/expire-subscriptions: daily

Data check (read-only)

No funding payment of $50+ exists on prod (all 20 are under $50), so there is nothing to backfill for bug 1.

Checks

The pre-commit hook is broken in fresh worktrees, so I ran these by hand on the committed tree:

  • npx tsc --noEmit: clean.
  • eslint on all changed files: clean.
  • Full vitest (--no-file-parallelism) on the final commit: 250 files, 2307 tests pass.
  • CLI vitest: 28 files, 191 tests pass.
  • New tests replay webhook payloads against an in-memory DB (src/test/fake-supabase.ts). They cover funding, forwarded-only, plan carry-over, failed/expired, the status poll, both crons, the post cap across POST/PATCH/PUT, checkout guards and the fee UI, the 410 checkout and the funding card refusal.

🤖 Generated with Claude Code

@github-actions

github-actions Bot commented Oct 6, 2026 •

Copy link
Copy Markdown

ThreatCrush Security Scan

46 finding(s)

HIGH/CRITICAL: 1 | MEDIUM: 7 | LOW: 38

Severity Rule Location
HIGH js-ssrf-outbound-request scripts/scan-all-skills.ts:38
MEDIUM js-open-redirect src/app/agent-login/AgentLoginForm.tsx:38
MEDIUM js-unescaped-html-sink src/app/blog/[slug]/page.tsx:79
MEDIUM js-open-redirect src/app/dashboard/subscription/page.tsx:100
MEDIUM js-open-redirect src/app/dashboard/subscription/page.tsx:133
MEDIUM js-open-redirect src/components/funding/FundingClient.tsx:138
MEDIUM js-dynamic-code-execution src/lib/skills/metadata-extract.ts:300
MEDIUM js-dynamic-code-execution src/lib/skills/security-scan.ts:48
LOW secret-generic-credential cli/src/commands/auth.test.ts:66
LOW secret-generic-credential cli/src/commands/auth.test.ts:85
LOW secret-generic-api-key docs/agents/integration-guide.md:893
LOW secret-generic-credential src/app/api/auth/login/route.test.ts:53
LOW secret-generic-credential src/app/api/auth/login/route.test.ts:68
LOW secret-generic-credential src/app/api/auth/login/route.test.ts:87
LOW secret-generic-credential src/app/api/auth/signup/route.test.ts:158
LOW secret-generic-credential src/app/api/auth/signup/route.test.ts:182
LOW secret-generic-credential src/app/api/auth/signup/route.test.ts:193
LOW secret-generic-credential src/app/api/auth/signup/route.test.ts:232
LOW js-dynamic-code-execution src/app/api/skills/[slug]/scan/route.test.ts:212
LOW js-dynamic-code-execution src/app/api/skills/[slug]/scan/route.test.ts:223
LOW js-dynamic-code-execution src/app/api/skills/[slug]/scan/route.test.ts:239
LOW secret-generic-credential src/lib/api.test.ts:126
LOW secret-generic-credential src/lib/api.test.ts:131
LOW js-dynamic-code-execution src/lib/skills/composite-scanner.test.ts:106
LOW js-dynamic-code-execution src/lib/skills/secureclaw-scanner.test.ts:36
LOW js-dynamic-code-execution src/lib/skills/secureclaw-scanner.test.ts:44
LOW js-dynamic-code-execution src/lib/skills/secureclaw-scanner.test.ts:66
LOW js-dynamic-code-execution src/lib/skills/secureclaw-scanner.test.ts:81
LOW js-dynamic-code-execution src/lib/skills/secureclaw-scanner.test.ts:94
LOW js-dynamic-code-execution src/lib/skills/secureclaw-scanner.test.ts:103
LOW js-dynamic-code-execution src/lib/skills/secureclaw-scanner.test.ts:118
LOW js-dynamic-code-execution src/lib/skills/secureclaw-scanner.test.ts:144
LOW js-dynamic-code-execution src/lib/skills/secureclaw-scanner.test.ts:162
LOW js-dynamic-code-execution src/lib/skills/security-scan.test.ts:28
LOW js-dynamic-code-execution src/lib/skills/url-import.test.ts:178
LOW js-dynamic-code-execution src/lib/skills/url-import.test.ts:191
LOW secret-jwt src/lib/supabase/service.test.ts:16
LOW secret-jwt src/lib/supabase/service.test.ts:40
LOW secret-generic-credential src/lib/validations.test.ts:148
LOW secret-generic-credential src/lib/validations.test.ts:512
LOW secret-generic-credential src/lib/validations.test.ts:523
LOW secret-generic-credential src/lib/validations.test.ts:538
LOW secret-generic-credential src/lib/validations.test.ts:548
LOW secret-generic-credential src/lib/validations.test.ts:557
LOW secret-generic-credential src/lib/validations.test.ts:567
LOW secret-generic-credential src/lib/validations.test.ts:582

Snippets are redacted; ThreatCrush never prints matched credential material.

ralyodio and others added 4 commits October 6, 2026 10:40
… enforce the free post cap

- src/lib/plans.ts is the one source for prices ($9/mo, $90/yr, $100 lifetime),
  perks, FREE_MONTHLY_GIG_POSTS and isPaidPlan/hasPaidAccess.
- Lifetime equals Pro: marketplace seller fees (skills, MCP, prompts), gig post
  cap, Stripe checkout guard.
- Free 10 posts/month cap actually counts: usage row is created with
  ignoreDuplicates instead of being reset to 1 on every post, and the cap is
  checked on POST /api/gigs, PATCH /status and PUT when a gig becomes active.
  Migration makes increment_gig_usage atomic and drops the user write policies.
- CoinPay webhook: funding payments of $50+ grant Lifetime and write
  funding_rewards_log (idempotent); forwarded without confirmed activates once;
  metadata is merged; the plan is stored at create time and read back (annual
  +12 months, lifetime no end); payment.failed maps to failed; late
  expired/failed never un-pay a settled row.
- Status poll writes through the service client, maps provider statuses and
  settles through the same code as the webhook.
- New crons: /api/cron/expire-payments (pending past expires_at or 24h) and
  /api/cron/expire-subscriptions (CoinPay Pro 3+ days past end -> free; never
  Lifetime or Stripe-managed).
- CLI `ugig payments status` calls the real status route.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…re payment

- Landing, for-employers, for-candidates, dashboard, gigs/new, subscription
  page, funding page, docs, openapi and openaccess read src/lib/plans.ts:
  Pro $9/month (card or crypto), $90/year and Lifetime $100 crypto only.
- Perks are only the real ones: unlimited gig posts and the 2% marketplace
  seller fee. Removed featured badge/placement, priority support, advanced
  analytics, "Post unlimited gigs for free", funding badges, premium
  placement and API access. The only funding perk is $50+ = Lifetime.
- Subscription page no longer imports the Stripe SDK into the client; adds
  crypto monthly/annual checkout; crypto Pro shows "Paid through" and no
  Stripe-only buttons; Lifetime cannot "downgrade".
- Fee disclosure before payment (Terms 5): escrow 5% before the escrow is
  created, zap 2% split, seller fee and net on listing forms, exact buyer
  price on purchase buttons. Rates come from src/lib/constants.ts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Stripe is no longer a payment rail. Pro ($9/mo, $90/yr) and Lifetime ($100)
are sold only through CoinPay.

- Subscription page: the card button is gone; monthly, annual and lifetime
  all go through the CoinPay checkout. Shows a confirmation when CoinPay
  returns the buyer (?payment=success, now kept by /settings/billing).
- POST /api/subscriptions/checkout returns 410 and points at the CoinPay
  create route (prod has no Stripe-billed subscriptions). The Stripe webhook,
  portal and cancel routes stay for a legacy subscriber. Removed the unused
  Stripe browser client and the createCheckout API helper.
- Funding: the card option is removed from the UI and refused by
  /api/funding/create-invoice.
- Copy (plans.ts rails, pricing block, landing, docs, openapi) no longer
  mentions cards or Stripe.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@ralyodio
ralyodio force-pushed the fix/pricing-and-payment-webhooks branch from 70ac076 to 6a68ec1 Compare October 6, 2026 10:46
@ralyodio ralyodio added the api-breaking Intentional breaking API change; lets the API contract check pass label Oct 6, 2026
@ralyodio
ralyodio merged commit 2a3e0ac into master Oct 6, 2026
6 of 7 checks passed
@ralyodio
ralyodio deleted the fix/pricing-and-payment-webhooks branch October 6, 2026 10:49
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

api-breaking Intentional breaking API change; lets the API contract check pass

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant