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.
- package/.github/workflows/ci.yml +253 -0
- package/ANDROID_TV_IMPLEMENTATION.md +313 -0
- package/COMPLETION_STATUS.md +165 -0
- package/CONTRIBUTING.md +376 -0
- package/FINAL_STATUS_REPORT.md +170 -0
- package/FRAMEWORK_REVIEW.md +247 -0
- package/IMPROVEMENTS_SUMMARY.md +168 -0
- package/LICENSE +21 -0
- package/NATIVE_APP_INTEGRATION_GUIDE.md +903 -0
- package/PAYWALL_RENTAL_FLOW.md +499 -0
- package/PLATFORM_SETUP_GUIDE.md +1636 -0
- package/README.md +315 -0
- package/RUN_LOCALLY.md +151 -0
- package/apps/demo/cast-sender-min.html +173 -0
- package/apps/demo/custom-player.html +883 -0
- package/apps/demo/demo.html +990 -0
- package/apps/demo/enhanced-player.html +3556 -0
- package/apps/demo/index.html +159 -0
- package/apps/rental-api/.env.example +24 -0
- package/apps/rental-api/README.md +23 -0
- package/apps/rental-api/migrations/001_init.sql +35 -0
- package/apps/rental-api/migrations/002_videos.sql +10 -0
- package/apps/rental-api/migrations/003_add_gateway_subref.sql +4 -0
- package/apps/rental-api/migrations/004_update_gateways.sql +4 -0
- package/apps/rental-api/migrations/005_seed_demo_video.sql +5 -0
- package/apps/rental-api/package-lock.json +2045 -0
- package/apps/rental-api/package.json +33 -0
- package/apps/rental-api/scripts/run-migration.js +42 -0
- package/apps/rental-api/scripts/update-video-currency.js +21 -0
- package/apps/rental-api/scripts/update-video-price.js +19 -0
- package/apps/rental-api/src/config.ts +14 -0
- package/apps/rental-api/src/db.ts +10 -0
- package/apps/rental-api/src/routes/cashfree.ts +167 -0
- package/apps/rental-api/src/routes/pesapal.ts +92 -0
- package/apps/rental-api/src/routes/rentals.ts +242 -0
- package/apps/rental-api/src/routes/webhooks.ts +73 -0
- package/apps/rental-api/src/server.ts +41 -0
- package/apps/rental-api/src/services/entitlements.ts +45 -0
- package/apps/rental-api/src/services/payments.ts +22 -0
- package/apps/rental-api/tsconfig.json +17 -0
- package/check-urls.ps1 +74 -0
- package/comparison-report.md +181 -0
- package/docs/PAYWALL.md +95 -0
- package/docs/PLAYER_UI_VISIBILITY.md +431 -0
- package/docs/README.md +7 -0
- package/docs/SYSTEM_ARCHITECTURE.md +612 -0
- package/docs/VDOCIPHER_CLONE_REQUIREMENTS.md +403 -0
- package/examples/android/JavaSampleApp/MainActivity.java +641 -0
- package/examples/android/JavaSampleApp/activity_main.xml +226 -0
- package/examples/android/SampleApp/MainActivity.kt +430 -0
- package/examples/ios/SampleApp/ViewController.swift +337 -0
- package/examples/ios/SwiftUISampleApp/ContentView.swift +304 -0
- package/iOS_IMPLEMENTATION_OPTIONS.md +470 -0
- package/ios/UnifiedVideoPlayer/UnifiedVideoPlayer.podspec +33 -0
- package/jest.config.js +33 -0
- package/jitpack.yml +5 -0
- package/lerna.json +35 -0
- package/package.json +69 -0
- package/packages/PLATFORM_STATUS.md +163 -0
- package/packages/android/build.gradle +135 -0
- package/packages/android/src/main/AndroidManifest.xml +36 -0
- package/packages/android/src/main/java/com/unifiedvideo/player/PlayerConfiguration.java +221 -0
- package/packages/android/src/main/java/com/unifiedvideo/player/UnifiedVideoPlayer.java +1037 -0
- package/packages/android/src/main/java/com/unifiedvideo/player/UnifiedVideoPlayer.kt +707 -0
- package/packages/android/src/main/java/com/unifiedvideo/player/analytics/AnalyticsProvider.java +9 -0
- package/packages/android/src/main/java/com/unifiedvideo/player/cast/CastManager.java +141 -0
- package/packages/android/src/main/java/com/unifiedvideo/player/cast/CastOptionsProvider.java +29 -0
- package/packages/android/src/main/java/com/unifiedvideo/player/overlay/WatermarkOverlayView.java +88 -0
- package/packages/android/src/main/java/com/unifiedvideo/player/pip/PipActionReceiver.java +33 -0
- package/packages/android/src/main/java/com/unifiedvideo/player/services/PlaybackService.java +110 -0
- package/packages/android/src/main/java/com/unifiedvideo/player/services/PlayerHolder.java +19 -0
- package/packages/core/package.json +34 -0
- package/packages/core/src/BasePlayer.ts +250 -0
- package/packages/core/src/VideoPlayer.ts +237 -0
- package/packages/core/src/VideoPlayerFactory.ts +145 -0
- package/packages/core/src/index.ts +20 -0
- package/packages/core/src/interfaces/IVideoPlayer.ts +184 -0
- package/packages/core/src/interfaces.ts +240 -0
- package/packages/core/src/utils/EventEmitter.ts +66 -0
- package/packages/core/src/utils/PlatformDetector.ts +300 -0
- package/packages/core/tsconfig.json +20 -0
- package/packages/enact/package.json +51 -0
- package/packages/enact/src/VideoPlayer.js +365 -0
- package/packages/enact/src/adapters/TizenAdapter.js +354 -0
- package/packages/enact/src/index.js +82 -0
- package/packages/ios/BUILD_INSTRUCTIONS.md +108 -0
- package/packages/ios/FIX_EMBED_ISSUE.md +142 -0
- package/packages/ios/GETTING_STARTED.md +100 -0
- package/packages/ios/Package.swift +35 -0
- package/packages/ios/README.md +84 -0
- package/packages/ios/Sources/UnifiedVideoPlayer/Analytics/AnalyticsEmitter.swift +26 -0
- package/packages/ios/Sources/UnifiedVideoPlayer/DRM/FairPlayDRMManager.swift +102 -0
- package/packages/ios/Sources/UnifiedVideoPlayer/Info.plist +24 -0
- package/packages/ios/Sources/UnifiedVideoPlayer/Remote/RemoteCommandCenter.swift +109 -0
- package/packages/ios/Sources/UnifiedVideoPlayer/UnifiedVideoPlayer.swift +811 -0
- package/packages/ios/Sources/UnifiedVideoPlayer/UnifiedVideoPlayerView.swift +640 -0
- package/packages/ios/Sources/UnifiedVideoPlayer/Utilities/Color+Hex.swift +36 -0
- package/packages/ios/UnifiedVideoPlayer.podspec +27 -0
- package/packages/ios/UnifiedVideoPlayer.xcodeproj/project.pbxproj +385 -0
- package/packages/ios/build_framework.sh +55 -0
- package/packages/react-native/android/src/main/java/com/unifiedvideo/UnifiedVideoPlayerModule.kt +482 -0
- package/packages/react-native/ios/UnifiedVideoPlayer.swift +436 -0
- package/packages/react-native/package.json +51 -0
- package/packages/react-native/src/ReactNativePlayer.tsx +423 -0
- package/packages/react-native/src/VideoPlayer.tsx +224 -0
- package/packages/react-native/src/index.ts +28 -0
- package/packages/react-native/src/utils/EventEmitter.ts +66 -0
- package/packages/react-native/tsconfig.json +31 -0
- package/packages/roku/components/UnifiedVideoPlayer.brs +400 -0
- package/packages/roku/package.json +44 -0
- package/packages/roku/source/VideoPlayer.brs +231 -0
- package/packages/roku/source/main.brs +28 -0
- package/packages/web/GETTING_STARTED.md +292 -0
- package/packages/web/jest.config.js +28 -0
- package/packages/web/jest.setup.ts +110 -0
- package/packages/web/package.json +50 -0
- package/packages/web/src/SecureVideoPlayer.ts +1164 -0
- package/packages/web/src/WebPlayer.ts +3110 -0
- package/packages/web/src/__tests__/WebPlayer.test.ts +314 -0
- package/packages/web/src/index.ts +14 -0
- package/packages/web/src/paywall/PaywallController.ts +215 -0
- package/packages/web/src/react/WebPlayerView.tsx +177 -0
- package/packages/web/tsconfig.json +23 -0
- package/packages/web/webpack.config.js +45 -0
- package/server.js +131 -0
- package/server.py +84 -0
- package/test-urls.ps1 +97 -0
- package/test-video-urls.ps1 +87 -0
- 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
|
+
|