OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-booking commits / 9bdb7d0

Add payments, forms, and deployment specs
Payments: Stripe Connect onboarding, checkout flow, webhook handling, installment plans, subscriptions, refunds tied to cancellation policy, promo codes, platform fee collection, receipt generation. Forms: intake form builder (12 field types), e-signatures with ed25519 cryptographic proof, exit surveys with aggregation, content-addressed material storage, session notes. Deployment: pear-git CI/CD pipeline, server architecture diagram, env vars / config, first-run bootstrap, provider onboarding wizard, data seeding, multi-instance sync, systemd + reverse proxy, health checks, backup/recovery, security checklist. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
9bdb7d0bf783256ca6ba2333b90f7e6b94bcc277
by plankatron <fleeky@gmail.com> · 2026-03-22T17:28:58.000Z

Changed files (3) +1813 -0

docs/design/v1-deployment.md +583 -0
……@@ -0,0 +1,583 @@
1# hyper-booking v1 — Deployment & Bootstrap Spec
2
3How to deploy, seed data, and onboard the first provider.
4
5---
6
7## 1. Deployment via pear-git CI/CD
8
9Same pipeline as all hyper-fleeky apps.
10
11### Developer Flow
12
13```bash
14# Push to deploy
15git 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
29steps:
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
61const HyperBookingServer = require('./lib/server.js')
62
63const 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
70server.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```
80index.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
123PORT=4050 # HTTP port
124STORAGE=./data # Corestore storage path
125
126# Stripe
127STRIPE_SECRET_KEY=sk_live_... # Stripe API key
128STRIPE_WEBHOOK_SECRET=whsec_... # Webhook endpoint signing secret
129STRIPE_PLATFORM_ACCOUNT=acct_... # Platform's own Stripe account
130
131# Email (pick one)
132SMTP_HOST=localhost # Self-hosted Mox/Postal
133SMTP_PORT=587
134SMTP_USER=bookings
135SMTP_PASS=...
136SMTP_FROM=bookings@yourdomain.com
137# OR
138EMAIL_PROVIDER=x402 # Use x402mail
139X402_WALLET_KEY=... # USDC wallet for x402 payments
140
141# SMS (pick one)
142SMS_PROVIDER=android-gateway # android-gateway | serial-modem | telnyx
143SMS_GATEWAY_URL=http://192.168.1.50:8080 # Android SMS Gateway URL
144SMS_GATEWAY_USER=admin
145SMS_GATEWAY_PASS=...
146# OR
147SMS_MODEM_PORT=/dev/ttyUSB0 # USB modem serial port
148# OR
149TELNYX_API_KEY=KEY_...
150TELNYX_FROM=+15551234567
151
152# Push Notifications (optional)
153APNS_KEY_ID=... # Apple Push Notification Service
154APNS_TEAM_ID=...
155APNS_KEY_PATH=./certs/apns.p8
156FCM_PROJECT_ID=... # Firebase Cloud Messaging
157FCM_SERVICE_ACCOUNT=./certs/firebase.json
158
159# WebPush
160VAPID_PUBLIC_KEY=...
161VAPID_PRIVATE_KEY=...
162VAPID_EMAIL=mailto:admin@yourdomain.com
163
164# Network
165NETWORK=true # Enable Hyperswarm replication
166PREFIX= # 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
205What happens when hyper-booking starts for the first time.
206
207### Auto-Setup
208
209```javascript
210async 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```
2381. First person to visit the site and sign in becomes admin
2392. They are automatically both admin AND provider
2403. They set up their provider profile
2414. They connect Stripe (onboarding link)
2425. They create their first package
2436. They set their availability
2447. Site is live for booking
245```
246
247---
248
249## 5. Provider Onboarding Wizard
250
251Step-by-step setup for new providers.
252
253### Steps
254
255```
256Step 1: Profile
257 - Display name
258 - Photo upload
259 - Bio (markdown)
260 - Specialties / tags
261 - Social links
262
263Step 2: Availability
264 - Weekly schedule (visual editor)
265 - Timezone selection
266 - Buffer time
267 - Advance booking window
268
269Step 3: Payments
270 - Connect with Stripe (redirect to Stripe Connect)
271 - Select enabled payment rails
272 - Review platform fee
273
274Step 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
283Step 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
314For testing with sample data.
315
316```javascript
317// scripts/seed.js
318async 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
399Run with: `bare scripts/seed.js`
400
401---
402
403## 7. Multi-Instance Deployment
404
405Running hyper-booking on multiple machines with Hyperswarm sync.
406
407### Architecture
408
409```
410tabertron (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
422Option 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
427Option B: **Autobase multiwriter** (v2)
428- All instances can write
429- Autobase resolves conflicts
430- Same pattern as hypernote multiwriter notebooks
431
432v1 recommendation: Single writer. One hyper-booking instance on tabertron,
433stortron as a read replica for redundancy.
434
435### Installation on Remote (via pear-git)
436
437```bash
438# On stortron:
439pgh app install --app hyper-booking
440hyper-booking # starts the server
441```
442
443### Systemd Service
444
445```ini
446[Unit]
447Description=hyper-booking
448After=network.target
449
450[Service]
451Type=simple
452User=fleeky
453WorkingDirectory=/home/fleeky/.local/share/hyper-booking
454ExecStart=/home/fleeky/.local/bin/hyper-booking
455Restart=on-failure
456RestartSec=5
457Environment="PORT=4050"
458Environment="STORAGE=/home/fleeky/.local/share/hyper-booking/data"
459Environment="NETWORK=true"
460
461[Install]
462WantedBy=multi-user.target
463```
464
465### Reverse Proxy (nginx/Apache)
466
467```nginx
468location /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```
487GET /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
511function 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:
521log('info', 'booking-created', { bookingId, providerId, clientId })
522log('info', 'payment-received', { purchaseId, amount, rail })
523log('warn', 'payment-failed', { purchaseId, error })
524log('error', 'notification-failed', { channel, clientId, error })
525log('info', 'slot-conflict', { providerId, slotStart })
526```
527
528---
529
530## 9. Backup & Recovery
531
532### Corestore Backup
533
534Data 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:
541cp -r ./data ./data-backup-$(date +%Y%m%d)
542```
543
544### Stripe Data
545
546Stripe 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```
5521. Restore Corestore data directory
5532. Start hyper-booking — it reads state from Hyperbee
5543. Stripe webhook events will re-sync any missed payment updates
5554. 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)
docs/design/v1-forms.md +462 -0
……@@ -0,0 +1,462 @@
1# hyper-booking v1 — Forms, Contracts & Content Spec
2
3Intake forms, e-signatures, surveys, and material sharing.
4
5---
6
7## 1. Form Builder
8
9Providers 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```
113form:{formId} → form template
114forms-by-provider:{providerId} → formId list
115form-response:{responseId} → response object
116form-responses-by-client:{clientId} → responseId list
117form-responses-by-form:{formId} → responseId list
118```
119
120### API
121
122```
123# Form templates (provider)
124POST /api/forms Create form
125GET /api/forms List provider's forms
126GET /api/forms/:id Get form template
127PUT /api/forms/:id Update form
128DEL /api/forms/:id Archive form
129
130# Form responses (client)
131GET /api/portal/forms Forms assigned to client (pending + completed)
132GET /api/portal/forms/:id Get form to fill out
133POST /api/portal/forms/:id Submit form response
134
135# Form responses (provider)
136GET /api/dashboard/forms/:id/responses All responses for a form
137GET /api/dashboard/clients/:id/forms Client's form responses
138```
139
140---
141
142## 2. Contracts & E-Signatures
143
144Legal 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
155Between **{{providerName}}** ("Coach") and **{{clientName}}** ("Client").
156
157## Terms
158
1591. Client agrees to attend all scheduled sessions or cancel with {{cancellationWindow}} notice.
1602. Coach agrees to maintain confidentiality of all session content.
1613. This agreement covers the "{{packageName}}" package ({{sessionCount}} sessions).
162
163## Cancellation Policy
164
165Sessions cancelled with less than {{cancellationWindow}} notice will be forfeited.
166
167## Acknowledgment
168
169By signing below, both parties agree to the terms above.
170
171Date: {{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```
2171. Client reaches contract step (after payment, before confirmation)
2182. Server renders contract template with variables filled in
2193. Client reviews rendered contract in browser
2204. Client clicks "I Agree & Sign"
221 └─ Browser signs sha256(renderedBody) with client's ed25519 secret key
222 └─ POST /api/contracts/:id/sign { signature, publicKey }
2235. Server verifies signature matches client's identity
2246. Server counter-signs with provider's key (or pre-signed template)
2257. Contract stored as immutable record
226```
227
228### Verification
229
230Anyone can verify a contract signature:
231
232```javascript
233async 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```
257contract:{contractId} → contract template
258contracts-by-provider:{providerId} → contractId list
259signed-contract:{signatureId} → signed contract record
260signed-by-client:{clientId} → signatureId list
261signed-by-purchase:{purchaseId} → signatureId
262```
263
264### API
265
266```
267# Contract templates (provider)
268POST /api/contracts Create contract template
269GET /api/contracts List contracts
270PUT /api/contracts/:id Update contract
271DEL /api/contracts/:id Archive contract
272
273# Signing (client)
274GET /api/portal/contracts/:id Get rendered contract to sign
275POST /api/portal/contracts/:id/sign Submit signature
276
277# Verification (anyone)
278GET /api/contracts/verify/:signatureId Verify signatures
279```
280
281---
282
283## 3. Exit Surveys
284
285Triggered after final session in a package.
286
287### How It Works
288
289```
2901. Package has sessionCount: 6
2912. Client completes session 6 (last in package)
2923. System triggers exit survey notification
2934. Client fills out survey in portal
2945. Provider sees aggregated results in dashboard
295```
296
297### Survey Object
298
299Same schema as intake forms, but with `type: 'exit-survey'`.
300
301Additional 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
315async 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
365Files 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
394Files stored content-addressed (same as hypernote media):
395
396```javascript
397async 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```
415GET /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
422After 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```
436material:{materialId} → material object
437materials-by-package:{packageId} → materialId list
438materials-by-booking:{bookingId} → materialId list
439media:{hash} → file content (content-addressed)
440session-notes:{bookingId} → notes object
441```
442
443### API
444
445```
446# Materials (provider)
447POST /api/materials Upload material
448PUT /api/materials/:id Update material metadata
449DEL /api/materials/:id Remove material
450POST /api/materials/:id/attach Attach to package or booking
451
452# Materials (client)
453GET /api/portal/materials All materials for client's packages
454GET /api/materials/:hash/:filename Download file
455
456# Session notes (provider)
457POST /api/bookings/:id/notes Add/update session notes
458GET /api/bookings/:id/notes Get session notes
459
460# Session notes (client)
461GET /api/portal/bookings/:id/notes Get notes shared with client
462```
docs/design/v1-payments.md +768 -0
……@@ -0,0 +1,768 @@
1# hyper-booking v1 — Payments & Checkout Spec
2
3All payments delegate to hyper-pay. hyper-booking never touches
4card numbers, bank accounts, or crypto keys directly.
5
6---
7
8## Architecture
9
10```
11hyper-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
27hyper-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
37Each provider is a Stripe Connected Account. Platform (us) collects fees.
38
39### Onboarding Flow
40
41```
421. Provider signs up on hyper-booking
432. Provider goes to Dashboard → Settings → Payments
443. Clicks "Connect with Stripe"
454. Redirected to Stripe Connect onboarding (hosted by Stripe)
46 - Business info, bank account, identity verification
47 - Stripe handles all KYC/compliance
485. Redirected back to hyper-booking with account ID
496. Provider can now accept payments
50```
51
52### Implementation
53
54```javascript
55// lib/payments/stripe-onboarding.js
56
57async 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
77async 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
90async 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```
1321. Client selects package + time slot
133 └─ Frontend has: packageId, price, slot, providerId
134
1352. 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
1403. Frontend renders hyper-pay widget
141 └─ Widget shows enabled payment methods for this provider
142 └─ Client picks method, enters details
143
1444. 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
1495. Payment succeeds
150 └─ Stripe webhook fires → server confirms booking
151 └─ OR client-side confirmation → server verifies → confirms booking
152
1536. 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
163async 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
247Skip payment entirely:
248
249```javascript
250if (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
271Stripe sends events when payment status changes.
272
273```javascript
274// POST /api/webhooks/stripe
275async 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
354Split a package price into N monthly payments.
355
356```javascript
357// Creating a payment schedule for installments
358async 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
387Two approaches:
388
389**Option A: Stripe Payment Schedules (simpler)**
390```javascript
391// Use Stripe Subscriptions with a fixed number of payments
392const 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
406await scheduler.schedule({
407 triggerAt: installment.dueDate,
408 action: 'charge-installment',
409 data: { purchaseId, installment: 2, amount: perPayment }
410})
411```
412
413v1: Use Option A (Stripe handles the complexity).
414
415---
416
417## 5. Subscriptions
418
419Recurring monthly/weekly billing for ongoing services.
420
421```javascript
422async 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
456async 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
471Tied to cancellation policy from scheduling spec.
472
473```javascript
474async 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```
524Client pays $100
525 → Stripe takes ~$3.20 (processing fee)
526 → Platform takes $5.00 (5% application_fee)
527 → Provider receives $91.80
528```
529
530Implemented via Stripe Connect `application_fee_amount`:
531
532```javascript
533const 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
547async 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
584async 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
596function 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```
606promo:{promoId} → promo object
607promo-by-code:{CODE} → promoId
608promos-by-provider:{pid} → promoId list
609```
610
611---
612
613## 9. Pending Payment Timeout
614
615If a client starts checkout but doesn't complete, release the slot.
616
617```javascript
618// Slot held for 15 minutes during checkout
619const CHECKOUT_TIMEOUT = 15 * 60 * 1000
620
621// On checkout creation, schedule timeout
622await scheduler.schedule({
623 triggerAt: Date.now() + CHECKOUT_TIMEOUT,
624 action: 'expire-pending-booking',
625 data: { bookingId }
626})
627
628// Timeout handler
629async 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
649Generated after successful payment.
650
651```javascript
652async 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```
687GET /api/purchases/:id/receipt → JSON receipt data
688GET /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
736POST /api/checkout Create checkout session (returns clientSecret)
737POST /api/checkout/free Book free session (discovery call)
738POST /api/checkout/promo Validate promo code
739POST /api/webhooks/stripe Stripe webhook receiver
740
741# Purchases (client)
742GET /api/portal/purchases Client's purchase history
743GET /api/portal/purchases/:id Purchase detail
744GET /api/portal/purchases/:id/receipt Receipt
745
746# Subscriptions (client)
747PUT /api/portal/subscriptions/:id/cancel Cancel subscription
748PUT /api/portal/subscriptions/:id/pause Pause subscription (v2)
749
750# Payment Settings (provider)
751GET /api/settings/payments Provider payment config
752POST /api/settings/payments/connect Start Stripe Connect onboarding
753GET /api/settings/payments/status Check onboarding status
754
755# Promos (provider)
756POST /api/promos Create promo code
757GET /api/promos List promos
758PUT /api/promos/:id Update promo
759DEL /api/promos/:id Deactivate promo
760
761# Revenue (provider)
762GET /api/dashboard/revenue Revenue analytics
763GET /api/dashboard/payouts Payout history (from Stripe)
764
765# Admin
766GET /api/admin/revenue Platform revenue overview
767GET /api/admin/fees Platform fee collection
768```