unified-video-framework 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (129) hide show
  1. package/.github/workflows/ci.yml +253 -0
  2. package/ANDROID_TV_IMPLEMENTATION.md +313 -0
  3. package/COMPLETION_STATUS.md +165 -0
  4. package/CONTRIBUTING.md +376 -0
  5. package/FINAL_STATUS_REPORT.md +170 -0
  6. package/FRAMEWORK_REVIEW.md +247 -0
  7. package/IMPROVEMENTS_SUMMARY.md +168 -0
  8. package/LICENSE +21 -0
  9. package/NATIVE_APP_INTEGRATION_GUIDE.md +903 -0
  10. package/PAYWALL_RENTAL_FLOW.md +499 -0
  11. package/PLATFORM_SETUP_GUIDE.md +1636 -0
  12. package/README.md +315 -0
  13. package/RUN_LOCALLY.md +151 -0
  14. package/apps/demo/cast-sender-min.html +173 -0
  15. package/apps/demo/custom-player.html +883 -0
  16. package/apps/demo/demo.html +990 -0
  17. package/apps/demo/enhanced-player.html +3556 -0
  18. package/apps/demo/index.html +159 -0
  19. package/apps/rental-api/.env.example +24 -0
  20. package/apps/rental-api/README.md +23 -0
  21. package/apps/rental-api/migrations/001_init.sql +35 -0
  22. package/apps/rental-api/migrations/002_videos.sql +10 -0
  23. package/apps/rental-api/migrations/003_add_gateway_subref.sql +4 -0
  24. package/apps/rental-api/migrations/004_update_gateways.sql +4 -0
  25. package/apps/rental-api/migrations/005_seed_demo_video.sql +5 -0
  26. package/apps/rental-api/package-lock.json +2045 -0
  27. package/apps/rental-api/package.json +33 -0
  28. package/apps/rental-api/scripts/run-migration.js +42 -0
  29. package/apps/rental-api/scripts/update-video-currency.js +21 -0
  30. package/apps/rental-api/scripts/update-video-price.js +19 -0
  31. package/apps/rental-api/src/config.ts +14 -0
  32. package/apps/rental-api/src/db.ts +10 -0
  33. package/apps/rental-api/src/routes/cashfree.ts +167 -0
  34. package/apps/rental-api/src/routes/pesapal.ts +92 -0
  35. package/apps/rental-api/src/routes/rentals.ts +242 -0
  36. package/apps/rental-api/src/routes/webhooks.ts +73 -0
  37. package/apps/rental-api/src/server.ts +41 -0
  38. package/apps/rental-api/src/services/entitlements.ts +45 -0
  39. package/apps/rental-api/src/services/payments.ts +22 -0
  40. package/apps/rental-api/tsconfig.json +17 -0
  41. package/check-urls.ps1 +74 -0
  42. package/comparison-report.md +181 -0
  43. package/docs/PAYWALL.md +95 -0
  44. package/docs/PLAYER_UI_VISIBILITY.md +431 -0
  45. package/docs/README.md +7 -0
  46. package/docs/SYSTEM_ARCHITECTURE.md +612 -0
  47. package/docs/VDOCIPHER_CLONE_REQUIREMENTS.md +403 -0
  48. package/examples/android/JavaSampleApp/MainActivity.java +641 -0
  49. package/examples/android/JavaSampleApp/activity_main.xml +226 -0
  50. package/examples/android/SampleApp/MainActivity.kt +430 -0
  51. package/examples/ios/SampleApp/ViewController.swift +337 -0
  52. package/examples/ios/SwiftUISampleApp/ContentView.swift +304 -0
  53. package/iOS_IMPLEMENTATION_OPTIONS.md +470 -0
  54. package/ios/UnifiedVideoPlayer/UnifiedVideoPlayer.podspec +33 -0
  55. package/jest.config.js +33 -0
  56. package/jitpack.yml +5 -0
  57. package/lerna.json +35 -0
  58. package/package.json +69 -0
  59. package/packages/PLATFORM_STATUS.md +163 -0
  60. package/packages/android/build.gradle +135 -0
  61. package/packages/android/src/main/AndroidManifest.xml +36 -0
  62. package/packages/android/src/main/java/com/unifiedvideo/player/PlayerConfiguration.java +221 -0
  63. package/packages/android/src/main/java/com/unifiedvideo/player/UnifiedVideoPlayer.java +1037 -0
  64. package/packages/android/src/main/java/com/unifiedvideo/player/UnifiedVideoPlayer.kt +707 -0
  65. package/packages/android/src/main/java/com/unifiedvideo/player/analytics/AnalyticsProvider.java +9 -0
  66. package/packages/android/src/main/java/com/unifiedvideo/player/cast/CastManager.java +141 -0
  67. package/packages/android/src/main/java/com/unifiedvideo/player/cast/CastOptionsProvider.java +29 -0
  68. package/packages/android/src/main/java/com/unifiedvideo/player/overlay/WatermarkOverlayView.java +88 -0
  69. package/packages/android/src/main/java/com/unifiedvideo/player/pip/PipActionReceiver.java +33 -0
  70. package/packages/android/src/main/java/com/unifiedvideo/player/services/PlaybackService.java +110 -0
  71. package/packages/android/src/main/java/com/unifiedvideo/player/services/PlayerHolder.java +19 -0
  72. package/packages/core/package.json +34 -0
  73. package/packages/core/src/BasePlayer.ts +250 -0
  74. package/packages/core/src/VideoPlayer.ts +237 -0
  75. package/packages/core/src/VideoPlayerFactory.ts +145 -0
  76. package/packages/core/src/index.ts +20 -0
  77. package/packages/core/src/interfaces/IVideoPlayer.ts +184 -0
  78. package/packages/core/src/interfaces.ts +240 -0
  79. package/packages/core/src/utils/EventEmitter.ts +66 -0
  80. package/packages/core/src/utils/PlatformDetector.ts +300 -0
  81. package/packages/core/tsconfig.json +20 -0
  82. package/packages/enact/package.json +51 -0
  83. package/packages/enact/src/VideoPlayer.js +365 -0
  84. package/packages/enact/src/adapters/TizenAdapter.js +354 -0
  85. package/packages/enact/src/index.js +82 -0
  86. package/packages/ios/BUILD_INSTRUCTIONS.md +108 -0
  87. package/packages/ios/FIX_EMBED_ISSUE.md +142 -0
  88. package/packages/ios/GETTING_STARTED.md +100 -0
  89. package/packages/ios/Package.swift +35 -0
  90. package/packages/ios/README.md +84 -0
  91. package/packages/ios/Sources/UnifiedVideoPlayer/Analytics/AnalyticsEmitter.swift +26 -0
  92. package/packages/ios/Sources/UnifiedVideoPlayer/DRM/FairPlayDRMManager.swift +102 -0
  93. package/packages/ios/Sources/UnifiedVideoPlayer/Info.plist +24 -0
  94. package/packages/ios/Sources/UnifiedVideoPlayer/Remote/RemoteCommandCenter.swift +109 -0
  95. package/packages/ios/Sources/UnifiedVideoPlayer/UnifiedVideoPlayer.swift +811 -0
  96. package/packages/ios/Sources/UnifiedVideoPlayer/UnifiedVideoPlayerView.swift +640 -0
  97. package/packages/ios/Sources/UnifiedVideoPlayer/Utilities/Color+Hex.swift +36 -0
  98. package/packages/ios/UnifiedVideoPlayer.podspec +27 -0
  99. package/packages/ios/UnifiedVideoPlayer.xcodeproj/project.pbxproj +385 -0
  100. package/packages/ios/build_framework.sh +55 -0
  101. package/packages/react-native/android/src/main/java/com/unifiedvideo/UnifiedVideoPlayerModule.kt +482 -0
  102. package/packages/react-native/ios/UnifiedVideoPlayer.swift +436 -0
  103. package/packages/react-native/package.json +51 -0
  104. package/packages/react-native/src/ReactNativePlayer.tsx +423 -0
  105. package/packages/react-native/src/VideoPlayer.tsx +224 -0
  106. package/packages/react-native/src/index.ts +28 -0
  107. package/packages/react-native/src/utils/EventEmitter.ts +66 -0
  108. package/packages/react-native/tsconfig.json +31 -0
  109. package/packages/roku/components/UnifiedVideoPlayer.brs +400 -0
  110. package/packages/roku/package.json +44 -0
  111. package/packages/roku/source/VideoPlayer.brs +231 -0
  112. package/packages/roku/source/main.brs +28 -0
  113. package/packages/web/GETTING_STARTED.md +292 -0
  114. package/packages/web/jest.config.js +28 -0
  115. package/packages/web/jest.setup.ts +110 -0
  116. package/packages/web/package.json +50 -0
  117. package/packages/web/src/SecureVideoPlayer.ts +1164 -0
  118. package/packages/web/src/WebPlayer.ts +3110 -0
  119. package/packages/web/src/__tests__/WebPlayer.test.ts +314 -0
  120. package/packages/web/src/index.ts +14 -0
  121. package/packages/web/src/paywall/PaywallController.ts +215 -0
  122. package/packages/web/src/react/WebPlayerView.tsx +177 -0
  123. package/packages/web/tsconfig.json +23 -0
  124. package/packages/web/webpack.config.js +45 -0
  125. package/server.js +131 -0
  126. package/server.py +84 -0
  127. package/test-urls.ps1 +97 -0
  128. package/test-video-urls.ps1 +87 -0
  129. package/tsconfig.json +39 -0
