This use case explains how to implement an auto-renewing subscription on iOS and macOS using the App Store platform and cordova-plugin-purchase v13+. Reliable subscription management requires server-side receipt validation.
1. Platform Setup
First, ensure your Apple Developer account, App Store Connect (including creating subscription products and groups), and Xcode project are correctly configured.
Next, set up the basic JavaScript to initialize the plugin, register your subscription products (including group if applicable), configure the mandatory validator, and display subscription status based on verified receipt data.
This section guides you through setting up the initial HTML and JavaScript required to initialize the purchase plugin for subscriptions, register your subscription products, configure a validator (essential for subscriptions), and display product information and subscription status. The actual purchase flow logic is deferred to platform-specific guides.
Explanation: We use #user-status for the overall subscription status, #product-details for the list of plans, and #management-buttons for actions like "Restore" or "Manage". Update the Content-Security-Policy to allow connections to your validator.
Step 2: Implement initializeStoreAndSetupListeners for Subscriptions
Replace the placeholder initializeStoreAndSetupListeners function with the following code. This version registers subscription products, mandates validator setup, listens for relevant events, and initializes the store.
// This function should be called by onDeviceReady after basic setup
function initializeStoreAndSetupListeners() {
console.log('Setting up store for Subscriptions...');
setStatus('Initializing Store for Subscriptions...');
const { store, ProductType, Platform, LogLevel, Utils } = CdvPurchase;
// --- Product Definitions ---
// Define your subscription product IDs and groups.
// Replace these with your actual IDs configured in the App/Play Store.
const MY_MONTHLY_SUB_ID = 'subscription_monthly';
const MY_YEARLY_SUB_ID = 'subscription_yearly';
const MY_SUBSCRIPTION_GROUP = 'premium_access'; // Group allows upgrades/downgrades
// --- Register Products ---
store.register([{
id: MY_MONTHLY_SUB_ID,
type: ProductType.PAID_SUBSCRIPTION,
platform: store.defaultPlatform(),
group: MY_SUBSCRIPTION_GROUP
}, {
id: MY_YEARLY_SUB_ID,
type: ProductType.PAID_SUBSCRIPTION,
platform: store.defaultPlatform(),
group: MY_SUBSCRIPTION_GROUP
}]);
// --- Setup Receipt Validator (MANDATORY for Subscriptions) ---
// Subscriptions REQUIRE server-side validation for reliable status tracking.
// Replace with your actual validator URL or function.
store.validator = "https://your-validator.com/validate"; // Example URL
// store.validator = new CdvPurchase.Iaptic({...}).validator; // Example using Iaptic helper
if (!store.validator || store.validator.includes("your-validator.com")) {
const msg = "VALIDATOR NOT CONFIGURED. Subscriptions require server-side validation.";
console.error(msg);
setStatus('ERROR: ' + msg);
// It's strongly recommended to halt or clearly warn the user if the validator isn't set up.
// return;
}
// --- Setup Event Listeners ---
store.when()
.productUpdated(product => {
// Update UI when product details like price change.
console.log('Product updated: ' + product.id);
refreshUI();
})
.receiptUpdated(receipt => {
// Local receipt changes. Refresh UI, but rely on 'verified' for definitive status.
console.log('Local receipt updated.');
refreshUI();
})
.verified(receipt => {
// Receipt validated with the server. This is the source of truth for subscription status.
console.log('Receipt verified.');
refreshUI(); // Refresh UI based on the latest verified data
})
// Purchase flow listeners (.approved, .finished, .cancelled)
// will be added in the platform-specific purchase flow sections.
; // End of store.when() chain
// --- Initialize the Store ---
// Initialize the platform(s) for your subscriptions.
store.initialize([store.defaultPlatform()])
.then(() => {
console.log('Store initialized successfully.');
setStatus('Store ready.');
refreshUI(); // Render the UI with initial data
})
.catch(err => {
console.error('Store initialization failed:', err);
setStatus('Store failed to initialize.');
});
}
// --- UI Rendering ---
// This function updates the UI based on product data and verified subscription status
function refreshUI() {
console.log('Refreshing Subscription UI...');
const { store, ProductType, Platform, RecurrenceMode, PaymentMode, Utils, RenewalIntent } = CdvPurchase;
const statusEl = document.getElementById('user-status'); // Target subscription status display
const productsEl = document.getElementById('product-details'); // Target product list display
const managementEl = document.getElementById('management-buttons'); // Target management buttons area
if (!statusEl || !productsEl || !managementEl) {
console.error("Required UI elements not found!");
return;
}
// --- Determine Active Subscription Status ---
// Find the latest, active, verified subscription purchase.
const activeSub = store.verifiedPurchases
.filter(p => store.get(p.id)?.type === ProductType.PAID_SUBSCRIPTION && !p.isExpired)
.sort((a, b) => (b.purchaseDate ?? 0) - (a.purchaseDate ?? 0))[0]; // Get the most recent active one
let statusMessage = 'Subscription: Inactive';
if (activeSub) {
const expiry = activeSub.expiryDate ? new Date(activeSub.expiryDate).toLocaleDateString() : 'N/A';
const productName = store.get(activeSub.id, activeSub.platform)?.title ?? activeSub.id;
statusMessage = `Subscription: ACTIVE (${productName})`;
statusMessage += ` - Expires: ${expiry}`;
if (activeSub.renewalIntent === RenewalIntent.LAPSE) statusMessage += ' <span style="color:orange;">[Will Not Renew]</span>';
if (activeSub.isTrialPeriod) statusMessage += ' (Trial)';
if (activeSub.isBillingRetryPeriod) statusMessage += ' <span style="color:red;">[Billing Issue!]</span>';
// Unlock premium features based on activeSub
} else {
// Lock premium features
// Optionally check for expired subs to show a message
const latestExpired = store.verifiedPurchases
.filter(p => store.get(p.id)?.type === ProductType.PAID_SUBSCRIPTION && p.isExpired)
.sort((a, b) => (b.expiryDate ?? 0) - (a.expiryDate ?? 0))[0];
if (latestExpired) {
const expiry = latestExpired.expiryDate ? new Date(latestExpired.expiryDate).toLocaleDateString() : 'N/A';
statusMessage = `Subscription: Expired on ${expiry}. Please resubscribe.`;
}
}
statusEl.innerHTML = statusMessage; // Use innerHTML for potential styling
// --- Render Subscription Products/Offers ---
productsEl.innerHTML = store.products
.filter(p => p.type === ProductType.PAID_SUBSCRIPTION) // Show only subscriptions
.map(product => {
let productHtml = `<div><h4>${product.title || product.id}</h4>`;
if (product.description) productHtml += `<p>${product.description}</p>`;
if (product.offers && product.offers.length > 0) {
product.offers.forEach(offer => {
productHtml += `<div style="margin-left: 10px; border-left: 2px solid #ccc; padding-left: 10px;">`;
// Format pricing phases
const priceDetails = offer.pricingPhases.map(phase => {
let phaseDesc = `${phase.price}`;
if (phase.billingPeriod) {
phaseDesc += ` / ${Utils.formatDurationEN(phase.billingPeriod, { omitOne: true })}`;
}
if (phase.paymentMode === PaymentMode.FREE_TRIAL) {
phaseDesc = `Free Trial for ${Utils.formatDurationEN(phase.billingPeriod)}`;
} else if (phase.paymentMode === PaymentMode.PAY_AS_YOU_GO && phase.recurrenceMode === RecurrenceMode.FINITE_RECURRING) {
phaseDesc += ` (for ${phase.billingCycles} cycles)`;
} else if (phase.paymentMode === PaymentMode.UP_FRONT && phase.billingPeriod) {
phaseDesc = `${phase.price} for ${Utils.formatDurationEN(phase.billingPeriod)}`;
}
return phaseDesc;
}).join(' then ');
productHtml += `<p><strong>Offer:</strong> ${priceDetails}</p>`;
// --- Button Logic ---
const isCurrentActiveSub = activeSub && activeSub.id === product.id && activeSub.platform === product.platform;
const canUpgradeDowngrade = activeSub && !isCurrentActiveSub && product.group && store.get(activeSub.id, activeSub.platform)?.group === product.group;
if (isCurrentActiveSub) {
productHtml += '<p><em>(Currently Active)</em></p>';
} else if ((!activeSub || canUpgradeDowngrade) && offer.canPurchase) {
const buttonLabel = canUpgradeDowngrade ? 'Switch Plan' : 'Subscribe';
// The 'subscribe' function will be implemented in platform-specific guides
productHtml += `<button onclick="subscribe('${product.id}', '${product.platform}', '${offer.id}')">${buttonLabel}</button>`;
} else {
productHtml += '<p><em>(Cannot purchase)</em></p>';
}
productHtml += `</div>`; // Close offer div
});
} else {
productHtml += '<p>Pricing information not available.</p>';
}
productHtml += `</div>`; // Close product div
return productHtml;
}).join('<hr/>');
// --- Render Management Buttons ---
managementEl.innerHTML = ''; // Clear previous
const currentPlatform = activeSub?.platform ?? store.defaultPlatform();
if (store.checkSupport(currentPlatform, 'manageSubscriptions')) {
managementEl.innerHTML += `<button onclick="CdvPurchase.store.manageSubscriptions('${currentPlatform}')">Manage Subscription</button> `;
}
if (store.checkSupport(currentPlatform, 'manageBilling')) {
managementEl.innerHTML += `<button onclick="CdvPurchase.store.manageBilling('${currentPlatform}')">Manage Billing</button> `;
}
// Restore button might be useful even without an active sub
if (store.checkSupport(currentPlatform, 'restorePurchases')) {
managementEl.innerHTML += `<button onclick="restoreSubscriptions()">Restore Purchases</button>`;
}
}
// --- Placeholder for Purchase Action ---
// This will be implemented in the platform-specific guides (subscription-*.md)
window.subscribe = function(productId, platform, offerId) {
console.log(`Placeholder: subscribe(${productId}, ${platform}, ${offerId}) called.`);
alert('Subscription purchase logic needs to be implemented for the specific platform.');
};
// --- Placeholder for Restore Action ---
// This will be implemented in the platform-specific guides
window.restoreSubscriptions = function() {
console.log('Placeholder: restoreSubscriptions() called.');
setStatus('Restoring purchases...');
CdvPurchase.store.restorePurchases().then((result) => {
setStatus(result ? `Restore failed: ${result.message}` : 'Restore attempt complete.');
refreshUI();
});
};
// Initial UI update on device ready
document.addEventListener('deviceready', () => {
// Ensure the initial call to initializeStoreAndSetupListeners happens
if (typeof initializeStoreAndSetupListeners === 'function') {
// Already called by onDeviceReady in the initial script
} else {
initializeStoreAndSetupListeners = initializeStore;
initializeStoreAndSetupListeners();
}
// Initial render based on potentially stored state or loading status
refreshUI();
}, false);
// Ensure setStatus is defined
if (typeof setStatus !== 'function') {
setStatus = (message) => console.log('[Status] ' + message);
}
// Helper to format ISO durations (ensure this is included, e.g., from initial setup)
if (!CdvPurchase.Utils) CdvPurchase.Utils = {};
if (!CdvPurchase.Utils.formatDurationEN) {
CdvPurchase.Utils.formatDurationEN = function(iso, options) {
if (!iso) return '';
const l = iso.length;
const n = iso.slice(1, l - 1);
if (n === '1') {
return options?.omitOne ?
({ 'D': 'day', 'W': 'week', 'M': 'month', 'Y': 'year' }[iso[l - 1]]) || iso[l - 1]
: ({ 'D': '1 day', 'W': '1 week', 'M': '1 month', 'Y': '1 year' }[iso[l - 1]]) || iso[l - 1];
} else {
const u = ({ 'D': 'days', 'W': 'weeks', 'M': 'months', 'Y': 'years' }[iso[l - 1]]) || iso[l - 1];
return `${n} ${u}`;
}
}
}
Explanation:
Product Definitions (Lines 9-13): Define IDs for your subscription products and optionally assign them to a group for handling upgrades/downgrades.
Register Products (Lines 16-27): Call store.register() with the id, type set to ProductType.PAID_SUBSCRIPTION, the platform, and the group.
Validator Setup (Lines 30-38):Crucially, set store.validator to your validation service URL. Subscriptions require server-side validation for reliable status tracking (expiry, renewals, cancellations). A warning is logged if it seems unconfigured.
Event Listeners (Lines 41-58):
productUpdated: Refreshes the UI when product details load.
receiptUpdated: Refreshes the UI when local receipt data changes.
verified: This is the most important listener for subscriptions. It fires after successful validation. The refreshUI function should rely on the data within store.verifiedReceipts (updated internally after this event) to determine the true subscription status.
Purchase flow listeners (approved, finished, etc.) are deferred.
Initialize Store (Lines 61-70): Call store.initialize() to activate the platform.
UI Rendering (refreshUI - Lines 74-151):
This function is key for displaying subscription status accurately.
It finds the active subscription by looking through store.verifiedPurchases (populated from validated receipts) for a non-expired, paid subscription.
It displays the status ("ACTIVE", "Inactive", "Expired"), the product name, expiry date, and potential issues like renewalIntent (will it lapse?) or isBillingRetryPeriod.
It renders the available subscription products/offers, showing pricing details (including formatted billing periods and trial info using Utils.formatDurationEN).
The "Subscribe" or "Switch Plan" button logic checks if there's an active subscription, if the current offer is for a different product within the same group (allowing upgrade/downgrade), and if the offer canPurchase.
It renders "Manage Subscription" and "Manage Billing" buttons using store.checkSupport().
Placeholders (Lines 154-168): Empty functions subscribe and restoreSubscriptions are defined for later implementation.
Initial Load & Helpers (Lines 171-end): Ensures initialization runs and includes the formatDurationEN helper if not already present.
This setup prepares your app to display subscription products and their status based on validated receipt data. The next steps involve implementing the platform-specific purchase flow (subscribe function and the .approved, .verified, .finished listeners).
Note: Replace placeholder product IDs ('subscription_monthly', 'subscription_yearly') and the group name ('premium_access') with your actual values. Ensure your store.validator URL is correctly configured.
3. Purchase Flow
Implement the logic to handle the subscription purchase or plan change process. This involves initiating the order, verifying the transaction via your validator, and acknowledging the purchase with receipt.finish().
Purchase Flow (iOS/App Store Subscription)
This section details the purchase logic for auto-renewing subscriptions on iOS/App Store, assuming you have completed the generic subscription initialization. Handling subscriptions reliably requires verification and acknowledgment via transaction.finish().
Step 1: Implement the Subscription Purchase Action (subscribe)
What: Replace the placeholder window.subscribe function (from the generic initialization) to call offer.order() specifically for the App Store platform.
Why: This initiates the subscription purchase flow via StoreKit when the user clicks the "Subscribe" or "Switch Plan" button.
Replace the placeholder window.subscribe function in www/js/index.js:
// In js/index.js
// Make this function globally accessible for the button's onclick
window.subscribe = function(productId, platform, offerId) {
console.log(`Subscribe button clicked for ${productId}, offer ${offerId} on ${platform}`);
const { store, Platform, ProductType } = CdvPurchase;
// Ensure we're acting on the correct platform
if (platform !== Platform.APPLE_APPSTORE) {
console.error("This function is currently specific to AppStore!");
return;
}
const product = store.get(productId, Platform.APPLE_APPSTORE);
const offer = product?.getOffer(offerId); // Get the specific offer
if (offer) {
console.log(`Initiating order for offer: ${offer.id} on platform ${offer.platform}`);
setStatus('Initiating subscription...');
// Optional: Pass applicationUsername for tracking/linking on your server
// const additionalData = { applicationUsername: 'hashed_user_id_or_uuid' };
// offer.order(additionalData)
// Optional: Pass discount details if ordering a promotional offer
// const discountData = { appStore: { discount: { id: 'promoId', key: '...', nonce: '...', signature: '...', timestamp: '...' } } };
// offer.order(discountData)
offer.order()
.then(result => {
// Promise resolves when the App Store sheet is dismissed.
if (result && result.isError) {
setStatus(`Subscription failed: ${result.message}`);
} else {
// Purchase flow started... status updated by listeners.
}
refreshUI();
})
.catch(err => {
console.error("Unexpected error during subscription order:", err);
setStatus('Unexpected error during subscription.');
refreshUI();
});
} else {
console.error(`Cannot subscribe: Product (${productId}) or Offer (${offerId}) not found.`);
setStatus('Error: Unable to subscribe. Product details missing.');
}
}
Explanation:
We retrieve the specific offer to purchase.
offer.order() initiates the flow.
additionalData can be used to pass applicationUsername or promotional offer discount details if needed.
What: Add the subscription purchase lifecycle event listeners within the store.when() chain in your initializeStoreAndSetupListeners function.
Why: These listeners handle the progression: approval by App Store, mandatory verification via your validator, and mandatory acknowledgment via finish().
Add these handlers inside the existing store.when() call:
// Inside initializeStoreAndSetupListeners() -> store.when() chain
.approved(transaction => {
console.log(`Transaction ${transaction.transactionId} approved for ${transaction.products[0]?.id}.`);
setStatus('Purchase approved. Verifying...');
// Verification is REQUIRED for subscriptions to get accurate status from Apple's servers.
if (store.validator) {
transaction.verify(); // Initiate verification
} else {
console.error("VALIDATOR REQUIRED: Cannot reliably manage subscriptions without receipt validation.");
setStatus("ERROR: Validator not configured.");
// Do NOT finish the transaction here without validation.
}
})
.verified(receipt => {
console.log(`Receipt verified, contains ${receipt.collection.length} verified purchases.`);
setStatus('Purchase verified. Finishing...');
// The verified receipt contains the authoritative status from App Store Server API.
// The refreshUI() function (called below) should use store.verifiedPurchases or store.owned()
// to update the UI based on this validated data.
// Finish (acknowledge) the transaction associated with this receipt with the App Store.
// This is MANDATORY for subscriptions.
console.log(`Finishing verified receipt's source transaction: ${receipt.sourceReceipt.transactions[0]?.transactionId}`);
receipt.finish();
})
.finished(transaction => {
// This confirms the finish() call was successful.
console.log(`Transaction ${transaction.transactionId} finished for ${transaction.products[0]?.id}.`);
setStatus('Subscription active!');
// Subscription state should reflect verified data. Refresh UI to be sure.
refreshUI();
})
.cancelled(transaction => {
console.log('Purchase Cancelled:', transaction.transactionId);
setStatus('Purchase cancelled.');
refreshUI();
});
// Ensure the .productUpdated, .receiptUpdated listeners from the generic setup are still present
Explanation:
.approved(): Triggers transaction.verify(). Validation is essential.
.verified(): The receipt is validated. The UI should be updated based on this (refreshUI() is called). Crucially, receipt.finish() is called to acknowledge the purchase with the App Store.
.finished(): Confirms acknowledgment. Update UI.
Build and Test (iOS/App Store Subscription)
Testing subscriptions follows the standard iOS procedure, paying attention to renewals and management:
Prepare & Build:
Save changes.
Run cordova prepare ios.
Open the project in Xcode (open platforms/ios/*.xcworkspace or .xcodeproj).
Configure & Run in Xcode:
Set up signing (Team, Certificates).
Select your physical test device. Simulators are unreliable for IAP.
Validator: Ensure store.validator is configured and your server has the correct App-Specific Shared Secret. See Setup Guide - Step 7.
Sandbox Tester: On the device, go to Settings -> App Store. Sign out of any production Apple ID. Do not sign in to the Sandbox account yet.
Run the app from Xcode (▶).
Test Subscription Purchase:
Observe UI and Xcode console logs. Initial status should be "Subscription: Inactive". Product details should load.
Tap the "Subscribe" button.
The App Store purchase sheet appears. Sign in with your Sandbox Tester account when prompted.
Confirm the subscription purchase (it will show "[Environment: Sandbox]").
The UI (refreshUI) should update based on the verified event data, showing "Subscription: ACTIVE" with the expiry date from the validator.
Test Renewals (Sandbox):
Sandbox subscriptions renew quickly (e.g., a 1-month sub renews every ~5 minutes).
Keep the app running or reopen it after the renewal time.
Observe new approved -> verified -> finished cycles in the logs. The expiry date in the UI should update after verification.
Test Management:
Tap the "Manage Subscription" button (rendered by refreshUI).
This opens the system's Sandbox subscription management UI. Test changing plans (if you have groups) or cancelling.
After making changes, call store.update() or wait for the next renewal cycle; the changes should reflect in the VerifiedPurchase data after validation.
This completes the subscription purchase flow for iOS/App Store. Accurate status relies heavily on the configured receipt validator. Remember to call transaction.finish() after successful verification.
4. Receipt Validation (Mandatory)
Server-side validation is essential for subscriptions to determine the current status, expiry date, renewal intent, and handle events like renewals, cancellations, and billing issues.
Receipt Validation Reminder
Remember, for subscriptions and non-consumables, relying solely on local device data is insecure and unreliable for managing entitlements.
Always implement server-side receipt validation using your own backend or a service like Iaptic to:
Confirm purchase legitimacy.
Get the authoritative subscription status and expiry date.
Prevent fraud.
Support cross-platform/device access.
Ensure store.validator is configured in your initStore() function.
## 5. Testing
Follow the specific testing procedures for iOS/macOS Sandbox environments outlined in the platform-specific purchase flow section above. Pay close attention to testing initial purchases, accelerated renewals, cancellations, and plan changes (if applicable) via the Sandbox subscription management UI.