@capgo/native-purchases 7.9.4 → 7.12.5
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/README.md +748 -50
- package/android/src/main/java/ee/forgr/nativepurchases/NativePurchasesPlugin.java +135 -4
- package/dist/docs.json +296 -36
- package/dist/esm/definitions.d.ts +186 -19
- package/dist/esm/definitions.js.map +1 -1
- package/dist/esm/web.d.ts +1 -0
- package/dist/esm/web.js +3 -0
- package/dist/esm/web.js.map +1 -1
- package/dist/plugin.cjs.js +3 -0
- package/dist/plugin.cjs.js.map +1 -1
- package/dist/plugin.js +3 -0
- package/dist/plugin.js.map +1 -1
- package/ios/Sources/NativePurchasesPlugin/NativePurchasesPlugin.swift +65 -264
- package/ios/Sources/NativePurchasesPlugin/TransactionHelpers.swift +132 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
<a href="https://capgo.app/"><img src='https://raw.githubusercontent.com/Cap-go/capgo/main/assets/capgo_banner.png' alt='Capgo - Instant updates for capacitor'/></a>
|
|
3
3
|
|
|
4
4
|
<div align="center">
|
|
5
|
-
<h2><a href="https://capgo.app/?ref=
|
|
6
|
-
<h2><a href="https://capgo.app/consulting/?ref=
|
|
5
|
+
<h2><a href="https://capgo.app/?ref=plugin_native_purchases"> ➡️ Get Instant updates for your App with Capgo</a></h2>
|
|
6
|
+
<h2><a href="https://capgo.app/consulting/?ref=plugin_native_purchases"> Missing a feature? We’ll build the plugin for you 💪</a></h2>
|
|
7
7
|
</div>
|
|
8
8
|
|
|
9
9
|
## In-app Purchases Made Easy
|
|
@@ -95,12 +95,18 @@ There are two types of purchases with different requirements:
|
|
|
95
95
|
| Purchase Type | productType | planIdentifier | Use Case |
|
|
96
96
|
|---------------|-------------|----------------|----------|
|
|
97
97
|
| **In-App Purchase** | `PURCHASE_TYPE.INAPP` | ❌ Not needed | One-time purchases (premium features, remove ads, etc.) |
|
|
98
|
-
| **Subscription** | `PURCHASE_TYPE.SUBS` | ✅ **REQUIRED** | Recurring purchases (monthly/yearly subscriptions) |
|
|
98
|
+
| **Subscription** | `PURCHASE_TYPE.SUBS` | ✅ **REQUIRED (Android only)** | Recurring purchases (monthly/yearly subscriptions) |
|
|
99
99
|
|
|
100
100
|
**Key Rules:**
|
|
101
|
-
- ✅ **In-App Products**: Use `productType: PURCHASE_TYPE.INAPP`, no `planIdentifier` needed
|
|
102
|
-
- ✅ **Subscriptions**: Must use `productType: PURCHASE_TYPE.SUBS` AND `planIdentifier: "your-plan-id"`
|
|
103
|
-
-
|
|
101
|
+
- ✅ **In-App Products**: Use `productType: PURCHASE_TYPE.INAPP`, no `planIdentifier` needed on any platform
|
|
102
|
+
- ✅ **Subscriptions on Android**: Must use `productType: PURCHASE_TYPE.SUBS` AND `planIdentifier: "your-plan-id"` (the Base Plan ID from Google Play Console)
|
|
103
|
+
- ✅ **Subscriptions on iOS**: Use `productType: PURCHASE_TYPE.SUBS`, `planIdentifier` is optional and ignored
|
|
104
|
+
- ❌ **Missing planIdentifier** for Android subscriptions will cause purchase failures
|
|
105
|
+
|
|
106
|
+
**About planIdentifier (Android-specific):**
|
|
107
|
+
The `planIdentifier` parameter is **only required for Android subscriptions**. It should be set to the Base Plan ID that you configure in the Google Play Console when creating your subscription product. For example, if you create a monthly subscription with base plan ID "monthly-plan" in Google Play Console, you would use `planIdentifier: "monthly-plan"` when purchasing that subscription.
|
|
108
|
+
|
|
109
|
+
iOS does not use this parameter - subscriptions on iOS only require the product identifier.
|
|
104
110
|
|
|
105
111
|
### Complete Example: Get Product Info and Purchase
|
|
106
112
|
|
|
@@ -113,12 +119,12 @@ class PurchaseManager {
|
|
|
113
119
|
// In-app product (one-time purchase)
|
|
114
120
|
private premiumProductId = 'com.yourapp.premium_features';
|
|
115
121
|
|
|
116
|
-
// Subscription products (require planIdentifier)
|
|
122
|
+
// Subscription products (require planIdentifier on Android)
|
|
117
123
|
private monthlySubId = 'com.yourapp.premium.monthly';
|
|
118
|
-
private monthlyPlanId = 'monthly-plan'; // Base plan ID from
|
|
119
|
-
|
|
124
|
+
private monthlyPlanId = 'monthly-plan'; // Base plan ID from Google Play Console (Android only)
|
|
125
|
+
|
|
120
126
|
private yearlySubId = 'com.yourapp.premium.yearly';
|
|
121
|
-
private yearlyPlanId = 'yearly-plan'; // Base plan ID from
|
|
127
|
+
private yearlyPlanId = 'yearly-plan'; // Base plan ID from Google Play Console (Android only)
|
|
122
128
|
|
|
123
129
|
async initializeStore() {
|
|
124
130
|
try {
|
|
@@ -203,42 +209,42 @@ class PurchaseManager {
|
|
|
203
209
|
}
|
|
204
210
|
}
|
|
205
211
|
|
|
206
|
-
// Purchase subscription (planIdentifier REQUIRED)
|
|
212
|
+
// Purchase subscription (planIdentifier REQUIRED for Android)
|
|
207
213
|
async purchaseMonthlySubscription() {
|
|
208
214
|
try {
|
|
209
215
|
console.log('Starting subscription purchase...');
|
|
210
|
-
|
|
216
|
+
|
|
211
217
|
const result = await NativePurchases.purchaseProduct({
|
|
212
218
|
productIdentifier: this.monthlySubId,
|
|
213
|
-
planIdentifier: this.monthlyPlanId, // REQUIRED for subscriptions
|
|
219
|
+
planIdentifier: this.monthlyPlanId, // REQUIRED for Android subscriptions, ignored on iOS
|
|
214
220
|
productType: PURCHASE_TYPE.SUBS, // REQUIRED for subscriptions
|
|
215
221
|
quantity: 1
|
|
216
222
|
});
|
|
217
|
-
|
|
223
|
+
|
|
218
224
|
console.log('Subscription purchase successful!', result.transactionId);
|
|
219
225
|
await this.handleSuccessfulPurchase(result.transactionId, 'monthly');
|
|
220
|
-
|
|
226
|
+
|
|
221
227
|
} catch (error) {
|
|
222
228
|
console.error('Subscription purchase failed:', error);
|
|
223
229
|
this.handlePurchaseError(error);
|
|
224
230
|
}
|
|
225
231
|
}
|
|
226
232
|
|
|
227
|
-
// Purchase yearly subscription (planIdentifier REQUIRED)
|
|
233
|
+
// Purchase yearly subscription (planIdentifier REQUIRED for Android)
|
|
228
234
|
async purchaseYearlySubscription() {
|
|
229
235
|
try {
|
|
230
236
|
console.log('Starting yearly subscription purchase...');
|
|
231
|
-
|
|
237
|
+
|
|
232
238
|
const result = await NativePurchases.purchaseProduct({
|
|
233
239
|
productIdentifier: this.yearlySubId,
|
|
234
|
-
planIdentifier: this.yearlyPlanId, // REQUIRED for subscriptions
|
|
235
|
-
productType: PURCHASE_TYPE.SUBS, // REQUIRED for subscriptions
|
|
240
|
+
planIdentifier: this.yearlyPlanId, // REQUIRED for Android subscriptions, ignored on iOS
|
|
241
|
+
productType: PURCHASE_TYPE.SUBS, // REQUIRED for subscriptions
|
|
236
242
|
quantity: 1
|
|
237
243
|
});
|
|
238
|
-
|
|
244
|
+
|
|
239
245
|
console.log('Yearly subscription successful!', result.transactionId);
|
|
240
246
|
await this.handleSuccessfulPurchase(result.transactionId, 'yearly');
|
|
241
|
-
|
|
247
|
+
|
|
242
248
|
} catch (error) {
|
|
243
249
|
console.error('Yearly subscription failed:', error);
|
|
244
250
|
this.handlePurchaseError(error);
|
|
@@ -289,15 +295,24 @@ class PurchaseManager {
|
|
|
289
295
|
try {
|
|
290
296
|
await NativePurchases.restorePurchases();
|
|
291
297
|
console.log('Purchases restored successfully');
|
|
292
|
-
|
|
298
|
+
|
|
293
299
|
// Check if user has active premium after restore
|
|
294
300
|
const product = await this.getProductInfo();
|
|
295
301
|
// Update UI based on restored purchases
|
|
296
|
-
|
|
302
|
+
|
|
297
303
|
} catch (error) {
|
|
298
304
|
console.error('Failed to restore purchases:', error);
|
|
299
305
|
}
|
|
300
306
|
}
|
|
307
|
+
|
|
308
|
+
async openSubscriptionManagement() {
|
|
309
|
+
try {
|
|
310
|
+
await NativePurchases.manageSubscriptions();
|
|
311
|
+
console.log('Opened subscription management page');
|
|
312
|
+
} catch (error) {
|
|
313
|
+
console.error('Failed to open subscription management:', error);
|
|
314
|
+
}
|
|
315
|
+
}
|
|
301
316
|
}
|
|
302
317
|
|
|
303
318
|
// Usage in your app
|
|
@@ -322,6 +337,10 @@ document.getElementById('buy-yearly-button')?.addEventListener('click', () => {
|
|
|
322
337
|
document.getElementById('restore-button')?.addEventListener('click', () => {
|
|
323
338
|
purchaseManager.restorePurchases();
|
|
324
339
|
});
|
|
340
|
+
|
|
341
|
+
document.getElementById('manage-subscriptions-button')?.addEventListener('click', () => {
|
|
342
|
+
purchaseManager.openSubscriptionManagement();
|
|
343
|
+
});
|
|
325
344
|
```
|
|
326
345
|
|
|
327
346
|
### Quick Examples
|
|
@@ -405,7 +424,11 @@ const buyInAppProduct = async () => {
|
|
|
405
424
|
productIdentifier: 'com.yourapp.premium_features',
|
|
406
425
|
productType: PURCHASE_TYPE.INAPP,
|
|
407
426
|
quantity: 1,
|
|
408
|
-
appAccountToken:
|
|
427
|
+
appAccountToken: uuidToken // Optional: User identifier in UUID format
|
|
428
|
+
// iOS: Must be valid UUID (required by StoreKit 2)
|
|
429
|
+
// Android: UUID works, or any obfuscated string (max 64 chars)
|
|
430
|
+
// RECOMMENDED: Use UUID v5 for cross-platform compatibility
|
|
431
|
+
// Example: uuidv5(userId, APP_NAMESPACE)
|
|
409
432
|
});
|
|
410
433
|
|
|
411
434
|
alert('Purchase successful! Transaction ID: ' + result.transactionId);
|
|
@@ -441,13 +464,17 @@ const buySubscription = async () => {
|
|
|
441
464
|
const confirmed = confirm(`Subscribe to ${product.title} for ${product.priceString}?`);
|
|
442
465
|
if (!confirmed) return;
|
|
443
466
|
|
|
444
|
-
// Make subscription purchase (planIdentifier REQUIRED for Android)
|
|
467
|
+
// Make subscription purchase (planIdentifier REQUIRED for Android, ignored on iOS)
|
|
445
468
|
const result = await NativePurchases.purchaseProduct({
|
|
446
469
|
productIdentifier: 'com.yourapp.premium.monthly',
|
|
447
|
-
planIdentifier: 'monthly-plan', // REQUIRED for Android subscriptions
|
|
470
|
+
planIdentifier: 'monthly-plan', // REQUIRED for Android subscriptions, ignored on iOS
|
|
448
471
|
productType: PURCHASE_TYPE.SUBS, // REQUIRED for subscriptions
|
|
449
472
|
quantity: 1,
|
|
450
|
-
appAccountToken:
|
|
473
|
+
appAccountToken: uuidToken // Optional: User identifier in UUID format
|
|
474
|
+
// iOS: Must be valid UUID (required by StoreKit 2)
|
|
475
|
+
// Android: UUID works, or any obfuscated string (max 64 chars)
|
|
476
|
+
// RECOMMENDED: Use UUID v5 for cross-platform compatibility
|
|
477
|
+
// Example: uuidv5(userId, APP_NAMESPACE)
|
|
451
478
|
});
|
|
452
479
|
|
|
453
480
|
alert('Subscription successful! Transaction ID: ' + result.transactionId);
|
|
@@ -484,6 +511,656 @@ const checkBillingSupport = async () => {
|
|
|
484
511
|
};
|
|
485
512
|
```
|
|
486
513
|
|
|
514
|
+
### Manage Subscriptions
|
|
515
|
+
|
|
516
|
+
Allow users to manage their subscriptions directly from your app. This opens the platform's native subscription management page:
|
|
517
|
+
|
|
518
|
+
```typescript
|
|
519
|
+
import { NativePurchases } from '@capgo/native-purchases';
|
|
520
|
+
|
|
521
|
+
const openSubscriptionSettings = async () => {
|
|
522
|
+
try {
|
|
523
|
+
await NativePurchases.manageSubscriptions();
|
|
524
|
+
// On iOS: Opens the App Store subscription management page
|
|
525
|
+
// On Android: Opens the Google Play subscription management page
|
|
526
|
+
} catch (error) {
|
|
527
|
+
console.error('Error opening subscription management:', error);
|
|
528
|
+
}
|
|
529
|
+
};
|
|
530
|
+
```
|
|
531
|
+
|
|
532
|
+
This is particularly useful for:
|
|
533
|
+
- Allowing users to cancel or modify their subscriptions
|
|
534
|
+
- Viewing subscription renewal dates
|
|
535
|
+
- Changing subscription plans
|
|
536
|
+
- Managing billing information
|
|
537
|
+
|
|
538
|
+
### Using appAccountToken for Fraud Detection and User Linking
|
|
539
|
+
|
|
540
|
+
The `appAccountToken` parameter is an optional but highly recommended security feature that helps both you and the platform stores detect fraud and link purchases to specific users in your app.
|
|
541
|
+
|
|
542
|
+
#### What is appAccountToken?
|
|
543
|
+
|
|
544
|
+
An identifier (max 64 characters) that uniquely associates transactions with user accounts in your app. It serves two main purposes:
|
|
545
|
+
|
|
546
|
+
1. **Fraud Detection**: Google Play and Apple use this to detect irregular activity, such as many devices making purchases on the same account within a brief timeframe
|
|
547
|
+
2. **User Linking**: Links purchases to specific in-game characters, avatars, or in-app profiles that initiated the purchase
|
|
548
|
+
|
|
549
|
+
#### Platform-Specific Requirements
|
|
550
|
+
|
|
551
|
+
**IMPORTANT: iOS and Android have different format requirements:**
|
|
552
|
+
|
|
553
|
+
| Platform | Format Requirement | Maps To |
|
|
554
|
+
|----------|-------------------|---------|
|
|
555
|
+
| **iOS** | **Must be a valid UUID** (e.g., `"550e8400-e29b-41d4-a716-446655440000"`) | Apple StoreKit 2's `appAccountToken` parameter |
|
|
556
|
+
| **Android** | Any obfuscated string (max 64 chars) | Google Play's `ObfuscatedAccountId` |
|
|
557
|
+
|
|
558
|
+
**iOS Specific:**
|
|
559
|
+
- Apple's StoreKit 2 requires the `appAccountToken` to be in UUID format
|
|
560
|
+
- The plugin validates and converts the string to UUID before passing to StoreKit
|
|
561
|
+
- If the format is invalid, the token will be ignored
|
|
562
|
+
|
|
563
|
+
**Android Specific:**
|
|
564
|
+
- Google recommends using encryption or one-way hash
|
|
565
|
+
- Storing PII in cleartext will result in purchases being blocked by Google Play
|
|
566
|
+
|
|
567
|
+
#### Critical Security Requirements
|
|
568
|
+
|
|
569
|
+
**DO NOT use Personally Identifiable Information (PII) in cleartext:**
|
|
570
|
+
- ❌ WRONG: `appAccountToken: 'user@example.com'`
|
|
571
|
+
- ❌ WRONG: `appAccountToken: 'john.doe'`
|
|
572
|
+
- ✅ CORRECT (iOS & Android): `appAccountToken: uuidv5(userId, NAMESPACE)`
|
|
573
|
+
- ✅ CORRECT (Android only): `appAccountToken: hash(userId).substring(0, 64)`
|
|
574
|
+
|
|
575
|
+
**For cross-platform compatibility, using UUID format is recommended for both platforms.**
|
|
576
|
+
|
|
577
|
+
#### Implementation Example
|
|
578
|
+
|
|
579
|
+
```typescript
|
|
580
|
+
// RECOMMENDED: Use UUID v5 for cross-platform compatibility (works on both iOS and Android)
|
|
581
|
+
import { v5 as uuidv5 } from 'uuid'; // npm install uuid
|
|
582
|
+
|
|
583
|
+
// Generate a deterministic UUID from user ID
|
|
584
|
+
function generateAppAccountToken(userId: string): string {
|
|
585
|
+
// Use a consistent namespace UUID for your app (generate once and keep constant)
|
|
586
|
+
const APP_NAMESPACE = '6ba7b810-9dad-11d1-80b4-00c04fd430c8';
|
|
587
|
+
|
|
588
|
+
// Generate deterministic UUID - same userId always produces same UUID
|
|
589
|
+
const uuid = uuidv5(userId, APP_NAMESPACE);
|
|
590
|
+
|
|
591
|
+
return uuid; // e.g., "550e8400-e29b-41d4-a716-446655440000"
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
// ALTERNATIVE: For Android-only apps (SHA-256 hash)
|
|
595
|
+
function generateAppAccountTokenAndroidOnly(userId: string): string {
|
|
596
|
+
// This works on Android but will be ignored on iOS (not UUID format)
|
|
597
|
+
const hash = crypto.createHash('sha256')
|
|
598
|
+
.update(userId)
|
|
599
|
+
.digest('hex')
|
|
600
|
+
.substring(0, 64); // Ensure max 64 chars
|
|
601
|
+
|
|
602
|
+
return hash;
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
// ALTERNATIVE: HMAC with secret key for Android-only apps
|
|
606
|
+
function generateSecureAppAccountTokenAndroidOnly(userId: string, secretKey: string): string {
|
|
607
|
+
// This works on Android but will be ignored on iOS (not UUID format)
|
|
608
|
+
const hmac = crypto.createHmac('sha256', secretKey)
|
|
609
|
+
.update(userId)
|
|
610
|
+
.digest('hex')
|
|
611
|
+
.substring(0, 64);
|
|
612
|
+
|
|
613
|
+
return hmac;
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
// Use in your purchase flow (cross-platform)
|
|
617
|
+
const userId = 'user-12345'; // Your internal user ID
|
|
618
|
+
const appAccountToken = generateAppAccountToken(userId);
|
|
619
|
+
|
|
620
|
+
await NativePurchases.purchaseProduct({
|
|
621
|
+
productIdentifier: 'com.yourapp.premium',
|
|
622
|
+
productType: PURCHASE_TYPE.INAPP,
|
|
623
|
+
appAccountToken: appAccountToken // UUID format works on both iOS and Android
|
|
624
|
+
});
|
|
625
|
+
|
|
626
|
+
// Later, retrieve purchases for this user
|
|
627
|
+
const { purchases } = await NativePurchases.getPurchases({
|
|
628
|
+
appAccountToken: appAccountToken
|
|
629
|
+
});
|
|
630
|
+
```
|
|
631
|
+
|
|
632
|
+
**Why UUID v5 is Recommended:**
|
|
633
|
+
- ✅ Works on both iOS (required) and Android (accepted)
|
|
634
|
+
- ✅ Deterministic: Same user ID always produces the same UUID
|
|
635
|
+
- ✅ Secure: No PII exposure
|
|
636
|
+
- ✅ Standard format: Widely supported
|
|
637
|
+
- ✅ Reversible mapping: You can store the mapping in your backend
|
|
638
|
+
|
|
639
|
+
#### Best Practices
|
|
640
|
+
|
|
641
|
+
1. **Use UUID v5 for cross-platform apps** - Works on both iOS (required) and Android (accepted)
|
|
642
|
+
2. **Keep your namespace UUID constant** - Generate once and hardcode it in your app
|
|
643
|
+
3. **Store the mapping** - Keep a record of userId → appAccountToken in your backend for reverse lookup
|
|
644
|
+
4. **Use during purchase** - Include it when calling `purchaseProduct()`
|
|
645
|
+
5. **Use for queries** - Use it when calling `getPurchases()` to filter by user
|
|
646
|
+
6. **Deterministic generation** - Same user should always get the same token
|
|
647
|
+
7. **Max 64 characters** - UUID format is 36 characters, well within the limit
|
|
648
|
+
|
|
649
|
+
#### Benefits
|
|
650
|
+
|
|
651
|
+
- **Fraud Prevention**: Platforms can detect suspicious patterns
|
|
652
|
+
- **Multi-device Support**: Link purchases across devices for the same user
|
|
653
|
+
- **User Management**: Query purchases for specific users
|
|
654
|
+
- **Analytics**: Better insights into user purchasing behavior
|
|
655
|
+
|
|
656
|
+
## Understanding Transaction Properties
|
|
657
|
+
|
|
658
|
+
When you inspect purchases using `getPurchases()` or `restorePurchases()`, you receive an array of `Transaction` objects. Understanding which properties are available and reliable for different scenarios is crucial for proper implementation.
|
|
659
|
+
|
|
660
|
+
### Transaction Properties by Platform & Product Type
|
|
661
|
+
|
|
662
|
+
Here's a comprehensive breakdown of which properties you can expect and rely on:
|
|
663
|
+
|
|
664
|
+
| Property | iOS IAP | iOS Subscription | Android IAP | Android Subscription | Notes |
|
|
665
|
+
|----------|---------|------------------|-------------|---------------------|-------|
|
|
666
|
+
| `transactionId` | ✅ Always | ✅ Always | ✅ Always | ✅ Always | Primary identifier for the transaction |
|
|
667
|
+
| `receipt` | ✅ Always | ✅ Always | ❌ Never | ❌ Never | iOS only - base64 receipt for validation |
|
|
668
|
+
| `productIdentifier` | ✅ Always | ✅ Always | ✅ Always | ✅ Always | Product ID purchased |
|
|
669
|
+
| `purchaseDate` | ✅ Always | ✅ Always | ✅ Always | ✅ Always | ISO 8601 format |
|
|
670
|
+
| `productType` | ✅ Always | ✅ Always | ✅ Always | ✅ Always | "inapp" or "subs" |
|
|
671
|
+
| `quantity` | ✅ Always | ✅ Always | ✅ Always 1 | ✅ Always 1 | iOS supports multiple, Android always 1 |
|
|
672
|
+
| `appAccountToken` | ✅ If provided | ✅ If provided | ✅ If provided | ✅ If provided | Set if passed during purchase |
|
|
673
|
+
| `isActive` | ❌ Not set | ✅ Always | ❌ Not set | ❌ Not set | **iOS subscriptions ONLY** - calculated as expiration > now |
|
|
674
|
+
| `willCancel` | ❌ Not set | ✅ Always | ✅ Always null | ✅ Always null | iOS: subscription renewal status; Android: always null |
|
|
675
|
+
| `originalPurchaseDate` | ❌ Not set | ✅ Always | ❌ Not set | ❌ Not set | **iOS subscriptions ONLY** |
|
|
676
|
+
| `expirationDate` | ❌ Not set | ✅ Always | ❌ Not set | ❌ Not set | **iOS subscriptions ONLY** |
|
|
677
|
+
| `purchaseState` | ❌ Not set | ❌ Not set | ✅ Always | ✅ Always | **Android ONLY** - "PURCHASED", "PENDING", "0" (numeric) |
|
|
678
|
+
| `orderId` | ❌ Not set | ❌ Not set | ✅ Always | ✅ Always | **Android ONLY** |
|
|
679
|
+
| `purchaseToken` | ❌ Not set | ❌ Not set | ✅ Always | ✅ Always | **Android ONLY** - for validation |
|
|
680
|
+
| `isAcknowledged` | ❌ Not set | ❌ Not set | ✅ Always | ✅ Always | **Android ONLY** |
|
|
681
|
+
|
|
682
|
+
### Validating Purchases: Platform-Specific Best Practices
|
|
683
|
+
|
|
684
|
+
#### iOS In-App Purchases (One-Time)
|
|
685
|
+
|
|
686
|
+
```typescript
|
|
687
|
+
const { purchases } = await NativePurchases.getPurchases({
|
|
688
|
+
productType: PURCHASE_TYPE.INAPP
|
|
689
|
+
});
|
|
690
|
+
|
|
691
|
+
// Example response for iOS IAP:
|
|
692
|
+
// {
|
|
693
|
+
// "transactionId": "2000001043762129",
|
|
694
|
+
// "receipt": "base64EncodedReceiptData...",
|
|
695
|
+
// "productIdentifier": "com.yourapp.premium",
|
|
696
|
+
// "purchaseDate": "2025-10-28T06:03:19Z",
|
|
697
|
+
// "productType": "inapp"
|
|
698
|
+
// }
|
|
699
|
+
|
|
700
|
+
purchases.forEach(purchase => {
|
|
701
|
+
// For iOS IAP, the mere presence in the list generally indicates a valid purchase
|
|
702
|
+
// However, for security, you should validate the receipt on your server
|
|
703
|
+
|
|
704
|
+
if (purchase.productIdentifier === 'com.yourapp.premium') {
|
|
705
|
+
// Option 1: Basic client-side check (not recommended for production)
|
|
706
|
+
if (purchase.receipt && purchase.transactionId) {
|
|
707
|
+
grantPremiumAccess();
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
// Option 2: Server-side validation (RECOMMENDED)
|
|
711
|
+
validateReceiptOnServer(purchase.receipt).then(isValid => {
|
|
712
|
+
if (isValid) {
|
|
713
|
+
grantPremiumAccess();
|
|
714
|
+
}
|
|
715
|
+
});
|
|
716
|
+
}
|
|
717
|
+
});
|
|
718
|
+
```
|
|
719
|
+
|
|
720
|
+
**Key Points for iOS IAP:**
|
|
721
|
+
- ✅ If a purchase appears in `getPurchases()`, it's generally valid
|
|
722
|
+
- ❌ `isActive` is **NOT set** for one-time IAP purchases (only for subscriptions)
|
|
723
|
+
- ❌ `expirationDate` and `originalPurchaseDate` are **NOT set** for IAP
|
|
724
|
+
- 🔒 **Always validate the receipt on your server for production apps**
|
|
725
|
+
- ⚠️ Refunded purchases may still appear but will fail server validation
|
|
726
|
+
|
|
727
|
+
#### Android In-App Purchases (One-Time)
|
|
728
|
+
|
|
729
|
+
```typescript
|
|
730
|
+
const { purchases } = await NativePurchases.getPurchases({
|
|
731
|
+
productType: PURCHASE_TYPE.INAPP
|
|
732
|
+
});
|
|
733
|
+
|
|
734
|
+
// Example response for Android IAP:
|
|
735
|
+
// {
|
|
736
|
+
// "transactionId": "GPA.1234-5678-9012-34567",
|
|
737
|
+
// "productIdentifier": "com.yourapp.premium",
|
|
738
|
+
// "purchaseDate": "2025-10-28T06:03:19Z",
|
|
739
|
+
// "purchaseState": "PURCHASED",
|
|
740
|
+
// "orderId": "GPA.1234-5678-9012-34567",
|
|
741
|
+
// "purchaseToken": "long-token-string...",
|
|
742
|
+
// "isAcknowledged": true,
|
|
743
|
+
// "productType": "inapp"
|
|
744
|
+
// }
|
|
745
|
+
|
|
746
|
+
purchases.forEach(purchase => {
|
|
747
|
+
// For Android IAP, ALWAYS check purchaseState
|
|
748
|
+
const isValidPurchase =
|
|
749
|
+
purchase.purchaseState === 'PURCHASED' &&
|
|
750
|
+
purchase.isAcknowledged === true;
|
|
751
|
+
|
|
752
|
+
if (purchase.productIdentifier === 'com.yourapp.premium' && isValidPurchase) {
|
|
753
|
+
grantPremiumAccess();
|
|
754
|
+
|
|
755
|
+
// For extra security, validate on server (RECOMMENDED)
|
|
756
|
+
validatePurchaseTokenOnServer(purchase.purchaseToken);
|
|
757
|
+
}
|
|
758
|
+
});
|
|
759
|
+
```
|
|
760
|
+
|
|
761
|
+
**Key Points for Android IAP:**
|
|
762
|
+
- ✅ **ALWAYS check** `purchaseState === "PURCHASED"` or `purchaseState === "1"` - this is critical
|
|
763
|
+
- ✅ Check `isAcknowledged === true` (this plugin auto-acknowledges)
|
|
764
|
+
- ❌ `isActive` is **NOT set** on Android (for either IAP or subscriptions)
|
|
765
|
+
- ❌ `expirationDate` and `originalPurchaseDate` are **NOT set** on Android
|
|
766
|
+
- 🔒 For production, validate `purchaseToken` on your server with Google Play API
|
|
767
|
+
|
|
768
|
+
#### iOS Subscriptions
|
|
769
|
+
|
|
770
|
+
```typescript
|
|
771
|
+
const { purchases } = await NativePurchases.getPurchases({
|
|
772
|
+
productType: PURCHASE_TYPE.SUBS
|
|
773
|
+
});
|
|
774
|
+
|
|
775
|
+
// Example response for active iOS subscription:
|
|
776
|
+
// {
|
|
777
|
+
// "transactionId": "2000001043762130",
|
|
778
|
+
// "receipt": "base64EncodedReceiptData...",
|
|
779
|
+
// "productIdentifier": "com.yourapp.premium.monthly",
|
|
780
|
+
// "purchaseDate": "2025-10-28T06:03:19Z",
|
|
781
|
+
// "originalPurchaseDate": "2025-09-28T06:03:19Z",
|
|
782
|
+
// "expirationDate": "2025-11-28T06:03:19Z",
|
|
783
|
+
// "isActive": true,
|
|
784
|
+
// "willCancel": false,
|
|
785
|
+
// "productType": "subs",
|
|
786
|
+
// "isTrialPeriod": false,
|
|
787
|
+
// "isInIntroPricePeriod": false
|
|
788
|
+
// }
|
|
789
|
+
|
|
790
|
+
purchases.forEach(purchase => {
|
|
791
|
+
// Check if subscription is currently active
|
|
792
|
+
const isSubscriptionActive = purchase.isActive === true;
|
|
793
|
+
|
|
794
|
+
// Alternative: Check expiration date
|
|
795
|
+
const expirationDate = new Date(purchase.expirationDate);
|
|
796
|
+
const isActiveByDate = expirationDate > new Date();
|
|
797
|
+
|
|
798
|
+
// Check if user has cancelled (still active until expiration)
|
|
799
|
+
const willAutoRenew = purchase.willCancel === false;
|
|
800
|
+
|
|
801
|
+
if (isSubscriptionActive) {
|
|
802
|
+
grantSubscriptionAccess();
|
|
803
|
+
|
|
804
|
+
if (willAutoRenew) {
|
|
805
|
+
console.log('Subscription will renew on', purchase.expirationDate);
|
|
806
|
+
} else {
|
|
807
|
+
console.log('Subscription cancelled, expires on', purchase.expirationDate);
|
|
808
|
+
}
|
|
809
|
+
}
|
|
810
|
+
});
|
|
811
|
+
```
|
|
812
|
+
|
|
813
|
+
**Key Points for iOS Subscriptions:**
|
|
814
|
+
- ✅ `isActive` is reliable for subscriptions
|
|
815
|
+
- ✅ `expirationDate` can be used to check validity
|
|
816
|
+
- ✅ `willCancel` tells you if subscription will auto-renew
|
|
817
|
+
- ⚠️ Even cancelled subscriptions show `isActive: true` until expiration
|
|
818
|
+
- 🔒 Validate receipt on server to detect refunds/revocations
|
|
819
|
+
|
|
820
|
+
#### Android Subscriptions
|
|
821
|
+
|
|
822
|
+
```typescript
|
|
823
|
+
const { purchases } = await NativePurchases.getPurchases({
|
|
824
|
+
productType: PURCHASE_TYPE.SUBS
|
|
825
|
+
});
|
|
826
|
+
|
|
827
|
+
// Example response for active Android subscription:
|
|
828
|
+
// {
|
|
829
|
+
// "transactionId": "GPA.1234-5678-9012-34568",
|
|
830
|
+
// "productIdentifier": "com.yourapp.premium.monthly",
|
|
831
|
+
// "purchaseDate": "2025-10-28T06:03:19Z",
|
|
832
|
+
// "originalPurchaseDate": "2025-09-28T06:03:19Z",
|
|
833
|
+
// "expirationDate": "2025-11-28T06:03:19Z",
|
|
834
|
+
// "isActive": true,
|
|
835
|
+
// "purchaseState": "PURCHASED",
|
|
836
|
+
// "orderId": "GPA.1234-5678-9012-34568",
|
|
837
|
+
// "purchaseToken": "long-token-string...",
|
|
838
|
+
// "isAcknowledged": true,
|
|
839
|
+
// "productType": "subs",
|
|
840
|
+
// "isTrialPeriod": false
|
|
841
|
+
// }
|
|
842
|
+
|
|
843
|
+
purchases.forEach(purchase => {
|
|
844
|
+
// Check if subscription is active using multiple signals
|
|
845
|
+
const isActiveSubscription =
|
|
846
|
+
purchase.purchaseState === 'PURCHASED' &&
|
|
847
|
+
purchase.isActive === true &&
|
|
848
|
+
purchase.isAcknowledged === true;
|
|
849
|
+
|
|
850
|
+
// Alternative: Check expiration date
|
|
851
|
+
const expirationDate = new Date(purchase.expirationDate);
|
|
852
|
+
const isActiveByDate = expirationDate > new Date();
|
|
853
|
+
|
|
854
|
+
if (isActiveSubscription || isActiveByDate) {
|
|
855
|
+
grantSubscriptionAccess();
|
|
856
|
+
}
|
|
857
|
+
});
|
|
858
|
+
```
|
|
859
|
+
|
|
860
|
+
**Key Points for Android Subscriptions:**
|
|
861
|
+
- ✅ Check `purchaseState === "PURCHASED"` or `purchaseState === "1"`
|
|
862
|
+
- ❌ `isActive` is **NOT set** on Android (even for subscriptions)
|
|
863
|
+
- ❌ `expirationDate` is **NOT set** on Android - must query Google Play API
|
|
864
|
+
- ❌ `originalPurchaseDate` is **NOT set** on Android
|
|
865
|
+
- ✅ `willCancel` is ALWAYS set to `null` on Android
|
|
866
|
+
- 🔒 For subscription status and expiration, query Google Play Developer API on your server
|
|
867
|
+
|
|
868
|
+
### Handling Refunds and Cancellations
|
|
869
|
+
|
|
870
|
+
Understanding how refunds and cancellations affect your transaction data is critical for proper access control.
|
|
871
|
+
|
|
872
|
+
#### iOS Refund Behavior
|
|
873
|
+
|
|
874
|
+
**What happens when a user requests a refund:**
|
|
875
|
+
|
|
876
|
+
1. **For In-App Purchases (IAP):**
|
|
877
|
+
- The transaction **may still appear** in `getPurchases()` and `restorePurchases()`
|
|
878
|
+
- `isActive` is **NOT set** for IAP purchases (only for subscriptions)
|
|
879
|
+
- The receipt will **NOT disappear** from the device
|
|
880
|
+
- ✅ **SOLUTION:** Validate the receipt with Apple's servers - refunded transactions will be marked as invalid
|
|
881
|
+
|
|
882
|
+
2. **For Subscriptions:**
|
|
883
|
+
- The transaction **will still appear** in purchase history
|
|
884
|
+
- `isActive` **will be set to `false`** (subscriptions only set this field)
|
|
885
|
+
- `expirationDate` will be set to the refund date (in the past)
|
|
886
|
+
- ✅ **SOLUTION:** Check `isActive === false` OR `expirationDate < now` OR validate receipt on server
|
|
887
|
+
|
|
888
|
+
**Example: Detecting iOS refunds**
|
|
889
|
+
|
|
890
|
+
```typescript
|
|
891
|
+
async function checkIOSPurchaseValidity(purchase: Transaction) {
|
|
892
|
+
// Client-side check (not foolproof)
|
|
893
|
+
if (purchase.isActive === false) {
|
|
894
|
+
console.log('Purchase appears to be refunded or expired');
|
|
895
|
+
return false;
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
// Server-side validation (RECOMMENDED)
|
|
899
|
+
const validationResult = await fetch('https://your-server.com/validate-receipt', {
|
|
900
|
+
method: 'POST',
|
|
901
|
+
body: JSON.stringify({
|
|
902
|
+
receipt: purchase.receipt,
|
|
903
|
+
productId: purchase.productIdentifier
|
|
904
|
+
})
|
|
905
|
+
}).then(r => r.json());
|
|
906
|
+
|
|
907
|
+
if (!validationResult.isValid || validationResult.isRefunded) {
|
|
908
|
+
console.log('Receipt validation failed - purchase refunded or invalid');
|
|
909
|
+
return false;
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
return true;
|
|
913
|
+
}
|
|
914
|
+
```
|
|
915
|
+
|
|
916
|
+
**Sandbox vs Production Behavior:**
|
|
917
|
+
- ✅ Refund behavior is **consistent** between sandbox and production
|
|
918
|
+
- ⚠️ Sandbox refunds are processed instantly, production may take hours/days
|
|
919
|
+
- ✅ Receipt validation works the same in both environments
|
|
920
|
+
|
|
921
|
+
#### Android Refund Behavior
|
|
922
|
+
|
|
923
|
+
**What happens when a user requests a refund:**
|
|
924
|
+
|
|
925
|
+
1. **For In-App Purchases (IAP):**
|
|
926
|
+
- The transaction **typically disappears entirely** from `getPurchases()`
|
|
927
|
+
- Google Play removes refunded purchases from the purchase history
|
|
928
|
+
- No receipt or transaction will be returned
|
|
929
|
+
- ✅ **SOLUTION:** If a previously-seen purchase is no longer in the list, it was likely refunded
|
|
930
|
+
|
|
931
|
+
2. **For Subscriptions:**
|
|
932
|
+
- The transaction **may disappear** OR
|
|
933
|
+
- `isActive` will be set to `false`
|
|
934
|
+
- `purchaseState` may be undefined or the transaction won't be returned at all
|
|
935
|
+
- ✅ **SOLUTION:** Track purchases on your server and listen for Google Play real-time developer notifications
|
|
936
|
+
|
|
937
|
+
**Example: Detecting Android refunds**
|
|
938
|
+
|
|
939
|
+
```typescript
|
|
940
|
+
// Store previously seen purchases in local storage or your database
|
|
941
|
+
const previousPurchases = getPreviouslyStoredPurchases();
|
|
942
|
+
|
|
943
|
+
const { purchases } = await NativePurchases.getPurchases({
|
|
944
|
+
productType: PURCHASE_TYPE.INAPP
|
|
945
|
+
});
|
|
946
|
+
|
|
947
|
+
// Check for missing purchases (likely refunded)
|
|
948
|
+
previousPurchases.forEach(oldPurchase => {
|
|
949
|
+
const stillExists = purchases.find(
|
|
950
|
+
p => p.transactionId === oldPurchase.transactionId
|
|
951
|
+
);
|
|
952
|
+
|
|
953
|
+
if (!stillExists) {
|
|
954
|
+
console.log(`Purchase ${oldPurchase.productIdentifier} no longer exists - likely refunded`);
|
|
955
|
+
revokePremiumAccess(oldPurchase.productIdentifier);
|
|
956
|
+
}
|
|
957
|
+
});
|
|
958
|
+
|
|
959
|
+
// Check current purchases for validity
|
|
960
|
+
purchases.forEach(purchase => {
|
|
961
|
+
const isValid =
|
|
962
|
+
purchase.purchaseState === 'PURCHASED' &&
|
|
963
|
+
purchase.isAcknowledged === true;
|
|
964
|
+
|
|
965
|
+
if (!isValid) {
|
|
966
|
+
console.log('Invalid purchase state detected');
|
|
967
|
+
// Don't grant access
|
|
968
|
+
}
|
|
969
|
+
});
|
|
970
|
+
|
|
971
|
+
// Store current purchases for next comparison
|
|
972
|
+
storePurchases(purchases);
|
|
973
|
+
```
|
|
974
|
+
|
|
975
|
+
**Sandbox vs Production Behavior:**
|
|
976
|
+
- ⚠️ Sandbox test accounts can make unlimited "purchases" without payment
|
|
977
|
+
- ⚠️ Sandbox refunds are **instant** - purchase disappears immediately
|
|
978
|
+
- ⚠️ Production refunds may take **several hours** before purchase disappears
|
|
979
|
+
- ✅ Testing refunds in production requires real money and real refund requests
|
|
980
|
+
|
|
981
|
+
**IMPORTANT: Without Server-Side Validation:**
|
|
982
|
+
|
|
983
|
+
If you're **not using a backend validator** (not recommended for production), here's what to expect:
|
|
984
|
+
|
|
985
|
+
| Scenario | iOS Behavior | Android Behavior |
|
|
986
|
+
|----------|-------------|------------------|
|
|
987
|
+
| User requests IAP refund | Transaction may still appear in `restorePurchases()`, check `isActive` | Transaction disappears from `getPurchases()` |
|
|
988
|
+
| User cancels subscription | `willCancel: true`, still active until expiration | Transaction remains, check `isActive` and `expirationDate` |
|
|
989
|
+
| Subscription expires naturally | `isActive: false`, `expirationDate` in past | Transaction disappears OR `isActive: false` |
|
|
990
|
+
| User refunds subscription | Transaction remains with `isActive: false` | Transaction may disappear |
|
|
991
|
+
|
|
992
|
+
**RECOMMENDATION: Always implement server-side validation**
|
|
993
|
+
- Listen to Apple's App Store Server Notifications (iOS)
|
|
994
|
+
- Listen to Google Play Real-Time Developer Notifications (Android)
|
|
995
|
+
- Validate receipts/tokens on your server before granting access
|
|
996
|
+
- See the [Backend Validation](#backend-validation) section for implementation
|
|
997
|
+
|
|
998
|
+
### Sandbox vs Production Differences
|
|
999
|
+
|
|
1000
|
+
#### iOS: Sandbox vs Production
|
|
1001
|
+
|
|
1002
|
+
**Similarities:**
|
|
1003
|
+
- ✅ Transaction structure is identical
|
|
1004
|
+
- ✅ All properties return the same data format
|
|
1005
|
+
- ✅ `receipt` validation works (use sandbox Apple endpoint)
|
|
1006
|
+
- ✅ Refund behavior is consistent
|
|
1007
|
+
|
|
1008
|
+
**Differences:**
|
|
1009
|
+
|
|
1010
|
+
| Aspect | Sandbox | Production |
|
|
1011
|
+
|--------|---------|-----------|
|
|
1012
|
+
| Payment processing | Instant, no real money | Real payment, takes seconds |
|
|
1013
|
+
| Receipt validation endpoint | `https://sandbox.itunes.apple.com/verifyReceipt` | `https://buy.itunes.apple.com/verifyReceipt` |
|
|
1014
|
+
| Subscription duration | Compressed (1 week = 5 minutes) | Real duration (1 week = 7 days) |
|
|
1015
|
+
| Refund processing | Instant (via StoreKit Testing) | Takes hours/days, must contact Apple |
|
|
1016
|
+
| Test user requirements | Sandbox Apple ID required | Real Apple ID |
|
|
1017
|
+
| Transaction IDs | Real format, unique per test | Real format, unique per purchase |
|
|
1018
|
+
| `receipt` data | Valid test receipt | Valid production receipt |
|
|
1019
|
+
|
|
1020
|
+
**Testing Refunds in Sandbox:**
|
|
1021
|
+
1. Use StoreKit Configuration file (local testing) for instant refunds
|
|
1022
|
+
2. Or sandbox testing with sandbox Apple ID
|
|
1023
|
+
3. Refunds are instant and can be tested repeatedly
|
|
1024
|
+
4. Receipt validation will show refunded status immediately
|
|
1025
|
+
|
|
1026
|
+
#### Android: Sandbox vs Production
|
|
1027
|
+
|
|
1028
|
+
**Similarities:**
|
|
1029
|
+
- ✅ Transaction structure is identical
|
|
1030
|
+
- ✅ All properties return the same data format
|
|
1031
|
+
- ✅ Purchase state values are the same
|
|
1032
|
+
|
|
1033
|
+
**Differences:**
|
|
1034
|
+
|
|
1035
|
+
| Aspect | License Testing (Sandbox) | Production |
|
|
1036
|
+
|--------|--------------------------|-----------|
|
|
1037
|
+
| Payment processing | No payment required | Real payment required |
|
|
1038
|
+
| Purchase token validation | Works with Google Play API | Works with Google Play API |
|
|
1039
|
+
| Transaction IDs | Test format: `GPA.1234-...` | Real format: `GPA.1234-...` |
|
|
1040
|
+
| Refund processing | Instant (test account only) | Takes hours, appears as purchase disappearing |
|
|
1041
|
+
| Test user requirements | Gmail added to license testers | Real Google account |
|
|
1042
|
+
| `purchaseState` values | Same as production | Same as sandbox |
|
|
1043
|
+
| Refund detection | Purchase disappears immediately | Purchase disappears after hours/days |
|
|
1044
|
+
|
|
1045
|
+
**Testing Refunds in Android Sandbox:**
|
|
1046
|
+
1. **License testers** can make unlimited purchases without payment
|
|
1047
|
+
2. Refunds are **instant** - purchase disappears from `getPurchases()` immediately
|
|
1048
|
+
3. Use **Internal Testing** track for most realistic testing
|
|
1049
|
+
4. Real refunds in production require real purchases and real refund requests via Google Play
|
|
1050
|
+
|
|
1051
|
+
**Key Difference:**
|
|
1052
|
+
- In **sandbox/test**, refunded purchases disappear instantly
|
|
1053
|
+
- In **production**, refunded purchases may remain visible for hours before disappearing
|
|
1054
|
+
- Always implement server-side validation with Google Play Developer API to catch refunds reliably
|
|
1055
|
+
|
|
1056
|
+
### Recommended Access Control Logic
|
|
1057
|
+
|
|
1058
|
+
Based on the above, here's the recommended approach for each platform and product type:
|
|
1059
|
+
|
|
1060
|
+
```typescript
|
|
1061
|
+
import { NativePurchases, PURCHASE_TYPE, Transaction } from '@capgo/native-purchases';
|
|
1062
|
+
import { Capacitor } from '@capacitor/core';
|
|
1063
|
+
|
|
1064
|
+
async function checkUserAccess(productId: string, productType: PURCHASE_TYPE): Promise<boolean> {
|
|
1065
|
+
try {
|
|
1066
|
+
const { purchases } = await NativePurchases.getPurchases({ productType });
|
|
1067
|
+
const purchase = purchases.find(p => p.productIdentifier === productId);
|
|
1068
|
+
|
|
1069
|
+
if (!purchase) {
|
|
1070
|
+
return false; // No purchase found
|
|
1071
|
+
}
|
|
1072
|
+
|
|
1073
|
+
const platform = Capacitor.getPlatform();
|
|
1074
|
+
|
|
1075
|
+
if (platform === 'ios') {
|
|
1076
|
+
// iOS Logic
|
|
1077
|
+
if (productType === PURCHASE_TYPE.INAPP) {
|
|
1078
|
+
// For IAP: presence in list + receipt validation
|
|
1079
|
+
// Note: isActive is NOT set for iOS IAP
|
|
1080
|
+
if (!purchase.receipt) return false;
|
|
1081
|
+
|
|
1082
|
+
// IMPORTANT: Validate receipt on server for production
|
|
1083
|
+
const isValid = await validateReceiptOnServer(purchase.receipt);
|
|
1084
|
+
return isValid;
|
|
1085
|
+
|
|
1086
|
+
} else {
|
|
1087
|
+
// For subscriptions: check isActive and expiration
|
|
1088
|
+
// iOS subscriptions ALWAYS have isActive and expirationDate
|
|
1089
|
+
if (purchase.isActive === false) return false;
|
|
1090
|
+
if (purchase.expirationDate) {
|
|
1091
|
+
const expiration = new Date(purchase.expirationDate);
|
|
1092
|
+
if (expiration < new Date()) return false;
|
|
1093
|
+
}
|
|
1094
|
+
return true;
|
|
1095
|
+
}
|
|
1096
|
+
|
|
1097
|
+
} else if (platform === 'android') {
|
|
1098
|
+
// Android Logic
|
|
1099
|
+
if (productType === PURCHASE_TYPE.INAPP) {
|
|
1100
|
+
// For IAP: check purchase state and acknowledgment
|
|
1101
|
+
// Note: Android does NOT set isActive, expirationDate, or originalPurchaseDate
|
|
1102
|
+
const isValid =
|
|
1103
|
+
(purchase.purchaseState === 'PURCHASED' || purchase.purchaseState === '1') &&
|
|
1104
|
+
purchase.isAcknowledged === true;
|
|
1105
|
+
|
|
1106
|
+
if (!isValid) return false;
|
|
1107
|
+
|
|
1108
|
+
// IMPORTANT: Validate purchaseToken on server for production
|
|
1109
|
+
await validatePurchaseTokenOnServer(purchase.purchaseToken);
|
|
1110
|
+
return true;
|
|
1111
|
+
|
|
1112
|
+
} else {
|
|
1113
|
+
// For subscriptions: check purchase state only
|
|
1114
|
+
// Android does NOT set isActive, expirationDate, or originalPurchaseDate
|
|
1115
|
+
// You MUST use Google Play Developer API on your server to get subscription details
|
|
1116
|
+
const isValidState =
|
|
1117
|
+
(purchase.purchaseState === 'PURCHASED' || purchase.purchaseState === '1') &&
|
|
1118
|
+
purchase.isAcknowledged === true;
|
|
1119
|
+
|
|
1120
|
+
if (!isValidState) return false;
|
|
1121
|
+
|
|
1122
|
+
// CRITICAL: Validate subscription status on server with Google Play API
|
|
1123
|
+
// The Purchase object doesn't include expiration dates
|
|
1124
|
+
const serverStatus = await validateAndGetSubscriptionStatus(purchase.purchaseToken);
|
|
1125
|
+
return serverStatus.isActive && serverStatus.expirationDate > new Date();
|
|
1126
|
+
}
|
|
1127
|
+
}
|
|
1128
|
+
|
|
1129
|
+
return false;
|
|
1130
|
+
} catch (error) {
|
|
1131
|
+
console.error('Error checking user access:', error);
|
|
1132
|
+
return false;
|
|
1133
|
+
}
|
|
1134
|
+
}
|
|
1135
|
+
|
|
1136
|
+
// Example usage
|
|
1137
|
+
async function grantAccessBasedOnPurchase() {
|
|
1138
|
+
// Check for premium IAP
|
|
1139
|
+
const hasPremium = await checkUserAccess(
|
|
1140
|
+
'com.yourapp.premium',
|
|
1141
|
+
PURCHASE_TYPE.INAPP
|
|
1142
|
+
);
|
|
1143
|
+
|
|
1144
|
+
// Check for active subscription
|
|
1145
|
+
const hasSubscription = await checkUserAccess(
|
|
1146
|
+
'com.yourapp.premium.monthly',
|
|
1147
|
+
PURCHASE_TYPE.SUBS
|
|
1148
|
+
);
|
|
1149
|
+
|
|
1150
|
+
if (hasPremium || hasSubscription) {
|
|
1151
|
+
unlockPremiumFeatures();
|
|
1152
|
+
}
|
|
1153
|
+
}
|
|
1154
|
+
```
|
|
1155
|
+
|
|
1156
|
+
**Critical Takeaways:**
|
|
1157
|
+
1. ✅ For **iOS IAP**: `isActive` is NOT set - validate receipt on server
|
|
1158
|
+
2. ✅ For **iOS Subscriptions**: `isActive` and `expirationDate` ARE set - use them!
|
|
1159
|
+
3. ✅ For **Android IAP**: Check `purchaseState === "PURCHASED"` (or "1")
|
|
1160
|
+
4. ✅ For **Android Subscriptions**: `isActive` and `expirationDate` are NOT set - must use Google Play API on server
|
|
1161
|
+
5. ✅ For **Refunds**: iOS purchases may linger (validate server-side), Android purchases disappear
|
|
1162
|
+
6. 🔒 **Always implement server-side validation for production apps**
|
|
1163
|
+
|
|
487
1164
|
### API Reference
|
|
488
1165
|
|
|
489
1166
|
#### Core Methods
|
|
@@ -499,14 +1176,17 @@ await NativePurchases.getProduct({ productIdentifier: 'product_id' });
|
|
|
499
1176
|
await NativePurchases.getProducts({ productIdentifiers: ['id1', 'id2'] });
|
|
500
1177
|
|
|
501
1178
|
// Purchase a product
|
|
502
|
-
await NativePurchases.purchaseProduct({
|
|
503
|
-
productIdentifier: 'product_id',
|
|
504
|
-
quantity: 1
|
|
1179
|
+
await NativePurchases.purchaseProduct({
|
|
1180
|
+
productIdentifier: 'product_id',
|
|
1181
|
+
quantity: 1
|
|
505
1182
|
});
|
|
506
1183
|
|
|
507
1184
|
// Restore previous purchases
|
|
508
1185
|
await NativePurchases.restorePurchases();
|
|
509
1186
|
|
|
1187
|
+
// Open subscription management page
|
|
1188
|
+
await NativePurchases.manageSubscriptions();
|
|
1189
|
+
|
|
510
1190
|
// Get plugin version
|
|
511
1191
|
await NativePurchases.getPluginVersion();
|
|
512
1192
|
```
|
|
@@ -731,6 +1411,7 @@ This approach balances immediate user gratification with proper server-side vali
|
|
|
731
1411
|
* [`isBillingSupported()`](#isbillingsupported)
|
|
732
1412
|
* [`getPluginVersion()`](#getpluginversion)
|
|
733
1413
|
* [`getPurchases(...)`](#getpurchases)
|
|
1414
|
+
* [`manageSubscriptions()`](#managesubscriptions)
|
|
734
1415
|
* [`addListener('transactionUpdated', ...)`](#addlistenertransactionupdated-)
|
|
735
1416
|
* [`removeAllListeners()`](#removealllisteners)
|
|
736
1417
|
* [Interfaces](#interfaces)
|
|
@@ -849,6 +1530,23 @@ This method queries the platform's purchase history for the current user.
|
|
|
849
1530
|
--------------------
|
|
850
1531
|
|
|
851
1532
|
|
|
1533
|
+
### manageSubscriptions()
|
|
1534
|
+
|
|
1535
|
+
```typescript
|
|
1536
|
+
manageSubscriptions() => Promise<void>
|
|
1537
|
+
```
|
|
1538
|
+
|
|
1539
|
+
Opens the platform's native subscription management page.
|
|
1540
|
+
This allows users to view, modify, or cancel their subscriptions.
|
|
1541
|
+
|
|
1542
|
+
- iOS: Opens the App Store subscription management page for the current app
|
|
1543
|
+
- Android: Opens the Google Play subscription management page
|
|
1544
|
+
|
|
1545
|
+
**Since:** 7.10.0
|
|
1546
|
+
|
|
1547
|
+
--------------------
|
|
1548
|
+
|
|
1549
|
+
|
|
852
1550
|
### addListener('transactionUpdated', ...)
|
|
853
1551
|
|
|
854
1552
|
```typescript
|
|
@@ -885,26 +1583,26 @@ Remove all registered listeners
|
|
|
885
1583
|
|
|
886
1584
|
#### Transaction
|
|
887
1585
|
|
|
888
|
-
| Prop | Type | Description
|
|
889
|
-
| -------------------------- | ---------------------------- |
|
|
890
|
-
| **`transactionId`** | <code>string</code> |
|
|
891
|
-
| **`receipt`** | <code>string</code> | Receipt data for validation (
|
|
892
|
-
| **`appAccountToken`** | <code>string \| null</code> |
|
|
893
|
-
| **`productIdentifier`** | <code>string</code> | <a href="#product">Product</a>
|
|
894
|
-
| **`purchaseDate`** | <code>string</code> | Purchase date of the transaction in ISO 8601 format.
|
|
895
|
-
| **`originalPurchaseDate`** | <code>string</code> | Original purchase date of the transaction in ISO 8601 format
|
|
896
|
-
| **`expirationDate`** | <code>string</code> | Expiration date of the transaction in ISO 8601 format
|
|
897
|
-
| **`isActive`** | <code>boolean</code> | Whether the
|
|
898
|
-
| **`willCancel`** | <code>boolean \| null</code> | Whether the subscription will be cancelled at the end of the billing cycle
|
|
899
|
-
| **`purchaseState`** | <code>string</code> | Purchase state of the transaction.
|
|
900
|
-
| **`orderId`** | <code>string</code> | Order ID associated with the transaction
|
|
901
|
-
| **`purchaseToken`** | <code>string</code> | Purchase token associated with the transaction
|
|
902
|
-
| **`isAcknowledged`** | <code>boolean</code> | Whether the purchase has been acknowledged
|
|
903
|
-
| **`quantity`** | <code>number</code> | Quantity purchased.
|
|
904
|
-
| **`productType`** | <code>string</code> | <a href="#product">Product</a> type
|
|
905
|
-
| **`isTrialPeriod`** | <code>boolean</code> | Whether the transaction is a trial period.
|
|
906
|
-
| **`isInIntroPricePeriod`** | <code>boolean</code> | Whether the transaction is in
|
|
907
|
-
| **`isInGracePeriod`** | <code>boolean</code> | Whether the transaction is in grace period.
|
|
1586
|
+
| Prop | Type | Description | Default | Since |
|
|
1587
|
+
| -------------------------- | ---------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------- | ----- |
|
|
1588
|
+
| **`transactionId`** | <code>string</code> | Unique identifier for the transaction. | | 1.0.0 |
|
|
1589
|
+
| **`receipt`** | <code>string</code> | Receipt data for validation (base64 encoded StoreKit receipt). Send this to your backend for server-side validation with Apple's receipt verification API. The receipt remains available even after refund - server validation is required to detect refunded transactions. | | 1.0.0 |
|
|
1590
|
+
| **`appAccountToken`** | <code>string \| null</code> | An optional obfuscated identifier that uniquely associates the transaction with a user account in your app. PURPOSE: - Fraud detection: Helps platforms detect irregular activity (e.g., many devices purchasing on the same account) - User linking: Links purchases to in-game characters, avatars, or in-app profiles PLATFORM DIFFERENCES: - iOS: Must be a valid UUID format (e.g., "550e8400-e29b-41d4-a716-446655440000") Apple's StoreKit 2 requires UUID format for the appAccountToken parameter - Android: Can be any obfuscated string (max 64 chars), maps to Google Play's ObfuscatedAccountId Google recommends using encryption or one-way hash SECURITY REQUIREMENTS (especially for Android): - DO NOT store Personally Identifiable Information (PII) like emails in cleartext - Use encryption or a one-way hash to generate an obfuscated identifier - Maximum length: 64 characters (both platforms) - Storing PII in cleartext will result in purchases being blocked by Google Play IMPLEMENTATION EXAMPLE: ```typescript // For iOS: Generate a deterministic UUID from user ID import { v5 as uuidv5 } from 'uuid'; const NAMESPACE = '6ba7b810-9dad-11d1-80b4-00c04fd430c8'; // Your app's namespace UUID const appAccountToken = uuidv5(userId, NAMESPACE); // For Android: Can also use UUID or any hashed value // The same UUID approach works for both platforms ``` | | |
|
|
1591
|
+
| **`productIdentifier`** | <code>string</code> | <a href="#product">Product</a> identifier associated with the transaction. | | 1.0.0 |
|
|
1592
|
+
| **`purchaseDate`** | <code>string</code> | Purchase date of the transaction in ISO 8601 format. | | 1.0.0 |
|
|
1593
|
+
| **`originalPurchaseDate`** | <code>string</code> | Original purchase date of the transaction in ISO 8601 format. For subscription renewals, this shows the date of the original subscription purchase, while purchaseDate shows the date of the current renewal. | | 1.0.0 |
|
|
1594
|
+
| **`expirationDate`** | <code>string</code> | Expiration date of the transaction in ISO 8601 format. Check this date to determine if a subscription is still valid. Compare with current date: if expirationDate > now, subscription is active. | | 1.0.0 |
|
|
1595
|
+
| **`isActive`** | <code>boolean</code> | Whether the subscription is still active/valid. For iOS subscriptions, check if isActive === true to verify an active subscription. For expired or refunded iOS subscriptions, this will be false. | | 1.0.0 |
|
|
1596
|
+
| **`willCancel`** | <code>boolean \| null</code> | Whether the subscription will be cancelled at the end of the billing cycle. - `true`: User has cancelled but subscription remains active until expiration - `false`: Subscription will auto-renew - `null`: Status unknown or not available | <code>null</code> | 1.0.0 |
|
|
1597
|
+
| **`purchaseState`** | <code>string</code> | Purchase state of the transaction (numeric string value). **Android Values:** - `"1"`: Purchase completed and valid (PURCHASED state) - `"0"`: Payment pending (PENDING state, e.g., cash payment processing) - Other numeric values: Various other states Always check `purchaseState === "1"` on Android to verify a valid purchase. Refunded purchases typically disappear from getPurchases() rather than showing a different state. | | 1.0.0 |
|
|
1598
|
+
| **`orderId`** | <code>string</code> | Order ID associated with the transaction. Use this for server-side verification on Android. This is the Google Play order ID. | | 1.0.0 |
|
|
1599
|
+
| **`purchaseToken`** | <code>string</code> | Purchase token associated with the transaction. Send this to your backend for server-side validation with Google Play Developer API. This is the Android equivalent of iOS's receipt field. | | 1.0.0 |
|
|
1600
|
+
| **`isAcknowledged`** | <code>boolean</code> | Whether the purchase has been acknowledged. Purchases must be acknowledged within 3 days or they will be refunded. This plugin automatically acknowledges purchases. | | 1.0.0 |
|
|
1601
|
+
| **`quantity`** | <code>number</code> | Quantity purchased. | <code>1</code> | 1.0.0 |
|
|
1602
|
+
| **`productType`** | <code>string</code> | <a href="#product">Product</a> type. - `"inapp"`: One-time in-app purchase - `"subs"`: Subscription | | 1.0.0 |
|
|
1603
|
+
| **`isTrialPeriod`** | <code>boolean</code> | Whether the transaction is in a trial period. - `true`: Currently in free trial period - `false`: Not in trial period | | 1.0.0 |
|
|
1604
|
+
| **`isInIntroPricePeriod`** | <code>boolean</code> | Whether the transaction is in an introductory price period. Introductory pricing is a discounted rate, different from a free trial. - `true`: Currently using introductory pricing - `false`: Not in intro period | | 1.0.0 |
|
|
1605
|
+
| **`isInGracePeriod`** | <code>boolean</code> | Whether the transaction is in a grace period. Grace period allows users to fix payment issues while maintaining access. You typically want to continue providing access during this time. - `true`: Subscription payment failed but user still has access - `false`: Not in grace period | | 1.0.0 |
|
|
908
1606
|
|
|
909
1607
|
|
|
910
1608
|
#### Product
|