diff --git a/docs/implementation-plans/pingpay-subscription-integration.md b/docs/implementation-plans/pingpay-subscription-integration.md new file mode 100644 index 000000000..420940dd0 --- /dev/null +++ b/docs/implementation-plans/pingpay-subscription-integration.md @@ -0,0 +1,481 @@ +# PingPay Subscription Payment Integration Plan + +## Summary + +Integrate [PingPay](https://pingpay.io) for treasury subscription payments, allowing users to pay in any token while Trezu receives USDC. This enables a monetization path for Trezu through upfront subscription payments (3, 6, or 12 months). + +## Motivation + +Trezu needs a sustainable revenue model. PingPay, powered by NEAR Intents, provides: +- **Pay in any token** - Users pay with whatever tokens they have +- **Receive in USDC** - Trezu always receives stable USDC +- **No custody risk** - Payments flow directly through the protocol +- **Cross-chain support** - Users can pay from multiple chains + +## Architecture + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ Frontend (Next.js) │ +│ ┌─────────────────────────────────────────────────────────┐ │ +│ │ Settings > Subscription Tab │ │ +│ │ ┌──────────┐ ┌──────────┐ ┌──────────────────────┐ │ │ +│ │ │ Plan │ │ Subscribe│ │ Payment History │ │ │ +│ │ │ Selector │ │ Button │ │ + Invoice Download │ │ │ +│ │ └──────────┘ └──────────┘ └──────────────────────┘ │ │ +│ └─────────────────────────────────────────────────────────┘ │ +└─────────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ Backend (Rust/Axum) │ +│ ┌──────────────────────────────────────────────────────────┐ │ +│ │ /api/subscriptions/* │ │ +│ │ ┌────────┐ ┌────────┐ ┌──────────┐ ┌──────────────────┐│ │ +│ │ │ plans │ │ status │ │ checkout │ │ callback ││ │ +│ │ │ GET │ │ GET │ │ POST │ │ GET (redirect) ││ │ +│ │ └────────┘ └────────┘ └──────────┘ └──────────────────┘│ │ +│ └──────────────────────────────────────────────────────────┘ │ +│ │ │ +│ ┌──────────────────────────────────────────────────────────┐ │ +│ │ PingPay Client │ │ +│ │ POST /api/checkout/sessions → sessionUrl │ │ +│ └──────────────────────────────────────────────────────────┘ │ +└─────────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ PingPay (External) │ +│ ┌──────────────────────────────────────────────────────────┐ │ +│ │ https://pay.pingpay.io │ │ +│ │ - Checkout UI (user pays in any token) │ │ +│ │ - NEAR Intents (token conversion) │ │ +│ │ - Redirect to success/cancel URL with payment status │ │ +│ └──────────────────────────────────────────────────────────┘ │ +└─────────────────────────────────────────────────────────────────┘ +``` + +## PingPay API (Verified) + +**Reference:** [ping-checkout-example](https://github.com/Pingpayio/ping-checkout-example) +**Dashboard:** [pay.pingpay.io/dashboard](https://pay.pingpay.io/dashboard) + +### Create Checkout Session + +``` +POST https://pay.pingpay.io/api/checkout/sessions +Headers: x-api-key: +``` + +**Request:** +```json +{ + "amount": "150000000", + "asset": { "chain": "NEAR", "symbol": "USDC" }, + "successUrl": "https://backend.trezu.app/api/subscriptions/callback?type=success&subscription_id=1&internal_payment_id=1", + "cancelUrl": "https://backend.trezu.app/api/subscriptions/callback?type=cancel&subscription_id=1&internal_payment_id=1", + "metadata": { + "treasury_id": "my-dao.sputnik-dao.near", + "plan_id": "12m", + "subscription_id": "1", + "payment_id": "1" + } +} +``` + +**Response (actual from API):** +```json +{ + "session": { + "sessionId": "cs_ZzHUU6qf_7NgK4_rC66MI", + "status": "CREATED", + "paymentId": null, + "amount": { + "assetId": "nep141:17208628f84f5d6ad33f0da3bbbeb27ffcb398eac501a31bd6ad2011e36133a1", + "amount": "50000000", + "decimals": 6 + }, + "recipient": { + "address": "webassemblymusic-treasury.sputnik-dao.near" + }, + "successUrl": "...", + "cancelUrl": "...", + "createdAt": "2026-02-02T19:10:10.447Z", + "expiresAt": "2026-02-02T20:10:10.448Z" + }, + "sessionUrl": "https://pay.pingpay.io/checkout?sessionId=cs_ZzHUU6qf_7NgK4_rC66MI" +} +``` + +> **Note:** The response `amount` is an object (not a string) and `recipient` is an object with `address` (not a flat string). The response structs in `pingpay.rs` have been updated to match. + +### Callback URL Parameters + +On payment completion, PingPay redirects user to the success/cancel URL with additional query params: +- `paymentId` - PingPay payment ID +- `sessionId` - Original session ID +- `txStatus` - `SUCCESS` | `FAILED` | `REFUNDED` +- `depositAddress` - Transaction reference + +Our callback URLs also include `subscription_id` and `internal_payment_id` for internal tracking. + +### Payment Flow (Redirect-based) + +``` +1. User clicks "Subscribe" → selects plan (3m/6m/12m) + │ + ▼ +2. Frontend POST /api/subscriptions/checkout + { accountId: "dao.sputnik-dao.near", planId: "12m" } + │ + ▼ +3. Backend: + - Creates treasury_subscriptions (status: pending) + - Creates subscription_payments (status: pending) + - Calls PingPay API → gets sessionUrl + - Returns { sessionUrl, sessionId, subscriptionId, paymentId } + │ + ▼ +4. Frontend redirects user to PingPay checkout (sessionUrl) + │ + ▼ +5. User pays (any token → USDC via NEAR Intents) + │ + ▼ +6. PingPay redirects to BACKEND callback: + /api/subscriptions/callback?type=success&sessionId=...&paymentId=...&txStatus=SUCCESS + │ + ▼ +7. Backend callback handler: + - Finds payment by sessionId or internal_payment_id + - Updates payment record (status: completed, pingpay_payment_id, etc.) + - Activates subscription (status: active, starts_at, expires_at) + - Redirects to FRONTEND success page + │ + ▼ +8. Frontend shows "Subscription Active!" +``` + +> **Important:** PingPay success/cancel URLs point to the **backend** callback handler (`BACKEND_URL/api/subscriptions/callback`). The backend then redirects to the **frontend** success/cancel pages (`SUBSCRIPTION_SUCCESS_URL` / `SUBSCRIPTION_CANCEL_URL`). + +## Database Schema + +```sql +-- Subscription plans (configurable pricing) +CREATE TABLE subscription_plans ( + id VARCHAR(16) PRIMARY KEY, -- '3m', '6m', '12m' + name VARCHAR(64) NOT NULL, + duration_months INTEGER NOT NULL, + price_usdc NUMERIC(18, 6) NOT NULL, + active BOOLEAN DEFAULT true +); + +-- Treasury subscriptions +CREATE TABLE treasury_subscriptions ( + id SERIAL PRIMARY KEY, + account_id VARCHAR(128) NOT NULL, -- Treasury account + plan_id VARCHAR(16) REFERENCES subscription_plans(id), + status VARCHAR(16) NOT NULL, -- pending, active, expired, cancelled + starts_at TIMESTAMPTZ, + expires_at TIMESTAMPTZ, + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW() +); + +-- Payment records +CREATE TABLE subscription_payments ( + id SERIAL PRIMARY KEY, + subscription_id INTEGER REFERENCES treasury_subscriptions(id), + usdc_amount NUMERIC(18, 6) NOT NULL, + pingpay_session_id VARCHAR(128), + pingpay_payment_id VARCHAR(128), + deposit_address VARCHAR(256), + tx_status VARCHAR(32), + status VARCHAR(16) NOT NULL, -- pending, completed, failed, expired + created_at TIMESTAMPTZ DEFAULT NOW(), + completed_at TIMESTAMPTZ +); + +-- Invoice records (future) +CREATE TABLE subscription_invoices ( + id SERIAL PRIMARY KEY, + payment_id INTEGER REFERENCES subscription_payments(id), + invoice_number VARCHAR(32) UNIQUE, + invoice_data JSONB NOT NULL +); +``` + +Default plans seeded: +| Plan | Duration | Price | +|------|----------|-------| +| 3m | 3 months | $50 USDC | +| 6m | 6 months | $90 USDC | +| 12m | 12 months | $150 USDC | + +## API Endpoints + +| Endpoint | Method | Purpose | +|----------|--------|---------| +| `/api/subscriptions/plans` | GET | List available subscription plans | +| `/api/subscriptions/status?account_id=...` | GET | Get treasury subscription status | +| `/api/subscriptions/checkout` | POST | Create PingPay checkout session | +| `/api/subscriptions/callback` | GET | Handle PingPay redirect | +| `/api/subscriptions/invoice/:payment_id` | GET | Download invoice (future) | + +### Response Examples + +**GET /api/subscriptions/plans** +```json +{ + "plans": [ + { "id": "3m", "name": "3 Month Subscription", "durationMonths": 3, "priceUsdc": "50.00" }, + { "id": "6m", "name": "6 Month Subscription", "durationMonths": 6, "priceUsdc": "90.00" }, + { "id": "12m", "name": "12 Month Subscription", "durationMonths": 12, "priceUsdc": "150.00" } + ] +} +``` + +**GET /api/subscriptions/status?account_id=test.sputnik-dao.near** (active) +```json +{ + "isActive": true, + "subscription": { + "id": 5, + "planId": "3m", + "planName": "3 Month Subscription", + "status": "active", + "startsAt": "2026-02-04T06:47:08.654909Z", + "expiresAt": "2026-05-05T06:47:08.654909Z", + "daysRemaining": 89 + }, + "payments": [ + { + "id": 5, + "usdcAmount": "50.00", + "status": "completed", + "createdAt": "2026-02-04T06:47:02.960766Z", + "completedAt": "2026-02-04T06:47:08.680217Z", + "hasInvoice": false + } + ] +} +``` + +**POST /api/subscriptions/checkout** `{ "accountId": "...", "planId": "3m" }` +```json +{ + "sessionUrl": "https://pay.pingpay.io/checkout?sessionId=cs_ZElSmlcdZZKTaLCyohcSr", + "sessionId": "cs_ZElSmlcdZZKTaLCyohcSr", + "subscriptionId": 5, + "paymentId": 5 +} +``` + +## Environment Variables + +```bash +# Backend public URL (for PingPay callback redirects) +BACKEND_URL=https://backend.trezu.app # default: http://localhost:3002 + +# PingPay configuration +PINGPAY_API_URL=https://pay.pingpay.io/api +PINGPAY_API_KEY= +PINGPAY_MOCK_MODE=false # true for sandbox testing + +# Frontend redirect URLs (where backend sends user after processing callback) +SUBSCRIPTION_SUCCESS_URL=https://trezu.app/subscription/success +SUBSCRIPTION_CANCEL_URL=https://trezu.app/subscription/cancel +``` + +## Sandbox Testing + +PingPay uses redirect-based confirmation (no webhooks). For sandbox/docker testing: + +**Mock PingPay Service** (when `PINGPAY_MOCK_MODE=true`): +1. Mock endpoint returns fake sessionUrl pointing to local mock checkout +2. Mock checkout page has "Simulate Payment" button +3. Button redirects to callback URL with success params + +This allows full flow testing without real payments. + +## Implementation Status + +### Completed + +- [x] Database migration (`nt-be/migrations/20260201000002_create_subscriptions.sql`) +- [x] Environment variables (`nt-be/src/utils/env.rs` - `BACKEND_URL`, `PINGPAY_API_URL`, `PINGPAY_API_KEY`, `PINGPAY_MOCK_MODE`, `SUBSCRIPTION_SUCCESS_URL`, `SUBSCRIPTION_CANCEL_URL`) +- [x] PingPay client module (`nt-be/src/handlers/subscriptions/pingpay.rs`) +- [x] Backend endpoints: + - [x] GET `/api/subscriptions/plans` (`plans.rs`) + - [x] GET `/api/subscriptions/status` (`status.rs`) + - [x] POST `/api/subscriptions/checkout` (`checkout.rs`) + - [x] GET `/api/subscriptions/callback` (`callback.rs`) +- [x] Routes configured (`nt-be/src/routes/mod.rs`) +- [x] Tested against real PingPay API (checkout session creation + callback flow verified) + +### Remaining Work + +- [ ] **Mock PingPay service for sandbox** - When `PINGPAY_MOCK_MODE=true`, intercept PingPay calls with local mock that simulates the checkout + redirect flow +- [ ] **Frontend subscription tab** (see details below) +- [ ] **Invoice generation** - Store invoice data as JSONB, generate PDF on demand +- [ ] **Tests** - Unit tests for PingPay client, integration tests for checkout flow + +## Remaining: Frontend Subscription Tab + +### Files to Create + +``` +nt-fe/ +├── app/(treasury)/[treasuryId]/settings/ +│ └── components/ +│ └── subscription-tab.tsx # New settings tab +├── app/subscription/ +│ ├── success/page.tsx # Success redirect page +│ └── cancel/page.tsx # Cancel redirect page +└── lib/ + └── subscription-api.ts # API client functions +``` + +### Files to Modify + +``` +nt-fe/app/(treasury)/[treasuryId]/settings/page.tsx + - Add { value: "subscription", label: "Subscription" } to tabs array + - Import and render for activeTab === "subscription" +``` + +### Frontend Patterns to Follow + +The settings page uses a tab-based architecture. Existing tabs (general, voting, preferences) provide patterns: + +- **State management:** `useState` for active tab, React Query for data fetching +- **Components:** `PageCard` for sections, `TabGroup` for tab switching +- **API calls:** `axios.get/post` from `lib/api.ts` with `NEXT_PUBLIC_BACKEND_API_BASE` +- **Notifications:** `toast` from `sonner` +- **Stores:** `useTreasury()` for `selectedTreasury`, `useNear()` for `accountId` + +### subscription-api.ts + +```typescript +import axios from "axios"; + +const API_BASE = process.env.NEXT_PUBLIC_BACKEND_API_BASE; + +export interface Plan { + id: string; + name: string; + durationMonths: number; + priceUsdc: string; +} + +export interface SubscriptionStatus { + isActive: boolean; + subscription?: { + id: number; + planId: string; + planName: string; + status: string; + startsAt: string; + expiresAt: string; + daysRemaining: number; + }; + payments: { + id: number; + usdcAmount: string; + status: string; + createdAt: string; + completedAt?: string; + hasInvoice: boolean; + }[]; +} + +export interface CheckoutResponse { + sessionUrl: string; + sessionId: string; + subscriptionId: number; + paymentId: number; +} + +export async function getSubscriptionPlans(): Promise { + const { data } = await axios.get(`${API_BASE}/api/subscriptions/plans`); + return data.plans; +} + +export async function getSubscriptionStatus(accountId: string): Promise { + const { data } = await axios.get(`${API_BASE}/api/subscriptions/status`, { + params: { account_id: accountId }, + }); + return data; +} + +export async function createCheckout(accountId: string, planId: string): Promise { + const { data } = await axios.post(`${API_BASE}/api/subscriptions/checkout`, { + accountId, + planId, + }); + return data; +} +``` + +### subscription-tab.tsx Outline + +```tsx +"use client"; + +// Components: +// 1. SubscriptionStatus - Shows current status (Active until X / No subscription) +// 2. PlanCards - Shows available plans with pricing, highlight current plan +// 3. SubscribeButton - Calls checkout API, redirects to PingPay +// 4. PaymentHistory - Table of past payments with status + +// Flow: +// - Fetch plans via getSubscriptionPlans() +// - Fetch status via getSubscriptionStatus(selectedTreasury) +// - On "Subscribe" click: createCheckout(selectedTreasury, planId) +// - Redirect to response.sessionUrl (window.location.href) +// - User pays on PingPay → redirected to backend callback → frontend success page +``` + +### Success/Cancel Pages + +**`app/subscription/success/page.tsx`:** +- Read `subscription_id` and `payment_id` from URL search params +- Show success message with subscription details +- Link back to treasury settings + +**`app/subscription/cancel/page.tsx`:** +- Read `error` and `cancelled` from URL search params +- Show appropriate message (cancelled vs. failed) +- Link to retry or go back to settings + +## Remaining: Mock PingPay Service + +When `PINGPAY_MOCK_MODE=true` in the backend: + +1. The `PingPayClient` should return a mock response with a `sessionUrl` pointing to a local HTML page +2. The mock page shows plan details and a "Simulate Payment" button +3. The button redirects to the callback URL with `txStatus=SUCCESS` + +This could be a simple static HTML page served by the backend, or an endpoint that returns HTML. + +## Remaining: Invoice Generation + +1. On successful payment, create `subscription_invoices` record with JSONB data +2. Invoice data includes: invoice number, date, treasury info, plan info, payment details, period +3. Endpoint `GET /api/subscriptions/invoice/:payment_id` generates and returns a PDF +4. Consider `genpdf` crate for server-side PDF, or return JSON and generate PDF on frontend + +## Future Enhancements + +1. **Recurring Subscriptions** - When PingPay adds subscription support (planned with Outlayer + TEE) +2. **Multiple Payment Methods** - Cards, other crypto +3. **Tiered Features** - Different feature sets per plan +4. **Usage-Based Billing** - Pay per export, per transaction, etc. + +## References + +- [PingPay](https://pingpay.io) +- [PingPay Checkout Example](https://github.com/Pingpayio/ping-checkout-example) +- [PingPay Dashboard](https://pay.pingpay.io/dashboard) +- [NEAR Intents](https://docs.near-intents.org) +- [GitHub Issue #120](https://github.com/NEAR-DevHub/treasury26/issues/120) diff --git a/nt-be/.sqlx/query-02b9aebd5dacb316de1a7b3c95b1f17ee8acdf1eeb9e189ea89a4be9e1ec7045.json b/nt-be/.sqlx/query-02b9aebd5dacb316de1a7b3c95b1f17ee8acdf1eeb9e189ea89a4be9e1ec7045.json new file mode 100644 index 000000000..ba7ca8d68 --- /dev/null +++ b/nt-be/.sqlx/query-02b9aebd5dacb316de1a7b3c95b1f17ee8acdf1eeb9e189ea89a4be9e1ec7045.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE subscription_payments\n SET status = 'expired'\n WHERE id = $1 AND status = 'pending'\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int4" + ] + }, + "nullable": [] + }, + "hash": "02b9aebd5dacb316de1a7b3c95b1f17ee8acdf1eeb9e189ea89a4be9e1ec7045" +} diff --git a/nt-be/.sqlx/query-18287389ed1bec5af5e733b3ca405288cb9b89f72c24367d99efbe0e8a2d01f1.json b/nt-be/.sqlx/query-18287389ed1bec5af5e733b3ca405288cb9b89f72c24367d99efbe0e8a2d01f1.json new file mode 100644 index 000000000..8f2ab6fcd --- /dev/null +++ b/nt-be/.sqlx/query-18287389ed1bec5af5e733b3ca405288cb9b89f72c24367d99efbe0e8a2d01f1.json @@ -0,0 +1,17 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE subscription_payments\n SET\n pingpay_payment_id = $1,\n deposit_address = $2,\n tx_status = $3,\n status = 'completed',\n completed_at = NOW()\n WHERE id = $4\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar", + "Varchar", + "Varchar", + "Int4" + ] + }, + "nullable": [] + }, + "hash": "18287389ed1bec5af5e733b3ca405288cb9b89f72c24367d99efbe0e8a2d01f1" +} diff --git a/nt-be/.sqlx/query-49f6c26661b28553c1295a08370420d3111ea49d2f60d36e9de10110f8c5b0cc.json b/nt-be/.sqlx/query-49f6c26661b28553c1295a08370420d3111ea49d2f60d36e9de10110f8c5b0cc.json new file mode 100644 index 000000000..9d9e0fcc6 --- /dev/null +++ b/nt-be/.sqlx/query-49f6c26661b28553c1295a08370420d3111ea49d2f60d36e9de10110f8c5b0cc.json @@ -0,0 +1,34 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT sp.id, sp.subscription_id, ts.plan_id\n FROM subscription_payments sp\n JOIN treasury_subscriptions ts ON ts.id = sp.subscription_id\n WHERE sp.pingpay_session_id = $1\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int4" + }, + { + "ordinal": 1, + "name": "subscription_id", + "type_info": "Int4" + }, + { + "ordinal": 2, + "name": "plan_id", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false, + false, + false + ] + }, + "hash": "49f6c26661b28553c1295a08370420d3111ea49d2f60d36e9de10110f8c5b0cc" +} diff --git a/nt-be/.sqlx/query-66275d1b086ec900477b01d00044a2fa5a92d970e6432fd4365fa30b23f22323.json b/nt-be/.sqlx/query-66275d1b086ec900477b01d00044a2fa5a92d970e6432fd4365fa30b23f22323.json new file mode 100644 index 000000000..b60a089b6 --- /dev/null +++ b/nt-be/.sqlx/query-66275d1b086ec900477b01d00044a2fa5a92d970e6432fd4365fa30b23f22323.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT duration_months FROM subscription_plans WHERE id = $1\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "duration_months", + "type_info": "Int4" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false + ] + }, + "hash": "66275d1b086ec900477b01d00044a2fa5a92d970e6432fd4365fa30b23f22323" +} diff --git a/nt-be/.sqlx/query-7799afcbd75f95de8761d622236a1fa42c1e1772fb97c76edd1d874a1ffd9c1e.json b/nt-be/.sqlx/query-7799afcbd75f95de8761d622236a1fa42c1e1772fb97c76edd1d874a1ffd9c1e.json new file mode 100644 index 000000000..cc2f6fa23 --- /dev/null +++ b/nt-be/.sqlx/query-7799afcbd75f95de8761d622236a1fa42c1e1772fb97c76edd1d874a1ffd9c1e.json @@ -0,0 +1,40 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT id, name, duration_months, price_usdc\n FROM subscription_plans\n WHERE id = $1 AND active = true\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Varchar" + }, + { + "ordinal": 1, + "name": "name", + "type_info": "Varchar" + }, + { + "ordinal": 2, + "name": "duration_months", + "type_info": "Int4" + }, + { + "ordinal": 3, + "name": "price_usdc", + "type_info": "Numeric" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false, + false, + false, + false + ] + }, + "hash": "7799afcbd75f95de8761d622236a1fa42c1e1772fb97c76edd1d874a1ffd9c1e" +} diff --git a/nt-be/.sqlx/query-81648b03b2f1dc07f02692070dc1f12a20067170b72a748f27a2c87a141a9c4a.json b/nt-be/.sqlx/query-81648b03b2f1dc07f02692070dc1f12a20067170b72a748f27a2c87a141a9c4a.json new file mode 100644 index 000000000..0d176a6ca --- /dev/null +++ b/nt-be/.sqlx/query-81648b03b2f1dc07f02692070dc1f12a20067170b72a748f27a2c87a141a9c4a.json @@ -0,0 +1,16 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE treasury_subscriptions\n SET\n status = 'active',\n starts_at = $1,\n expires_at = $2,\n updated_at = NOW()\n WHERE id = $3\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Timestamptz", + "Timestamptz", + "Int4" + ] + }, + "nullable": [] + }, + "hash": "81648b03b2f1dc07f02692070dc1f12a20067170b72a748f27a2c87a141a9c4a" +} diff --git a/nt-be/.sqlx/query-9ed0b6622bbebe3a413902310e7107001f1b15dbd63af5d1fe86ed8ce67bd681.json b/nt-be/.sqlx/query-9ed0b6622bbebe3a413902310e7107001f1b15dbd63af5d1fe86ed8ce67bd681.json new file mode 100644 index 000000000..0a9bb01df --- /dev/null +++ b/nt-be/.sqlx/query-9ed0b6622bbebe3a413902310e7107001f1b15dbd63af5d1fe86ed8ce67bd681.json @@ -0,0 +1,23 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO treasury_subscriptions (account_id, plan_id, status)\n VALUES ($1, $2, 'pending')\n RETURNING id\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int4" + } + ], + "parameters": { + "Left": [ + "Varchar", + "Varchar" + ] + }, + "nullable": [ + false + ] + }, + "hash": "9ed0b6622bbebe3a413902310e7107001f1b15dbd63af5d1fe86ed8ce67bd681" +} diff --git a/nt-be/.sqlx/query-a155c439e576a1ba11346adc81fd6c30c564262f731b8c307a975641af93f4a1.json b/nt-be/.sqlx/query-a155c439e576a1ba11346adc81fd6c30c564262f731b8c307a975641af93f4a1.json new file mode 100644 index 000000000..9e523adf5 --- /dev/null +++ b/nt-be/.sqlx/query-a155c439e576a1ba11346adc81fd6c30c564262f731b8c307a975641af93f4a1.json @@ -0,0 +1,52 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT\n ts.id, ts.plan_id, ts.status, ts.starts_at, ts.expires_at,\n sp.name as plan_name\n FROM treasury_subscriptions ts\n JOIN subscription_plans sp ON sp.id = ts.plan_id\n WHERE ts.account_id = $1\n AND ts.status IN ('active', 'pending')\n ORDER BY ts.created_at DESC\n LIMIT 1\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int4" + }, + { + "ordinal": 1, + "name": "plan_id", + "type_info": "Varchar" + }, + { + "ordinal": 2, + "name": "status", + "type_info": "Varchar" + }, + { + "ordinal": 3, + "name": "starts_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 4, + "name": "expires_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 5, + "name": "plan_name", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false, + false, + false, + true, + true, + false + ] + }, + "hash": "a155c439e576a1ba11346adc81fd6c30c564262f731b8c307a975641af93f4a1" +} diff --git a/nt-be/.sqlx/query-aa39a917eae1b33dcc18e323d51c1f86aef52006f8ab8df3b3d711e6f0d2c585.json b/nt-be/.sqlx/query-aa39a917eae1b33dcc18e323d51c1f86aef52006f8ab8df3b3d711e6f0d2c585.json new file mode 100644 index 000000000..addfcce7c --- /dev/null +++ b/nt-be/.sqlx/query-aa39a917eae1b33dcc18e323d51c1f86aef52006f8ab8df3b3d711e6f0d2c585.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE subscription_payments\n SET pingpay_session_id = $1\n WHERE id = $2\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar", + "Int4" + ] + }, + "nullable": [] + }, + "hash": "aa39a917eae1b33dcc18e323d51c1f86aef52006f8ab8df3b3d711e6f0d2c585" +} diff --git a/nt-be/.sqlx/query-bb918076ccadef9b54181984890bdd553e8e5cdda4cf4be5eda5948343a072a6.json b/nt-be/.sqlx/query-bb918076ccadef9b54181984890bdd553e8e5cdda4cf4be5eda5948343a072a6.json new file mode 100644 index 000000000..a89179c1a --- /dev/null +++ b/nt-be/.sqlx/query-bb918076ccadef9b54181984890bdd553e8e5cdda4cf4be5eda5948343a072a6.json @@ -0,0 +1,34 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT sp.id, sp.subscription_id, ts.plan_id\n FROM subscription_payments sp\n JOIN treasury_subscriptions ts ON ts.id = sp.subscription_id\n WHERE sp.id = $1\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int4" + }, + { + "ordinal": 1, + "name": "subscription_id", + "type_info": "Int4" + }, + { + "ordinal": 2, + "name": "plan_id", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [ + "Int4" + ] + }, + "nullable": [ + false, + false, + false + ] + }, + "hash": "bb918076ccadef9b54181984890bdd553e8e5cdda4cf4be5eda5948343a072a6" +} diff --git a/nt-be/.sqlx/query-bf0bddec41d4de83bd8d17cdbfe9c4890a91b6b89dc621cbb767532d9d0067dd.json b/nt-be/.sqlx/query-bf0bddec41d4de83bd8d17cdbfe9c4890a91b6b89dc621cbb767532d9d0067dd.json new file mode 100644 index 000000000..6386ca029 --- /dev/null +++ b/nt-be/.sqlx/query-bf0bddec41d4de83bd8d17cdbfe9c4890a91b6b89dc621cbb767532d9d0067dd.json @@ -0,0 +1,23 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO subscription_payments (subscription_id, usdc_amount, status)\n VALUES ($1, $2, 'pending')\n RETURNING id\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int4" + } + ], + "parameters": { + "Left": [ + "Int4", + "Numeric" + ] + }, + "nullable": [ + false + ] + }, + "hash": "bf0bddec41d4de83bd8d17cdbfe9c4890a91b6b89dc621cbb767532d9d0067dd" +} diff --git a/nt-be/.sqlx/query-c0b0906ea1957bac5a42e0a6848d596ed9a8c88f11502a9db64b6ac330f7a52c.json b/nt-be/.sqlx/query-c0b0906ea1957bac5a42e0a6848d596ed9a8c88f11502a9db64b6ac330f7a52c.json new file mode 100644 index 000000000..f6e579ef0 --- /dev/null +++ b/nt-be/.sqlx/query-c0b0906ea1957bac5a42e0a6848d596ed9a8c88f11502a9db64b6ac330f7a52c.json @@ -0,0 +1,17 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE subscription_payments\n SET\n pingpay_payment_id = $1,\n deposit_address = $2,\n tx_status = $3,\n status = 'failed'\n WHERE id = $4\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar", + "Varchar", + "Varchar", + "Int4" + ] + }, + "nullable": [] + }, + "hash": "c0b0906ea1957bac5a42e0a6848d596ed9a8c88f11502a9db64b6ac330f7a52c" +} diff --git a/nt-be/.sqlx/query-cc0165ecefa6100e55b20a9a9b89fd4b476d6e424edb7060eff203be841e7259.json b/nt-be/.sqlx/query-cc0165ecefa6100e55b20a9a9b89fd4b476d6e424edb7060eff203be841e7259.json new file mode 100644 index 000000000..15ee9b01b --- /dev/null +++ b/nt-be/.sqlx/query-cc0165ecefa6100e55b20a9a9b89fd4b476d6e424edb7060eff203be841e7259.json @@ -0,0 +1,38 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT id, name, duration_months, price_usdc\n FROM subscription_plans\n WHERE active = true\n ORDER BY duration_months ASC\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Varchar" + }, + { + "ordinal": 1, + "name": "name", + "type_info": "Varchar" + }, + { + "ordinal": 2, + "name": "duration_months", + "type_info": "Int4" + }, + { + "ordinal": 3, + "name": "price_usdc", + "type_info": "Numeric" + } + ], + "parameters": { + "Left": [] + }, + "nullable": [ + false, + false, + false, + false + ] + }, + "hash": "cc0165ecefa6100e55b20a9a9b89fd4b476d6e424edb7060eff203be841e7259" +} diff --git a/nt-be/.sqlx/query-e10ce8c564b46e083b7d8b364f3b4568d84ba90c0065612673f2e7678ee2d0d5.json b/nt-be/.sqlx/query-e10ce8c564b46e083b7d8b364f3b4568d84ba90c0065612673f2e7678ee2d0d5.json new file mode 100644 index 000000000..f06d8156c --- /dev/null +++ b/nt-be/.sqlx/query-e10ce8c564b46e083b7d8b364f3b4568d84ba90c0065612673f2e7678ee2d0d5.json @@ -0,0 +1,52 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT\n sp.id, sp.usdc_amount, sp.status, sp.created_at, sp.completed_at,\n EXISTS(SELECT 1 FROM subscription_invoices si WHERE si.payment_id = sp.id) as \"has_invoice!\"\n FROM subscription_payments sp\n JOIN treasury_subscriptions ts ON ts.id = sp.subscription_id\n WHERE ts.account_id = $1\n ORDER BY sp.created_at DESC\n LIMIT 10\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int4" + }, + { + "ordinal": 1, + "name": "usdc_amount", + "type_info": "Numeric" + }, + { + "ordinal": 2, + "name": "status", + "type_info": "Varchar" + }, + { + "ordinal": 3, + "name": "created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 4, + "name": "completed_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 5, + "name": "has_invoice!", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false, + false, + false, + false, + true, + null + ] + }, + "hash": "e10ce8c564b46e083b7d8b364f3b4568d84ba90c0065612673f2e7678ee2d0d5" +} diff --git a/nt-be/.sqlx/query-feaebe9f6412afb2c068bc7da2531231f22b7c745ad7ee4fe3a1f703c38be667.json b/nt-be/.sqlx/query-feaebe9f6412afb2c068bc7da2531231f22b7c745ad7ee4fe3a1f703c38be667.json new file mode 100644 index 000000000..d71925c16 --- /dev/null +++ b/nt-be/.sqlx/query-feaebe9f6412afb2c068bc7da2531231f22b7c745ad7ee4fe3a1f703c38be667.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT id FROM treasury_subscriptions\n WHERE account_id = $1 AND status = 'pending'\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int4" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false + ] + }, + "hash": "feaebe9f6412afb2c068bc7da2531231f22b7c745ad7ee4fe3a1f703c38be667" +} diff --git a/nt-be/migrations/20260201000002_create_subscriptions.sql b/nt-be/migrations/20260201000002_create_subscriptions.sql new file mode 100644 index 000000000..2fbed3609 --- /dev/null +++ b/nt-be/migrations/20260201000002_create_subscriptions.sql @@ -0,0 +1,84 @@ +-- Create subscription tables for PingPay integration + +-- Subscription plans (configurable pricing) +CREATE TABLE subscription_plans ( + id VARCHAR(16) PRIMARY KEY, + name VARCHAR(64) NOT NULL, + duration_months INTEGER NOT NULL, + price_usdc NUMERIC(18, 6) NOT NULL, + active BOOLEAN NOT NULL DEFAULT true, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- Treasury subscriptions +CREATE TABLE treasury_subscriptions ( + id SERIAL PRIMARY KEY, + account_id VARCHAR(128) NOT NULL, + plan_id VARCHAR(16) NOT NULL REFERENCES subscription_plans(id), + status VARCHAR(16) NOT NULL DEFAULT 'pending', + starts_at TIMESTAMPTZ, + expires_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + CONSTRAINT valid_subscription_status CHECK (status IN ('pending', 'active', 'expired', 'cancelled')) +); + +-- Payment records +CREATE TABLE subscription_payments ( + id SERIAL PRIMARY KEY, + subscription_id INTEGER NOT NULL REFERENCES treasury_subscriptions(id), + usdc_amount NUMERIC(18, 6) NOT NULL, + pingpay_session_id VARCHAR(128), + pingpay_payment_id VARCHAR(128), + deposit_address VARCHAR(256), + tx_status VARCHAR(32), + status VARCHAR(16) NOT NULL DEFAULT 'pending', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + completed_at TIMESTAMPTZ, + + CONSTRAINT valid_payment_status CHECK (status IN ('pending', 'completed', 'failed', 'expired')) +); + +-- Invoice records +CREATE TABLE subscription_invoices ( + id SERIAL PRIMARY KEY, + payment_id INTEGER NOT NULL REFERENCES subscription_payments(id), + invoice_number VARCHAR(32) NOT NULL UNIQUE, + invoice_data JSONB NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- Indexes +CREATE INDEX idx_treasury_subscriptions_account ON treasury_subscriptions(account_id); +CREATE INDEX idx_treasury_subscriptions_status ON treasury_subscriptions(status); +CREATE INDEX idx_treasury_subscriptions_expires ON treasury_subscriptions(expires_at); +CREATE INDEX idx_subscription_payments_subscription ON subscription_payments(subscription_id); +CREATE INDEX idx_subscription_payments_session ON subscription_payments(pingpay_session_id); +CREATE INDEX idx_subscription_payments_status ON subscription_payments(status); +CREATE INDEX idx_subscription_invoices_payment ON subscription_invoices(payment_id); + +-- Trigger to auto-update updated_at on treasury_subscriptions +CREATE OR REPLACE FUNCTION update_treasury_subscriptions_updated_at() +RETURNS TRIGGER AS $$ +BEGIN + NEW.updated_at = NOW(); + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER trigger_treasury_subscriptions_updated_at + BEFORE UPDATE ON treasury_subscriptions + FOR EACH ROW + EXECUTE FUNCTION update_treasury_subscriptions_updated_at(); + +-- Seed default plans (placeholder pricing - update as needed) +INSERT INTO subscription_plans (id, name, duration_months, price_usdc) VALUES + ('3m', '3 Month Subscription', 3, 50.000000), + ('6m', '6 Month Subscription', 6, 90.000000), + ('12m', '12 Month Subscription', 12, 150.000000); + +COMMENT ON TABLE subscription_plans IS 'Available subscription plans with pricing'; +COMMENT ON TABLE treasury_subscriptions IS 'Treasury subscription records'; +COMMENT ON TABLE subscription_payments IS 'Payment records for subscriptions (PingPay integration)'; +COMMENT ON TABLE subscription_invoices IS 'Generated invoices for subscription payments'; diff --git a/nt-be/src/handlers/mod.rs b/nt-be/src/handlers/mod.rs index 35cdf3d3e..b6a6036b0 100644 --- a/nt-be/src/handlers/mod.rs +++ b/nt-be/src/handlers/mod.rs @@ -4,6 +4,7 @@ pub mod intents; pub mod lookup; pub mod proposals; pub mod proxy; +pub mod subscriptions; pub mod token; pub mod treasury; pub mod user; diff --git a/nt-be/src/handlers/subscriptions/callback.rs b/nt-be/src/handlers/subscriptions/callback.rs new file mode 100644 index 000000000..2eebb1156 --- /dev/null +++ b/nt-be/src/handlers/subscriptions/callback.rs @@ -0,0 +1,302 @@ +use axum::{ + extract::{Query, State}, + http::StatusCode, + response::Redirect, +}; +use chrono::{Duration, Utc}; +use serde::Deserialize; +use std::sync::Arc; + +use crate::AppState; + +/// Query parameters from PingPay callback +#[derive(Deserialize, Debug)] +#[serde(rename_all = "camelCase")] +pub struct CallbackQuery { + /// Callback type: "success" or "cancel" + #[serde(rename = "type")] + pub callback_type: Option, + + /// PingPay payment ID + pub payment_id: Option, + + /// PingPay session ID + pub session_id: Option, + + /// Transaction status: SUCCESS, FAILED, REFUNDED + pub tx_status: Option, + + /// Deposit address/transaction reference + pub deposit_address: Option, + + /// Our internal subscription ID (passed via success/cancel URL) + pub subscription_id: Option, + + /// Our internal payment ID (passed via success/cancel URL) + #[serde(alias = "payment_id")] + pub internal_payment_id: Option, +} + +/// Payment info needed for callback processing +struct PaymentInfo { + id: i32, + subscription_id: i32, + plan_id: String, +} + +/// GET /api/subscriptions/callback +/// +/// Handles PingPay redirect after payment completion +/// Redirects to frontend success or error page +pub async fn handle_callback( + State(state): State>, + Query(query): Query, +) -> Result { + log::info!("Received subscription callback: {:?}", query); + + // Determine redirect base URL + let base_success_url = &state.env_vars.subscription_success_url; + let base_cancel_url = &state.env_vars.subscription_cancel_url; + + // Handle cancel callback + if query.callback_type.as_deref() == Some("cancel") { + // Mark payment as expired if we have the internal payment ID + if let Some(payment_id) = query.internal_payment_id { + let _ = sqlx::query!( + r#" + UPDATE subscription_payments + SET status = 'expired' + WHERE id = $1 AND status = 'pending' + "#, + payment_id + ) + .execute(&state.db_pool) + .await; + } + + // Redirect to cancel page + let redirect_url = format!("{}?cancelled=true", base_cancel_url); + return Ok(Redirect::temporary(&redirect_url)); + } + + // Handle success callback - verify we have required params + let tx_status = query.tx_status.as_deref().unwrap_or("UNKNOWN"); + let pingpay_payment_id = query.payment_id.clone(); + let pingpay_session_id = query.session_id.clone(); + let deposit_address = query.deposit_address.clone(); + + // Find the payment record by session ID or internal payment ID + let payment = find_payment(&state, &pingpay_session_id, query.internal_payment_id).await?; + + let payment = match payment { + Some(p) => p, + None => { + log::error!("Payment record not found for callback"); + let redirect_url = format!("{}?error=payment_not_found", base_cancel_url); + return Ok(Redirect::temporary(&redirect_url)); + } + }; + + // Check transaction status + match tx_status { + "SUCCESS" => { + // Get plan duration to calculate expiry + let plan = sqlx::query!( + r#" + SELECT duration_months FROM subscription_plans WHERE id = $1 + "#, + payment.plan_id + ) + .fetch_optional(&state.db_pool) + .await + .map_err(|e| { + log::error!("Failed to fetch plan: {}", e); + (StatusCode::INTERNAL_SERVER_ERROR, "Database error".to_string()) + })?; + + let duration_months = plan.map(|p| p.duration_months).unwrap_or(12); + + // Calculate subscription period + let now = Utc::now(); + let expires_at = now + Duration::days(duration_months as i64 * 30); + + // Update payment record + sqlx::query!( + r#" + UPDATE subscription_payments + SET + pingpay_payment_id = $1, + deposit_address = $2, + tx_status = $3, + status = 'completed', + completed_at = NOW() + WHERE id = $4 + "#, + pingpay_payment_id, + deposit_address, + tx_status, + payment.id + ) + .execute(&state.db_pool) + .await + .map_err(|e| { + log::error!("Failed to update payment: {}", e); + (StatusCode::INTERNAL_SERVER_ERROR, "Database error".to_string()) + })?; + + // Activate subscription + sqlx::query!( + r#" + UPDATE treasury_subscriptions + SET + status = 'active', + starts_at = $1, + expires_at = $2, + updated_at = NOW() + WHERE id = $3 + "#, + now, + expires_at, + payment.subscription_id + ) + .execute(&state.db_pool) + .await + .map_err(|e| { + log::error!("Failed to activate subscription: {}", e); + (StatusCode::INTERNAL_SERVER_ERROR, "Database error".to_string()) + })?; + + log::info!( + "Subscription {} activated for payment {} - expires at {}", + payment.subscription_id, + payment.id, + expires_at + ); + + // Redirect to success page + let redirect_url = format!( + "{}?subscription_id={}&payment_id={}", + base_success_url, payment.subscription_id, payment.id + ); + Ok(Redirect::temporary(&redirect_url)) + } + + "FAILED" | "REFUNDED" => { + // Update payment record with failure status + sqlx::query!( + r#" + UPDATE subscription_payments + SET + pingpay_payment_id = $1, + deposit_address = $2, + tx_status = $3, + status = 'failed' + WHERE id = $4 + "#, + pingpay_payment_id, + deposit_address, + tx_status, + payment.id + ) + .execute(&state.db_pool) + .await + .map_err(|e| { + log::error!("Failed to update payment: {}", e); + (StatusCode::INTERNAL_SERVER_ERROR, "Database error".to_string()) + })?; + + log::warn!( + "Payment {} failed with status: {}", + payment.id, + tx_status + ); + + // Redirect to cancel page with error + let redirect_url = format!( + "{}?error=payment_{}&payment_id={}", + base_cancel_url, + tx_status.to_lowercase(), + payment.id + ); + Ok(Redirect::temporary(&redirect_url)) + } + + _ => { + log::warn!( + "Unknown transaction status: {} for payment {}", + tx_status, + payment.id + ); + + // Redirect to cancel page with unknown status + let redirect_url = format!( + "{}?error=unknown_status&status={}", + base_cancel_url, tx_status + ); + Ok(Redirect::temporary(&redirect_url)) + } + } +} + +/// Find payment by PingPay session ID or internal payment ID +async fn find_payment( + state: &AppState, + session_id: &Option, + internal_payment_id: Option, +) -> Result, (StatusCode, String)> { + // Try finding by session ID first + if let Some(session_id) = session_id { + let result = sqlx::query!( + r#" + SELECT sp.id, sp.subscription_id, ts.plan_id + FROM subscription_payments sp + JOIN treasury_subscriptions ts ON ts.id = sp.subscription_id + WHERE sp.pingpay_session_id = $1 + "#, + session_id + ) + .fetch_optional(&state.db_pool) + .await + .map_err(|e| { + log::error!("Failed to find payment by session: {}", e); + (StatusCode::INTERNAL_SERVER_ERROR, "Database error".to_string()) + })?; + + if let Some(row) = result { + return Ok(Some(PaymentInfo { + id: row.id, + subscription_id: row.subscription_id, + plan_id: row.plan_id, + })); + } + } + + // Fall back to internal payment ID + if let Some(payment_id) = internal_payment_id { + let result = sqlx::query!( + r#" + SELECT sp.id, sp.subscription_id, ts.plan_id + FROM subscription_payments sp + JOIN treasury_subscriptions ts ON ts.id = sp.subscription_id + WHERE sp.id = $1 + "#, + payment_id + ) + .fetch_optional(&state.db_pool) + .await + .map_err(|e| { + log::error!("Failed to find payment by ID: {}", e); + (StatusCode::INTERNAL_SERVER_ERROR, "Database error".to_string()) + })?; + + if let Some(row) = result { + return Ok(Some(PaymentInfo { + id: row.id, + subscription_id: row.subscription_id, + plan_id: row.plan_id, + })); + } + } + + Ok(None) +} diff --git a/nt-be/src/handlers/subscriptions/checkout.rs b/nt-be/src/handlers/subscriptions/checkout.rs new file mode 100644 index 000000000..b753267fb --- /dev/null +++ b/nt-be/src/handlers/subscriptions/checkout.rs @@ -0,0 +1,231 @@ +use axum::{extract::State, http::StatusCode, Json}; +use bigdecimal::{BigDecimal, ToPrimitive}; +use serde::{Deserialize, Serialize}; +use std::sync::Arc; + +use crate::AppState; + +use super::pingpay::{Asset, CreateCheckoutSessionRequest, PingPayClient}; + +/// Request to create a checkout session +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CheckoutRequest { + /// Treasury account ID + pub account_id: String, + /// Plan ID (e.g., "3m", "6m", "12m") + pub plan_id: String, +} + +/// Response with checkout session URL +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct CheckoutResponse { + /// PingPay checkout URL to redirect user + pub session_url: String, + /// Session ID for tracking + pub session_id: String, + /// Internal subscription ID + pub subscription_id: i32, + /// Internal payment ID + pub payment_id: i32, +} + +/// Convert USDC amount to smallest units (6 decimals) +fn usdc_to_smallest_units(amount: &BigDecimal) -> String { + // USDC has 6 decimals, so multiply by 1_000_000 + let multiplier = BigDecimal::from(1_000_000i64); + let smallest_units = amount * multiplier; + + // Convert to integer string (no decimals) + smallest_units + .to_i64() + .map(|i| i.to_string()) + .unwrap_or_else(|| "0".to_string()) +} + +/// POST /api/subscriptions/checkout +/// +/// Creates a checkout session for a subscription payment +pub async fn create_checkout( + State(state): State>, + Json(request): Json, +) -> Result, (StatusCode, String)> { + let account_id = &request.account_id; + let plan_id = &request.plan_id; + + // Validate plan exists and get price + let plan = sqlx::query!( + r#" + SELECT id, name, duration_months, price_usdc + FROM subscription_plans + WHERE id = $1 AND active = true + "#, + plan_id + ) + .fetch_optional(&state.db_pool) + .await + .map_err(|e| { + log::error!("Failed to fetch plan: {}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + "Failed to fetch subscription plan".to_string(), + ) + })? + .ok_or_else(|| { + ( + StatusCode::BAD_REQUEST, + format!("Invalid plan_id: {}", plan_id), + ) + })?; + + // Check for existing pending subscription + let existing = sqlx::query!( + r#" + SELECT id FROM treasury_subscriptions + WHERE account_id = $1 AND status = 'pending' + "#, + account_id + ) + .fetch_optional(&state.db_pool) + .await + .map_err(|e| { + log::error!("Failed to check existing subscription: {}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + "Database error".to_string(), + ) + })?; + + if existing.is_some() { + return Err(( + StatusCode::CONFLICT, + "A pending subscription already exists. Complete or cancel it first.".to_string(), + )); + } + + // Create subscription record (pending) + let subscription = sqlx::query!( + r#" + INSERT INTO treasury_subscriptions (account_id, plan_id, status) + VALUES ($1, $2, 'pending') + RETURNING id + "#, + account_id, + plan_id + ) + .fetch_one(&state.db_pool) + .await + .map_err(|e| { + log::error!("Failed to create subscription: {}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + "Failed to create subscription".to_string(), + ) + })?; + + // Create payment record (pending) + let payment = sqlx::query!( + r#" + INSERT INTO subscription_payments (subscription_id, usdc_amount, status) + VALUES ($1, $2, 'pending') + RETURNING id + "#, + subscription.id, + plan.price_usdc + ) + .fetch_one(&state.db_pool) + .await + .map_err(|e| { + log::error!("Failed to create payment: {}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + "Failed to create payment record".to_string(), + ) + })?; + + // Build callback URLs pointing to the backend callback handler + // PingPay will redirect here, then the callback handler redirects to frontend + let backend_base = state.env_vars.backend_url.trim_end_matches('/'); + let success_url = format!( + "{}/api/subscriptions/callback?type=success&subscription_id={}&internal_payment_id={}", + backend_base, + subscription.id, + payment.id + ); + let cancel_url = format!( + "{}/api/subscriptions/callback?type=cancel&subscription_id={}&internal_payment_id={}", + backend_base, + subscription.id, + payment.id + ); + + // Create PingPay checkout session + let pingpay_client = PingPayClient::new( + state.http_client.clone(), + state.env_vars.pingpay_api_url.clone(), + state.env_vars.pingpay_api_key.clone(), + ); + + let amount_smallest_units = usdc_to_smallest_units(&plan.price_usdc); + + let pingpay_request = CreateCheckoutSessionRequest { + amount: amount_smallest_units, + asset: Asset::default(), // NEAR/USDC + success_url, + cancel_url, + metadata: Some(serde_json::json!({ + "treasury_id": account_id, + "plan_id": plan_id, + "subscription_id": subscription.id.to_string(), + "payment_id": payment.id.to_string(), + })), + }; + + let pingpay_response = pingpay_client + .create_checkout_session(pingpay_request) + .await + .map_err(|e| { + log::error!("PingPay API error: {}", e); + ( + StatusCode::BAD_GATEWAY, + format!("Payment provider error: {}", e), + ) + })?; + + // Update payment record with PingPay session ID + sqlx::query!( + r#" + UPDATE subscription_payments + SET pingpay_session_id = $1 + WHERE id = $2 + "#, + pingpay_response.session.session_id, + payment.id + ) + .execute(&state.db_pool) + .await + .map_err(|e| { + log::error!("Failed to update payment with session ID: {}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + "Failed to update payment record".to_string(), + ) + })?; + + log::info!( + "Created checkout session for treasury {} plan {} - session_id: {}, subscription_id: {}, payment_id: {}", + account_id, + plan_id, + pingpay_response.session.session_id, + subscription.id, + payment.id + ); + + Ok(Json(CheckoutResponse { + session_url: pingpay_response.session_url, + session_id: pingpay_response.session.session_id, + subscription_id: subscription.id, + payment_id: payment.id, + })) +} diff --git a/nt-be/src/handlers/subscriptions/mod.rs b/nt-be/src/handlers/subscriptions/mod.rs new file mode 100644 index 000000000..e9d8be153 --- /dev/null +++ b/nt-be/src/handlers/subscriptions/mod.rs @@ -0,0 +1,5 @@ +pub mod callback; +pub mod checkout; +pub mod pingpay; +pub mod plans; +pub mod status; diff --git a/nt-be/src/handlers/subscriptions/pingpay.rs b/nt-be/src/handlers/subscriptions/pingpay.rs new file mode 100644 index 000000000..6182b8334 --- /dev/null +++ b/nt-be/src/handlers/subscriptions/pingpay.rs @@ -0,0 +1,220 @@ +use reqwest::Client; +use serde::{Deserialize, Serialize}; + +/// Asset specification for checkout session +#[derive(Serialize, Clone, Debug)] +pub struct Asset { + pub chain: String, + pub symbol: String, +} + +impl Default for Asset { + fn default() -> Self { + Self { + chain: "NEAR".to_string(), + symbol: "USDC".to_string(), + } + } +} + +/// Request to create a checkout session +#[derive(Serialize, Clone, Debug)] +#[serde(rename_all = "camelCase")] +pub struct CreateCheckoutSessionRequest { + /// Amount in smallest units (6 decimals for USDC: "150000000" = 150 USDC) + pub amount: String, + + /// Asset to receive (default: NEAR/USDC) + pub asset: Asset, + + /// URL to redirect on successful payment + pub success_url: String, + + /// URL to redirect on cancelled payment + pub cancel_url: String, + + /// Optional metadata for tracking (treasury_id, plan_id, etc.) + #[serde(skip_serializing_if = "Option::is_none")] + pub metadata: Option, +} + +/// Amount details in checkout response +#[derive(Deserialize, Clone, Debug)] +#[serde(rename_all = "camelCase")] +pub struct AmountDetails { + pub asset_id: Option, + pub amount: String, + pub decimals: Option, +} + +/// Recipient details in checkout response +#[derive(Deserialize, Clone, Debug)] +pub struct RecipientDetails { + pub address: String, +} + +/// Session details in checkout response +#[derive(Deserialize, Clone, Debug)] +#[serde(rename_all = "camelCase")] +pub struct SessionDetails { + pub session_id: String, + pub status: String, + pub amount: AmountDetails, + pub recipient: RecipientDetails, + #[serde(default)] + pub payment_id: Option, + pub created_at: String, + #[serde(default)] + pub expires_at: Option, +} + +/// Response from creating a checkout session +#[derive(Deserialize, Clone, Debug)] +#[serde(rename_all = "camelCase")] +pub struct CheckoutSessionResponse { + pub session: SessionDetails, + pub session_url: String, +} + +/// PingPay API client +pub struct PingPayClient { + http_client: Client, + api_url: String, + api_key: Option, +} + +impl PingPayClient { + /// Create a new PingPay client + pub fn new(http_client: Client, api_url: String, api_key: Option) -> Self { + Self { + http_client, + api_url, + api_key, + } + } + + /// Create a checkout session for payment + /// + /// # Arguments + /// * `request` - The checkout session request details + /// + /// # Returns + /// * `Ok(CheckoutSessionResponse)` - Session details including the URL to redirect user + /// * `Err(String)` - Error message if the API call fails + pub async fn create_checkout_session( + &self, + request: CreateCheckoutSessionRequest, + ) -> Result { + let api_key = self + .api_key + .as_ref() + .ok_or_else(|| "Missing PingPay API key".to_string())?; + + let url = format!("{}/checkout/sessions", self.api_url); + + log::info!( + "Creating PingPay checkout session: amount={}, asset={}/{}", + request.amount, + request.asset.chain, + request.asset.symbol + ); + + let response = self + .http_client + .post(&url) + .header("x-api-key", api_key) + .header("Content-Type", "application/json") + .json(&request) + .send() + .await + .map_err(|e| format!("HTTP request failed: {}", e))?; + + let status = response.status(); + + if !status.is_success() { + let error_text = response + .text() + .await + .unwrap_or_else(|_| "Unknown error".to_string()); + + log::error!( + "PingPay API error: status={}, body={}", + status.as_u16(), + error_text + ); + + return Err(format!("PingPay API error {}: {}", status.as_u16(), error_text)); + } + + let checkout_response: CheckoutSessionResponse = response + .json() + .await + .map_err(|e| format!("Failed to parse response: {}", e))?; + + log::info!( + "Created PingPay checkout session: session_id={}, url={}", + checkout_response.session.session_id, + checkout_response.session_url + ); + + Ok(checkout_response) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_create_checkout_request_serialization() { + let request = CreateCheckoutSessionRequest { + amount: "150000000".to_string(), // 150 USDC + asset: Asset::default(), + success_url: "https://example.com/success".to_string(), + cancel_url: "https://example.com/cancel".to_string(), + metadata: Some(serde_json::json!({ + "treasury_id": "test.sputnik-dao.near", + "plan_id": "12m", + "subscription_id": "123" + })), + }; + + let json = serde_json::to_string_pretty(&request).unwrap(); + println!("Request JSON:\n{}", json); + + // Verify camelCase serialization + assert!(json.contains("successUrl")); + assert!(json.contains("cancelUrl")); + assert!(!json.contains("success_url")); + } + + #[test] + fn test_checkout_response_deserialization() { + let json = r#"{ + "session": { + "sessionId": "cs_KkSF2ZfdK4N7oAzKdri_L", + "status": "CREATED", + "paymentId": null, + "amount": { + "assetId": "nep141:17208628f84f5d6ad33f0da3bbbeb27ffcb398eac501a31bd6ad2011e36133a1", + "amount": "150000000", + "decimals": 6 + }, + "recipient": { + "address": "trezu.near" + }, + "createdAt": "2026-02-01T12:00:00Z", + "expiresAt": "2026-02-01T13:00:00Z" + }, + "sessionUrl": "https://pay.pingpay.io/checkout?sessionId=cs_KkSF2ZfdK4N7oAzKdri_L" + }"#; + + let response: CheckoutSessionResponse = serde_json::from_str(json).unwrap(); + + assert_eq!(response.session.session_id, "cs_KkSF2ZfdK4N7oAzKdri_L"); + assert_eq!(response.session.status, "CREATED"); + assert_eq!(response.session.amount.amount, "150000000"); + assert_eq!(response.session.recipient.address, "trezu.near"); + assert!(response.session_url.contains("cs_KkSF2ZfdK4N7oAzKdri_L")); + } +} diff --git a/nt-be/src/handlers/subscriptions/plans.rs b/nt-be/src/handlers/subscriptions/plans.rs new file mode 100644 index 000000000..2aa505587 --- /dev/null +++ b/nt-be/src/handlers/subscriptions/plans.rs @@ -0,0 +1,64 @@ +use axum::{extract::State, http::StatusCode, Json}; +use bigdecimal::ToPrimitive; +use serde::Serialize; +use std::sync::Arc; + +use crate::AppState; + +/// Subscription plan returned to clients +#[derive(Serialize, Clone, Debug)] +#[serde(rename_all = "camelCase")] +pub struct SubscriptionPlan { + pub id: String, + pub name: String, + pub duration_months: i32, + /// Price in USDC as a string (e.g., "150.00") + pub price_usdc: String, +} + +/// Response containing available subscription plans +#[derive(Serialize, Debug)] +pub struct PlansResponse { + pub plans: Vec, +} + +/// GET /api/subscriptions/plans +/// +/// Returns all active subscription plans with pricing +pub async fn get_plans( + State(state): State>, +) -> Result, (StatusCode, String)> { + let plans = sqlx::query!( + r#" + SELECT id, name, duration_months, price_usdc + FROM subscription_plans + WHERE active = true + ORDER BY duration_months ASC + "# + ) + .fetch_all(&state.db_pool) + .await + .map_err(|e| { + log::error!("Failed to fetch subscription plans: {}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + "Failed to fetch subscription plans".to_string(), + ) + })?; + + let plans: Vec = plans + .into_iter() + .map(|row| SubscriptionPlan { + id: row.id, + name: row.name, + duration_months: row.duration_months, + price_usdc: row + .price_usdc + .to_f64() + .map(|f| format!("{:.2}", f)) + .unwrap_or_else(|| "0.00".to_string()), + }) + .collect(); + + Ok(Json(PlansResponse { plans })) +} diff --git a/nt-be/src/handlers/subscriptions/status.rs b/nt-be/src/handlers/subscriptions/status.rs new file mode 100644 index 000000000..e7e64c6a9 --- /dev/null +++ b/nt-be/src/handlers/subscriptions/status.rs @@ -0,0 +1,175 @@ +use axum::{ + extract::{Query, State}, + http::StatusCode, + Json, +}; +use bigdecimal::ToPrimitive; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use std::sync::Arc; + +use crate::AppState; + +/// Query parameters for subscription status +#[derive(Deserialize)] +pub struct StatusQuery { + pub account_id: String, +} + +/// Subscription status for a treasury +#[derive(Serialize, Debug)] +#[serde(rename_all = "camelCase")] +pub struct SubscriptionStatus { + /// Whether the treasury has an active subscription + pub is_active: bool, + /// Current subscription details (if any) + #[serde(skip_serializing_if = "Option::is_none")] + pub subscription: Option, + /// Payment history + pub payments: Vec, +} + +/// Details of a subscription +#[derive(Serialize, Debug)] +#[serde(rename_all = "camelCase")] +pub struct SubscriptionDetails { + pub id: i32, + pub plan_id: String, + pub plan_name: String, + pub status: String, + pub starts_at: Option>, + pub expires_at: Option>, + /// Days remaining until expiration (negative if expired) + pub days_remaining: Option, +} + +/// Summary of a payment +#[derive(Serialize, Debug)] +#[serde(rename_all = "camelCase")] +pub struct PaymentSummary { + pub id: i32, + pub usdc_amount: String, + pub status: String, + pub created_at: DateTime, + pub completed_at: Option>, + /// Whether an invoice has been generated + pub has_invoice: bool, +} + +/// GET /api/subscriptions/status +/// +/// Returns subscription status for a treasury account +pub async fn get_subscription_status( + State(state): State>, + Query(query): Query, +) -> Result, (StatusCode, String)> { + let account_id = &query.account_id; + + // Get the most recent active or pending subscription + let subscription = sqlx::query!( + r#" + SELECT + ts.id, ts.plan_id, ts.status, ts.starts_at, ts.expires_at, + sp.name as plan_name + FROM treasury_subscriptions ts + JOIN subscription_plans sp ON sp.id = ts.plan_id + WHERE ts.account_id = $1 + AND ts.status IN ('active', 'pending') + ORDER BY ts.created_at DESC + LIMIT 1 + "#, + account_id + ) + .fetch_optional(&state.db_pool) + .await + .map_err(|e| { + log::error!("Failed to fetch subscription: {}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + "Failed to fetch subscription".to_string(), + ) + })?; + + // Check if subscription is truly active (not expired) + let now = Utc::now(); + let (is_active, subscription_details) = if let Some(sub) = subscription { + let is_expired = sub + .expires_at + .map(|exp| exp < now) + .unwrap_or(false); + + let days_remaining = sub.expires_at.map(|exp| { + let duration = exp.signed_duration_since(now); + duration.num_days() + }); + + let actual_status = if is_expired && sub.status == "active" { + "expired".to_string() + } else { + sub.status + }; + + let is_active = actual_status == "active" && !is_expired; + + ( + is_active, + Some(SubscriptionDetails { + id: sub.id, + plan_id: sub.plan_id, + plan_name: sub.plan_name, + status: actual_status, + starts_at: sub.starts_at, + expires_at: sub.expires_at, + days_remaining, + }), + ) + } else { + (false, None) + }; + + // Get payment history for this account + let payments = sqlx::query!( + r#" + SELECT + sp.id, sp.usdc_amount, sp.status, sp.created_at, sp.completed_at, + EXISTS(SELECT 1 FROM subscription_invoices si WHERE si.payment_id = sp.id) as "has_invoice!" + FROM subscription_payments sp + JOIN treasury_subscriptions ts ON ts.id = sp.subscription_id + WHERE ts.account_id = $1 + ORDER BY sp.created_at DESC + LIMIT 10 + "#, + account_id + ) + .fetch_all(&state.db_pool) + .await + .map_err(|e| { + log::error!("Failed to fetch payment history: {}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + "Failed to fetch payment history".to_string(), + ) + })?; + + let payments: Vec = payments + .into_iter() + .map(|row| PaymentSummary { + id: row.id, + usdc_amount: row + .usdc_amount + .to_f64() + .map(|f| format!("{:.2}", f)) + .unwrap_or_else(|| "0.00".to_string()), + status: row.status, + created_at: row.created_at, + completed_at: row.completed_at, + has_invoice: row.has_invoice, + }) + .collect(); + + Ok(Json(SubscriptionStatus { + is_active, + subscription: subscription_details, + payments, + })) +} diff --git a/nt-be/src/routes/mod.rs b/nt-be/src/routes/mod.rs index 737f287f1..949bb2f2d 100644 --- a/nt-be/src/routes/mod.rs +++ b/nt-be/src/routes/mod.rs @@ -217,6 +217,23 @@ pub fn create_routes(state: Arc) -> Router { "/api/proxy/{*path}", get(handlers::proxy::external::proxy_external_api), ) + // Subscription endpoints + .route( + "/api/subscriptions/plans", + get(handlers::subscriptions::plans::get_plans), + ) + .route( + "/api/subscriptions/status", + get(handlers::subscriptions::status::get_subscription_status), + ) + .route( + "/api/subscriptions/checkout", + post(handlers::subscriptions::checkout::create_checkout), + ) + .route( + "/api/subscriptions/callback", + get(handlers::subscriptions::callback::handle_callback), + ) // Auth endpoints .route( "/api/auth/challenge", diff --git a/nt-be/src/utils/env.rs b/nt-be/src/utils/env.rs index 5fa502032..8350615b0 100644 --- a/nt-be/src/utils/env.rs +++ b/nt-be/src/utils/env.rs @@ -31,6 +31,14 @@ pub struct EnvVars { pub oneclick_app_fee_bps: Option, pub oneclick_app_fee_recipient: Option, pub oneclick_referral: Option, + // Backend public URL (for PingPay callbacks) + pub backend_url: String, + // PingPay configuration for subscription payments + pub pingpay_api_url: String, + pub pingpay_api_key: Option, + pub pingpay_mock_mode: bool, + pub subscription_success_url: String, + pub subscription_cancel_url: String, // JWT authentication configuration pub jwt_secret: String, pub jwt_expiry_hours: u64, @@ -122,6 +130,23 @@ impl Default for EnvVars { .ok() .filter(|s| !s.is_empty()) .or_else(|| Some("near-treasury".to_string())), + // Backend public URL + backend_url: std::env::var("BACKEND_URL") + .unwrap_or_else(|_| "http://localhost:3002".to_string()), + // PingPay configuration + pingpay_api_url: std::env::var("PINGPAY_API_URL") + .unwrap_or_else(|_| "https://pay.pingpay.io".to_string()), + pingpay_api_key: std::env::var("PINGPAY_API_KEY") + .ok() + .filter(|s| !s.is_empty()), + pingpay_mock_mode: std::env::var("PINGPAY_MOCK_MODE") + .unwrap_or_else(|_| "false".to_string()) + .parse() + .unwrap_or(false), + subscription_success_url: std::env::var("SUBSCRIPTION_SUCCESS_URL") + .unwrap_or_else(|_| "http://localhost:3000/subscription/success".to_string()), + subscription_cancel_url: std::env::var("SUBSCRIPTION_CANCEL_URL") + .unwrap_or_else(|_| "http://localhost:3000/subscription/cancel".to_string()), // JWT configuration jwt_secret: std::env::var("JWT_SECRET").expect("JWT_SECRET is not set"), jwt_expiry_hours: std::env::var("JWT_EXPIRY_HOURS")