| … | … | @@ -0,0 +1,583 @@ |
| 1 | # hyper-booking v1 — Deployment & Bootstrap Spec | |
| 2 | ||
| 3 | How to deploy, seed data, and onboard the first provider. | |
| 4 | ||
| 5 | --- | |
| 6 | ||
| 7 | ## 1. Deployment via pear-git CI/CD | |
| 8 | ||
| 9 | Same pipeline as all hyper-fleeky apps. | |
| 10 | ||
| 11 | ### Developer Flow | |
| 12 | ||
| 13 | ```bash | |
| 14 | # Push to deploy | |
| 15 | git push pear master | |
| 16 | ||
| 17 | # What happens: | |
| 18 | # 1. pear-git post-receive hook triggers | |
| 19 | # 2. .pear-git/ci.yaml runs build steps | |
| 20 | # 3. Release branch updated | |
| 21 | # 4. Protomux ref-update notifies peers | |
| 22 | # 5. Peers pull release branch | |
| 23 | # 6. Sidecar restarts server on exit code 75 | |
| 24 | ``` | |
| 25 | ||
| 26 | ### .pear-git/ci.yaml | |
| 27 | ||
| 28 | ```yaml | |
| 29 | steps: | |
| 30 | - name: Install dependencies | |
| 31 | run: npm install --production | |
| 32 | timeout: 120 | |
| 33 | ||
| 34 | - name: Run tests | |
| 35 | run: npm test | |
| 36 | timeout: 60 | |
| 37 | ``` | |
| 38 | ||
| 39 | ### package.json | |
| 40 | ||
| 41 | ```json | |
| 42 | { | |
| 43 | "name": "hyper-booking", | |
| 44 | "version": "0.1.0", | |
| 45 | "main": "index.js", | |
| 46 | "scripts": { | |
| 47 | "start": "bare index.js", | |
| 48 | "test": "brittle test/*.js" | |
| 49 | }, | |
| 50 | "pear": { | |
| 51 | "stage": { | |
| 52 | "ignore": ["test/", ".git/", "*.md", "docs/", ".pear-git/"] | |
| 53 | } | |
| 54 | } | |
| 55 | } | |
| 56 | ``` | |
| 57 | ||
| 58 | ### Entry Point (index.js) | |
| 59 | ||
| 60 | ```javascript | |
| 61 | const HyperBookingServer = require('./lib/server.js') | |
| 62 | ||
| 63 | const server = new HyperBookingServer({ | |
| 64 | port: process.env.PORT || 4050, | |
| 65 | storage: process.env.STORAGE || './data', | |
| 66 | prefix: process.env.PREFIX || '', | |
| 67 | network: process.env.NETWORK !== 'false' | |
| 68 | }) | |
| 69 | ||
| 70 | server.start().then(() => { | |
| 71 | console.log(`hyper-booking v${require('./package.json').version} listening on :${server.port}`) | |
| 72 | }) | |
| 73 | ``` | |
| 74 | ||
| 75 | --- | |
| 76 | ||
| 77 | ## 2. Server Architecture | |
| 78 | ||
| 79 | ``` | |
| 80 | index.js | |
| 81 | └── lib/server.js (HyperBookingServer) | |
| 82 | ├── Corestore (data storage) | |
| 83 | │ ├── Hyperbee (structured data — providers, packages, bookings, etc.) | |
| 84 | │ └── Media store (content-addressed files) | |
| 85 | ├── AuthManager (from pear-git/hypernote pattern) | |
| 86 | │ ├── ChallengeManager | |
| 87 | │ ├── IdentityRegistry | |
| 88 | │ ├── WebIdentityProvider | |
| 89 | │ ├── BearerTokenProvider | |
| 90 | │ └── RateLimiter | |
| 91 | ├── NotificationManager | |
| 92 | │ ├── InAppChannel (Hyperbee + SSE) | |
| 93 | │ ├── EmailChannel (Mox / SMTP) | |
| 94 | │ ├── SMSChannel (Android SMS Gateway / serialport-gsm) | |
| 95 | │ ├── PushChannel (APNs / FCM) | |
| 96 | │ ├── WebPushChannel (VAPID) | |
| 97 | │ └── Scheduler (timed reminders) | |
| 98 | ├── SchedulingEngine | |
| 99 | │ ├── Availability manager | |
| 100 | │ ├── Slot calculator | |
| 101 | │ └── Booking lifecycle | |
| 102 | ├── HTTP Server (bare-http1) | |
| 103 | │ ├── /api/providers/* (public) | |
| 104 | │ ├── /api/checkout (client) | |
| 105 | │ ├── /api/portal/* (client) | |
| 106 | │ ├── /api/dashboard/* (provider) | |
| 107 | │ ├── /api/admin/* (admin) | |
| 108 | │ ├── /api/auth/* (auth) | |
| 109 | │ ├── /api/webhooks/* (stripe) | |
| 110 | │ └── /web/* (static files) | |
| 111 | └── Hyperswarm (P2P replication) | |
| 112 | └── Multiwriter sync (if multi-instance) | |
| 113 | ``` | |
| 114 | ||
| 115 | --- | |
| 116 | ||
| 117 | ## 3. Configuration | |
| 118 | ||
| 119 | ### Environment Variables | |
| 120 | ||
| 121 | ```bash | |
| 122 | # Required | |
| 123 | PORT=4050 # HTTP port | |
| 124 | STORAGE=./data # Corestore storage path | |
| 125 | ||
| 126 | # Stripe | |
| 127 | STRIPE_SECRET_KEY=sk_live_... # Stripe API key | |
| 128 | STRIPE_WEBHOOK_SECRET=whsec_... # Webhook endpoint signing secret | |
| 129 | STRIPE_PLATFORM_ACCOUNT=acct_... # Platform's own Stripe account | |
| 130 | ||
| 131 | # Email (pick one) | |
| 132 | SMTP_HOST=localhost # Self-hosted Mox/Postal | |
| 133 | SMTP_PORT=587 | |
| 134 | SMTP_USER=bookings | |
| 135 | SMTP_PASS=... | |
| 136 | SMTP_FROM=bookings@yourdomain.com | |
| 137 | # OR | |
| 138 | EMAIL_PROVIDER=x402 # Use x402mail | |
| 139 | X402_WALLET_KEY=... # USDC wallet for x402 payments | |
| 140 | ||
| 141 | # SMS (pick one) | |
| 142 | SMS_PROVIDER=android-gateway # android-gateway | serial-modem | telnyx | |
| 143 | SMS_GATEWAY_URL=http://192.168.1.50:8080 # Android SMS Gateway URL | |
| 144 | SMS_GATEWAY_USER=admin | |
| 145 | SMS_GATEWAY_PASS=... | |
| 146 | # OR | |
| 147 | SMS_MODEM_PORT=/dev/ttyUSB0 # USB modem serial port | |
| 148 | # OR | |
| 149 | TELNYX_API_KEY=KEY_... | |
| 150 | TELNYX_FROM=+15551234567 | |
| 151 | ||
| 152 | # Push Notifications (optional) | |
| 153 | APNS_KEY_ID=... # Apple Push Notification Service | |
| 154 | APNS_TEAM_ID=... | |
| 155 | APNS_KEY_PATH=./certs/apns.p8 | |
| 156 | FCM_PROJECT_ID=... # Firebase Cloud Messaging | |
| 157 | FCM_SERVICE_ACCOUNT=./certs/firebase.json | |
| 158 | ||
| 159 | # WebPush | |
| 160 | VAPID_PUBLIC_KEY=... | |
| 161 | VAPID_PRIVATE_KEY=... | |
| 162 | VAPID_EMAIL=mailto:admin@yourdomain.com | |
| 163 | ||
| 164 | # Network | |
| 165 | NETWORK=true # Enable Hyperswarm replication | |
| 166 | PREFIX= # URL prefix (e.g., /booking for reverse proxy) | |
| 167 | ``` | |
| 168 | ||
| 169 | ### Config File (alternative) | |
| 170 | ||
| 171 | ```json | |
| 172 | { | |
| 173 | "port": 4050, | |
| 174 | "storage": "./data", | |
| 175 | "stripe": { | |
| 176 | "secretKey": "sk_live_...", | |
| 177 | "webhookSecret": "whsec_...", | |
| 178 | "platformFeePercent": 5 | |
| 179 | }, | |
| 180 | "email": { | |
| 181 | "provider": "smtp", | |
| 182 | "host": "localhost", | |
| 183 | "port": 587, | |
| 184 | "from": "bookings@yourdomain.com" | |
| 185 | }, | |
| 186 | "sms": { | |
| 187 | "provider": "android-gateway", | |
| 188 | "url": "http://192.168.1.50:8080", | |
| 189 | "user": "admin", | |
| 190 | "pass": "..." | |
| 191 | }, | |
| 192 | "notifications": { | |
| 193 | "reminder24h": true, | |
| 194 | "reminder1h": true, | |
| 195 | "emailConfirmation": true, | |
| 196 | "smsReminders": true | |
| 197 | } | |
| 198 | } | |
| 199 | ``` | |
| 200 | ||
| 201 | --- | |
| 202 | ||
| 203 | ## 4. First-Run Bootstrap | |
| 204 | ||
| 205 | What happens when hyper-booking starts for the first time. | |
| 206 | ||
| 207 | ### Auto-Setup | |
| 208 | ||
| 209 | ```javascript | |
| 210 | async function bootstrap (server) { | |
| 211 | const isFirstRun = !(await server.db.get('meta:initialized')) | |
| 212 | ||
| 213 | if (isFirstRun) { | |
| 214 | // 1. Initialize Hyperbee | |
| 215 | await server.db.put('meta:initialized', { at: Date.now(), version: '0.1.0' }) | |
| 216 | ||
| 217 | // 2. Generate server keypair (for attestations) | |
| 218 | const serverKeypair = ed25519.generateKeypair() | |
| 219 | await server.db.put('meta:server-keypair', { | |
| 220 | publicKey: serverKeypair.publicKey.toString('hex'), | |
| 221 | secretKey: serverKeypair.secretKey.toString('hex') | |
| 222 | }) | |
| 223 | ||
| 224 | // 3. Generate VAPID keys for WebPush (if not provided) | |
| 225 | if (!process.env.VAPID_PUBLIC_KEY) { | |
| 226 | const vapidKeys = webpush.generateVAPIDKeys() | |
| 227 | await server.db.put('meta:vapid-keys', vapidKeys) | |
| 228 | } | |
| 229 | ||
| 230 | console.log('First run — admin account will be created on first sign-in') | |
| 231 | } | |
| 232 | } | |
| 233 | ``` | |
| 234 | ||
| 235 | ### First User = Admin Provider | |
| 236 | ||
| 237 | ``` | |
| 238 | 1. First person to visit the site and sign in becomes admin | |
| 239 | 2. They are automatically both admin AND provider | |
| 240 | 3. They set up their provider profile | |
| 241 | 4. They connect Stripe (onboarding link) | |
| 242 | 5. They create their first package | |
| 243 | 6. They set their availability | |
| 244 | 7. Site is live for booking | |
| 245 | ``` | |
| 246 | ||
| 247 | --- | |
| 248 | ||
| 249 | ## 5. Provider Onboarding Wizard | |
| 250 | ||
| 251 | Step-by-step setup for new providers. | |
| 252 | ||
| 253 | ### Steps | |
| 254 | ||
| 255 | ``` | |
| 256 | Step 1: Profile | |
| 257 | - Display name | |
| 258 | - Photo upload | |
| 259 | - Bio (markdown) | |
| 260 | - Specialties / tags | |
| 261 | - Social links | |
| 262 | ||
| 263 | Step 2: Availability | |
| 264 | - Weekly schedule (visual editor) | |
| 265 | - Timezone selection | |
| 266 | - Buffer time | |
| 267 | - Advance booking window | |
| 268 | ||
| 269 | Step 3: Payments | |
| 270 | - Connect with Stripe (redirect to Stripe Connect) | |
| 271 | - Select enabled payment rails | |
| 272 | - Review platform fee | |
| 273 | ||
| 274 | Step 4: First Package | |
| 275 | - Package name | |
| 276 | - Type (single, bundle, subscription, discovery) | |
| 277 | - Price | |
| 278 | - Session duration | |
| 279 | - Session count (for bundles) | |
| 280 | - Description | |
| 281 | - Optional: attach intake form, contract | |
| 282 | ||
| 283 | Step 5: Review & Publish | |
| 284 | - Preview landing page | |
| 285 | - Confirm settings | |
| 286 | - Publish (makes page live) | |
| 287 | ``` | |
| 288 | ||
| 289 | ### Onboarding State | |
| 290 | ||
| 291 | ```javascript | |
| 292 | { | |
| 293 | providerId: 'prov_xyz', | |
| 294 | onboarding: { | |
| 295 | completed: false, | |
| 296 | steps: { | |
| 297 | profile: true, | |
| 298 | availability: true, | |
| 299 | payments: false, | |
| 300 | firstPackage: false, | |
| 301 | published: false | |
| 302 | }, | |
| 303 | currentStep: 'payments' | |
| 304 | } | |
| 305 | } | |
| 306 | ``` | |
| 307 | ||
| 308 | **Storage:** `onboarding:{providerId}` → onboarding state | |
| 309 | ||
| 310 | --- | |
| 311 | ||
| 312 | ## 6. Data Seeding (Development) | |
| 313 | ||
| 314 | For testing with sample data. | |
| 315 | ||
| 316 | ```javascript | |
| 317 | // scripts/seed.js | |
| 318 | async function seed (server) { | |
| 319 | // Create test provider | |
| 320 | const provider = { | |
| 321 | id: 'prov_test', | |
| 322 | slug: 'test-coach', | |
| 323 | displayName: 'Test Coach', | |
| 324 | bio: 'A test coaching provider for development.', | |
| 325 | timezone: 'America/New_York', | |
| 326 | photo: null, | |
| 327 | socialLinks: {}, | |
| 328 | active: true, | |
| 329 | createdAt: Date.now() | |
| 330 | } | |
| 331 | await server.db.put(`provider:${provider.id}`, provider) | |
| 332 | await server.db.put(`provider-by-slug:${provider.slug}`, provider.id) | |
| 333 | ||
| 334 | // Create test packages | |
| 335 | const packages = [ | |
| 336 | { | |
| 337 | id: 'pkg_discovery', | |
| 338 | providerId: 'prov_test', | |
| 339 | name: 'Free Discovery Call', | |
| 340 | type: 'discovery', | |
| 341 | sessionDuration: 15, | |
| 342 | price: 0, | |
| 343 | active: true | |
| 344 | }, | |
| 345 | { | |
| 346 | id: 'pkg_single', | |
| 347 | providerId: 'prov_test', | |
| 348 | name: '60-Minute Coaching Session', | |
| 349 | type: 'single', | |
| 350 | sessionDuration: 60, | |
| 351 | sessionCount: 1, | |
| 352 | price: 12000, // $120 | |
| 353 | active: true | |
| 354 | }, | |
| 355 | { | |
| 356 | id: 'pkg_bundle', | |
| 357 | providerId: 'prov_test', | |
| 358 | name: '6-Session Package', | |
| 359 | type: 'bundle', | |
| 360 | sessionDuration: 60, | |
| 361 | sessionCount: 6, | |
| 362 | price: 60000, // $600 | |
| 363 | discountFullPay: 10, | |
| 364 | installments: 3, | |
| 365 | active: true | |
| 366 | } | |
| 367 | ] | |
| 368 | ||
| 369 | for (const pkg of packages) { | |
| 370 | pkg.createdAt = Date.now() | |
| 371 | await server.db.put(`package:${pkg.id}`, pkg) | |
| 372 | } | |
| 373 | ||
| 374 | // Set availability | |
| 375 | const availability = { | |
| 376 | providerId: 'prov_test', | |
| 377 | timezone: 'America/New_York', | |
| 378 | weekly: { | |
| 379 | monday: [{ start: '09:00', end: '12:00' }, { start: '14:00', end: '17:00' }], | |
| 380 | tuesday: [{ start: '09:00', end: '17:00' }], | |
| 381 | wednesday: [{ start: '09:00', end: '12:00' }], | |
| 382 | thursday: [{ start: '10:00', end: '18:00' }], | |
| 383 | friday: [{ start: '09:00', end: '15:00' }], | |
| 384 | saturday: [], | |
| 385 | sunday: [] | |
| 386 | }, | |
| 387 | bufferMinutes: 15, | |
| 388 | advanceBooking: { minHours: 24, maxDays: 60 }, | |
| 389 | cancellation: { windowHours: 24, refundPolicy: 'full' }, | |
| 390 | reschedule: { windowHours: 24, maxPerBooking: 2 }, | |
| 391 | noShow: { penalty: 'forfeit' } | |
| 392 | } | |
| 393 | await server.db.put(`availability:${provider.id}`, availability) | |
| 394 | ||
| 395 | console.log('Seeded: 1 provider, 3 packages, availability set') | |
| 396 | } | |
| 397 | ``` | |
| 398 | ||
| 399 | Run with: `bare scripts/seed.js` | |
| 400 | ||
| 401 | --- | |
| 402 | ||
| 403 | ## 7. Multi-Instance Deployment | |
| 404 | ||
| 405 | Running hyper-booking on multiple machines with Hyperswarm sync. | |
| 406 | ||
| 407 | ### Architecture | |
| 408 | ||
| 409 | ``` | |
| 410 | tabertron (primary) stortron (replica) | |
| 411 | ┌──────────────────┐ ┌──────────────────┐ | |
| 412 | │ hyper-booking │ │ hyper-booking │ | |
| 413 | │ :4050 │ ◄── Hyperswarm──► :4050 │ | |
| 414 | │ │ replication │ │ | |
| 415 | │ Corestore │ │ Corestore │ | |
| 416 | │ (writer) │ │ (reader/writer) │ | |
| 417 | └──────────────────┘ └──────────────────┘ | |
| 418 | ``` | |
| 419 | ||
| 420 | ### Sync Strategy | |
| 421 | ||
| 422 | Option A: **Single writer** (simpler — v1) | |
| 423 | - One instance is the primary writer | |
| 424 | - Other instances replicate for read-only serving | |
| 425 | - Writes go to primary via RPC or redirect | |
| 426 | ||
| 427 | Option B: **Autobase multiwriter** (v2) | |
| 428 | - All instances can write | |
| 429 | - Autobase resolves conflicts | |
| 430 | - Same pattern as hypernote multiwriter notebooks | |
| 431 | ||
| 432 | v1 recommendation: Single writer. One hyper-booking instance on tabertron, | |
| 433 | stortron as a read replica for redundancy. | |
| 434 | ||
| 435 | ### Installation on Remote (via pear-git) | |
| 436 | ||
| 437 | ```bash | |
| 438 | # On stortron: | |
| 439 | pgh app install --app hyper-booking | |
| 440 | hyper-booking # starts the server | |
| 441 | ``` | |
| 442 | ||
| 443 | ### Systemd Service | |
| 444 | ||
| 445 | ```ini | |
| 446 | [Unit] | |
| 447 | Description=hyper-booking | |
| 448 | After=network.target | |
| 449 | ||
| 450 | [Service] | |
| 451 | Type=simple | |
| 452 | User=fleeky | |
| 453 | WorkingDirectory=/home/fleeky/.local/share/hyper-booking | |
| 454 | ExecStart=/home/fleeky/.local/bin/hyper-booking | |
| 455 | Restart=on-failure | |
| 456 | RestartSec=5 | |
| 457 | Environment="PORT=4050" | |
| 458 | Environment="STORAGE=/home/fleeky/.local/share/hyper-booking/data" | |
| 459 | Environment="NETWORK=true" | |
| 460 | ||
| 461 | [Install] | |
| 462 | WantedBy=multi-user.target | |
| 463 | ``` | |
| 464 | ||
| 465 | ### Reverse Proxy (nginx/Apache) | |
| 466 | ||
| 467 | ```nginx | |
| 468 | location /booking/ { | |
| 469 | proxy_pass http://localhost:4050/; | |
| 470 | proxy_http_version 1.1; | |
| 471 | proxy_set_header Upgrade $http_upgrade; | |
| 472 | proxy_set_header Connection 'upgrade'; | |
| 473 | proxy_set_header Host $host; | |
| 474 | proxy_set_header X-Real-IP $remote_addr; | |
| 475 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; | |
| 476 | proxy_set_header X-Forwarded-Proto $scheme; | |
| 477 | } | |
| 478 | ``` | |
| 479 | ||
| 480 | --- | |
| 481 | ||
| 482 | ## 8. Monitoring & Health | |
| 483 | ||
| 484 | ### Health Check Endpoint | |
| 485 | ||
| 486 | ``` | |
| 487 | GET /api/health | |
| 488 | → { | |
| 489 | status: 'ok', | |
| 490 | version: '0.1.0', | |
| 491 | uptime: 86400, | |
| 492 | storage: { | |
| 493 | providers: 3, | |
| 494 | packages: 12, | |
| 495 | bookings: 156, | |
| 496 | clients: 89 | |
| 497 | }, | |
| 498 | stripe: 'connected', | |
| 499 | notifications: { | |
| 500 | email: 'ok', | |
| 501 | sms: 'ok', | |
| 502 | push: 'ok' | |
| 503 | } | |
| 504 | } | |
| 505 | ``` | |
| 506 | ||
| 507 | ### Logs | |
| 508 | ||
| 509 | ```javascript | |
| 510 | // Structured logging | |
| 511 | function log (level, msg, data) { | |
| 512 | console.log(JSON.stringify({ | |
| 513 | ts: new Date().toISOString(), | |
| 514 | level, // info | warn | error | |
| 515 | msg, | |
| 516 | ...data | |
| 517 | })) | |
| 518 | } | |
| 519 | ||
| 520 | // Key events to log: | |
| 521 | log('info', 'booking-created', { bookingId, providerId, clientId }) | |
| 522 | log('info', 'payment-received', { purchaseId, amount, rail }) | |
| 523 | log('warn', 'payment-failed', { purchaseId, error }) | |
| 524 | log('error', 'notification-failed', { channel, clientId, error }) | |
| 525 | log('info', 'slot-conflict', { providerId, slotStart }) | |
| 526 | ``` | |
| 527 | ||
| 528 | --- | |
| 529 | ||
| 530 | ## 9. Backup & Recovery | |
| 531 | ||
| 532 | ### Corestore Backup | |
| 533 | ||
| 534 | Data lives in Corestore (Hypercore-based). Backup = replicate to another machine. | |
| 535 | ||
| 536 | ```bash | |
| 537 | # Continuous replication via Hyperswarm (if NETWORK=true) | |
| 538 | # Data automatically replicated to any connected peer | |
| 539 | ||
| 540 | # Manual backup: | |
| 541 | cp -r ./data ./data-backup-$(date +%Y%m%d) | |
| 542 | ``` | |
| 543 | ||
| 544 | ### Stripe Data | |
| 545 | ||
| 546 | Stripe is the source of truth for payment data. hyper-booking stores references | |
| 547 | (paymentId, subscriptionId) but can always re-fetch from Stripe API. | |
| 548 | ||
| 549 | ### Recovery | |
| 550 | ||
| 551 | ``` | |
| 552 | 1. Restore Corestore data directory | |
| 553 | 2. Start hyper-booking — it reads state from Hyperbee | |
| 554 | 3. Stripe webhook events will re-sync any missed payment updates | |
| 555 | 4. Identity registry recovers via attestations (if needed) | |
| 556 | ``` | |
| 557 | ||
| 558 | --- | |
| 559 | ||
| 560 | ## 10. Security Checklist | |
| 561 | ||
| 562 | ### Before Launch | |
| 563 | ||
| 564 | - [ ] Stripe webhook signature verification enabled | |
| 565 | - [ ] Rate limiting on all auth + checkout endpoints | |
| 566 | - [ ] HTTPS / TLS via reverse proxy | |
| 567 | - [ ] CORS restricted to booking domain | |
| 568 | - [ ] Input validation on all API endpoints | |
| 569 | - [ ] File upload size limits enforced | |
| 570 | - [ ] Content-Security-Policy headers set | |
| 571 | - [ ] XSS protection (sanitize markdown output) | |
| 572 | - [ ] No secrets in client-side code | |
| 573 | - [ ] VAPID keys generated and stored securely | |
| 574 | - [ ] Stripe API keys not in git | |
| 575 | ||
| 576 | ### Ongoing | |
| 577 | ||
| 578 | - [ ] Monitor Stripe webhook failures | |
| 579 | - [ ] Monitor SMS delivery rates | |
| 580 | - [ ] Monitor email bounce rates | |
| 581 | - [ ] Review rate limit logs for abuse | |
| 582 | - [ ] Rotate bearer tokens periodically | |
| 583 | - [ ] Keep dependencies updated (npm audit) |
| … | … | @@ -0,0 +1,462 @@ |
| 1 | # hyper-booking v1 — Forms, Contracts & Content Spec | |
| 2 | ||
| 3 | Intake forms, e-signatures, surveys, and material sharing. | |
| 4 | ||
| 5 | --- | |
| 6 | ||
| 7 | ## 1. Form Builder | |
| 8 | ||
| 9 | Providers create custom forms attached to packages. | |
| 10 | ||
| 11 | ### Field Types | |
| 12 | ||
| 13 | | Type | Renders As | Validation | | |
| 14 | |------|-----------|------------| | |
| 15 | | `text` | Single-line input | maxLength, pattern (regex) | | |
| 16 | | `textarea` | Multi-line input | maxLength | | |
| 17 | | `select` | Dropdown | options array | | |
| 18 | | `radio` | Radio buttons | options array | | |
| 19 | | `checkbox` | Checkboxes | options array, min/max selections | | |
| 20 | | `toggle` | Yes/No toggle | — | | |
| 21 | | `date` | Date picker | min/max date | | |
| 22 | | `number` | Number input | min, max, step | | |
| 23 | | `email` | Email input | email format validation | | |
| 24 | | `phone` | Phone input | phone format validation | | |
| 25 | | `file` | File upload | maxSize, acceptTypes | | |
| 26 | | `heading` | Section header (no input) | — | | |
| 27 | | `paragraph` | Description text (no input) | — | | |
| 28 | ||
| 29 | ### Form Object | |
| 30 | ||
| 31 | ```javascript | |
| 32 | { | |
| 33 | id: 'form_abc', | |
| 34 | providerId: 'prov_xyz', | |
| 35 | name: 'New Client Intake Form', | |
| 36 | type: 'intake', // intake | exit-survey | custom | |
| 37 | fields: [ | |
| 38 | { | |
| 39 | id: 'f1', | |
| 40 | type: 'heading', | |
| 41 | label: 'Health History' | |
| 42 | }, | |
| 43 | { | |
| 44 | id: 'f2', | |
| 45 | type: 'textarea', | |
| 46 | label: 'Please describe any current health conditions', | |
| 47 | required: true, | |
| 48 | maxLength: 2000, | |
| 49 | placeholder: 'Include medications, allergies, injuries...' | |
| 50 | }, | |
| 51 | { | |
| 52 | id: 'f3', | |
| 53 | type: 'select', | |
| 54 | label: 'How did you hear about me?', | |
| 55 | required: false, | |
| 56 | options: ['Google', 'Instagram', 'Friend referral', 'Other'] | |
| 57 | }, | |
| 58 | { | |
| 59 | id: 'f4', | |
| 60 | type: 'radio', | |
| 61 | label: 'Preferred session format', | |
| 62 | required: true, | |
| 63 | options: ['In-person', 'Video call', 'Phone call', 'No preference'] | |
| 64 | }, | |
| 65 | { | |
| 66 | id: 'f5', | |
| 67 | type: 'checkbox', | |
| 68 | label: 'Goals (select all that apply)', | |
| 69 | required: true, | |
| 70 | minSelections: 1, | |
| 71 | options: ['Weight loss', 'Stress management', 'Better sleep', | |
| 72 | 'Nutrition', 'Exercise', 'Mental health', 'Other'] | |
| 73 | }, | |
| 74 | { | |
| 75 | id: 'f6', | |
| 76 | type: 'file', | |
| 77 | label: 'Upload any relevant medical documents (optional)', | |
| 78 | required: false, | |
| 79 | maxSize: 10485760, // 10MB | |
| 80 | acceptTypes: ['.pdf', '.jpg', '.png', '.doc', '.docx'] | |
| 81 | } | |
| 82 | ], | |
| 83 | packageIds: ['pkg_abc', 'pkg_def'], // attached to these packages (null = all) | |
| 84 | active: true, | |
| 85 | createdAt: timestamp | |
| 86 | } | |
| 87 | ``` | |
| 88 | ||
| 89 | ### Form Response Object | |
| 90 | ||
| 91 | ```javascript | |
| 92 | { | |
| 93 | id: 'fr_abc', | |
| 94 | formId: 'form_abc', | |
| 95 | clientId: 'cli_abc', | |
| 96 | providerId: 'prov_xyz', | |
| 97 | packageId: 'pkg_abc', | |
| 98 | purchaseId: 'pur_abc', | |
| 99 | responses: { | |
| 100 | f2: 'I have mild asthma and take...', | |
| 101 | f3: 'Instagram', | |
| 102 | f4: 'Video call', | |
| 103 | f5: ['Stress management', 'Better sleep', 'Nutrition'], | |
| 104 | f6: { filename: 'bloodwork.pdf', hash: 'abc123', size: 245000 } | |
| 105 | }, | |
| 106 | submittedAt: timestamp | |
| 107 | } | |
| 108 | ``` | |
| 109 | ||
| 110 | ### Storage | |
| 111 | ||
| 112 | ``` | |
| 113 | form:{formId} → form template | |
| 114 | forms-by-provider:{providerId} → formId list | |
| 115 | form-response:{responseId} → response object | |
| 116 | form-responses-by-client:{clientId} → responseId list | |
| 117 | form-responses-by-form:{formId} → responseId list | |
| 118 | ``` | |
| 119 | ||
| 120 | ### API | |
| 121 | ||
| 122 | ``` | |
| 123 | # Form templates (provider) | |
| 124 | POST /api/forms Create form | |
| 125 | GET /api/forms List provider's forms | |
| 126 | GET /api/forms/:id Get form template | |
| 127 | PUT /api/forms/:id Update form | |
| 128 | DEL /api/forms/:id Archive form | |
| 129 | ||
| 130 | # Form responses (client) | |
| 131 | GET /api/portal/forms Forms assigned to client (pending + completed) | |
| 132 | GET /api/portal/forms/:id Get form to fill out | |
| 133 | POST /api/portal/forms/:id Submit form response | |
| 134 | ||
| 135 | # Form responses (provider) | |
| 136 | GET /api/dashboard/forms/:id/responses All responses for a form | |
| 137 | GET /api/dashboard/clients/:id/forms Client's form responses | |
| 138 | ``` | |
| 139 | ||
| 140 | --- | |
| 141 | ||
| 142 | ## 2. Contracts & E-Signatures | |
| 143 | ||
| 144 | Legal agreements signed with ed25519 keypairs (cryptographic proof). | |
| 145 | ||
| 146 | ### Contract Template | |
| 147 | ||
| 148 | ```javascript | |
| 149 | { | |
| 150 | id: 'ctr_abc', | |
| 151 | providerId: 'prov_xyz', | |
| 152 | name: 'Coaching Agreement', | |
| 153 | body: `# Coaching Agreement | |
| 154 | ||
| 155 | Between **{{providerName}}** ("Coach") and **{{clientName}}** ("Client"). | |
| 156 | ||
| 157 | ## Terms | |
| 158 | ||
| 159 | 1. Client agrees to attend all scheduled sessions or cancel with {{cancellationWindow}} notice. | |
| 160 | 2. Coach agrees to maintain confidentiality of all session content. | |
| 161 | 3. This agreement covers the "{{packageName}}" package ({{sessionCount}} sessions). | |
| 162 | ||
| 163 | ## Cancellation Policy | |
| 164 | ||
| 165 | Sessions cancelled with less than {{cancellationWindow}} notice will be forfeited. | |
| 166 | ||
| 167 | ## Acknowledgment | |
| 168 | ||
| 169 | By signing below, both parties agree to the terms above. | |
| 170 | ||
| 171 | Date: {{date}} | |
| 172 | `, | |
| 173 | variables: ['providerName', 'clientName', 'packageName', | |
| 174 | 'sessionCount', 'cancellationWindow', 'date'], | |
| 175 | packageIds: ['pkg_abc'], | |
| 176 | active: true, | |
| 177 | createdAt: timestamp | |
| 178 | } | |
| 179 | ``` | |
| 180 | ||
| 181 | ### Signed Contract | |
| 182 | ||
| 183 | ```javascript | |
| 184 | { | |
| 185 | id: 'sig_abc', | |
| 186 | contractId: 'ctr_abc', | |
| 187 | clientId: 'cli_abc', | |
| 188 | providerId: 'prov_xyz', | |
| 189 | purchaseId: 'pur_abc', | |
| 190 | ||
| 191 | // Rendered contract (variables filled in) | |
| 192 | renderedBody: '# Coaching Agreement\n\nBetween **Alexis Mattos**...', | |
| 193 | ||
| 194 | // Cryptographic signatures | |
| 195 | clientSignature: { | |
| 196 | publicKey: '<client ed25519 pubkey>', | |
| 197 | signature: '<ed25519 signature of sha256(renderedBody)>', | |
| 198 | signedAt: timestamp | |
| 199 | }, | |
| 200 | providerSignature: { | |
| 201 | publicKey: '<provider ed25519 pubkey>', | |
| 202 | signature: '<ed25519 signature of sha256(renderedBody)>', | |
| 203 | signedAt: timestamp | |
| 204 | }, | |
| 205 | ||
| 206 | // Hash of the signed document (immutable reference) | |
| 207 | documentHash: '<sha256 of renderedBody>', | |
| 208 | ||
| 209 | status: 'signed', // pending | client-signed | signed | |
| 210 | createdAt: timestamp | |
| 211 | } | |
| 212 | ``` | |
| 213 | ||
| 214 | ### Signing Flow | |
| 215 | ||
| 216 | ``` | |
| 217 | 1. Client reaches contract step (after payment, before confirmation) | |
| 218 | 2. Server renders contract template with variables filled in | |
| 219 | 3. Client reviews rendered contract in browser | |
| 220 | 4. Client clicks "I Agree & Sign" | |
| 221 | └─ Browser signs sha256(renderedBody) with client's ed25519 secret key | |
| 222 | └─ POST /api/contracts/:id/sign { signature, publicKey } | |
| 223 | 5. Server verifies signature matches client's identity | |
| 224 | 6. Server counter-signs with provider's key (or pre-signed template) | |
| 225 | 7. Contract stored as immutable record | |
| 226 | ``` | |
| 227 | ||
| 228 | ### Verification | |
| 229 | ||
| 230 | Anyone can verify a contract signature: | |
| 231 | ||
| 232 | ```javascript | |
| 233 | async function verifyContractSignature (signedContract) { | |
| 234 | const { renderedBody, clientSignature, providerSignature } = signedContract | |
| 235 | ||
| 236 | const documentHash = sha256(renderedBody) | |
| 237 | ||
| 238 | const clientValid = ed25519.verify( | |
| 239 | clientSignature.signature, | |
| 240 | documentHash, | |
| 241 | clientSignature.publicKey | |
| 242 | ) | |
| 243 | ||
| 244 | const providerValid = ed25519.verify( | |
| 245 | providerSignature.signature, | |
| 246 | documentHash, | |
| 247 | providerSignature.publicKey | |
| 248 | ) | |
| 249 | ||
| 250 | return { clientValid, providerValid, documentHash } | |
| 251 | } | |
| 252 | ``` | |
| 253 | ||
| 254 | ### Storage | |
| 255 | ||
| 256 | ``` | |
| 257 | contract:{contractId} → contract template | |
| 258 | contracts-by-provider:{providerId} → contractId list | |
| 259 | signed-contract:{signatureId} → signed contract record | |
| 260 | signed-by-client:{clientId} → signatureId list | |
| 261 | signed-by-purchase:{purchaseId} → signatureId | |
| 262 | ``` | |
| 263 | ||
| 264 | ### API | |
| 265 | ||
| 266 | ``` | |
| 267 | # Contract templates (provider) | |
| 268 | POST /api/contracts Create contract template | |
| 269 | GET /api/contracts List contracts | |
| 270 | PUT /api/contracts/:id Update contract | |
| 271 | DEL /api/contracts/:id Archive contract | |
| 272 | ||
| 273 | # Signing (client) | |
| 274 | GET /api/portal/contracts/:id Get rendered contract to sign | |
| 275 | POST /api/portal/contracts/:id/sign Submit signature | |
| 276 | ||
| 277 | # Verification (anyone) | |
| 278 | GET /api/contracts/verify/:signatureId Verify signatures | |
| 279 | ``` | |
| 280 | ||
| 281 | --- | |
| 282 | ||
| 283 | ## 3. Exit Surveys | |
| 284 | ||
| 285 | Triggered after final session in a package. | |
| 286 | ||
| 287 | ### How It Works | |
| 288 | ||
| 289 | ``` | |
| 290 | 1. Package has sessionCount: 6 | |
| 291 | 2. Client completes session 6 (last in package) | |
| 292 | 3. System triggers exit survey notification | |
| 293 | 4. Client fills out survey in portal | |
| 294 | 5. Provider sees aggregated results in dashboard | |
| 295 | ``` | |
| 296 | ||
| 297 | ### Survey Object | |
| 298 | ||
| 299 | Same schema as intake forms, but with `type: 'exit-survey'`. | |
| 300 | ||
| 301 | Additional fields: | |
| 302 | ||
| 303 | ```javascript | |
| 304 | { | |
| 305 | // ... same as form object ... | |
| 306 | type: 'exit-survey', | |
| 307 | anonymous: true, // responses don't show client name to provider | |
| 308 | triggerOn: 'package-complete', // when to show the survey | |
| 309 | } | |
| 310 | ``` | |
| 311 | ||
| 312 | ### Aggregation (for provider analytics) | |
| 313 | ||
| 314 | ```javascript | |
| 315 | async function getSurveyResults (formId) { | |
| 316 | const responses = await getAllResponses(formId) | |
| 317 | ||
| 318 | const results = {} | |
| 319 | const form = await db.get(`form:${formId}`) | |
| 320 | ||
| 321 | for (const field of form.fields) { | |
| 322 | if (field.type === 'heading' || field.type === 'paragraph') continue | |
| 323 | ||
| 324 | if (field.type === 'radio' || field.type === 'select') { | |
| 325 | // Count selections | |
| 326 | results[field.id] = { | |
| 327 | label: field.label, | |
| 328 | type: field.type, | |
| 329 | counts: {} | |
| 330 | } | |
| 331 | for (const option of field.options) { | |
| 332 | results[field.id].counts[option] = responses.filter( | |
| 333 | r => r.responses[field.id] === option | |
| 334 | ).length | |
| 335 | } | |
| 336 | } else if (field.type === 'number') { | |
| 337 | // Average + distribution | |
| 338 | const values = responses.map(r => r.responses[field.id]).filter(v => v != null) | |
| 339 | results[field.id] = { | |
| 340 | label: field.label, | |
| 341 | type: 'number', | |
| 342 | average: values.reduce((a, b) => a + b, 0) / values.length, | |
| 343 | min: Math.min(...values), | |
| 344 | max: Math.max(...values), | |
| 345 | count: values.length | |
| 346 | } | |
| 347 | } else { | |
| 348 | // Text responses — list them | |
| 349 | results[field.id] = { | |
| 350 | label: field.label, | |
| 351 | type: field.type, | |
| 352 | responses: responses.map(r => r.responses[field.id]).filter(Boolean) | |
| 353 | } | |
| 354 | } | |
| 355 | } | |
| 356 | ||
| 357 | return { formId, responseCount: responses.length, results } | |
| 358 | } | |
| 359 | ``` | |
| 360 | ||
| 361 | --- | |
| 362 | ||
| 363 | ## 4. Materials & Content | |
| 364 | ||
| 365 | Files and links attached to packages or shared per-session. | |
| 366 | ||
| 367 | ### Material Object | |
| 368 | ||
| 369 | ```javascript | |
| 370 | { | |
| 371 | id: 'mat_abc', | |
| 372 | providerId: 'prov_xyz', | |
| 373 | packageId: 'pkg_abc', // attached to package (null = standalone) | |
| 374 | bookingId: null, // attached to specific session (null = package-level) | |
| 375 | type: 'file', // file | link | text | |
| 376 | name: 'Pre-Session Questionnaire', | |
| 377 | description: 'Please complete before your first session', | |
| 378 | // For files: | |
| 379 | filename: 'questionnaire.pdf', | |
| 380 | hash: '<content-addressed hash>', | |
| 381 | size: 245000, | |
| 382 | mimeType: 'application/pdf', | |
| 383 | // For links: | |
| 384 | url: null, | |
| 385 | // For text: | |
| 386 | body: null, | |
| 387 | timing: 'pre-session', // pre-session | post-session | anytime | |
| 388 | createdAt: timestamp | |
| 389 | } | |
| 390 | ``` | |
| 391 | ||
| 392 | ### File Storage | |
| 393 | ||
| 394 | Files stored content-addressed (same as hypernote media): | |
| 395 | ||
| 396 | ```javascript | |
| 397 | async function uploadMaterial (providerId, file) { | |
| 398 | const hash = sha256(file.buffer).toString('hex') | |
| 399 | ||
| 400 | // Store file content-addressed | |
| 401 | await mediaStore.put(hash, file.buffer) | |
| 402 | ||
| 403 | return { | |
| 404 | hash, | |
| 405 | filename: file.originalname, | |
| 406 | size: file.size, | |
| 407 | mimeType: file.mimetype | |
| 408 | } | |
| 409 | } | |
| 410 | ``` | |
| 411 | ||
| 412 | ### Serving Files | |
| 413 | ||
| 414 | ``` | |
| 415 | GET /api/materials/:hash/:filename | |
| 416 | → Stream file from content-addressed store | |
| 417 | → Auth required (client must have access to the package) | |
| 418 | ``` | |
| 419 | ||
| 420 | ### Provider Session Notes | |
| 421 | ||
| 422 | After each session, provider can add notes visible to the client: | |
| 423 | ||
| 424 | ```javascript | |
| 425 | { | |
| 426 | bookingId: 'bk_abc', | |
| 427 | notes: 'Discussed stress management techniques. Homework: 10 min daily meditation.', | |
| 428 | sharedWithClient: true, // false = private provider notes | |
| 429 | createdAt: timestamp | |
| 430 | } | |
| 431 | ``` | |
| 432 | ||
| 433 | ### Storage | |
| 434 | ||
| 435 | ``` | |
| 436 | material:{materialId} → material object | |
| 437 | materials-by-package:{packageId} → materialId list | |
| 438 | materials-by-booking:{bookingId} → materialId list | |
| 439 | media:{hash} → file content (content-addressed) | |
| 440 | session-notes:{bookingId} → notes object | |
| 441 | ``` | |
| 442 | ||
| 443 | ### API | |
| 444 | ||
| 445 | ``` | |
| 446 | # Materials (provider) | |
| 447 | POST /api/materials Upload material | |
| 448 | PUT /api/materials/:id Update material metadata | |
| 449 | DEL /api/materials/:id Remove material | |
| 450 | POST /api/materials/:id/attach Attach to package or booking | |
| 451 | ||
| 452 | # Materials (client) | |
| 453 | GET /api/portal/materials All materials for client's packages | |
| 454 | GET /api/materials/:hash/:filename Download file | |
| 455 | ||
| 456 | # Session notes (provider) | |
| 457 | POST /api/bookings/:id/notes Add/update session notes | |
| 458 | GET /api/bookings/:id/notes Get session notes | |
| 459 | ||
| 460 | # Session notes (client) | |
| 461 | GET /api/portal/bookings/:id/notes Get notes shared with client | |
| 462 | ``` |
| … | … | @@ -0,0 +1,768 @@ |
| 1 | # hyper-booking v1 — Payments & Checkout Spec | |
| 2 | ||
| 3 | All payments delegate to hyper-pay. hyper-booking never touches | |
| 4 | card numbers, bank accounts, or crypto keys directly. | |
| 5 | ||
| 6 | --- | |
| 7 | ||
| 8 | ## Architecture | |
| 9 | ||
| 10 | ``` | |
| 11 | hyper-booking hyper-pay External | |
| 12 | ┌──────────┐ ┌──────────────┐ ┌─────────┐ | |
| 13 | │ Checkout │ ──charge()────→ │ Rail Router │ ──────────→ │ Stripe │ | |
| 14 | │ Page │ │ │ │ Connect │ | |
| 15 | │ │ ←─result──────── │ stripe.js │ ←────────────│ │ | |
| 16 | │ │ │ stablecoin.js│ └─────────┘ | |
| 17 | │ Purchase │ │ x402.js │ ┌─────────┐ | |
| 18 | │ Tracker │ ──refund()────→ │ wdk.js │ ──────────→ │ Coinbase│ | |
| 19 | │ │ │ wallet.js │ │ x402 │ | |
| 20 | │ Webhook │ ←─event──────── │ │ └─────────┘ | |
| 21 | │ Handler │ └──────────────┘ ┌─────────┐ | |
| 22 | └──────────┘ │ P2P │ | |
| 23 | │ Network │ | |
| 24 | └─────────┘ | |
| 25 | ``` | |
| 26 | ||
| 27 | hyper-booking only knows: | |
| 28 | - `hyperPay.charge(amount, merchantId, options)` → payment result | |
| 29 | - `hyperPay.refund(paymentId, amount)` → refund result | |
| 30 | - `hyperPay.subscribe(amount, interval, merchantId)` → subscription | |
| 31 | - `hyperPay.onWebhook(event)` → payment status updates | |
| 32 | ||
| 33 | --- | |
| 34 | ||
| 35 | ## 1. Stripe Connect Onboarding | |
| 36 | ||
| 37 | Each provider is a Stripe Connected Account. Platform (us) collects fees. | |
| 38 | ||
| 39 | ### Onboarding Flow | |
| 40 | ||
| 41 | ``` | |
| 42 | 1. Provider signs up on hyper-booking | |
| 43 | 2. Provider goes to Dashboard → Settings → Payments | |
| 44 | 3. Clicks "Connect with Stripe" | |
| 45 | 4. Redirected to Stripe Connect onboarding (hosted by Stripe) | |
| 46 | - Business info, bank account, identity verification | |
| 47 | - Stripe handles all KYC/compliance | |
| 48 | 5. Redirected back to hyper-booking with account ID | |
| 49 | 6. Provider can now accept payments | |
| 50 | ``` | |
| 51 | ||
| 52 | ### Implementation | |
| 53 | ||
| 54 | ```javascript | |
| 55 | // lib/payments/stripe-onboarding.js | |
| 56 | ||
| 57 | async function createConnectAccount (providerId) { | |
| 58 | const account = await stripe.accounts.create({ | |
| 59 | type: 'express', // Stripe handles dashboard + payouts | |
| 60 | capabilities: { | |
| 61 | card_payments: { requested: true }, | |
| 62 | transfers: { requested: true } | |
| 63 | }, | |
| 64 | metadata: { providerId } | |
| 65 | }) | |
| 66 | ||
| 67 | // Store account ID | |
| 68 | await db.put(`stripe-account:${providerId}`, { | |
| 69 | accountId: account.id, | |
| 70 | status: 'pending', // pending | active | restricted | |
| 71 | createdAt: Date.now() | |
| 72 | }) | |
| 73 | ||
| 74 | return account.id | |
| 75 | } | |
| 76 | ||
| 77 | async function createOnboardingLink (providerId, returnUrl) { | |
| 78 | const { accountId } = await db.get(`stripe-account:${providerId}`) | |
| 79 | ||
| 80 | const link = await stripe.accountLinks.create({ | |
| 81 | account: accountId, | |
| 82 | refresh_url: `${returnUrl}/settings/payments?refresh=true`, | |
| 83 | return_url: `${returnUrl}/settings/payments?onboarded=true`, | |
| 84 | type: 'account_onboarding' | |
| 85 | }) | |
| 86 | ||
| 87 | return link.url | |
| 88 | } | |
| 89 | ||
| 90 | async function checkOnboardingStatus (providerId) { | |
| 91 | const { accountId } = await db.get(`stripe-account:${providerId}`) | |
| 92 | const account = await stripe.accounts.retrieve(accountId) | |
| 93 | ||
| 94 | const status = account.charges_enabled ? 'active' : 'restricted' | |
| 95 | await db.put(`stripe-account:${providerId}`, { | |
| 96 | accountId, | |
| 97 | status, | |
| 98 | chargesEnabled: account.charges_enabled, | |
| 99 | payoutsEnabled: account.payouts_enabled, | |
| 100 | updatedAt: Date.now() | |
| 101 | }) | |
| 102 | ||
| 103 | return { status, chargesEnabled: account.charges_enabled } | |
| 104 | } | |
| 105 | ``` | |
| 106 | ||
| 107 | ### Provider Payment Settings | |
| 108 | ||
| 109 | ```javascript | |
| 110 | { | |
| 111 | providerId: 'prov_xyz', | |
| 112 | stripeAccountId: 'acct_abc123', | |
| 113 | stripeStatus: 'active', | |
| 114 | enabledRails: ['stripe-card', 'stripe-ach'], // provider chooses | |
| 115 | platformFeePercent: 5, // our cut | |
| 116 | currency: 'USD', | |
| 117 | // Future: | |
| 118 | // hyperWalletAddress: '...', | |
| 119 | // enabledRails: ['stripe-card', 'stripe-ach', 'stablecoin', 'hyper-wallet'] | |
| 120 | } | |
| 121 | ``` | |
| 122 | ||
| 123 | **Storage:** `payment-settings:{providerId}` → settings object | |
| 124 | ||
| 125 | --- | |
| 126 | ||
| 127 | ## 2. Checkout Flow | |
| 128 | ||
| 129 | ### Step-by-Step | |
| 130 | ||
| 131 | ``` | |
| 132 | 1. Client selects package + time slot | |
| 133 | └─ Frontend has: packageId, price, slot, providerId | |
| 134 | ||
| 135 | 2. Frontend creates checkout session | |
| 136 | POST /api/checkout | |
| 137 | { packageId, slotStart, slotEnd, displayName, email, phone? } | |
| 138 | └─ Server creates pending booking + returns clientSecret (Stripe PaymentIntent) | |
| 139 | ||
| 140 | 3. Frontend renders hyper-pay widget | |
| 141 | └─ Widget shows enabled payment methods for this provider | |
| 142 | └─ Client picks method, enters details | |
| 143 | ||
| 144 | 4. Client submits payment | |
| 145 | └─ hyper-pay processes via selected rail | |
| 146 | └─ For Stripe: PaymentIntent confirmed client-side (Stripe.js) | |
| 147 | └─ For P2P: wallet signs transaction | |
| 148 | ||
| 149 | 5. Payment succeeds | |
| 150 | └─ Stripe webhook fires → server confirms booking | |
| 151 | └─ OR client-side confirmation → server verifies → confirms booking | |
| 152 | ||
| 153 | 6. Booking confirmed | |
| 154 | └─ Notifications sent (email, push, in-app) | |
| 155 | └─ Reminders scheduled | |
| 156 | └─ Client redirected to confirmation page | |
| 157 | ``` | |
| 158 | ||
| 159 | ### Server: Create Checkout Session | |
| 160 | ||
| 161 | ```javascript | |
| 162 | // POST /api/checkout | |
| 163 | async function handleCheckout (req, res) { | |
| 164 | const { packageId, slotStart, slotEnd, displayName, email, phone } = req.body | |
| 165 | const auth = req.auth // from identity middleware | |
| 166 | ||
| 167 | // 1. Get package and provider | |
| 168 | const pkg = await db.get(`package:${packageId}`) | |
| 169 | const provider = await db.get(`provider:${pkg.providerId}`) | |
| 170 | const paymentSettings = await db.get(`payment-settings:${pkg.providerId}`) | |
| 171 | ||
| 172 | // 2. Verify slot is available | |
| 173 | const conflicts = await getConflictingBookings(pkg.providerId, slotStart, slotEnd) | |
| 174 | if (conflicts.length > 0) { | |
| 175 | return res.status(409).json({ error: 'Slot no longer available' }) | |
| 176 | } | |
| 177 | ||
| 178 | // 3. Create or get client record | |
| 179 | const client = await getOrCreateClient(auth.identityId, { | |
| 180 | displayName, email, phone, providerId: pkg.providerId | |
| 181 | }) | |
| 182 | ||
| 183 | // 4. Calculate amount | |
| 184 | let amount = pkg.price | |
| 185 | // Apply discount if paying in full for a bundle | |
| 186 | if (pkg.discountFullPay && req.body.paymentOption === 'full') { | |
| 187 | amount = Math.round(pkg.price * (1 - pkg.discountFullPay / 100)) | |
| 188 | } | |
| 189 | ||
| 190 | // 5. Create pending booking | |
| 191 | const booking = await createBooking( | |
| 192 | client.id, pkg.providerId, packageId, null, slotStart, slotEnd | |
| 193 | ) | |
| 194 | ||
| 195 | // 6. Create payment via hyper-pay | |
| 196 | const platformFee = Math.round(amount * paymentSettings.platformFeePercent / 100) | |
| 197 | ||
| 198 | const payment = await hyperPay.createPaymentIntent({ | |
| 199 | amount, | |
| 200 | currency: paymentSettings.currency, | |
| 201 | merchantId: paymentSettings.stripeAccountId, | |
| 202 | platformFee, | |
| 203 | metadata: { | |
| 204 | bookingId: booking.id, | |
| 205 | packageId, | |
| 206 | providerId: pkg.providerId, | |
| 207 | clientId: client.id | |
| 208 | }, | |
| 209 | enabledRails: paymentSettings.enabledRails | |
| 210 | }) | |
| 211 | ||
| 212 | // 7. Create purchase record (pending) | |
| 213 | const purchase = { | |
| 214 | id: generateId('pur'), | |
| 215 | clientId: client.id, | |
| 216 | providerId: pkg.providerId, | |
| 217 | packageId, | |
| 218 | bookingId: booking.id, | |
| 219 | paymentId: payment.id, | |
| 220 | amount, | |
| 221 | platformFee, | |
| 222 | status: 'pending', | |
| 223 | sessionsTotal: pkg.sessionCount || 1, | |
| 224 | sessionsUsed: 0, | |
| 225 | createdAt: Date.now() | |
| 226 | } | |
| 227 | await db.put(`purchase:${purchase.id}`, purchase) | |
| 228 | ||
| 229 | // Update booking with purchase reference | |
| 230 | booking.purchaseId = purchase.id | |
| 231 | await db.put(`booking:${booking.id}`, booking) | |
| 232 | ||
| 233 | // 8. Return client secret for frontend payment confirmation | |
| 234 | res.json({ | |
| 235 | bookingId: booking.id, | |
| 236 | purchaseId: purchase.id, | |
| 237 | clientSecret: payment.clientSecret, // Stripe PaymentIntent client_secret | |
| 238 | enabledRails: paymentSettings.enabledRails, | |
| 239 | amount, | |
| 240 | currency: paymentSettings.currency | |
| 241 | }) | |
| 242 | } | |
| 243 | ``` | |
| 244 | ||
| 245 | ### Free Sessions (Discovery Calls) | |
| 246 | ||
| 247 | Skip payment entirely: | |
| 248 | ||
| 249 | ```javascript | |
| 250 | if (pkg.price === 0 || pkg.type === 'discovery') { | |
| 251 | // No payment needed — confirm immediately | |
| 252 | booking.status = 'confirmed' | |
| 253 | booking.confirmedAt = Date.now() | |
| 254 | await db.put(`booking:${booking.id}`, booking) | |
| 255 | ||
| 256 | await scheduleReminders(booking) | |
| 257 | await sendConfirmationNotification(booking, client) | |
| 258 | ||
| 259 | return res.json({ | |
| 260 | bookingId: booking.id, | |
| 261 | status: 'confirmed', | |
| 262 | redirect: `/${provider.slug}/confirmed/${booking.id}` | |
| 263 | }) | |
| 264 | } | |
| 265 | ``` | |
| 266 | ||
| 267 | --- | |
| 268 | ||
| 269 | ## 3. Webhook Handling | |
| 270 | ||
| 271 | Stripe sends events when payment status changes. | |
| 272 | ||
| 273 | ```javascript | |
| 274 | // POST /api/webhooks/stripe | |
| 275 | async function handleStripeWebhook (req, res) { | |
| 276 | const event = stripe.webhooks.constructEvent( | |
| 277 | req.rawBody, req.headers['stripe-signature'], webhookSecret | |
| 278 | ) | |
| 279 | ||
| 280 | switch (event.type) { | |
| 281 | case 'payment_intent.succeeded': { | |
| 282 | const { bookingId, purchaseId } = event.data.object.metadata | |
| 283 | ||
| 284 | // Confirm booking | |
| 285 | const booking = await db.get(`booking:${bookingId}`) | |
| 286 | await confirmBooking(bookingId) | |
| 287 | ||
| 288 | // Update purchase status | |
| 289 | const purchase = await db.get(`purchase:${purchaseId}`) | |
| 290 | purchase.status = 'paid' | |
| 291 | purchase.paidAt = Date.now() | |
| 292 | purchase.paymentRail = detectRail(event.data.object) | |
| 293 | await db.put(`purchase:${purchaseId}`, purchase) | |
| 294 | ||
| 295 | break | |
| 296 | } | |
| 297 | ||
| 298 | case 'payment_intent.payment_failed': { | |
| 299 | const { bookingId, purchaseId } = event.data.object.metadata | |
| 300 | ||
| 301 | // Cancel pending booking (release slot) | |
| 302 | const booking = await db.get(`booking:${bookingId}`) | |
| 303 | booking.status = 'cancelled' | |
| 304 | booking.cancelReason = 'payment_failed' | |
| 305 | await db.put(`booking:${bookingId}`, booking) | |
| 306 | ||
| 307 | // Update purchase | |
| 308 | const purchase = await db.get(`purchase:${purchaseId}`) | |
| 309 | purchase.status = 'failed' | |
| 310 | await db.put(`purchase:${purchaseId}`, purchase) | |
| 311 | ||
| 312 | break | |
| 313 | } | |
| 314 | ||
| 315 | case 'charge.refunded': { | |
| 316 | const { purchaseId } = event.data.object.metadata | |
| 317 | const purchase = await db.get(`purchase:${purchaseId}`) | |
| 318 | const refundAmount = event.data.object.amount_refunded | |
| 319 | ||
| 320 | purchase.status = refundAmount >= purchase.amount ? 'refunded' : 'partial-refund' | |
| 321 | purchase.refundedAmount = refundAmount | |
| 322 | purchase.refundedAt = Date.now() | |
| 323 | await db.put(`purchase:${purchaseId}`, purchase) | |
| 324 | ||
| 325 | break | |
| 326 | } | |
| 327 | ||
| 328 | case 'invoice.payment_succeeded': { | |
| 329 | // Subscription renewal | |
| 330 | const { providerId, clientId, packageId } = event.data.object.metadata | |
| 331 | await handleSubscriptionRenewal(providerId, clientId, packageId, event.data.object) | |
| 332 | break | |
| 333 | } | |
| 334 | ||
| 335 | case 'invoice.payment_failed': { | |
| 336 | // Subscription payment failed | |
| 337 | const { clientId } = event.data.object.metadata | |
| 338 | await notifier.notify(clientId, { | |
| 339 | type: 'subscription-payment-failed', | |
| 340 | message: 'Your subscription payment failed. Please update your payment method.' | |
| 341 | }) | |
| 342 | break | |
| 343 | } | |
| 344 | } | |
| 345 | ||
| 346 | res.json({ received: true }) | |
| 347 | } | |
| 348 | ``` | |
| 349 | ||
| 350 | --- | |
| 351 | ||
| 352 | ## 4. Installment Plans | |
| 353 | ||
| 354 | Split a package price into N monthly payments. | |
| 355 | ||
| 356 | ```javascript | |
| 357 | // Creating a payment schedule for installments | |
| 358 | async function createInstallmentPlan (purchaseId, totalAmount, installmentCount) { | |
| 359 | const perPayment = Math.ceil(totalAmount / installmentCount) | |
| 360 | // First payment may be slightly different to handle rounding | |
| 361 | const firstPayment = totalAmount - (perPayment * (installmentCount - 1)) | |
| 362 | ||
| 363 | const schedule = [] | |
| 364 | for (let i = 0; i < installmentCount; i++) { | |
| 365 | schedule.push({ | |
| 366 | installment: i + 1, | |
| 367 | amount: i === 0 ? firstPayment : perPayment, | |
| 368 | dueDate: addMonths(Date.now(), i), | |
| 369 | status: i === 0 ? 'due' : 'scheduled' // due | paid | failed | overdue | |
| 370 | }) | |
| 371 | } | |
| 372 | ||
| 373 | await db.put(`installment-plan:${purchaseId}`, { | |
| 374 | purchaseId, | |
| 375 | totalAmount, | |
| 376 | installmentCount, | |
| 377 | schedule, | |
| 378 | createdAt: Date.now() | |
| 379 | }) | |
| 380 | ||
| 381 | return schedule | |
| 382 | } | |
| 383 | ``` | |
| 384 | ||
| 385 | ### Stripe Implementation | |
| 386 | ||
| 387 | Two approaches: | |
| 388 | ||
| 389 | **Option A: Stripe Payment Schedules (simpler)** | |
| 390 | ```javascript | |
| 391 | // Use Stripe Subscriptions with a fixed number of payments | |
| 392 | const subscription = await stripe.subscriptions.create({ | |
| 393 | customer: customerId, | |
| 394 | items: [{ price: priceId }], | |
| 395 | cancel_at_period_end: false, | |
| 396 | metadata: { purchaseId, installmentCount: 3 }, | |
| 397 | // Cancel after N invoices | |
| 398 | billing_cycle_anchor: Math.floor(Date.now() / 1000), | |
| 399 | }) | |
| 400 | // Use subscription webhook to track installments | |
| 401 | ``` | |
| 402 | ||
| 403 | **Option B: Manual scheduling (more control)** | |
| 404 | ```javascript | |
| 405 | // Create PaymentIntents on a schedule using the notification scheduler | |
| 406 | await scheduler.schedule({ | |
| 407 | triggerAt: installment.dueDate, | |
| 408 | action: 'charge-installment', | |
| 409 | data: { purchaseId, installment: 2, amount: perPayment } | |
| 410 | }) | |
| 411 | ``` | |
| 412 | ||
| 413 | v1: Use Option A (Stripe handles the complexity). | |
| 414 | ||
| 415 | --- | |
| 416 | ||
| 417 | ## 5. Subscriptions | |
| 418 | ||
| 419 | Recurring monthly/weekly billing for ongoing services. | |
| 420 | ||
| 421 | ```javascript | |
| 422 | async function createSubscription (clientId, providerId, packageId) { | |
| 423 | const pkg = await db.get(`package:${packageId}`) | |
| 424 | const paymentSettings = await db.get(`payment-settings:${providerId}`) | |
| 425 | const client = await db.get(`client:${clientId}`) | |
| 426 | ||
| 427 | // Create Stripe subscription via hyper-pay | |
| 428 | const subscription = await hyperPay.subscribe({ | |
| 429 | amount: pkg.price, | |
| 430 | currency: paymentSettings.currency, | |
| 431 | interval: pkg.subscriptionInterval, // 'week' | 'month' | |
| 432 | merchantId: paymentSettings.stripeAccountId, | |
| 433 | platformFee: Math.round(pkg.price * paymentSettings.platformFeePercent / 100), | |
| 434 | customerEmail: client.email, | |
| 435 | metadata: { providerId, clientId, packageId } | |
| 436 | }) | |
| 437 | ||
| 438 | const purchase = { | |
| 439 | id: generateId('pur'), | |
| 440 | clientId, | |
| 441 | providerId, | |
| 442 | packageId, | |
| 443 | subscriptionId: subscription.id, | |
| 444 | type: 'subscription', | |
| 445 | amount: pkg.price, | |
| 446 | interval: pkg.subscriptionInterval, | |
| 447 | status: 'active', // active | paused | cancelled | past_due | |
| 448 | currentPeriodEnd: subscription.currentPeriodEnd, | |
| 449 | createdAt: Date.now() | |
| 450 | } | |
| 451 | ||
| 452 | await db.put(`purchase:${purchase.id}`, purchase) | |
| 453 | return purchase | |
| 454 | } | |
| 455 | ||
| 456 | async function cancelSubscription (purchaseId) { | |
| 457 | const purchase = await db.get(`purchase:${purchaseId}`) | |
| 458 | ||
| 459 | await hyperPay.cancelSubscription(purchase.subscriptionId) | |
| 460 | ||
| 461 | purchase.status = 'cancelled' | |
| 462 | purchase.cancelledAt = Date.now() | |
| 463 | await db.put(`purchase:${purchaseId}`, purchase) | |
| 464 | } | |
| 465 | ``` | |
| 466 | ||
| 467 | --- | |
| 468 | ||
| 469 | ## 6. Refunds | |
| 470 | ||
| 471 | Tied to cancellation policy from scheduling spec. | |
| 472 | ||
| 473 | ```javascript | |
| 474 | async function processRefund (bookingId) { | |
| 475 | const booking = await db.get(`booking:${bookingId}`) | |
| 476 | const purchase = await db.get(`purchase:${booking.purchaseId}`) | |
| 477 | const avail = await db.get(`availability:${booking.providerId}`) | |
| 478 | ||
| 479 | const hoursUntil = (booking.startTime - Date.now()) / 3600000 | |
| 480 | const withinPolicy = hoursUntil >= avail.cancellation.windowHours | |
| 481 | ||
| 482 | let refundAmount = 0 | |
| 483 | ||
| 484 | if (booking.cancelledBy === 'provider') { | |
| 485 | // Provider cancels → always full refund | |
| 486 | refundAmount = purchase.amount | |
| 487 | } else if (withinPolicy) { | |
| 488 | switch (avail.cancellation.refundPolicy) { | |
| 489 | case 'full': | |
| 490 | refundAmount = purchase.amount | |
| 491 | break | |
| 492 | case 'partial': | |
| 493 | refundAmount = Math.round(purchase.amount * 0.5) // 50% refund | |
| 494 | break | |
| 495 | case 'none': | |
| 496 | refundAmount = 0 | |
| 497 | break | |
| 498 | } | |
| 499 | } | |
| 500 | // Outside policy window → no refund (unless provider cancels) | |
| 501 | ||
| 502 | if (refundAmount > 0) { | |
| 503 | await hyperPay.refund({ | |
| 504 | paymentId: purchase.paymentId, | |
| 505 | amount: refundAmount | |
| 506 | }) | |
| 507 | ||
| 508 | purchase.refundedAmount = refundAmount | |
| 509 | purchase.status = refundAmount >= purchase.amount ? 'refunded' : 'partial-refund' | |
| 510 | await db.put(`purchase:${purchase.id}`, purchase) | |
| 511 | } | |
| 512 | ||
| 513 | return { refundAmount, refundPolicy: avail.cancellation.refundPolicy } | |
| 514 | } | |
| 515 | ``` | |
| 516 | ||
| 517 | --- | |
| 518 | ||
| 519 | ## 7. Platform Revenue | |
| 520 | ||
| 521 | ### Fee Collection | |
| 522 | ||
| 523 | ``` | |
| 524 | Client pays $100 | |
| 525 | → Stripe takes ~$3.20 (processing fee) | |
| 526 | → Platform takes $5.00 (5% application_fee) | |
| 527 | → Provider receives $91.80 | |
| 528 | ``` | |
| 529 | ||
| 530 | Implemented via Stripe Connect `application_fee_amount`: | |
| 531 | ||
| 532 | ```javascript | |
| 533 | const paymentIntent = await stripe.paymentIntents.create({ | |
| 534 | amount: 10000, // $100 | |
| 535 | currency: 'usd', | |
| 536 | application_fee_amount: 500, // $5 platform fee | |
| 537 | transfer_data: { | |
| 538 | destination: providerStripeAccountId | |
| 539 | } | |
| 540 | }) | |
| 541 | ``` | |
| 542 | ||
| 543 | ### Revenue Dashboard | |
| 544 | ||
| 545 | ```javascript | |
| 546 | // Platform analytics | |
| 547 | async function getPlatformRevenue (period) { | |
| 548 | // Query all purchases for period | |
| 549 | const purchases = await queryPurchasesByPeriod(period) | |
| 550 | ||
| 551 | return { | |
| 552 | totalVolume: sum(purchases, 'amount'), | |
| 553 | platformFees: sum(purchases, 'platformFee'), | |
| 554 | transactionCount: purchases.length, | |
| 555 | byRail: groupBy(purchases, 'paymentRail'), | |
| 556 | byProvider: groupBy(purchases, 'providerId') | |
| 557 | } | |
| 558 | } | |
| 559 | ``` | |
| 560 | ||
| 561 | --- | |
| 562 | ||
| 563 | ## 8. Promo Codes & Discounts | |
| 564 | ||
| 565 | ```javascript | |
| 566 | { | |
| 567 | id: 'promo_abc', | |
| 568 | code: 'WELCOME20', | |
| 569 | type: 'percent', // percent | fixed | |
| 570 | value: 20, // 20% off or $20 off | |
| 571 | providerId: 'prov_xyz', // provider-specific or null for platform-wide | |
| 572 | packageIds: null, // null = all packages, or specific package IDs | |
| 573 | maxUses: 100, | |
| 574 | currentUses: 23, | |
| 575 | validFrom: timestamp, | |
| 576 | validUntil: timestamp, | |
| 577 | active: true | |
| 578 | } | |
| 579 | ``` | |
| 580 | ||
| 581 | ### Apply at Checkout | |
| 582 | ||
| 583 | ```javascript | |
| 584 | async function applyPromoCode (code, packageId, providerId) { | |
| 585 | const promo = await db.get(`promo-by-code:${code.toUpperCase()}`) | |
| 586 | if (!promo) throw new Error('Invalid promo code') | |
| 587 | if (!promo.active) throw new Error('Promo code expired') | |
| 588 | if (promo.currentUses >= promo.maxUses) throw new Error('Promo code fully redeemed') | |
| 589 | if (promo.validUntil && Date.now() > promo.validUntil) throw new Error('Promo code expired') | |
| 590 | if (promo.providerId && promo.providerId !== providerId) throw new Error('Invalid for this provider') | |
| 591 | if (promo.packageIds && !promo.packageIds.includes(packageId)) throw new Error('Invalid for this package') | |
| 592 | ||
| 593 | return promo | |
| 594 | } | |
| 595 | ||
| 596 | function calculateDiscount (price, promo) { | |
| 597 | if (promo.type === 'percent') { | |
| 598 | return Math.round(price * promo.value / 100) | |
| 599 | } | |
| 600 | return Math.min(promo.value, price) // fixed amount, don't exceed price | |
| 601 | } | |
| 602 | ``` | |
| 603 | ||
| 604 | **Storage:** | |
| 605 | ``` | |
| 606 | promo:{promoId} → promo object | |
| 607 | promo-by-code:{CODE} → promoId | |
| 608 | promos-by-provider:{pid} → promoId list | |
| 609 | ``` | |
| 610 | ||
| 611 | --- | |
| 612 | ||
| 613 | ## 9. Pending Payment Timeout | |
| 614 | ||
| 615 | If a client starts checkout but doesn't complete, release the slot. | |
| 616 | ||
| 617 | ```javascript | |
| 618 | // Slot held for 15 minutes during checkout | |
| 619 | const CHECKOUT_TIMEOUT = 15 * 60 * 1000 | |
| 620 | ||
| 621 | // On checkout creation, schedule timeout | |
| 622 | await scheduler.schedule({ | |
| 623 | triggerAt: Date.now() + CHECKOUT_TIMEOUT, | |
| 624 | action: 'expire-pending-booking', | |
| 625 | data: { bookingId } | |
| 626 | }) | |
| 627 | ||
| 628 | // Timeout handler | |
| 629 | async function expirePendingBooking (bookingId) { | |
| 630 | const booking = await db.get(`booking:${bookingId}`) | |
| 631 | if (booking.status !== 'pending') return // already confirmed or cancelled | |
| 632 | ||
| 633 | booking.status = 'cancelled' | |
| 634 | booking.cancelReason = 'checkout_timeout' | |
| 635 | await db.put(`booking:${bookingId}`, booking) | |
| 636 | ||
| 637 | // Cancel Stripe PaymentIntent if exists | |
| 638 | const purchase = await db.get(`purchase:${booking.purchaseId}`) | |
| 639 | if (purchase?.paymentId) { | |
| 640 | await hyperPay.cancelPayment(purchase.paymentId) | |
| 641 | } | |
| 642 | } | |
| 643 | ``` | |
| 644 | ||
| 645 | --- | |
| 646 | ||
| 647 | ## 10. Receipts | |
| 648 | ||
| 649 | Generated after successful payment. | |
| 650 | ||
| 651 | ```javascript | |
| 652 | async function generateReceipt (purchaseId) { | |
| 653 | const purchase = await db.get(`purchase:${purchaseId}`) | |
| 654 | const pkg = await db.get(`package:${purchase.packageId}`) | |
| 655 | const provider = await db.get(`provider:${purchase.providerId}`) | |
| 656 | const client = await db.get(`client:${purchase.clientId}`) | |
| 657 | ||
| 658 | return { | |
| 659 | receiptNumber: `RCP-${purchase.id.slice(4).toUpperCase()}`, | |
| 660 | date: new Date(purchase.paidAt).toLocaleDateString(), | |
| 661 | provider: { | |
| 662 | name: provider.displayName, | |
| 663 | business: provider.businessName || provider.displayName | |
| 664 | }, | |
| 665 | client: { | |
| 666 | name: client.displayName, | |
| 667 | email: client.email | |
| 668 | }, | |
| 669 | items: [{ | |
| 670 | description: pkg.name, | |
| 671 | quantity: pkg.sessionCount || 1, | |
| 672 | unitPrice: pkg.sessionCount ? purchase.amount / pkg.sessionCount : purchase.amount, | |
| 673 | total: purchase.amount | |
| 674 | }], | |
| 675 | subtotal: purchase.amount, | |
| 676 | discount: purchase.discountAmount || 0, | |
| 677 | total: purchase.amount - (purchase.discountAmount || 0), | |
| 678 | paymentMethod: formatRailName(purchase.paymentRail), | |
| 679 | currency: 'USD' | |
| 680 | } | |
| 681 | } | |
| 682 | ``` | |
| 683 | ||
| 684 | ### Receipt Page | |
| 685 | ||
| 686 | ``` | |
| 687 | GET /api/purchases/:id/receipt → JSON receipt data | |
| 688 | GET /portal/receipts/:id → HTML receipt page (printable) | |
| 689 | ``` | |
| 690 | ||
| 691 | --- | |
| 692 | ||
| 693 | ## 11. Future Payment Rails (v2+) | |
| 694 | ||
| 695 | ### Stablecoin (USDC via Stripe) | |
| 696 | ||
| 697 | ```javascript | |
| 698 | // Already supported by Stripe as of 2025 | |
| 699 | // Just enable 'stablecoin' in provider's enabledRails | |
| 700 | // Stripe handles the wallet connection + settlement | |
| 701 | // 1.5% fee instead of 2.9% | |
| 702 | ``` | |
| 703 | ||
| 704 | ### x402 Protocol | |
| 705 | ||
| 706 | ```javascript | |
| 707 | // For API/agent bookings | |
| 708 | // Client sends booking request, gets 402, pays in USDC, booking confirmed | |
| 709 | // Near-zero cost ($0.001) | |
| 710 | // Best for: automated booking bots, AI agent scheduling | |
| 711 | ``` | |
| 712 | ||
| 713 | ### hyper-wallet P2P | |
| 714 | ||
| 715 | ```javascript | |
| 716 | // Direct peer-to-peer settlement | |
| 717 | // Client wallet → Provider wallet | |
| 718 | // Zero platform fee on the payment itself | |
| 719 | // Platform fee collected separately (invoice or voluntary) | |
| 720 | ``` | |
| 721 | ||
| 722 | ### Tether WDK | |
| 723 | ||
| 724 | ```javascript | |
| 725 | // USDT payments via WDK | |
| 726 | // Gasless via Paymaster | |
| 727 | // Self-custodial for both parties | |
| 728 | ``` | |
| 729 | ||
| 730 | --- | |
| 731 | ||
| 732 | ## API Endpoints | |
| 733 | ||
| 734 | ``` | |
| 735 | # Checkout | |
| 736 | POST /api/checkout Create checkout session (returns clientSecret) | |
| 737 | POST /api/checkout/free Book free session (discovery call) | |
| 738 | POST /api/checkout/promo Validate promo code | |
| 739 | POST /api/webhooks/stripe Stripe webhook receiver | |
| 740 | ||
| 741 | # Purchases (client) | |
| 742 | GET /api/portal/purchases Client's purchase history | |
| 743 | GET /api/portal/purchases/:id Purchase detail | |
| 744 | GET /api/portal/purchases/:id/receipt Receipt | |
| 745 | ||
| 746 | # Subscriptions (client) | |
| 747 | PUT /api/portal/subscriptions/:id/cancel Cancel subscription | |
| 748 | PUT /api/portal/subscriptions/:id/pause Pause subscription (v2) | |
| 749 | ||
| 750 | # Payment Settings (provider) | |
| 751 | GET /api/settings/payments Provider payment config | |
| 752 | POST /api/settings/payments/connect Start Stripe Connect onboarding | |
| 753 | GET /api/settings/payments/status Check onboarding status | |
| 754 | ||
| 755 | # Promos (provider) | |
| 756 | POST /api/promos Create promo code | |
| 757 | GET /api/promos List promos | |
| 758 | PUT /api/promos/:id Update promo | |
| 759 | DEL /api/promos/:id Deactivate promo | |
| 760 | ||
| 761 | # Revenue (provider) | |
| 762 | GET /api/dashboard/revenue Revenue analytics | |
| 763 | GET /api/dashboard/payouts Payout history (from Stripe) | |
| 764 | ||
| 765 | # Admin | |
| 766 | GET /api/admin/revenue Platform revenue overview | |
| 767 | GET /api/admin/fees Platform fee collection | |
| 768 | ``` |