Skip to content

Commit 3af9a45

Browse files
authored
feat(ios): add renewalInfoIOS for subscription status tracking (#240)
Applied hyodotdev/openiap-apple#24 <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * iOS subscription status cards: Upgrade Detected and Cancellation Detected with per-subscription details (current vs. upgrading product, dates, auto-renew status) and per-item renewal-info viewing. * Dedicated renewal-info view button with alert-based details. * **Improvements** * More reliable status refresh by waiting until subscriptions load. * **Style** * New visual styles for upgrade/cancellation cards and renewal-info controls. * **New Support** * Added an additional subscription product (e.g., yearly). <!-- end of auto-generated comment: release notes by coderabbit.ai -->
1 parent 33f0f4c commit 3af9a45

File tree

6 files changed

+379
-9
lines changed

6 files changed

+379
-9
lines changed

example/__tests__/subscription-flow.test.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -262,7 +262,7 @@ describe('SubscriptionFlow Component', () => {
262262

263263
fireEvent.press(retryButton);
264264
expect(mockFetchProducts).toHaveBeenCalledWith({
265-
skus: ['dev.hyo.martie.premium'],
265+
skus: ['dev.hyo.martie.premium', 'dev.hyo.martie.premium_year'],
266266
type: 'subs',
267267
});
268268
});

example/app/subscription-flow.tsx

Lines changed: 320 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -356,6 +356,217 @@ function SubscriptionFlow({
356356
) : null}
357357
</View>
358358

359+
{/* Subscription Upgrade Detection - iOS renewalInfo */}
360+
{(() => {
361+
if (Platform.OS !== 'ios' || activeSubscriptions.length === 0) {
362+
return null;
363+
}
364+
365+
const upgradablePurchases = availablePurchases.filter((p) => {
366+
const iosPurchase = p as PurchaseIOS;
367+
const pendingProductId =
368+
iosPurchase.renewalInfoIOS?.pendingUpgradeProductId;
369+
370+
// Show upgrade card if there's a pending upgrade product that's different
371+
// from the current product. In production, you might want to also check
372+
// willAutoRenew, but Apple Sandbox behavior can be inconsistent.
373+
return (
374+
pendingProductId &&
375+
pendingProductId !== p.productId &&
376+
activeSubscriptions.some((sub) => sub.productId === p.productId)
377+
);
378+
});
379+
380+
if (upgradablePurchases.length === 0) {
381+
return null;
382+
}
383+
384+
return (
385+
<View style={styles.upgradeDetectionCard}>
386+
<Text style={styles.upgradeDetectionTitle}>
387+
🎉 Subscription Upgrade Detected
388+
</Text>
389+
{upgradablePurchases.map((purchase, idx) => {
390+
const iosPurchase = purchase as PurchaseIOS;
391+
const renewalInfo = iosPurchase.renewalInfoIOS;
392+
const currentProduct = subscriptions.find(
393+
(s) => s.id === purchase.productId,
394+
);
395+
const upgradeProduct = subscriptions.find(
396+
(s) => s.id === renewalInfo?.pendingUpgradeProductId,
397+
);
398+
399+
return (
400+
<View key={idx} style={styles.upgradeInfoBox}>
401+
<View style={styles.upgradeRow}>
402+
<Text style={styles.upgradeLabel}>Current:</Text>
403+
<Text style={styles.upgradeValue}>
404+
{currentProduct?.title || purchase.productId}
405+
</Text>
406+
</View>
407+
<View style={styles.upgradeArrow}>
408+
<Text style={styles.upgradeArrowText}>⬇️</Text>
409+
</View>
410+
<View style={styles.upgradeRow}>
411+
<Text style={styles.upgradeLabel}>Upgrading to:</Text>
412+
<Text
413+
style={[styles.upgradeValue, styles.highlightText]}
414+
>
415+
{upgradeProduct?.title ||
416+
renewalInfo?.pendingUpgradeProductId ||
417+
'Unknown'}
418+
</Text>
419+
</View>
420+
{iosPurchase.expirationDateIOS ? (
421+
<View style={styles.upgradeRow}>
422+
<Text style={styles.upgradeLabel}>Upgrade Date:</Text>
423+
<Text style={styles.upgradeValue}>
424+
{new Date(
425+
iosPurchase.expirationDateIOS,
426+
).toLocaleDateString()}
427+
</Text>
428+
</View>
429+
) : null}
430+
{renewalInfo?.willAutoRenew !== undefined ? (
431+
<View style={styles.upgradeRow}>
432+
<Text style={styles.upgradeLabel}>Auto-Renew:</Text>
433+
<Text
434+
style={[
435+
styles.upgradeValue,
436+
renewalInfo.willAutoRenew
437+
? styles.activeStatus
438+
: styles.cancelledStatus,
439+
]}
440+
>
441+
{renewalInfo.willAutoRenew
442+
? '✅ Enabled'
443+
: '⚠️ Disabled'}
444+
</Text>
445+
</View>
446+
) : null}
447+
<Text style={styles.upgradeNote}>
448+
💡 Your subscription will automatically upgrade when the
449+
current period ends.
450+
{renewalInfo?.willAutoRenew === false
451+
? ' Note: Auto-renew is currently disabled.'
452+
: ''}
453+
</Text>
454+
455+
{/* Show renewalInfo details */}
456+
<TouchableOpacity
457+
style={styles.viewRenewalInfoButton}
458+
onPress={() => {
459+
Alert.alert(
460+
'Renewal Info Details',
461+
JSON.stringify(renewalInfo, null, 2),
462+
[{text: 'OK'}],
463+
);
464+
}}
465+
>
466+
<Text style={styles.viewRenewalInfoButtonText}>
467+
📋 View renewalInfo
468+
</Text>
469+
</TouchableOpacity>
470+
</View>
471+
);
472+
})}
473+
</View>
474+
);
475+
})()}
476+
477+
{/* Subscription Cancellation Detection - iOS renewalInfo */}
478+
{(() => {
479+
if (Platform.OS !== 'ios') {
480+
return null;
481+
}
482+
483+
const cancelledPurchases = availablePurchases.filter((p) => {
484+
const iosPurchase = p as PurchaseIOS;
485+
return (
486+
iosPurchase.renewalInfoIOS?.willAutoRenew === false &&
487+
!iosPurchase.renewalInfoIOS?.pendingUpgradeProductId &&
488+
activeSubscriptions.some((sub) => sub.productId === p.productId)
489+
);
490+
});
491+
492+
if (cancelledPurchases.length === 0) {
493+
return null;
494+
}
495+
496+
return (
497+
<View style={styles.cancellationDetectionCard}>
498+
<Text style={styles.cancellationDetectionTitle}>
499+
⚠️ Subscription Cancelled
500+
</Text>
501+
{cancelledPurchases.map((purchase, idx) => {
502+
const iosPurchase = purchase as PurchaseIOS;
503+
const renewalInfo = iosPurchase.renewalInfoIOS;
504+
const currentProduct = subscriptions.find(
505+
(s) => s.id === purchase.productId,
506+
);
507+
const preferredProduct = subscriptions.find(
508+
(s) => s.id === renewalInfo?.autoRenewPreference,
509+
);
510+
511+
return (
512+
<View key={idx} style={styles.cancellationInfoBox}>
513+
<View style={styles.upgradeRow}>
514+
<Text style={styles.upgradeLabel}>Product:</Text>
515+
<Text style={styles.upgradeValue}>
516+
{currentProduct?.title || purchase.productId}
517+
</Text>
518+
</View>
519+
{iosPurchase.expirationDateIOS ? (
520+
<View style={styles.upgradeRow}>
521+
<Text style={styles.upgradeLabel}>Expires:</Text>
522+
<Text
523+
style={[styles.upgradeValue, styles.expiredText]}
524+
>
525+
{new Date(
526+
iosPurchase.expirationDateIOS,
527+
).toLocaleDateString()}
528+
</Text>
529+
</View>
530+
) : null}
531+
{renewalInfo?.pendingUpgradeProductId &&
532+
renewalInfo.pendingUpgradeProductId !==
533+
purchase.productId ? (
534+
<View style={styles.upgradeRow}>
535+
<Text style={styles.upgradeLabel}>Next Renewal:</Text>
536+
<Text style={styles.upgradeValue}>
537+
{preferredProduct?.title ||
538+
renewalInfo.autoRenewPreference ||
539+
'None'}
540+
</Text>
541+
</View>
542+
) : null}
543+
<Text style={styles.cancellationNote}>
544+
💡 Your subscription will not auto-renew. You'll have
545+
access until the expiration date.
546+
</Text>
547+
548+
{/* Show renewalInfo details */}
549+
<TouchableOpacity
550+
style={styles.viewRenewalInfoButton}
551+
onPress={() => {
552+
Alert.alert(
553+
'Renewal Info Details',
554+
JSON.stringify(renewalInfo, null, 2),
555+
[{text: 'OK'}],
556+
);
557+
}}
558+
>
559+
<Text style={styles.viewRenewalInfoButtonText}>
560+
📋 View renewalInfo
561+
</Text>
562+
</TouchableOpacity>
563+
</View>
564+
);
565+
})}
566+
</View>
567+
);
568+
})()}
569+
359570
<View style={styles.subscriptionActionButtons}>
360571
<TouchableOpacity
361572
style={styles.refreshButton}
@@ -823,10 +1034,11 @@ function SubscriptionFlowContainer() {
8231034
}, [connected, fetchProducts, getAvailablePurchases]);
8241035