@@ -0,0 +1,499 @@
1
+ # Paywall Rental Flow (Stripe, Pesapal, Optional Google Pay)
2
+
3
+ This document describes a complete, production-ready flow to integrate a per-video rental paywall with Stripe, Pesapal, and optionally direct Google Pay. The video player remains payment‑agnostic; your app displays an overlay and unlocks playback after entitlement is confirmed by the backend.
4
+
5
+
6
+ ## Step 0) Decisions and prerequisites
7
+
8
+ - Payments:
9
+ - Stripe (recommended; supports Apple Pay/Google Pay through Payment Request Button or Payment Element).
10
+ - Pesapal (regional support; requires IPN handling and server confirmation).
11
+ - Optional: Direct Google Pay only if not using Stripe for it.
12
+ - Entitlements: Per‑video rental with a time window (e.g., 48 hours).
13
+ - Keep player internals clean: implement paywall as an overlay in your app and unlock playback when entitled.
14
+
15
+
16
+ ## Step 1) Database schema (Payments + Rentals)
17
+
18
+ Two tables: `payments`, `entitlements` (type='rental'). Example (PostgreSQL):
19
+
20
+ ```sql
21
+ -- Payments
22
+ CREATE TABLE payments (
23
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
24
+ gateway TEXT NOT NULL CHECK (gateway IN ('stripe','pesapal','google_pay')),
25
+ gateway_ref TEXT NOT NULL UNIQUE, -- e.g. Stripe session.id or PI id, Pesapal orderTrackingId
26
+ amount_cents INT NOT NULL,
27
+ currency TEXT NOT NULL,
28
+ status TEXT NOT NULL CHECK (status IN ('succeeded','pending','failed','refunded')),
29
+ raw_payload JSONB NOT NULL,
30
+ user_id TEXT NOT NULL,
31
+ video_id TEXT NOT NULL,
32
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
33
+ );
34
+
35
+ CREATE INDEX idx_payments_user_video ON payments(user_id, video_id);
36
+
37
+ -- Entitlements (rentals only)
38
+ CREATE TABLE entitlements (
39
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
40
+ user_id TEXT NOT NULL,
41
+ video_id TEXT NOT NULL,
42
+ type TEXT NOT NULL CHECK (type = 'rental'),
43
+ starts_at TIMESTAMPTZ NOT NULL,
44
+ expires_at TIMESTAMPTZ NOT NULL,
45
+ status TEXT NOT NULL CHECK (status IN ('active','expired')),
46
+ source_payment_id UUID NOT NULL REFERENCES payments(id) ON DELETE RESTRICT,
47
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
48
+ UNIQUE (user_id, video_id, status) -- at most one active rental
49
+ );
50
+
51
+ CREATE INDEX idx_entitlements_user_video ON entitlements(user_id, video_id);
52
+ ```
53
+
54
+ In your admin “Products/Videos” table, store: `video_id`, `title`, `price_cents`, `currency`, `rental_duration_hours`.
55
+
56
+
57
+ ## Step 2) Backend project structure (Node + Express + TypeScript example)
58
+
59
+ - Directory layout:
60
+ - `src/server.ts`: Express app setup
61
+ - `src/config.ts`: Env loading
62
+ - `src/db.ts`: DB client
63
+ - `src/routes/rentals.ts`: REST endpoints (entitlement checks, Stripe/Pesapal session creation)
64
+ - `src/routes/webhooks.ts`: Stripe webhook
65
+ - `src/routes/pesapal.ts`: Pesapal order + IPN
66
+ - `src/services/payments.ts`: Payment creation/lookup helpers
67
+ - `src/services/entitlements.ts`: Entitlement issue/lookup helpers
68
+ - Environment variables (do not log them):
69
+ - `STRIPE_SECRET_KEY`
70
+ - `STRIPE_WEBHOOK_SECRET`
71
+ - `PESAPAL_CONSUMER_KEY`, `PESAPAL_CONSUMER_SECRET`, `PESAPAL_BASE_URL` (sandbox/production)
72
+ - `APP_BASE_URL`
73
+ - `DATABASE_URL`
74
+ - Install packages:
75
+ - `stripe`, `express`, `body-parser`, `pg` (or your DB lib), `axios` (Pesapal), `dotenv`, `crypto`
76
+
77
+
78
+ ## Step 3) Config and app bootstrap
79
+
80
+ `src/config.ts`
81
+ ```ts
82
+ import 'dotenv/config';
83
+
84
+ export const config = {
85
+ stripeSecretKey: process.env.STRIPE_SECRET_KEY!,
86
+ stripeWebhookSecret: process.env.STRIPE_WEBHOOK_SECRET!,
87
+ pesapal: {
88
+ consumerKey: process.env.PESAPAL_CONSUMER_KEY!,
89
+ consumerSecret: process.env.PESAPAL_CONSUMER_SECRET!,
90
+ baseUrl: process.env.PESAPAL_BASE_URL || 'https://pay.pesapal.com', // or sandbox
91
+ },
92
+ appBaseUrl: process.env.APP_BASE_URL || 'http://localhost:3000',
93
+ dbUrl: process.env.DATABASE_URL!,
94
+ };
95
+ ```
96
+
97
+ `src/server.ts`
98
+ ```ts
99
+ import express from 'express';
100
+ import bodyParser from 'body-parser';
101
+ import { rentalsRouter } from './routes/rentals';
102
+ import { stripeWebhookRouter } from './routes/webhooks';
103
+ import { pesapalRouter } from './routes/pesapal';
104
+
105
+ const app = express();
106
+
107
+ // Stripe webhook needs raw body for signature verification
108
+ app.use('/api/webhooks/stripe', bodyParser.raw({ type: 'application/json' }));
109
+ // Other routes use JSON
110
+ app.use(bodyParser.json());
111
+
112
+ app.use('/api/rentals', rentalsRouter);
113
+ app.use('/api/webhooks', stripeWebhookRouter);
114
+ app.use('/api/ipn', pesapalRouter);
115
+
116
+ app.listen(3000, () => console.log('API on :3000'));
117
+ ```
118
+
119
+
120
+ ## Step 4) Core services (payments, entitlements)
121
+
122
+ `src/services/entitlements.ts`
123
+ ```ts
124
+ import { db } from '../db';
125
+
126
+ export async function getEntitlement(userId: string, videoId: string) {
127
+ const { rows } = await db.query(
128
+ `SELECT * FROM entitlements
129
+ WHERE user_id=$1 AND video_id=$2
130
+ ORDER BY created_at DESC LIMIT 1`,
131
+ [userId, videoId]
132
+ );
133
+ if (!rows[0]) return { entitled: false as const };
134
+ const e = rows[0];
135
+ const now = new Date();
136
+ const entitled = new Date(e.expires_at) > now && e.status === 'active';
137
+ return { entitled, expiresAt: e.expires_at };
138
+ }
139
+
140
+ export async function issueRentalEntitlement(args: {
141
+ userId: string; videoId: string; paymentId: string; rentalDurationHours: number;
142
+ }) {
143
+ const startsAt = new Date();
144
+ const expiresAt = new Date(startsAt.getTime() + args.rentalDurationHours * 3600 * 1000);
145
+
146
+ // Expire previous active entitlements that are no longer valid (optional guard)
147
+ await db.query(
148
+ `UPDATE entitlements SET status='expired'
149
+ WHERE user_id=$1 AND video_id=$2 AND status='active' AND expires_at <= NOW()`,
150
+ [args.userId, args.videoId]
151
+ );
152
+
153
+ const { rows } = await db.query(
154
+ `INSERT INTO entitlements (user_id, video_id, type, starts_at, expires_at, status, source_payment_id)
155
+ VALUES ($1,$2,'rental',$3,$4,'active',$5)
156
+ RETURNING *`,
157
+ [args.userId, args.videoId, startsAt.toISOString(), expiresAt.toISOString(), args.paymentId]
158
+ );
159
+ return rows[0];
160
+ }
161
+ ```
162
+
163
+ `src/services/payments.ts`
164
+ ```ts
165
+ import { db } from '../db';
166
+
167
+ export async function upsertPayment(args: {
168
+ gateway: 'stripe'|'pesapal'|'google_pay',
169
+ gatewayRef: string,
170
+ amountCents: number,
171
+ currency: string,
172
+ status: 'succeeded'|'pending'|'failed'|'refunded',
173
+ rawPayload: any,
174
+ userId: string,
175
+ videoId: string
176
+ }) {
177
+ const { rows } = await db.query(
178
+ `INSERT INTO payments (gateway,gateway_ref,amount_cents,currency,status,raw_payload,user_id,video_id)
179
+ VALUES ($1,$2,$3,$4,$5,$6,$7,$8)
180
+ ON CONFLICT (gateway_ref) DO UPDATE SET status=EXCLUDED.status, raw_payload=EXCLUDED.raw_payload
181
+ RETURNING *`,
182
+ [args.gateway, args.gatewayRef, args.amountCents, args.currency, args.status, args.rawPayload, args.userId, args.videoId]
183
+ );
184
+ return rows[0];
185
+ }
186
+ ```
187
+
188
+
189
+ ## Step 5) Entitlement and Stripe endpoints
190
+
191
+ `src/routes/rentals.ts`
192
+ ```ts
193
+ import { Router } from 'express';
194
+ import Stripe from 'stripe';
195
+ import { config } from '../config';
196
+ import { getEntitlement } from '../services/entitlements';
197
+ import { db } from '../db';
198
+
199
+ export const rentalsRouter = Router();
200
+ const stripe = new Stripe(config.stripeSecretKey, { apiVersion: '2024-06-20' });
201
+
202
+ // GET /api/rentals/entitlement?userId=&videoId=
203
+ rentalsRouter.get('/entitlement', async (req, res) => {
204
+ const { userId, videoId } = req.query as any;
205
+ if (!userId || !videoId) return res.status(400).json({ error: 'userId and videoId required' });
206
+ const out = await getEntitlement(userId, videoId);
207
+ return res.json(out);
208
+ });
209
+
210
+ // POST /api/rentals/stripe/checkout-session
211
+ // Body: { userId, videoId, successUrl, cancelUrl }
212
+ rentalsRouter.post('/stripe/checkout-session', async (req, res) => {
213
+ const { userId, videoId, successUrl, cancelUrl } = req.body || {};
214
+ if (!userId || !videoId || !successUrl || !cancelUrl) {
215
+ return res.status(400).json({ error: 'userId, videoId, successUrl, cancelUrl required' });
216
+ }
217
+
218
+ // Fetch product/pricing from your DB
219
+ const { rows } = await db.query(
220
+ `SELECT price_cents, currency, rental_duration_hours, title
221
+ FROM videos WHERE video_id=$1`,
222
+ [videoId]
223
+ );
224
+ if (!rows[0]) return res.status(404).json({ error: 'Video not found' });
225
+ const { price_cents, currency, rental_duration_hours, title } = rows[0];
226
+
227
+ const session = await stripe.checkout.sessions.create({
228
+ mode: 'payment',
229
+ payment_method_types: ['card'],
230
+ line_items: [{
231
+ price_data: {
232
+ currency: currency || 'usd',
233
+ unit_amount: price_cents,
234
+ product_data: { name: `Rent: ${title || videoId}` }
235
+ },
236
+ quantity: 1
237
+ }],
238
+ success_url: successUrl,
239
+ cancel_url: cancelUrl,
240
+ metadata: {
241
+ userId, videoId, rentalDurationHours: String(rental_duration_hours || 48)
242
+ }
243
+ });
244
+
245
+ // Return url or id for redirect
246
+ return res.json({ url: session.url, id: session.id });
247
+ });
248
+ ```
249
+
250
+
251
+ ## Step 6) Stripe webhook: grant rental on success
252
+
253
+ `src/routes/webhooks.ts`
254
+ ```ts
255
+ import { Router } from 'express';
256
+ import Stripe from 'stripe';
257
+ import { config } from '../config';
258
+ import { upsertPayment } from '../services/payments';
259
+ import { issueRentalEntitlement } from '../services/entitlements';
260
+
261
+ export const stripeWebhookRouter = Router();
262
+ const stripe = new Stripe(config.stripeSecretKey, { apiVersion: '2024-06-20' });
263
+
264
+ // Stripe requires raw body; configured in server.ts
265
+ stripeWebhookRouter.post('/stripe', async (req, res) => {
266
+ const sig = req.headers['stripe-signature'];
267
+ if (!sig) return res.status(400).send('Missing signature');
268
+
269
+ let event: Stripe.Event;
270
+ try {
271
+ event = stripe.webhooks.constructEvent(req.body, sig, config.stripeWebhookSecret);
272
+ } catch (err) {
273
+ return res.status(400).send(`Webhook signature verification failed`);
274
+ }
275
+
276
+ if (event.type === 'checkout.session.completed') {
277
+ const session = event.data.object as Stripe.Checkout.Session;
278
+
279
+ const amountCents = session.amount_total ?? 0;
280
+ const currency = session.currency?.toUpperCase() || 'USD';
281
+ const userId = session.metadata?.userId || '';
282
+ const videoId = session.metadata?.videoId || '';
283
+ const rentalDurationHours = Number(session.metadata?.rentalDurationHours || 48);
284
+
285
+ // Store payment (idempotent by session.id)
286
+ const payment = await upsertPayment({
287
+ gateway: 'stripe',
288
+ gatewayRef: session.id,
289
+ amountCents,
290
+ currency,
291
+ status: 'succeeded',
292
+ rawPayload: session,
293
+ userId,
294
+ videoId
295
+ });
296
+
297
+ // Issue rental entitlement
298
+ await issueRentalEntitlement({
299
+ userId, videoId, paymentId: payment.id, rentalDurationHours
300
+ });
301
+ }
302
+
303
+ // TODO: handle refunds/chargebacks -> expire entitlement
304
+ res.status(200).send('ok');
305
+ });
306
+ ```
307
+
308
+
309
+ ## Step 7) Pesapal integration: order + IPN + confirm
310
+
311
+ Create order and redirect (or embed in iframe). Confirm status in IPN before granting rental.
312
+
313
+ `src/routes/pesapal.ts`
314
+ ```ts
315
+ import { Router } from 'express';
316
+ import axios from 'axios';
317
+ import { config } from '../config';
318
+ import { db } from '../db';
319
+ import { upsertPayment } from '../services/payments';
320
+ import { issueRentalEntitlement } from '../services/entitlements';
321
+ import { randomUUID } from 'crypto';
322
+
323
+ export const pesapalRouter = Router();
324
+
325
+ // POST /api/rentals/pesapal/order { userId, videoId, returnUrl }
326
+ pesapalRouter.post('/pesapal/order', async (req, res) => {
327
+ const { userId, videoId, returnUrl } = req.body || {};
328
+ if (!userId || !videoId || !returnUrl) return res.status(400).json({ error: 'userId, videoId, returnUrl required' });
329
+
330
+ const token = await pesapalAuthToken();
331
+
332
+ const { rows } = await db.query(
333
+ `SELECT price_cents, currency, rental_duration_hours, title
334
+ FROM videos WHERE video_id=$1`, [videoId]);
335
+ if (!rows[0]) return res.status(404).json({ error: 'Video not found' });
336
+
337
+ const { price_cents, currency, rental_duration_hours, title } = rows[0];
338
+
339
+ const orderReq = {
340
+ id: randomUUID(),
341
+ currency: (currency || 'USD').toUpperCase(),
342
+ amount: (price_cents / 100).toFixed(2),
343
+ description: `Rent: ${title || videoId}`,
344
+ callback_url: `${config.appBaseUrl}/api/ipn/pesapal/callback`,
345
+ notification_id: 'your-pesapal-ipn-ref', // configured in Pesapal portal
346
+ billing_address: { email_address: 'customer@example.com' }
347
+ };
348
+
349
+ const submit = await axios.post(`${config.pesapal.baseUrl}/api/Transactions/SubmitOrderRequest`,
350
+ orderReq, { headers: { Authorization: `Bearer ${token}` } });
351
+
352
+ // Returns redirect_url and order_tracking_id
353
+ return res.json({ redirectUrl: submit.data.redirect_url, orderTrackingId: submit.data.order_tracking_id });
354
+ });
355
+
356
+ async function pesapalAuthToken(): Promise<string> {
357
+ const res = await axios.post(`${config.pesapal.baseUrl}/api/Auth/RequestToken`, {
358
+ consumer_key: config.pesapal.consumerKey,
359
+ consumer_secret: config.pesapal.consumerSecret
360
+ });
361
+ return res.data.token;
362
+ }
363
+
364
+ // Pesapal IPN target: POST /api/ipn/pesapal/callback
365
+ pesapalRouter.post('/pesapal/callback', async (req, res) => {
366
+ // Read orderTrackingId from request (adapt to Pesapal IPN payload format)
367
+ const { order_tracking_id, userId, videoId, rentalDurationHours } = req.body || {}; // align based on your mapping
368
+ if (!order_tracking_id || !userId || !videoId) return res.status(400).send('Bad IPN payload');
369
+
370
+ // Confirm status with Pesapal before granting
371
+ const token = await pesapalAuthToken();
372
+ const statusRes = await axios.get(
373
+ `${config.pesapal.baseUrl}/api/Transactions/GetTransactionStatus?orderTrackingId=${order_tracking_id}`,
374
+ { headers: { Authorization: `Bearer ${token}` } }
375
+ );
376
+ const status = statusRes.data; // inspect fields for success
377
+
378
+ if (status.payment_status_code === 'COMPLETED' || status.status === 'COMPLETED') {
379
+ const amountCents = Math.round(Number(status.amount) * 100);
380
+ const currency = (status.currency || 'USD').toUpperCase();
381
+
382
+ const payment = await upsertPayment({
383
+ gateway: 'pesapal',
384
+ gatewayRef: order_tracking_id,
385
+ amountCents,
386
+ currency,
387
+ status: 'succeeded',
388
+ rawPayload: status,
389
+ userId,
390
+ videoId
391
+ });
392
+
393
+ await issueRentalEntitlement({
394
+ userId, videoId, paymentId: payment.id, rentalDurationHours: Number(rentalDurationHours || 48)
395
+ });
396
+ }
397
+
398
+ res.status(200).send('ok');
399
+ });
400
+ ```
401
+
402
+ Notes:
403
+ - Align IPN payload mapping (`userId`, `videoId`) by storing them server‑side with `orderTrackingId`, or encode them in a merchant reference you can look up.
404
+ - Use sandbox endpoints until production‑ready.
405
+
406
+
407
+ ## Step 8) Frontend integration (overlay + polling)
408
+
409
+ Show overlay if not entitled, and provide buttons to initiate payments. After user completes payment (redirect back or completes in modal), poll entitlement every 2–5 seconds for up to 2 minutes, then unlock playback.
410
+
411
+ ```ts
412
+ // pseudo-frontend integration
413
+ async function initVideoPage({ userId, videoId }) {
414
+ const ent = await fetchJSON(`/api/rentals/entitlement?userId=${userId}&videoId=${videoId}`);
415
+ await player.initialize(document.getElementById('player'), { autoPlay: ent.entitled });
416
+
417
+ if (!ent.entitled) showPaywall();
418
+
419
+ // Stripe flow
420
+ document.getElementById('btnStripe').onclick = async () => {
421
+ const { url } = await postJSON('/api/rentals/stripe/checkout-session', {
422
+ userId, videoId,
423
+ successUrl: window.location.href,
424
+ cancelUrl: window.location.href
425
+ });
426
+ window.location.href = url;
427
+ };
428
+
429
+ // Pesapal flow
430
+ document.getElementById('btnPesapal').onclick = async () => {
431
+ const { redirectUrl } = await postJSON('/api/rentals/pesapal/order', {
432
+ userId, videoId, returnUrl: window.location.href
433
+ });
434
+ window.open(redirectUrl, '_blank'); // or iframe modal
435
+ await pollEntitlement(userId, videoId);
436
+ };
437
+
438
+ // After returning from Stripe success
439
+ if (new URLSearchParams(location.search).get('paid') === '1') {
440
+ await pollEntitlement(userId, videoId);
441
+ }
442
+ }
443
+
444
+ async function pollEntitlement(userId, videoId) {
445
+ const start = Date.now();
446
+ while (Date.now() - start < 120000) {
447
+ const ent = await fetchJSON(`/api/rentals/entitlement?userId=${userId}&videoId=${videoId}`);
448
+ if (ent.entitled) {
449
+ hidePaywall();
450
+ await player.play();
451
+ return true;
452
+ }
453
+ await delay(2000);
454
+ }
455
+ alert('Payment is processing; please try again shortly.');
456
+ return false;
457
+ }
458
+ ```
459
+
460
+
461
+ ## Step 9) Recommended: Stripe Payment Request Button (Google Pay via Stripe)
462
+
463
+ - Instead of separate Google Pay code, enable Google Pay in Stripe and render the Payment Request Button inside your Paywall modal for a one‑click wallet experience. This keeps your flow unified and your backend unchanged (still using Stripe webhooks).
464
+ - If you must do direct Google Pay:
465
+ - Use Google Pay API client‑side to obtain a token.
466
+ - POST it to a server endpoint (`/api/rentals/googlepay/charge`) where you charge via your PSP.
467
+ - On success, upsert Payment and issue entitlement (same as other flows).
468
+
469
+
470
+ ## Step 10) Security, idempotency, and refunds
471
+
472
+ - Only grant entitlements from webhooks/IPN after server‑side verification.
473
+ - Use idempotency by `gateway_ref` so retries don’t duplicate records.
474
+ - On refunds/chargebacks webhooks/IPN, expire rentals (e.g., set `status='expired'` or adjust `expires_at` to `NOW()`).
475
+ - Use server time (UTC) for entitlement. Return ISO 8601 to clients.
476
+
477
+
478
+ ## Step 11) Testing checklist
479
+
480
+ - Stripe:
481
+ - Create Checkout Session, complete test payment, verify webhook creates Payment + Entitlement.
482
+ - Verify entitlement query returns true; player plays.
483
+ - Test `?paid=1` redirect path and polling.
484
+ - Pesapal (sandbox):
485
+ - Create order, complete test payment, verify IPN hits your endpoint.
486
+ - Confirm status retrieval and entitlement issuance.
487
+ - Expiry:
488
+ - Set `rentalDurationHours=0.01` in dev to test expiry logic. Ensure entitlement flips and overlay reappears.
489
+
490
+
491
+ ## Next steps / What can be prepared
492
+
493
+ - A ready‑to‑run Express starter with the above routes wired.
494
+ - SQL migrations for Postgres.
495
+ - Minimal PaywallModal HTML/CSS/JS for your demo page.
496
+ - Stripe Payment Element/Payment Request Button example to support card + wallets inline.
497
+
498
+ If you confirm your backend stack (Node/Express/TS or something else) and DB (Postgres/MySQL), this template can be tailored and delivered as a drop‑in starter.
499
+