@payvia-sdk/sdk 1.1.4

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 (3) hide show
  1. package/README.md +463 -0
  2. package/package.json +32 -0
  3. package/payvia.js +696 -0
package/README.md ADDED
@@ -0,0 +1,463 @@
1
+ # PayVia SDK
2
+
3
+ A lightweight JavaScript SDK for connecting your Chrome Extension / SaaS app to PayVia, for accepting PayPal payments and managing subscriptions, license validation, tier-based feature gating, trial management and monthly / yearly / lifetime plans.
4
+
5
+ ## Quick Start
6
+
7
+ The `sample-extension` comes pre-configured with a shared demo project and works out of the box:
8
+
9
+ 1. Open `chrome://extensions/`
10
+ 2. Enable "Developer mode"
11
+ 3. Click "Load unpacked" and select the `sample-extension` folder
12
+ 4. Click the extension icon — everything works!
13
+
14
+ > **Note:** The demo project is shared across all users and is meant for testing only.
15
+ > To accept real payments, create your own project at [PayVia Dashboard](https://payvia.site/dashboard).
16
+
17
+ ## Installation
18
+
19
+ ### Option 1: Copy directly
20
+
21
+ Copy `payvia.js` into your extension folder.
22
+
23
+ ### Option 2: npm
24
+
25
+ ```bash
26
+ npm install payvia-sdk
27
+ ```
28
+
29
+ ## Basic Usage
30
+
31
+ ### 1. Add to manifest.json
32
+
33
+ ```json
34
+ {
35
+ "manifest_version": 3,
36
+ "name": "Your Extension",
37
+ "permissions": ["storage"],
38
+ "host_permissions": [
39
+ "https://api.payvia.site/*",
40
+ "https://payvia.site/*"
41
+ ],
42
+ "background": {
43
+ "service_worker": "background.js"
44
+ }
45
+ }
46
+ ```
47
+
48
+ If using Google Identity for automatic user detection, also add:
49
+ ```json
50
+ {
51
+ "permissions": ["storage", "identity", "identity.email"]
52
+ }
53
+ ```
54
+
55
+ ### 2. Initialize in background.js
56
+
57
+ ```javascript
58
+ import PayVia from './payvia.js';
59
+
60
+ const payvia = PayVia('YOUR_API_KEY');
61
+
62
+ // Check if user has paid
63
+ chrome.runtime.onMessage.addListener((request, sender, sendResponse) => {
64
+ if (request.action === 'checkPaid') {
65
+ payvia.getUser().then(user => {
66
+ sendResponse({ paid: user.paid });
67
+ });
68
+ return true; // async response
69
+ }
70
+ });
71
+ ```
72
+
73
+ ### 3. Check payment status
74
+
75
+ ```javascript
76
+ const user = await payvia.getUser();
77
+
78
+ if (user.paid) {
79
+ enablePremiumFeatures();
80
+ } else {
81
+ showUpgradeButton();
82
+ }
83
+ ```
84
+
85
+ ### 4. Open payment page
86
+
87
+ ```javascript
88
+ // Option 1: Pricing page — shows all plans (recommended)
89
+ await payvia.openPaymentPage({ mode: 'pricing', email: userEmail });
90
+
91
+ // Option 2: Hosted checkout — specific plan
92
+ await payvia.openPaymentPage({ mode: 'hosted', planId: 'your-plan-id', email: userEmail });
93
+
94
+ // Option 3: Direct PayPal — no PayVia UI
95
+ await payvia.openPaymentPage({ mode: 'direct', planId: 'your-plan-id', email: userEmail });
96
+ ```
97
+
98
+ ### 5. Listen for payment changes
99
+
100
+ ```javascript
101
+ const unsubscribe = payvia.onPaid((user) => {
102
+ console.log('User just paid!', user);
103
+ enablePremiumFeatures();
104
+ });
105
+
106
+ // Call unsubscribe() to stop listening
107
+ ```
108
+
109
+ ---
110
+
111
+ ## Checkout Modes
112
+
113
+ | Mode | Description | When to use |
114
+ |------|-------------|-------------|
115
+ | `pricing` | PayVia plan selection page | **Default** — when you have multiple plans |
116
+ | `hosted` | PayVia checkout for a specific plan | When you have one plan or want to skip selection |
117
+ | `direct` | Straight to PayPal, no PayVia UI | For developers who want full UI control |
118
+
119
+ ---
120
+
121
+ ## User Identity
122
+
123
+ The SDK automatically identifies users:
124
+
125
+ 1. **Google Identity** — if the extension has the `identity` permission, uses the user's Google email
126
+ 2. **Random ID fallback** — generates a persistent `pv_`-prefixed UUID, synced via `chrome.storage.sync`
127
+
128
+ ```javascript
129
+ const identity = await payvia.getIdentity();
130
+ // { id: "user@gmail.com", email: "user@gmail.com", source: "google" }
131
+ // or: { id: "pv_abc123...", email: null, source: "random" }
132
+ ```
133
+
134
+ ---
135
+
136
+ ## Tier-Based Feature Gating
137
+
138
+ Tiers define feature sets (Free/Pro/Super). Plans are pricing options within tiers.
139
+
140
+ ```javascript
141
+ // Check tier level (0=Free, 1=Pro, 2=Super)
142
+ const hasPro = await payvia.hasTierLevel(1); // Pro or above
143
+
144
+ // Check specific feature
145
+ const canExport = await payvia.hasFeature('export_pdf');
146
+
147
+ // Get full tier info
148
+ const tier = await payvia.getTier();
149
+ // { id: "...", name: "Pro", level: 1, features: ["export_pdf", "api_access"] }
150
+
151
+ // Or via user object
152
+ const user = await payvia.getUser();
153
+ // user.tier — tier object
154
+ // user.features — shortcut to user.tier.features
155
+ ```
156
+
157
+ ---
158
+
159
+ ## Trial Support
160
+
161
+ ```javascript
162
+ // Start trial on first use (idempotent — safe to call multiple times)
163
+ if (await payvia.isFirstRun()) {
164
+ const trial = await payvia.startTrial();
165
+ if (trial) {
166
+ console.log(`Trial started! ${trial.daysRemaining} days remaining`);
167
+ // { subscriptionId, status, planId, planName, trialExpiresAt, daysRemaining }
168
+ }
169
+ await payvia.markFirstRunDone();
170
+ }
171
+
172
+ // Check trial status
173
+ const status = await payvia.getTrialStatus();
174
+ // { status, trialExpiresAt, daysRemaining, canConvert, planIds }
175
+ ```
176
+
177
+ Trial info is also available on the user object:
178
+
179
+ ```javascript
180
+ const user = await payvia.getUser();
181
+ // user.isTrial — boolean
182
+ // user.trialExpiresAt — Date or null
183
+ // user.daysRemaining — number or null
184
+ ```
185
+
186
+ ---
187
+
188
+ ## License Caching
189
+
190
+ The SDK caches license data for offline resilience. No setup required — it works automatically.
191
+
192
+ - **TTL**: 7 days (server-controlled)
193
+ - **Grace period**: 30 days (allows offline access after TTL expires)
194
+ - **Storage**: `chrome.storage.local` (extension) or `localStorage` (web)
195
+ - **Anti-tamper**: HMAC-SHA256 signature verification
196
+
197
+ ```javascript
198
+ // Refresh cache in background (e.g., service worker startup)
199
+ chrome.runtime.onStartup.addListener(async () => {
200
+ await payvia.refreshLicenseCache();
201
+ });
202
+
203
+ // Check if data came from cache
204
+ const user = await payvia.getUser();
205
+ if (user.fromCache) {
206
+ // Using cached data — works offline
207
+ }
208
+ ```
209
+
210
+ ---
211
+
212
+ ## API Reference
213
+
214
+ ### `PayVia(apiKey)`
215
+
216
+ Creates a new PayVia instance.
217
+
218
+ | Parameter | Type | Description |
219
+ |-----------|------|-------------|
220
+ | `apiKey` | string | API key from the PayVia dashboard |
221
+
222
+ ---
223
+
224
+ ### `payvia.getUser(options?)`
225
+
226
+ Returns the user's payment status and subscription info. Uses cache automatically.
227
+
228
+ **Options:** `{ forceRefresh: boolean }`
229
+
230
+ **Returns:** `Promise<PayViaUser>`
231
+
232
+ ```typescript
233
+ interface PayViaUser {
234
+ id: string; // Unique user identifier
235
+ email: string | null; // User email (if available)
236
+ identitySource: 'google' | 'random'; // Identity source
237
+ paid: boolean; // true if ACTIVE or TRIAL
238
+ status: 'ACTIVE' | 'TRIAL' | 'INACTIVE'; // Subscription status
239
+ tier: { // Current tier (or null)
240
+ id: string;
241
+ name: string;
242
+ level: number; // 0=Free, 1=Pro, 2=Super
243
+ features: string[];
244
+ } | null;
245
+ features: string[]; // Shortcut to tier.features
246
+ planIds: string[]; // Purchased plan IDs
247
+ isTrial: boolean; // Whether on trial
248
+ trialExpiresAt: Date | null; // Trial expiration date
249
+ daysRemaining: number | null; // Trial days remaining
250
+ fromCache: boolean; // true if data came from cache
251
+ checkedAt: number | null; // Unix timestamp (ms)
252
+ ttl: number | null; // Cache TTL in ms
253
+ signature: string | null; // HMAC anti-tamper signature
254
+ }
255
+ ```
256
+
257
+ ---
258
+
259
+ ### `payvia.refresh()`
260
+
261
+ Force-refreshes user status from the server (bypasses cache).
262
+
263
+ **Returns:** `Promise<PayViaUser>`
264
+
265
+ ---
266
+
267
+ ### `payvia.refreshLicenseCache()`
268
+
269
+ Refreshes the license cache if expired. Ideal for background service worker startup.
270
+
271
+ **Returns:** `Promise<void>`
272
+
273
+ ---
274
+
275
+ ### `payvia.openPaymentPage(options)`
276
+
277
+ Opens the payment page in a new tab.
278
+
279
+ | Option | Type | Required | Description |
280
+ |--------|------|----------|-------------|
281
+ | `mode` | `'pricing' \| 'hosted' \| 'direct'` | No | Checkout mode (default: `pricing`) |
282
+ | `planId` | string | For hosted/direct | Plan ID to purchase |
283
+ | `email` | string | No | Customer email |
284
+ | `successUrl` | string | For direct | Redirect URL after successful payment |
285
+ | `cancelUrl` | string | For direct | Redirect URL if user cancels |
286
+
287
+ **Returns:** `Promise<{ mode, pricingUrl? | checkoutUrl? }>`
288
+
289
+ ---
290
+
291
+ ### `payvia.onPaid(callback)`
292
+
293
+ Listens for payment status changes (polls every 5 seconds).
294
+
295
+ **Returns:** `Function` — call it to stop listening
296
+
297
+ ---
298
+
299
+ ### `payvia.hasFeature(name)`
300
+
301
+ Checks if the user's tier includes a specific feature.
302
+
303
+ **Returns:** `Promise<boolean>`
304
+
305
+ ---
306
+
307
+ ### `payvia.hasTierLevel(level)`
308
+
309
+ Checks if the user's tier is at or above the required level.
310
+
311
+ **Returns:** `Promise<boolean>`
312
+
313
+ ---
314
+
315
+ ### `payvia.getTier()`
316
+
317
+ Returns the user's current tier info.
318
+
319
+ **Returns:** `Promise<{ id, name, level, features } | null>`
320
+
321
+ ---
322
+
323
+ ### `payvia.getIdentity()`
324
+
325
+ Returns the current user identity.
326
+
327
+ **Returns:** `Promise<{ id: string, email: string | null, source: 'google' | 'random' }>`
328
+
329
+ ---
330
+
331
+ ### `payvia.needsEmailForPayment()`
332
+
333
+ Checks if an email prompt is needed (user has no Google identity).
334
+
335
+ **Returns:** `Promise<boolean>`
336
+
337
+ ---
338
+
339
+ ### `payvia.getPlans()`
340
+
341
+ Returns available plans for the project.
342
+
343
+ **Returns:** `Promise<Plan[]>`
344
+
345
+ ---
346
+
347
+ ### `payvia.startTrial()`
348
+
349
+ Starts a trial for the current user. Idempotent — safe to call multiple times.
350
+
351
+ **Returns:** `Promise<{ subscriptionId, status, planId, planName, trialExpiresAt, daysRemaining } | null>`
352
+
353
+ ---
354
+
355
+ ### `payvia.getTrialStatus()`
356
+
357
+ Returns the trial status for the current user.
358
+
359
+ **Returns:** `Promise<{ status, trialExpiresAt, daysRemaining, canConvert, planIds }>`
360
+
361
+ ---
362
+
363
+ ### `payvia.isFirstRun()`
364
+
365
+ Checks if the extension is being used for the first time.
366
+
367
+ **Returns:** `Promise<boolean>`
368
+
369
+ ---
370
+
371
+ ### `payvia.markFirstRunDone()`
372
+
373
+ Marks the first run as complete.
374
+
375
+ ---
376
+
377
+ ### `payvia.cancelSubscription(options?)`
378
+
379
+ Cancels the user's subscription.
380
+
381
+ | Option | Type | Required | Description |
382
+ |--------|------|----------|-------------|
383
+ | `planId` | string | No | Specific plan to cancel |
384
+ | `reason` | string | No | Cancellation reason |
385
+
386
+ **Returns:** `Promise<{ success, message, canceledPlanId }>`
387
+
388
+ ---
389
+
390
+ ### `payvia.resetLicense()`
391
+
392
+ Resets the user's license (for testing/demo purposes). Deletes all subscriptions for the current user.
393
+
394
+ **Returns:** `Promise<{ message }>`
395
+
396
+ ---
397
+
398
+ ## Plan Description Format
399
+
400
+ In the PayVia Dashboard, you can write plan descriptions with feature lists:
401
+
402
+ ```
403
+ Everything in Free, plus:
404
+ +++Unlimited access
405
+ +++Priority support
406
+ +++Custom features
407
+ Contact us for enterprise plans
408
+ ```
409
+
410
+ Lines starting with `+++` are displayed with a green checkmark on checkout pages.
411
+
412
+ ---
413
+
414
+ ## Sample Extension
415
+
416
+ See the `sample-extension/` folder for a working example that demonstrates:
417
+
418
+ 1. **Payment status check** — automatic user detection
419
+ 2. **Three checkout modes** — Pricing Page, Hosted Checkout, Direct PayPal
420
+ 3. **Feature-per-Plan** — each plan unlocks different features
421
+ 4. **Smart identity** — Google Identity and Random ID support
422
+ 5. **Reset demo** — button to reset license for repeated testing
423
+
424
+ ### Running the sample:
425
+
426
+ 1. Open `chrome://extensions/`
427
+ 2. Enable Developer mode
428
+ 3. Click "Load unpacked"
429
+ 4. Select the `sdk/sample-extension/` folder
430
+ 5. Click the extension icon
431
+
432
+ ---
433
+
434
+ ## FAQ
435
+
436
+ ### How is the user identified?
437
+
438
+ PayVia uses `chrome.storage.sync` to create a unique ID that persists even if the user uninstalls the extension. If the user is signed into Chrome and you have the `identity` permission, identification uses their Google email instead.
439
+
440
+ ### What happens offline?
441
+
442
+ The SDK uses cached license data with a 7-day TTL and 30-day grace period. During network outages within the grace period, the cached status is returned. If no valid cache exists, `getUser()` returns `{ paid: false, status: 'INACTIVE' }`.
443
+
444
+ ### What's the difference between checkout modes?
445
+
446
+ - **pricing** — user sees all plans and picks one
447
+ - **hosted** — user sees a styled checkout page for a specific plan
448
+ - **direct** — user is sent straight to PayPal with no PayVia UI
449
+
450
+ ### How do I test in development?
451
+
452
+ Use PayPal Sandbox credentials and PayPal Sandbox accounts.
453
+
454
+ ---
455
+
456
+ ## Resources
457
+
458
+ - **Dashboard**: https://payvia.site
459
+ - **API Base URL**: https://api.payvia.site
460
+ - **Sample Extension**: `sample-extension/` in this repo
461
+ - **Sample SaaS App**: `sample-saas/` in this repo
462
+ - **MCP Server** (for AI agents): See [`mcp-server/`](mcp-server/) in this repo
463
+ - **AI Agent Skill**: See [`skill.md`](skill.md) in this repo
package/package.json ADDED
@@ -0,0 +1,32 @@
1
+ {
2
+ "name": "@payvia-sdk/sdk",
3
+ "version": "1.1.4",
4
+ "description": "A lightweight JavaScript SDK for connecting your Chrome Extension / SaaS app to PayVia, for accepting PayPal payments and managing subscriptions, license validation, tier-based feature gating, trial management and monthly / yearly / lifetime plans",
5
+ "main": "payvia.js",
6
+ "type": "module",
7
+ "exports": {
8
+ ".": "./payvia.js"
9
+ },
10
+ "files": [
11
+ "payvia.js",
12
+ "README.md"
13
+ ],
14
+ "keywords": [
15
+ "paypal",
16
+ "payments",
17
+ "chrome-extension",
18
+ "saas",
19
+ "subscriptions",
20
+ "monetization"
21
+ ],
22
+ "author": "PayVia",
23
+ "license": "MIT",
24
+ "homepage": "https://payvia.site",
25
+ "repository": {
26
+ "type": "git",
27
+ "url": "https://github.com/Asia-Digital/PayVia-SDK.git"
28
+ },
29
+ "bugs": {
30
+ "url": "https://github.com/Asia-Digital/PayVia-SDK/issues"
31
+ }
32
+ }
package/payvia.js ADDED
@@ -0,0 +1,696 @@
1
+ /**
2
+ * PayVia SDK for Chrome Extensions
3
+ *
4
+ * ספריית JavaScript שהסולק מטמיע בתוסף הכרום שלו
5
+ * מאפשרת בדיקת רישיון, פתיחת חלון תשלום, וניהול מנויים
6
+ *
7
+ * Usage:
8
+ * ```javascript
9
+ * import PayVia from './payvia.js';
10
+ *
11
+ * const payvia = PayVia('YOUR_API_KEY');
12
+ *
13
+ * // Check if user paid
14
+ * const user = await payvia.getUser();
15
+ * if (user.paid) {
16
+ * // Enable premium features
17
+ * }
18
+ *
19
+ * // Open payment page
20
+ * payvia.openPaymentPage();
21
+ * ```
22
+ */
23
+
24
+ function PayVia(apiKey) {
25
+ if (!apiKey) {
26
+ throw new Error('PayVia: API key is required');
27
+ }
28
+
29
+ const PAYVIA_API_URL = 'https://api.payvia.site';
30
+
31
+ const LICENSE_CACHE_KEY = 'payvia_license_cache';
32
+ const DEFAULT_TTL_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
33
+ const GRACE_PERIOD_MS = 30 * 24 * 60 * 60 * 1000; // 30 days
34
+
35
+ const instance = {};
36
+ let cachedUser = null;
37
+ let userPromise = null;
38
+
39
+ // ============ License Cache Storage ============
40
+
41
+ /**
42
+ * Get storage interface (chrome.storage.local or localStorage)
43
+ */
44
+ function getStorage() {
45
+ if (typeof chrome !== 'undefined' && chrome.storage && chrome.storage.local) {
46
+ return {
47
+ async get(key) {
48
+ return new Promise(resolve => {
49
+ chrome.storage.local.get([key], result => resolve(result[key]));
50
+ });
51
+ },
52
+ async set(key, value) {
53
+ return new Promise(resolve => {
54
+ chrome.storage.local.set({ [key]: value }, resolve);
55
+ });
56
+ }
57
+ };
58
+ }
59
+ // Fallback to localStorage
60
+ return {
61
+ async get(key) {
62
+ const value = localStorage.getItem(key);
63
+ return value ? JSON.parse(value) : null;
64
+ },
65
+ async set(key, value) {
66
+ localStorage.setItem(key, JSON.stringify(value));
67
+ }
68
+ };
69
+ }
70
+
71
+ /**
72
+ * Get cached license data
73
+ */
74
+ async function getCachedLicense() {
75
+ try {
76
+ const storage = getStorage();
77
+ return await storage.get(LICENSE_CACHE_KEY);
78
+ } catch (error) {
79
+ console.warn('PayVia: Failed to read license cache', error);
80
+ return null;
81
+ }
82
+ }
83
+
84
+ /**
85
+ * Save license data to cache
86
+ */
87
+ async function setCachedLicense(data) {
88
+ try {
89
+ const storage = getStorage();
90
+ await storage.set(LICENSE_CACHE_KEY, data);
91
+ } catch (error) {
92
+ console.warn('PayVia: Failed to write license cache', error);
93
+ }
94
+ }
95
+
96
+ /**
97
+ * Check if cache is still valid (within TTL)
98
+ */
99
+ function isCacheValid(cache) {
100
+ if (!cache || !cache.checkedAt) return false;
101
+ const ttl = cache.ttl || DEFAULT_TTL_MS;
102
+ return Date.now() < cache.checkedAt + ttl;
103
+ }
104
+
105
+ /**
106
+ * Check if cache is within grace period (expired but still usable offline)
107
+ */
108
+ function isWithinGracePeriod(cache) {
109
+ if (!cache || !cache.checkedAt) return false;
110
+ const ttl = cache.ttl || DEFAULT_TTL_MS;
111
+ return Date.now() < cache.checkedAt + ttl + GRACE_PERIOD_MS;
112
+ }
113
+
114
+ let cachedIdentity = null; // Stores { id, email, source }
115
+
116
+ /**
117
+ * Try to get the user's Google account email via chrome.identity
118
+ * @returns {Promise<{email: string, source: 'google'} | null>}
119
+ */
120
+ async function getGoogleIdentity() {
121
+ return new Promise((resolve) => {
122
+ if (typeof chrome !== 'undefined' && chrome.identity && chrome.identity.getProfileUserInfo) {
123
+ chrome.identity.getProfileUserInfo({ accountStatus: 'ANY' }, (userInfo) => {
124
+ if (chrome.runtime.lastError) {
125
+ console.log('PayVia: Could not get Google identity:', chrome.runtime.lastError.message);
126
+ resolve(null);
127
+ return;
128
+ }
129
+ if (userInfo && userInfo.email) {
130
+ resolve({ email: userInfo.email, source: 'google' });
131
+ } else {
132
+ resolve(null);
133
+ }
134
+ });
135
+ } else {
136
+ resolve(null);
137
+ }
138
+ });
139
+ }
140
+
141
+ /**
142
+ * Get or generate a random fallback ID
143
+ * Uses chrome.storage.sync to persist across devices
144
+ * @returns {Promise<string>}
145
+ */
146
+ async function getRandomId() {
147
+ return new Promise((resolve) => {
148
+ if (typeof chrome !== 'undefined' && chrome.storage && chrome.storage.sync) {
149
+ chrome.storage.sync.get(['payvia_user_id'], (result) => {
150
+ if (result.payvia_user_id) {
151
+ resolve(result.payvia_user_id);
152
+ } else {
153
+ const newUserId = 'pv_' + generateUUID();
154
+ chrome.storage.sync.set({ payvia_user_id: newUserId }, () => {
155
+ resolve(newUserId);
156
+ });
157
+ }
158
+ });
159
+ } else {
160
+ // Fallback for non-extension environments (testing)
161
+ let userId = localStorage.getItem('payvia_user_id');
162
+ if (!userId) {
163
+ userId = 'pv_' + generateUUID();
164
+ localStorage.setItem('payvia_user_id', userId);
165
+ }
166
+ resolve(userId);
167
+ }
168
+ });
169
+ }
170
+
171
+ /**
172
+ * Get user identity - tries Google first, falls back to random ID
173
+ * @returns {Promise<{id: string, email: string | null, source: 'google' | 'random'}>}
174
+ */
175
+ async function getUserIdentity() {
176
+ if (cachedIdentity) {
177
+ return cachedIdentity;
178
+ }
179
+
180
+ // Try Google Identity first
181
+ const googleInfo = await getGoogleIdentity();
182
+ if (googleInfo && googleInfo.email) {
183
+ cachedIdentity = {
184
+ id: googleInfo.email,
185
+ email: googleInfo.email,
186
+ source: 'google'
187
+ };
188
+ console.log('PayVia: Using Google identity:', cachedIdentity.email);
189
+ return cachedIdentity;
190
+ }
191
+
192
+ // Fall back to random ID
193
+ const randomId = await getRandomId();
194
+ cachedIdentity = {
195
+ id: randomId,
196
+ email: null,
197
+ source: 'random'
198
+ };
199
+ console.log('PayVia: Using random identity:', cachedIdentity.id);
200
+ return cachedIdentity;
201
+ }
202
+
203
+ /**
204
+ * Generate UUID v4
205
+ */
206
+ function generateUUID() {
207
+ return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) {
208
+ const r = Math.random() * 16 | 0;
209
+ const v = c === 'x' ? r : (r & 0x3 | 0x8);
210
+ return v.toString(16);
211
+ });
212
+ }
213
+
214
+ /**
215
+ * Make API request to PayVia server
216
+ */
217
+ async function apiRequest(endpoint, options = {}) {
218
+ const response = await fetch(`${PAYVIA_API_URL}${endpoint}`, {
219
+ ...options,
220
+ headers: {
221
+ 'Content-Type': 'application/json',
222
+ 'X-API-Key': apiKey,
223
+ ...options.headers,
224
+ },
225
+ });
226
+
227
+ if (!response.ok) {
228
+ const error = await response.json().catch(() => ({ error: 'Request failed' }));
229
+ throw new Error(error.error || 'PayVia API request failed');
230
+ }
231
+
232
+ return response.json();
233
+ }
234
+
235
+ /**
236
+ * Get user's payment status
237
+ * Uses cache first, then server. Falls back to cache during network errors.
238
+ * @param {Object} options - Options
239
+ * @param {boolean} options.forceRefresh - Force fetch from server
240
+ * @returns {Promise<PayViaUser>} User object with payment status
241
+ */
242
+ instance.getUser = async function (options = {}) {
243
+ const identity = await getUserIdentity();
244
+
245
+ // Check in-memory cache first
246
+ if (cachedUser && !options.forceRefresh) {
247
+ return cachedUser;
248
+ }
249
+
250
+ if (userPromise) {
251
+ return userPromise;
252
+ }
253
+
254
+ userPromise = (async () => {
255
+ try {
256
+ // Check persistent cache
257
+ if (!options.forceRefresh) {
258
+ const cached = await getCachedLicense();
259
+ if (cached && isCacheValid(cached)) {
260
+ cachedUser = buildUserFromCache(identity, cached, true);
261
+ return cachedUser;
262
+ }
263
+ }
264
+
265
+ // Fetch from server
266
+ const response = await apiRequest('/api/v1/license/validate', {
267
+ method: 'POST',
268
+ body: JSON.stringify({ customerId: identity.id, email: identity.email }),
269
+ });
270
+
271
+ // Save to cache
272
+ const cacheData = {
273
+ status: response.status,
274
+ planIds: response.planIds || [],
275
+ tier: response.tier || null,
276
+ isTrial: response.isTrial || false,
277
+ trialExpiresAt: response.trialExpiresAt || null,
278
+ daysRemaining: response.daysRemaining || null,
279
+ checkedAt: response.checkedAt || Date.now(),
280
+ ttl: response.ttl || DEFAULT_TTL_MS,
281
+ signature: response.signature || null,
282
+ version: response.version || null,
283
+ };
284
+ await setCachedLicense(cacheData);
285
+
286
+ cachedUser = buildUserFromCache(identity, cacheData, false);
287
+ return cachedUser;
288
+ } catch (error) {
289
+ console.error('PayVia: Failed to get user status', error);
290
+
291
+ // Try to use cached data if within grace period
292
+ const cached = await getCachedLicense();
293
+ if (cached && isWithinGracePeriod(cached)) {
294
+ console.log('PayVia: Using cached license (network error, within grace period)');
295
+ cachedUser = buildUserFromCache(identity, cached, true);
296
+ return cachedUser;
297
+ }
298
+
299
+ // No valid cache, return inactive
300
+ return {
301
+ id: identity.id,
302
+ email: identity.email,
303
+ identitySource: identity.source,
304
+ paid: false,
305
+ status: 'INACTIVE',
306
+ tier: null,
307
+ features: [],
308
+ planIds: [],
309
+ isTrial: false,
310
+ fromCache: false,
311
+ error: error.message,
312
+ };
313
+ } finally {
314
+ userPromise = null;
315
+ }
316
+ })();
317
+
318
+ return userPromise;
319
+ };
320
+
321
+ /**
322
+ * Build user object from cache data
323
+ */
324
+ function buildUserFromCache(identity, cache, fromCache) {
325
+ return {
326
+ id: identity.id,
327
+ email: identity.email,
328
+ identitySource: identity.source,
329
+ paid: cache.status === 'ACTIVE' || cache.status === 'TRIAL',
330
+ status: cache.status,
331
+ tier: cache.tier || null,
332
+ features: cache.tier?.features || [],
333
+ planIds: cache.planIds || [],
334
+ isTrial: cache.isTrial || false,
335
+ trialExpiresAt: cache.trialExpiresAt ? new Date(cache.trialExpiresAt) : null,
336
+ daysRemaining: cache.daysRemaining || null,
337
+ fromCache: fromCache,
338
+ checkedAt: cache.checkedAt || null,
339
+ ttl: cache.ttl || null,
340
+ signature: cache.signature || null,
341
+ };
342
+ }
343
+
344
+ /**
345
+ * Force refresh user status from server
346
+ * @returns {Promise<PayViaUser>} Updated user object
347
+ */
348
+ instance.refresh = async function () {
349
+ cachedUser = null;
350
+ return instance.getUser({ forceRefresh: true });
351
+ };
352
+
353
+ /**
354
+ * Refresh license cache from server (for background refresh)
355
+ * @returns {Promise<void>}
356
+ */
357
+ instance.refreshLicenseCache = async function () {
358
+ const cached = await getCachedLicense();
359
+ if (!cached || !isCacheValid(cached)) {
360
+ await instance.refresh();
361
+ }
362
+ };
363
+
364
+ /**
365
+ * Check if user has a specific feature
366
+ * @param {string} featureName - Feature name to check
367
+ * @returns {Promise<boolean>}
368
+ */
369
+ instance.hasFeature = async function (featureName) {
370
+ const user = await instance.getUser();
371
+ if (!user.tier || !user.tier.features) return false;
372
+ return user.tier.features.includes(featureName);
373
+ };
374
+
375
+ /**
376
+ * Check if user has a tier at or above the required level
377
+ * @param {number} requiredLevel - Minimum tier level required
378
+ * @returns {Promise<boolean>}
379
+ */
380
+ instance.hasTierLevel = async function (requiredLevel) {
381
+ const user = await instance.getUser();
382
+ if (!user.tier) return false;
383
+ return user.tier.level >= requiredLevel;
384
+ };
385
+
386
+ /**
387
+ * Get the user's current tier info
388
+ * @returns {Promise<{id: string, name: string, level: number, features: string[]}|null>}
389
+ */
390
+ instance.getTier = async function () {
391
+ const user = await instance.getUser();
392
+ return user.tier || null;
393
+ };
394
+
395
+ /**
396
+ * Start a trial for the current user
397
+ * Call this when user first installs/uses the extension
398
+ * Idempotent: if user already has a trial/active subscription, returns existing info
399
+ * @returns {Promise<{subscriptionId: string, status: string, planId: string, planName: string, trialExpiresAt: Date, daysRemaining: number} | null>}
400
+ */
401
+ instance.startTrial = async function () {
402
+ try {
403
+ const identity = await getUserIdentity();
404
+ const response = await apiRequest('/api/v1/trial/start', {
405
+ method: 'POST',
406
+ body: JSON.stringify({
407
+ customerId: identity.id,
408
+ email: identity.email,
409
+ }),
410
+ });
411
+
412
+ // Clear cached user so next getUser() fetches fresh data
413
+ cachedUser = null;
414
+
415
+ return {
416
+ subscriptionId: response.subscriptionId,
417
+ status: response.status,
418
+ planId: response.planId,
419
+ planName: response.planName,
420
+ trialExpiresAt: new Date(response.trialExpiresAt),
421
+ daysRemaining: response.daysRemaining,
422
+ };
423
+ } catch (error) {
424
+ console.log('PayVia: Could not start trial:', error.message);
425
+ return null;
426
+ }
427
+ };
428
+
429
+ /**
430
+ * Get trial status for the current user
431
+ * @returns {Promise<{status: string, trialExpiresAt: Date | null, daysRemaining: number | null, canConvert: boolean, planIds: string[]}>}
432
+ */
433
+ instance.getTrialStatus = async function () {
434
+ try {
435
+ const identity = await getUserIdentity();
436
+ const response = await apiRequest('/api/v1/trial/status', {
437
+ method: 'POST',
438
+ body: JSON.stringify({ customerId: identity.id }),
439
+ });
440
+
441
+ return {
442
+ status: response.status,
443
+ trialExpiresAt: response.trialExpiresAt ? new Date(response.trialExpiresAt) : null,
444
+ daysRemaining: response.daysRemaining || null,
445
+ canConvert: response.canConvert,
446
+ planIds: response.planIds || [],
447
+ };
448
+ } catch (error) {
449
+ console.error('PayVia: Failed to get trial status', error);
450
+ return {
451
+ status: 'UNKNOWN',
452
+ trialExpiresAt: null,
453
+ daysRemaining: null,
454
+ canConvert: true,
455
+ planIds: [],
456
+ };
457
+ }
458
+ };
459
+
460
+ /**
461
+ * Check if this is the first time the extension is being used
462
+ * @returns {Promise<boolean>}
463
+ */
464
+ instance.isFirstRun = async function () {
465
+ return new Promise((resolve) => {
466
+ if (typeof chrome !== 'undefined' && chrome.storage && chrome.storage.local) {
467
+ chrome.storage.local.get(['payvia_first_run_done'], (result) => {
468
+ resolve(!result.payvia_first_run_done);
469
+ });
470
+ } else {
471
+ resolve(!localStorage.getItem('payvia_first_run_done'));
472
+ }
473
+ });
474
+ };
475
+
476
+ /**
477
+ * Mark first run as complete
478
+ */
479
+ instance.markFirstRunDone = async function () {
480
+ return new Promise((resolve) => {
481
+ if (typeof chrome !== 'undefined' && chrome.storage && chrome.storage.local) {
482
+ chrome.storage.local.set({ payvia_first_run_done: true }, resolve);
483
+ } else {
484
+ localStorage.setItem('payvia_first_run_done', 'true');
485
+ resolve();
486
+ }
487
+ });
488
+ };
489
+
490
+ /**
491
+ * Check if email is required for payment (user has no Google identity)
492
+ * @returns {Promise<boolean>}
493
+ */
494
+ instance.needsEmailForPayment = async function () {
495
+ const identity = await getUserIdentity();
496
+ return identity.source === 'random';
497
+ };
498
+
499
+ /**
500
+ * Get the current user identity info
501
+ * @returns {Promise<{id: string, email: string | null, source: 'google' | 'random'}>}
502
+ */
503
+ instance.getIdentity = async function () {
504
+ return getUserIdentity();
505
+ };
506
+
507
+ /**
508
+ * Open payment page for the user
509
+ * @param {Object} options - Payment options
510
+ * @param {string} options.planId - Plan ID to purchase (required for direct/hosted modes)
511
+ * @param {string} options.email - Customer email
512
+ * @param {'pricing'|'hosted'|'direct'} options.mode - Checkout mode:
513
+ * - 'pricing': Shows all plans for user to choose (default, most secure)
514
+ * - 'hosted': Goes directly to checkout for specific plan
515
+ * - 'direct': Bypasses PayVia, goes straight to PayPal
516
+ * @param {string} options.successUrl - URL to redirect after successful payment (direct mode only)
517
+ * @param {string} options.cancelUrl - URL to redirect if user cancels (direct mode only)
518
+ */
519
+ instance.openPaymentPage = async function (options = {}) {
520
+ const identity = await getUserIdentity();
521
+ const customerEmail = options.email || identity.email;
522
+ const mode = options.mode || 'pricing';
523
+
524
+ const PAYVIA_BASE_URL = 'https://payvia.site';
525
+
526
+ if (mode === 'pricing') {
527
+ // Pricing mode: Get secure token, show all plans
528
+ if (!customerEmail) {
529
+ throw new Error('Email is required for payment. Please provide an email address.');
530
+ }
531
+
532
+ const tokenResponse = await apiRequest('/api/v1/checkout/token', {
533
+ method: 'POST',
534
+ body: JSON.stringify({
535
+ customerId: identity.id,
536
+ customerEmail: customerEmail,
537
+ mode: 'pricing',
538
+ }),
539
+ });
540
+
541
+ const pricingUrl = `${PAYVIA_BASE_URL}/pricing?token=${encodeURIComponent(tokenResponse.token)}`;
542
+
543
+ if (typeof chrome !== 'undefined' && chrome.tabs) {
544
+ chrome.tabs.create({ url: pricingUrl });
545
+ } else {
546
+ window.open(pricingUrl, '_blank');
547
+ }
548
+
549
+ return { mode: 'pricing', pricingUrl };
550
+ } else if (mode === 'hosted') {
551
+ // Hosted mode: Get secure token, go to specific plan checkout
552
+ if (!options.planId) {
553
+ throw new Error('planId is required for hosted mode');
554
+ }
555
+ if (!customerEmail) {
556
+ throw new Error('Email is required for payment. Please provide an email address.');
557
+ }
558
+
559
+ const tokenResponse = await apiRequest('/api/v1/checkout/token', {
560
+ method: 'POST',
561
+ body: JSON.stringify({
562
+ customerId: identity.id,
563
+ customerEmail: customerEmail,
564
+ planId: options.planId,
565
+ mode: 'checkout',
566
+ }),
567
+ });
568
+
569
+ const checkoutUrl = `${PAYVIA_BASE_URL}/checkout?token=${encodeURIComponent(tokenResponse.token)}`;
570
+
571
+ if (typeof chrome !== 'undefined' && chrome.tabs) {
572
+ chrome.tabs.create({ url: checkoutUrl });
573
+ } else {
574
+ window.open(checkoutUrl, '_blank');
575
+ }
576
+
577
+ return { mode: 'hosted', checkoutUrl };
578
+ } else {
579
+ // Direct mode: Call API and redirect straight to PayPal (original behavior)
580
+ if (!options.planId) {
581
+ throw new Error('planId is required for direct mode');
582
+ }
583
+
584
+ try {
585
+ const response = await apiRequest('/api/v1/checkout-session', {
586
+ method: 'POST',
587
+ body: JSON.stringify({
588
+ planId: options.planId,
589
+ customerId: identity.id,
590
+ customerEmail: customerEmail || '',
591
+ successUrl: options.successUrl || 'https://payvia.site/success',
592
+ cancelUrl: options.cancelUrl || 'https://payvia.site/cancel',
593
+ }),
594
+ });
595
+
596
+ // Open PayPal checkout in new tab
597
+ if (response.checkoutUrl) {
598
+ if (typeof chrome !== 'undefined' && chrome.tabs) {
599
+ chrome.tabs.create({ url: response.checkoutUrl });
600
+ } else {
601
+ window.open(response.checkoutUrl, '_blank');
602
+ }
603
+ }
604
+
605
+ return response;
606
+ } catch (error) {
607
+ console.error('PayVia: Failed to open payment page', error);
608
+ throw error;
609
+ }
610
+ }
611
+ };
612
+
613
+ /**
614
+ * Listen for payment status changes
615
+ * @param {Function} callback - Called when payment status changes
616
+ */
617
+ instance.onPaid = function (callback) {
618
+ // Poll for status changes every 5 seconds when tab is visible
619
+ let lastPaidStatus = null;
620
+
621
+ const checkStatus = async () => {
622
+ const user = await instance.refresh();
623
+ if (lastPaidStatus !== null && lastPaidStatus !== user.paid) {
624
+ callback(user);
625
+ }
626
+ lastPaidStatus = user.paid;
627
+ };
628
+
629
+ // Check immediately
630
+ checkStatus();
631
+
632
+ // Set up polling
633
+ const intervalId = setInterval(checkStatus, 5000);
634
+
635
+ // Return cleanup function
636
+ return () => clearInterval(intervalId);
637
+ };
638
+
639
+ /**
640
+ * Get available plans for this project
641
+ * @returns {Promise<Plan[]>} List of available plans
642
+ */
643
+ instance.getPlans = async function () {
644
+ try {
645
+ const response = await apiRequest('/api/v1/plans');
646
+ return response;
647
+ } catch (error) {
648
+ console.error('PayVia: Failed to get plans', error);
649
+ return [];
650
+ }
651
+ };
652
+
653
+ /**
654
+ * Reset the user's license (delete all subscriptions).
655
+ * This is for demo/testing purposes only.
656
+ * @returns {Promise<{message: string}>}
657
+ */
658
+ instance.resetLicense = async function () {
659
+ const identity = await getUserIdentity();
660
+ const response = await apiRequest('/api/v1/license/reset', {
661
+ method: 'POST',
662
+ body: JSON.stringify({ customerId: identity.id }),
663
+ });
664
+ cachedUser = null;
665
+ return response;
666
+ };
667
+
668
+ /**
669
+ * Cancel a subscription for the current user
670
+ * @param {Object} options - Cancel options
671
+ * @param {string} options.planId - Specific plan to cancel (optional)
672
+ * @param {string} options.reason - Cancellation reason (optional)
673
+ * @returns {Promise<{success: boolean, message: string, canceledPlanId: string}>}
674
+ */
675
+ instance.cancelSubscription = async function (options = {}) {
676
+ const identity = await getUserIdentity();
677
+
678
+ const response = await apiRequest('/api/v1/license/cancel', {
679
+ method: 'POST',
680
+ body: JSON.stringify({
681
+ customerId: identity.id,
682
+ planId: options.planId || null,
683
+ reason: options.reason || 'User requested cancellation',
684
+ }),
685
+ });
686
+
687
+ // Clear cached user so next getUser() fetches fresh data
688
+ cachedUser = null;
689
+
690
+ return response;
691
+ };
692
+
693
+ return instance;
694
+ }
695
+
696
+ export default PayVia;