8251036
useEffect(() => {
826-
if (connected) {
1037+
if (connected && subscriptions.length > 0) {
1038+
// Wait until subscriptions are loaded before checking status
8271039
void handleRefreshStatus();
8281040
}
829-
}, [connected, handleRefreshStatus]);
1041+
}, [connected, subscriptions.length, handleRefreshStatus]);
8301042

8311043
useEffect(() => {
8321044
ExpoIapConsole.log(
@@ -1403,4 +1615,110 @@ const styles = StyleSheet.create({
14031615
fontSize: 16,
14041616
fontWeight: '600',
14051617
},
1618+
upgradeDetectionCard: {
1619+
backgroundColor: '#fff5e6',
1620+
borderRadius: 12,
1621+
padding: 16,
1622+
marginTop: 16,
1623+
borderWidth: 2,
1624+
borderColor: '#ff9800',
1625+
},
1626+
upgradeDetectionTitle: {
1627+
fontSize: 16,
1628+
fontWeight: '700',
1629+
color: '#e65100',
1630+
marginBottom: 12,
1631+
},
1632+
upgradeInfoBox: {
1633+
backgroundColor: '#fff',
1634+
borderRadius: 8,
1635+
padding: 12,
1636+
marginTop: 8,
1637+
},
1638+
upgradeRow: {
1639+
flexDirection: 'row',
1640+
justifyContent: 'space-between',
1641+
alignItems: 'center',
1642+
paddingVertical: 6,
1643+
},
1644+
upgradeLabel: {
1645+
fontSize: 14,
1646+
fontWeight: '500',
1647+
color: '#666',
1648+
},
1649+
upgradeValue: {
1650+
fontSize: 14,
1651+
fontWeight: '600',
1652+
color: '#333',
1653+
flex: 1,
1654+
textAlign: 'right',
1655+
},
1656+
highlightText: {
1657+
color: '#ff9800',
1658+
fontWeight: '700',
1659+
},
1660+
upgradeArrow: {
1661+
alignItems: 'center',
1662+
paddingVertical: 8,
1663+
},
1664+
upgradeArrowText: {
1665+
fontSize: 24,
1666+
},
1667+
upgradeNote: {
1668+
fontSize: 12,
1669+
color: '#666',
1670+
fontStyle: 'italic',
1671+
marginTop: 12,
1672+
lineHeight: 18,
1673+
backgroundColor: '#f5f5f5',
1674+
padding: 8,
1675+
borderRadius: 6,
1676+
},
1677+
viewRenewalInfoButton: {
1678+
marginTop: 12,
1679+
paddingVertical: 10,
1680+
paddingHorizontal: 16,
1681+
backgroundColor: '#007AFF',
1682+
borderRadius: 8,
1683+
alignItems: 'center',
1684+
},
1685+
viewRenewalInfoButtonText: {
1686+
color: '#fff',
1687+
fontSize: 14,
1688+
fontWeight: '600',
1689+
},
1690+
cancellationDetectionCard: {
1691+
backgroundColor: '#fff3cd',
1692+
borderRadius: 12,
1693+
padding: 16,
1694+
marginTop: 16,
1695+
borderWidth: 2,
1696+
borderColor: '#ffc107',
1697+
},
1698+
cancellationDetectionTitle: {
1699+
fontSize: 16,
1700+
fontWeight: '700',
1701+
color: '#856404',
1702+
marginBottom: 12,
1703+
},
1704+
cancellationInfoBox: {
1705+
backgroundColor: '#fff',
1706+
borderRadius: 8,
1707+
padding: 12,
1708+
marginTop: 8,
1709+
},
1710+
expiredText: {
1711+
color: '#dc3545',
1712+
fontWeight: '700',
1713+
},
1714+
cancellationNote: {
1715+
fontSize: 12,
1716+
color: '#856404',
1717+
fontStyle: 'italic',
1718+
marginTop: 12,
1719+
lineHeight: 18,
1720+
backgroundColor: '#fffbf0',
1721+
padding: 8,
1722+
borderRadius: 6,
1723+
},
14061724
});

example/src/utils/constants.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,10 @@ export const PRODUCT_IDS: string[] = [
1717
];
1818

1919
// Subscription product IDs
20-
export const SUBSCRIPTION_PRODUCT_IDS: string[] = ['dev.hyo.martie.premium'];
20+
export const SUBSCRIPTION_PRODUCT_IDS: string[] = [
21+
'dev.hyo.martie.premium',
22+
'dev.hyo.martie.premium_year',
23+
];
2124

2225
// Optionally export a single default subscription for convenience
2326
export const DEFAULT_SUBSCRIPTION_PRODUCT_ID = SUBSCRIPTION_PRODUCT_IDS[0];

expo-module.config.json

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,16 @@
11
{
2-
"platforms": ["ios", "android"],
2+
"platforms": [
3+
"ios",
4+
"android"
5+
],
36
"ios": {
4-
"modules": ["ExpoIapModule"]
7+
"modules": [
8+
"ExpoIapModule"
9+
]
510
},
611
"android": {
7-
"modules": ["expo.modules.iap.ExpoIapModule"]
12+
"modules": [
13+
"expo.modules.iap.ExpoIapModule"
14+
]
815
}
916
}

openiap-versions.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
{
2-
"apple": "1.2.19",
2+
"apple": "1.2.20",
33
"google": "1.2.12",
4-
"gql": "1.0.12"
4+
"gql": "1.2.0"
55
}

0 commit comments

Comments
 (0)