Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 6 additions & 4 deletions cli/src/commands/payments.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ describe("payments create", () => {
payment_id: "pay1",
checkout_url: "https://example.com/pay",
address: "0x123",
amount_crypto: 29,
amount_crypto: 9,
currency: "usdc_pol",
expires_at: "2025-01-01T00:00:00Z",
});
Expand Down Expand Up @@ -128,9 +128,11 @@ describe("payments create", () => {
});

describe("payments status", () => {
it("calls GET /api/payments/coinpayportal/:id", async () => {
mockClient.get.mockResolvedValue({ id: "pay1", status: "pending", amount_usd: 29 });
it("calls the real status route with payment_id", async () => {
mockClient.get.mockResolvedValue({ status: "pending", updated_at: "2026-10-06T00:00:00Z" });
await run(["payments", "status", "pay1"]);
expect(mockClient.get).toHaveBeenCalledWith("/api/payments/coinpayportal/pay1");
expect(mockClient.get).toHaveBeenCalledWith("/api/payments/coinpayportal/status", {
payment_id: "pay1",
});
});
});
19 changes: 11 additions & 8 deletions cli/src/commands/payments.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,10 @@ export function registerPaymentsCommands(program: Command): void {
"--currency <currency>",
"Currency: usdc_pol|usdc_sol|pol|sol|btc|eth|usdc_eth|usdt"
)
.option("--plan <plan>", "Subscription plan: monthly|annual")
.option(
"--plan <plan>",
"Subscription plan: monthly ($9/mo) | annual ($90/yr) | lifetime ($100 one-time)"
)
.option("--amount <usd>", "Amount in USD (required for tip)")
.action(async (cmdOpts: { type: string; currency: string; plan?: string; amount?: string }) => {
const opts = program.opts() as GlobalOpts;
Expand Down Expand Up @@ -68,26 +71,26 @@ export function registerPaymentsCommands(program: Command): void {

payments
.command("status <id>")
.description("Check payment status")
.description("Check payment status (id = Payment ID from `payments create`)")
.action(async (id: string) => {
const opts = program.opts() as GlobalOpts;
const spinner = opts.json ? null : ora("Fetching payment status...").start();
try {
const client = createClient(opts);
const result = await client.get<Record<string, unknown>>(
`/api/payments/coinpayportal/${id}`
"/api/payments/coinpayportal/status",
{ payment_id: id }
);
spinner?.stop();
printDetail(
[
{ label: "Payment ID", key: "id" },
{ label: "Status", key: "status" },
{ label: "Amount (USD)", key: "amount_usd" },
{ label: "Currency", key: "currency" },
{ label: "Type", key: "type" },
{ label: "Created", key: "created_at" },
{ label: "Provider status", key: "provider_status" },
{ label: "Tx hash", key: "tx_hash" },
{ label: "Updated", key: "updated_at" },
],
result,
{ id, ...result },
opts as OutputOptions
);
} catch (err) {
Expand Down
13 changes: 9 additions & 4 deletions docs/deployment.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
ugig.net is deployed using:
- **Railway** - Next.js application hosting
- **Supabase Cloud** - Database, Auth, Storage, Realtime
- **Stripe** - Payment processing
- **CoinPay** - Payment processing (crypto only; see `src/lib/plans.ts` for prices)

---

Expand All @@ -14,7 +14,6 @@ ugig.net is deployed using:
1. **Accounts Required**:
- [Railway](https://railway.app) account
- [Supabase](https://supabase.com) account
- [Stripe](https://stripe.com) account
- [GitHub](https://github.com) account (for deployment)

2. **Domain** (optional but recommended):
Expand Down Expand Up @@ -116,7 +115,13 @@ USING (

---

## Stripe Setup
## Stripe (retired)

ugig.net does not take card payments. Pro and Lifetime are sold only through
CoinPay, and `POST /api/subscriptions/checkout` returns 410. The Stripe webhook,
portal and cancel routes remain only for a legacy Stripe subscriber (there were
none on 2026-10-06). Do not set up new Stripe products. The steps below are kept
for reference only.

### 1. Create Account & Get Keys

Expand All @@ -130,7 +135,7 @@ USING (
1. Go to **Products**
2. Create product: "ugig Pro"
3. Add price:
- Amount: $5.99
- Amount: $9.00 (must match `PRO_MONTHLY_PRICE_USD` in `src/lib/plans.ts`)
- Billing period: Monthly
- Copy Price ID for checkout

Expand Down
19 changes: 16 additions & 3 deletions docs/features.md
Original file line number Diff line number Diff line change
Expand Up @@ -136,8 +136,10 @@ Draft → Active → [Paused] → Closed/Filled
```

**Posting Limits**:
- Free tier: 10 posts per calendar month
- Pro tier ($5.99/mo): Unlimited posts
- Free tier: 10 posts per calendar month (hiring gigs and for-hire ads both count)
- Pro and Lifetime: Unlimited posts

Prices and perks live in `src/lib/plans.ts`; that file is the source of truth.

---

Expand Down Expand Up @@ -265,15 +267,26 @@ Schedule → Calendar Event → Reminder → Join Call

**Pricing Tiers**:

| Feature | Free | Pro ($5.99/mo) |
| Feature | Free | Pro / Lifetime |
|---------|------|----------------|
| Browse gigs | ✓ | ✓ |
| Apply to gigs | ✓ | ✓ |
| Post gigs | 10/month | Unlimited |
| Marketplace seller fee (skills, MCP, prompts) | 5% | 2% |
| Messaging | ✓ | ✓ |
| Video calls | ✓ | ✓ |
| Profile | ✓ | ✓ |

**Prices** (source of truth: `src/lib/plans.ts`):

| Plan | Price | Rails |
|------|-------|-------|
| Pro monthly | $9/month | crypto, via CoinPay |
| Pro annual | $90/year | crypto only (CoinPay) |
| Lifetime | $100 one-time | crypto only (CoinPay) |

Funding ugig.net with $50 or more on `/funding` also grants Lifetime.

**Upgrade Prompts**:
- When user exceeds 10 posts: Modal to upgrade
- Dashboard showing usage: "8 of 10 posts used this month"
Expand Down
38 changes: 16 additions & 22 deletions docs/funding.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,24 +45,24 @@ Credits are stored in user account and consumed within UGIG platform.

### 2. Lifetime Access Tier

Any investment of **$20 or more** automatically receives a **free Lifetime Premium plan**.
Any contribution of **$50 or more** (`FUNDING_LIFETIME_THRESHOLD_USD` in `src/lib/plans.ts`)
automatically grants a **free Lifetime membership** to the signed-in contributor.

- $20+ (any amount) → Lifetime Premium Plan
- $50+ (any amount) → Lifetime membership
- No cap on number of users

Includes:
- Unlimited job postings
- Premium placement
- API access (future)
- Founder badge
- All future Premium features at no additional cost
Lifetime is the Pro plan forever:
- Unlimited gig posts
- 2% marketplace seller fee instead of 5%

This is the only funding perk. There are no badges and no placement perks.

---

### 3. Supporter Tier (Optional)
### 3. Smaller contributions

- 10k–50k sats → supporter badge
- Public leaderboard (optional)
- Any amount below $50 is a thank-you contribution with no perks attached
- Contributors appear in the public recent-contributions list

---

Expand All @@ -74,12 +74,12 @@ A dedicated BTC/LN address will be used exclusively for funding contributions:
- **Lightning address**: fund@ugig.net (via LNbits LNURLp)
- **LNURL-pay**: Encoded LNURL for QR-based payments

All incoming payments to this address are tracked as funding contributions and automatically trigger the reward logic (credits, lifetime plan, badges).
All incoming payments to this address are tracked as funding contributions and automatically trigger the reward logic (credits, lifetime plan).

Implementation requirements:
- Separate LNbits wallet dedicated to funding (not the platform operational wallet)
- Webhook routes specific to funding payments
- Automatic $20+ threshold check to grant Lifetime Premium
- Automatic $50+ threshold check to grant Lifetime

---

Expand Down Expand Up @@ -165,11 +165,9 @@ Response:
IF tier == credits:
add credits to user account

IF amount_usd >= 20:
set user.plan = "lifetime_premium"

IF tier == supporter:
assign badge
IF amount_usd >= 50 AND user is signed in:
set subscriptions.plan = "lifetime" (idempotent)
write funding_rewards_log (reward_type = lifetime)

---

Expand Down Expand Up @@ -272,10 +270,6 @@ IF tier == supporter:
### Gamification

- Top contributors leaderboard
- Badges:
- Early supporter
- Whale
- OG

---

Expand Down
2 changes: 1 addition & 1 deletion public/.well-known/openaccess.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@
{
"product": "ugig.net/pro",
"name": "Pro",
"price": "29 USD/month",
"price": "9 USD/month",
"links": {
"pay": "https://ugig.net/dashboard/subscription?principal={principal}",
"cancel": "https://ugig.net/settings/billing",
Expand Down
66 changes: 3 additions & 63 deletions public/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -1837,7 +1837,7 @@
"post": {
"tags": ["Gigs"],
"summary": "Create a gig",
"description": "Post a new gig. Free accounts are limited to 10 active gig posts per month.",
"description": "Post a new gig. Free accounts may publish 10 gigs (hiring gigs and for-hire ads) per calendar month; Pro and Lifetime are unlimited.",
"operationId": "createGig",
"security": [{ "bearerAuth": [] }, { "apiKey": [] }],
"requestBody": {
Expand Down Expand Up @@ -4944,66 +4944,6 @@
}
}
},
"/api/subscriptions/checkout": {
"post": {
"tags": ["Subscriptions"],
"summary": "Create Stripe checkout session",
"description": "Generate a Stripe checkout URL for Pro subscription.",
"operationId": "createCheckout",
"security": [{ "bearerAuth": [] }, { "apiKey": [] }],
"responses": {
"200": {
"description": "Checkout URL",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"url": { "type": "string", "format": "uri" }
}
}
}
}
},
"401": {
"description": "Unauthorized",
"content": {
"application/json": { "schema": { "$ref": "#/components/schemas/Error" } }
}
}
}
}
},
"/api/subscriptions/portal": {
"post": {
"tags": ["Subscriptions"],
"summary": "Create Stripe billing portal session",
"description": "Generate a Stripe billing portal URL for managing the subscription.",
"operationId": "createPortal",
"security": [{ "bearerAuth": [] }, { "apiKey": [] }],
"responses": {
"200": {
"description": "Portal URL",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"url": { "type": "string", "format": "uri" }
}
}
}
}
},
"401": {
"description": "Unauthorized",
"content": {
"application/json": { "schema": { "$ref": "#/components/schemas/Error" } }
}
}
}
}
},
"/api/payments/coinpayportal/create": {
"post": {
"tags": ["Payments"],
Expand All @@ -5022,8 +4962,8 @@
"type": { "type": "string", "enum": ["subscription", "tip", "funding"] },
"plan": {
"type": "string",
"enum": ["monthly", "annual"],
"description": "Required for subscription type"
"enum": ["monthly", "annual", "lifetime"],
"description": "Required for subscription type. monthly = $9 (1 month of Pro), annual = $90 (12 months of Pro), lifetime = $100 one-time (Pro forever). Prices: src/lib/plans.ts."
},
"currency": {
"type": "string",
Expand Down
61 changes: 61 additions & 0 deletions src/app/api/cron/expire-payments/route.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
import { createFakeSupabase, type FakeSupabase } from "@/test/fake-supabase";

let db: FakeSupabase;
vi.mock("@/lib/supabase/service", () => ({ createServiceClient: () => db }));

import { POST } from "./route";

const HOUR = 60 * 60 * 1000;
const ago = (ms: number) => new Date(Date.now() - ms).toISOString();
const ahead = (ms: number) => new Date(Date.now() + ms).toISOString();

function call(query = "", secret = "cron-secret") {
return POST(
new NextRequest(`http://localhost/api/cron/expire-payments${query}`, {
method: "POST",
headers: { authorization: `Bearer ${secret}` },
})
);
}

beforeEach(() => {
process.env.CRON_SECRET = "cron-secret";
db = createFakeSupabase({
payments: [
{ id: "past-expiry", status: "pending", created_at: ago(2 * HOUR), metadata: { expires_at: ago(HOUR) } },
{ id: "before-expiry", status: "pending", created_at: ago(2 * HOUR), metadata: { expires_at: ahead(HOUR) } },
{ id: "no-expiry-old", status: "pending", created_at: ago(25 * HOUR), metadata: {} },
{ id: "no-expiry-new", status: "pending", created_at: ago(23 * HOUR), metadata: null },
{ id: "confirmed-old", status: "confirmed", created_at: ago(100 * HOUR), metadata: { expires_at: ago(99 * HOUR) } },
],
});
});

const statusOf = (id: string) => db.tables.payments.find((p) => p.id === id)?.status;

describe("POST /api/cron/expire-payments", () => {
it("requires CRON_SECRET", async () => {
expect((await call("", "wrong")).status).toBe(401);
delete process.env.CRON_SECRET;
expect((await call("", "")).status).toBe(401);
});

it("expires pending rows past expires_at, or 24h old without one", async () => {
const res = await call();
expect(res.status).toBe(200);
expect(await res.json()).toMatchObject({ checked: 4, expired: 2 });
expect(statusOf("past-expiry")).toBe("expired");
expect(statusOf("no-expiry-old")).toBe("expired");
expect(statusOf("before-expiry")).toBe("pending");
expect(statusOf("no-expiry-new")).toBe("pending");
expect(statusOf("confirmed-old")).toBe("confirmed");
});

it("dry run changes nothing", async () => {
const body = await (await call("?dry_run=1")).json();
expect(body.would_expire.sort()).toEqual(["no-expiry-old", "past-expiry"]);
expect(statusOf("past-expiry")).toBe("pending");
});
});
Loading
Loading