///
///
///
///
///
namespace CdvPurchase {
/**
* Apple AppStore adapter using StoreKit version 1
*/
export namespace AppleAppStore {
export type PaymentMonitorStatus = 'cancelled' | 'failed' | 'purchased' | 'deferred';
export type PaymentMonitor = (status: PaymentMonitorStatus) => void;
/** Additional data passed with an order on AppStore */
export interface AdditionalData {
/** Information about the payment discount */
discount?: PaymentDiscount;
}
/**
* Determine which discount the user is eligible to.
*
* @param applicationReceipt An apple appstore receipt
* @param requests List of discount offers to evaluate eligibility for
* @param callback Get the response, a boolean for each request (matched by index).
*/
export type DiscountEligibilityDeterminer = ((applicationReceipt: ApplicationReceipt, requests: DiscountEligibilityRequest[], callback: (response: boolean[]) => void) => void) & {
cacheReceipt?: (receipt: VerifiedReceipt) => void;
};
/**
* Optional options for the AppleAppStore adapter
*/
export interface AdapterOptions {
/**
* Determine which discount the user is eligible to.
*
* @param applicationReceipt An apple appstore receipt
* @param requests List of discount offers to evaluate eligibility for
* @param callback Get the response, a boolean for each request (matched by index).
*/
discountEligibilityDeterminer?: DiscountEligibilityDeterminer;
/**
* Set to false if you don't need to verify the application receipt
*
* Verifying the application receipt at startup is useful in different cases:
*
* - Retrieve information about the user's first app download.
* - Make it harder to side-load your application.
* - Determine eligibility to introductory prices.
*
* The default is "true", use "false" is an optimization.
*/
needAppReceipt?: boolean;
/**
* Auto-finish pending transaction
*
* Use this if the transaction queue is filled with unwanted transactions (in development).
* It's safe to keep this option to "true" when using a receipt validation server and you only
* sell subscriptions.
*/
autoFinish?: boolean;
}
/**
* In the first stages of a purchase, the transaction doesn't have an identifier.
*
* In the meantime, we generate a virtual transaction identifier.
*/
function virtualTransactionId(productId: string) {
return `virtual.${productId}`;
}
/**
* Adapter for Apple AppStore using StoreKit version 1
*/
export class Adapter implements CdvPurchase.Adapter {
id = Platform.APPLE_APPSTORE;
name = 'AppStore';
ready = false;
_canMakePayments = false;
/**
* Set to true to force a full refresh of the receipt when preparing a receipt validation call.
*
* This is typically done when placing an order and restoring purchases.
*/
forceReceiptReload = false;
/** List of products loaded from AppStore */
_products: SKProduct[] = [];
get products(): Product[] { return this._products; }
/** Find a given product from ID */
getProduct(id: string): SKProduct | undefined { return this._products.find(p => p.id === id); }
/** The application receipt, contains all transactions */
_receipt?: SKApplicationReceipt;
/** The pseudo receipt stores purchases in progress */
pseudoReceipt: Receipt;
get receipts(): Receipt[] {
if (!this.isSupported) return [];
return ((this._receipt ? [this._receipt] : []) as Receipt[])
.concat(this.pseudoReceipt ? this.pseudoReceipt : []);
}
private validProducts: { [id: string]: Bridge.ValidProduct & IRegisterProduct; } = {};
addValidProducts(registerProducts: IRegisterProduct[], validProducts: Bridge.ValidProduct[]) {
validProducts.forEach(vp => {
const rp = registerProducts.find(p => p.id === vp.id);
if (!rp) return;
this.validProducts[vp.id] = {
...vp,
...rp,
}
});
}
bridge: Bridge.BridgeInterface;
/** True when the StoreKit 2 extension is active */
readonly useSK2: boolean;
context: CdvPurchase.Internal.AdapterContext;
log: Logger;
/** Component that determine eligibility to a given discount offer */
discountEligibilityDeterminer?: DiscountEligibilityDeterminer;
/** True when we need to validate the application receipt */
needAppReceipt: boolean;
/** True to auto-finish all transactions */
autoFinish: boolean;
/** Callback called when the restore process is completed */
onRestoreCompleted?: (code: IError | undefined) => void;
/** Debounced version of _receiptUpdated */
receiptsUpdated: Utils.Debouncer;
constructor(context: CdvPurchase.Internal.AdapterContext, options: AdapterOptions) {
this.context = context;
this.log = context.log.child('AppleAppStore');
const useCapacitor = CapacitorBridge.CapacitorNativeBridge.isAvailable();
this.useSK2 = useCapacitor || SK2Bridge.SK2NativeBridge.isAvailable();
if (useCapacitor) {
this.log.info('Capacitor plugin detected, using Capacitor SK2 bridge');
this.bridge = new CapacitorBridge.CapacitorNativeBridge();
} else if (SK2Bridge.SK2NativeBridge.isAvailable()) {
this.log.info('StoreKit 2 extension detected, using SK2 bridge');
this.bridge = new SK2Bridge.SK2NativeBridge();
} else {
this.bridge = new Bridge.Bridge();
}
this.discountEligibilityDeterminer = options.discountEligibilityDeterminer;
this.needAppReceipt = this.useSK2 ? false : (options.needAppReceipt ?? true);
this.autoFinish = options.autoFinish ?? false;
this.pseudoReceipt = new Receipt(Platform.APPLE_APPSTORE, this.context.apiDecorators);
this.receiptsUpdated = Utils.createDebouncer(() => {
this._receiptsUpdated();
}, 300);
}
/** Returns true on iOS, the only platform supported by this adapter */
get isSupported(): boolean {
return Utils.platformId() === 'ios';
}
private upsertTransactionInProgress(productId: string, state: TransactionState): Promise {
const transactionId = virtualTransactionId(productId);
return new Promise(resolve => {
const existing = this.pseudoReceipt.transactions.find(t => t.transactionId === transactionId) as SKTransaction | undefined;
if (existing) {
existing.state = state;
existing.refresh(productId);
resolve(existing);
}
else {
const tr = new SKTransaction(Platform.APPLE_APPSTORE, this.pseudoReceipt, this.context.apiDecorators);
tr.state = state;
tr.transactionId = transactionId;
tr.refresh(productId);
this.pseudoReceipt.transactions.push(tr);
resolve(tr);
}
});
}
/** Remove a transaction from the pseudo receipt */
private removeTransactionInProgress(productId: string) {
const transactionId = virtualTransactionId(productId);
this.pseudoReceipt.transactions = this.pseudoReceipt.transactions.filter(t => t.transactionId !== transactionId);
}
/** Insert or update a transaction in the pseudo receipt, based on data collected from the native side */
private async upsertTransaction(productId: string, transactionId: string, state: TransactionState): Promise {
return new Promise(resolve => {
this.initializeAppReceipt(() => {
if (!this._receipt) {
// this should not happen
this.log.warn('Failed to load the application receipt, cannot proceed with handling the purchase');
return;
}
const existing = this._receipt?.transactions.find(t => t.transactionId === transactionId) as SKTransaction | undefined;
if (existing) {
existing.state = state;
existing.refresh(productId);
resolve(existing);
}
else {
const tr = new SKTransaction(Platform.APPLE_APPSTORE, this._receipt, this.context.apiDecorators);
tr.state = state;
tr.transactionId = transactionId;
tr.refresh(productId);
this._receipt.transactions.push(tr);
resolve(tr);
}
});
});
}
private removeTransaction(transactionId: string) {
if (this._receipt) {
this._receipt.transactions = this._receipt.transactions.filter(t => t.transactionId !== transactionId);
}
}
/** Notify the store that the receipts have been updated */
private _receiptsUpdated() {
if (this._receipt) {
this.log.debug("receipt updated and ready.");
this.context.listener.receiptsUpdated(Platform.APPLE_APPSTORE, [this._receipt, this.pseudoReceipt]);
this.context.listener.receiptsReady(Platform.APPLE_APPSTORE);
}
else {
this.log.debug("receipt updated.");
this.context.listener.receiptsUpdated(Platform.APPLE_APPSTORE, [this.pseudoReceipt]);
}
}
private _paymentMonitor: PaymentMonitor = () => {};
private setPaymentMonitor(fn: PaymentMonitor) {
this._paymentMonitor = fn;
}
private callPaymentMonitor(status: PaymentMonitorStatus, code?: ErrorCode, message?: string) {
this._paymentMonitor(status);
}
initialize(): Promise {
return new Promise(resolve => {
this.log.info('bridge.init');
const bridgeLogger = this.log.child('Bridge');
this.bridge.init({
autoFinish: this.autoFinish,
debug: this.context.verbosity === LogLevel.DEBUG,
log: msg => bridgeLogger.debug(msg),
error: (code: ErrorCode, message: string, options?: { productId: string, quantity?: number }) => {
this.log.error('ERROR: ' + code + ' - ' + message);
if (code === ErrorCode.PAYMENT_CANCELLED) {
// When the user closes the payment sheet, this generates a
// PAYMENT_CANCELLED error that isn't an error anymore since version 13
// of the plugin.
this.callPaymentMonitor('cancelled', ErrorCode.PAYMENT_CANCELLED, message);
return;
}
else {
this.context.error(appStoreError(code, message,options?.productId || null));
}
},
ready: () => {
this.log.info('ready');
},
purchased: async (transactionIdentifier: string, productId: string,
originalTransactionIdentifier?: string, transactionDate?: string,
discountId?: string, expirationDate?: string, jwsRepresentation?: string,
quantity?: number) => {
this.log.info('purchase: id:' + transactionIdentifier + ' product:' + productId +
' originalTransaction:' + originalTransactionIdentifier +
' - date:' + transactionDate + ' - discount:' + discountId +
(jwsRepresentation ? ' - jws:present' : '') +
(quantity && quantity > 1 ? ' - quantity:' + quantity : ''));
// we can add the transaction to the receipt here
const transaction = await this.upsertTransaction(productId, transactionIdentifier, TransactionState.APPROVED);
transaction.refresh(productId, originalTransactionIdentifier, transactionDate,
discountId, expirationDate, jwsRepresentation, quantity);
this.removeTransactionInProgress(productId);
this.receiptsUpdated.call();
this.callPaymentMonitor('purchased');
},
purchaseEnqueued: async (productId: string, quantity: number) => {
this.log.info('purchaseEnqueued: ' + productId + ' - ' + quantity);
// let create a temporary transaction
await this.upsertTransactionInProgress(productId, TransactionState.INITIATED);
this.context.listener.receiptsUpdated(Platform.APPLE_APPSTORE, [this.pseudoReceipt]);
},
purchaseFailed: (productId: string, code: ErrorCode, message: string) => {
this.log.info('purchaseFailed: ' + productId + ' - ' + code + ' - ' + message);
this.removeTransactionInProgress(productId);
this.context.listener.receiptsUpdated(Platform.APPLE_APPSTORE, [this.pseudoReceipt]);
this.callPaymentMonitor('failed', code, message);
},
purchasing: async (productId: string) => {
// purchase has been requested, but there's no transactionIdentifier yet.
// we can create a dummy transaction
this.log.info('purchasing: ' + productId);
await this.upsertTransactionInProgress(productId, TransactionState.INITIATED);
// In order to prevent a receipt validation attempt here
// (which might happen if it hasn't been possible earlier)
// We should add "purchasing" transactions into a second, pseudo receipt.
this.context.listener.receiptsUpdated(Platform.APPLE_APPSTORE, [this.pseudoReceipt]);
},
deferred: async (productId: string) => {
this.log.info('deferred: ' + productId);
await this.upsertTransactionInProgress(productId, TransactionState.PENDING);
this.context.listener.receiptsUpdated(Platform.APPLE_APPSTORE, [this.pseudoReceipt]);
this.callPaymentMonitor('deferred');
},
finished: async (transactionIdentifier: string, productId: string) => {
// An issue occurs here if finished is triggered the "debounced" receiptUpdated call has
// been performed for the APPROVED event. Because the transaction will go straight to
// FINISHED, skipping validation.
//
// This was observed specifically when "autoFinish" is set.
//
// In order to get rid of that bug, we want to wait for processing of the previous
// receiptUpdated call.
//
// A side effect will be that when there are many "finished" transactions, how could we process
// them all in batch?
await this.receiptsUpdated.wait();
this.log.info('finish: ' + transactionIdentifier + ' - ' + productId);
this.removeTransactionInProgress(productId);
await this.upsertTransaction(productId, transactionIdentifier, TransactionState.FINISHED);
this.receiptsUpdated.call();
},
restored: async (transactionIdentifier: string, productId: string,
originalTransactionIdentifier?: string, transactionDate?: string,
discountId?: string, expirationDate?: string, jwsRepresentation?: string,
quantity?: number) => {
this.log.info('restore: ' + transactionIdentifier + ' - ' + productId);
const transaction = await this.upsertTransaction(productId, transactionIdentifier, TransactionState.APPROVED);
transaction.refresh(productId, originalTransactionIdentifier, transactionDate,
discountId, expirationDate, jwsRepresentation, quantity);
this.receiptsUpdated.call();
},
receiptsRefreshed: (receipt: ApplicationReceipt) => {
this.log.info('receiptsRefreshed');
if (this._receipt) this._receipt.refresh(receipt, this.needAppReceipt, this.context.apiDecorators);
},
restoreFailed: (errorCode: ErrorCode) => {
this.log.info('restoreFailed: ' + errorCode);
if (this.onRestoreCompleted) {
this.onRestoreCompleted(appStoreError(errorCode, 'Restore purchases failed', null));
this.onRestoreCompleted = undefined;
}
},
restoreCompleted: () => {
this.log.info('restoreCompleted');
if (this.onRestoreCompleted) {
this.onRestoreCompleted(undefined);
this.onRestoreCompleted = undefined;
}
},
}, async () => {
this.log.info('bridge.init done');
await this.canMakePayments();
resolve(undefined);
}, (code: ErrorCode, message: string) => {
this.log.info('bridge.init failed: ' + code + ' - ' + message);
resolve(appStoreError(code, message, null));
});
});
}
supportsParallelLoading = true;
loadReceipts(): Promise {
return new Promise((resolve) => {
setTimeout(() => {
this.initializeAppReceipt(() => {
this.receiptsUpdated.call();
if (this._receipt) {
resolve([this._receipt, this.pseudoReceipt]);
}
else {
resolve([this.pseudoReceipt]);
}
});
}, 300);
});
}
private async canMakePayments(): Promise {
return new Promise(resolve => {
this.bridge.canMakePayments(() => {
this._canMakePayments = true;
resolve(true);
}, (message) => {
this.log.warn(`canMakePayments: ${message}`);
this._canMakePayments = false;
resolve(false);
});
});
}
/** True iff the appStoreReceipt is already being initialized */
private _appStoreReceiptLoading = false;
/** List of functions waiting for the appStoreReceipt to be initialized */
private _appStoreReceiptCallbacks: Callback[] = [];
/**
* Create the application receipt
*/
private async initializeAppReceipt(callback: Callback) {
if (this._receipt) {
this.log.debug('initializeAppReceipt() => already initialized.');
return callback(undefined);
}
this._appStoreReceiptCallbacks.push(callback);
if (this._appStoreReceiptLoading) {
this.log.debug('initializeAppReceipt() => already loading.');
return;
}
this._appStoreReceiptLoading = true;
const nativeData = await this.loadAppStoreReceipt();
const callCallbacks = (arg: IError | undefined) => {
const callbacks = this._appStoreReceiptCallbacks;
this._appStoreReceiptCallbacks = [];
callbacks.forEach(cb => {
cb(arg);
});
}
if (!nativeData?.appStoreReceipt) {
if (this.useSK2) {
// SK2 doesn't use monolithic receipts — create receipt with empty data
this.log.info('SK2 mode: no appStoreReceipt (expected), creating empty receipt');
this._receipt = new SKApplicationReceipt(
nativeData || { appStoreReceipt: '', bundleIdentifier: '',
bundleShortVersion: '', bundleNumericVersion: 0, bundleSignature: '' },
this.needAppReceipt, this.context.apiDecorators);
this._appStoreReceiptLoading = false;
callCallbacks(undefined);
return;
}
this.log.warn('no appStoreReceipt');
this._appStoreReceiptLoading = false;
callCallbacks(appStoreError(ErrorCode.REFRESH, 'No appStoreReceipt', null));
return;
}
this._receipt = new SKApplicationReceipt(nativeData, this.needAppReceipt, this.context.apiDecorators);
callCallbacks(undefined);
}
private prepareReceipt(nativeData: ApplicationReceipt | undefined) {
if (nativeData?.appStoreReceipt) {
if (!this._receipt) {
this._receipt = new SKApplicationReceipt(nativeData, this.needAppReceipt, this.context.apiDecorators);
}
else {
this._receipt.refresh(nativeData, this.needAppReceipt, this.context.apiDecorators);
}
}
}
/** Promisified loading of the AppStore receipt */
private async loadAppStoreReceipt(): Promise {
let resolved = false;
return new Promise(resolve => {
if (this.bridge.appStoreReceipt?.appStoreReceipt && !this.forceReceiptReload) {
this.log.debug('using cached appstore receipt');
return resolve(this.bridge.appStoreReceipt);
}
this.log.debug('loading appstore receipt...');
this.forceReceiptReload = false;
this.bridge.loadReceipts(receipt => {
this.log.debug('appstore receipt loaded');
if (!resolved) resolve(receipt);
resolved = true;
}, (code, message) => {
// this should not happen: native side never triggers an error
this.log.warn('Failed to load appStoreReceipt: ' + code + ' - ' + message);
if (!resolved) resolve(undefined);
resolved = true;
});
// If the receipt cannot be loaded, timeout after 5 seconds
setTimeout(function() {
if (!resolved) resolve(undefined);
resolved = true;
}, 5000);
}).then(result => {
this.context.listener.receiptsReady(Platform.APPLE_APPSTORE);
return result;
}).catch(reason => {
this.context.listener.receiptsReady(Platform.APPLE_APPSTORE);
return reason;
});
}
private async loadEligibility(validProducts: Bridge.ValidProduct[]): Promise {
this.log.debug('load eligibility: ' + JSON.stringify(validProducts));
if (!this.discountEligibilityDeterminer) {
this.log.debug('No discount eligibility determiner, skipping...');
return new Internal.DiscountEligibilities([], []);
}
const eligibilityRequests: DiscountEligibilityRequest[] = [];
validProducts.forEach(valid => {
valid.discounts?.forEach(discount => {
eligibilityRequests.push({
productId: valid.id,
discountId: discount.id,
discountType: discount.type,
});
});
if ((valid.discounts?.length ?? 0) === 0 && valid.introPrice) {
// sometime apple returns the discounts in the deprecated "introductory" info
// we create a special "discount" with the id "intro" to check for eligibility.
eligibilityRequests.push({
productId: valid.id,
discountId: 'intro',
discountType: 'Introductory',
});
}
});
if (eligibilityRequests.length > 0) {
const applicationReceipt = await this.loadAppStoreReceipt();
if (!applicationReceipt || !applicationReceipt.appStoreReceipt) {
this.log.debug('no receipt, assuming introductory price are available.');
return new Internal.DiscountEligibilities(eligibilityRequests, eligibilityRequests.map(r => r.discountType === "Introductory"));
}
else {
this.log.debug('calling discount eligibility determiner.');
const response = await this.callDiscountEligibilityDeterminer(applicationReceipt, eligibilityRequests);
this.log.debug('response: ' + JSON.stringify(response));
return new Internal.DiscountEligibilities(eligibilityRequests, response);
}
}
else {
return new Internal.DiscountEligibilities([], []);
}
}
private callDiscountEligibilityDeterminer(applicationReceipt: ApplicationReceipt, eligibilityRequests: DiscountEligibilityRequest[]): Promise {
return new Promise(resolve => {
if (!this.discountEligibilityDeterminer) return resolve([]);
this.discountEligibilityDeterminer(applicationReceipt, eligibilityRequests, resolve);
});
}
loadProducts(products: IRegisterProduct[]): Promise<(Product | IError)[]> {
return new Promise(resolve => {
this.log.info('bridge.load');
this.bridge.load(
products.map(p => p.id),
async (validProducts, invalidProducts) => {
this.log.info('bridge.loaded: ' + JSON.stringify({ validProducts, invalidProducts }));
this.addValidProducts(products, validProducts);
const eligibilities = await this.loadEligibility(validProducts);
this.log.info('eligibilities ready: ' + JSON.stringify(eligibilities));
// for any valid product that includes a discount, check the eligibility.
const ret = products.map(p => {
if (invalidProducts.indexOf(p.id) >= 0) {
this.log.debug(`${p.id} is invalid`);
return appStoreError(ErrorCode.INVALID_PRODUCT_ID, 'Product not found in AppStore. #400', p.id);
}
else {
const valid = validProducts.find(v => v.id === p.id);
this.log.debug(`${p.id} is valid: ${JSON.stringify(valid)}`);
if (!valid)
return appStoreError(ErrorCode.INVALID_PRODUCT_ID, 'Product not found in AppStore. #404', p.id);
let product = this.getProduct(p.id);
if (product) {
this.log.debug('refreshing existing product');
product?.refresh(valid, this.context.apiDecorators, eligibilities);
}
else {
this.log.debug('registering new product');
product = new SKProduct(valid, p, this.context.apiDecorators, eligibilities);
this._products.push(product);
}
return product;
}
});
this.log.debug(`Products loaded: ${JSON.stringify(ret)}`);
resolve(ret);
},
(code: ErrorCode, message: string) => {
return products.map(p => appStoreError(code, message, null));
});
});
}
async order(offer: Offer, additionalData: CdvPurchase.AdditionalData): Promise {
let resolved = false;
return new Promise(resolve => {
const callResolve = (result: undefined | IError) => {
if (resolved) return;
this.setPaymentMonitor(() => { });
resolved = true;
resolve(result);
}
this.log.info('order');
const quantity = additionalData?.quantity ?? 1;
if (quantity < 1 || quantity > 10 || !Number.isInteger(quantity)) {
return callResolve(appStoreError(ErrorCode.PURCHASE, 'Invalid quantity: must be an integer between 1 and 10', offer.productId));
}
const discountId = offer.id !== DEFAULT_OFFER_ID ? offer.id : undefined;
const discount = additionalData?.appStore?.discount;
if (discountId && !discount) {
return callResolve(appStoreError(ErrorCode.MISSING_OFFER_PARAMS, 'Missing additionalData.appStore.discount when ordering a discount offer', offer.productId));
}
if (discountId && (discount?.id !== discountId)) {
return callResolve(appStoreError(ErrorCode.INVALID_OFFER_IDENTIFIER, 'Offer identifier does not match additionalData.appStore.discount.id', offer.productId));
}
this.setPaymentMonitor((status: PaymentMonitorStatus, code?: ErrorCode, message?: string) => {
this.log.info('order.paymentMonitor => ' + status + ' ' + (code ?? '') + ' ' + (message ?? ''));
if (resolved) return;
switch (status) {
case 'cancelled':
callResolve(appStoreError(code ?? ErrorCode.PAYMENT_CANCELLED, message ?? 'The user cancelled the order.', offer.productId));
break;
case 'failed':
// note, "failed" might be triggered before "cancelled",
// so we'll give some time to catch the "cancelled" event.
setTimeout(() => {
callResolve(appStoreError(code ?? ErrorCode.PURCHASE, message ?? 'Purchase failed', offer.productId));
}, 500);
break;
case 'purchased':
case 'deferred':
callResolve(undefined);
break;
}
});
const success = () => {
this.log.info('order.success');
// We'll monitor the payment before resolving.
}
const error = () => {
this.log.info('order.error');
callResolve(appStoreError(ErrorCode.PURCHASE, 'Failed to place order', offer.productId));
}
// When we switch AppStore user, the cached receipt isn't from the new user.
// so after a purchase, we want to make sure we're using the receipt from the logged in user.
this.forceReceiptReload = true;
this.bridge.purchase(offer.productId, quantity, this.context.getApplicationUsername(), discount, success, error);
});
}
finish(transaction: Transaction): Promise {
return new Promise(resolve => {
this.log.info('finish(' + transaction.transactionId + ')');
if (transaction.transactionId === APPLICATION_VIRTUAL_TRANSACTION_ID || transaction.transactionId === virtualTransactionId(transaction.products[0].id)) {
// this is a virtual transaction, nothing to do.
transaction.state = TransactionState.FINISHED;
this.receiptsUpdated.call();
return resolve(undefined);
}
const success = () => {
transaction.state = TransactionState.FINISHED;
this.receiptsUpdated.call();
resolve(undefined);
}
const error = (msg: string) => {
if (msg?.includes('[#CdvPurchase:100]')) {
// already finished
success();
}
else {
resolve(appStoreError(ErrorCode.FINISH, 'Failed to finish transaction', transaction.products[0]?.id ?? null));
}
}
this.bridge.finish(transaction.transactionId, success, error);
});
}
refreshReceipt(): Promise {
return new Promise(resolve => {
const success = (receipt: ApplicationReceipt): void => {
// at that point, the receipt should have been refreshed.
resolve(receipt);
}
const error = (code: ErrorCode, message: string): void => {
resolve(appStoreError(code, message, null));
}
this.bridge.refreshReceipts(success, error);
});
}
async receiptValidationBody(receipt: Receipt): Promise {
if (receipt.platform !== Platform.APPLE_APPSTORE) return;
if (receipt !== this._receipt) return; // do not validate the pseudo receipt
const skReceipt = receipt as SKApplicationReceipt;
let applicationReceipt = skReceipt.nativeData;
if (this.forceReceiptReload) {
const nativeData = await this.loadAppStoreReceipt();
this.forceReceiptReload = false;
if (nativeData) {
applicationReceipt = nativeData;
this.prepareReceipt(nativeData);
}
}
// SK2 doesn't use monolithic receipts — skip the appStoreReceipt check
if (!this.useSK2 && !skReceipt.nativeData.appStoreReceipt) {
this.log.info('Cannot prepare the receipt validation body, because appStoreReceipt is missing. Refreshing...');
const result = await this.refreshReceipt();
if (!result || 'isError' in result) {
this.log.warn('Failed to refresh receipt, cannot run receipt validation.');
if (result) this.log.error(result);
return;
}
this.log.info('Receipt refreshed.');
applicationReceipt = result;
}
const transaction = skReceipt.transactions.slice(-1)[0] as (SKTransaction | undefined);
const products = Utils.objectValues(this.validProducts).map(vp =>
new SKProduct(vp, vp, this.context.apiDecorators, { isEligible: () => true }));
// SK2 uses a completely different transaction type ('apple-sk2') with JWS
// SK1 uses 'ios-appstore' with the monolithic appStoreReceipt
if (this.useSK2) {
if (!transaction?.jwsRepresentation) {
this.log.warn('SK2 mode but no JWS on transaction, skipping validation');
return undefined;
}
return {
id: applicationReceipt.bundleIdentifier,
type: ProductType.APPLICATION,
products,
transaction: {
type: 'apple-sk2' as const,
id: transaction?.products?.[0]?.id,
jwsRepresentation: transaction.jwsRepresentation,
},
};
}
const txBody = {
type: 'ios-appstore' as const,
id: transaction?.transactionId,
appStoreReceipt: applicationReceipt.appStoreReceipt,
};
return {
id: applicationReceipt.bundleIdentifier,
type: ProductType.APPLICATION,
products,
transaction: txBody,
}
}
async handleReceiptValidationResponse(_receipt: Receipt, response: Validator.Response.Payload): Promise {
// we can add the purchaseDate to the application transaction
let localReceiptUpdated = false;
if (response.ok) {
const vTransaction = response.data?.transaction;
const isApple = vTransaction?.type === 'ios-appstore' || vTransaction?.type === 'apple-sk2';
if (isApple && vTransaction && 'original_application_version' in vTransaction) {
this._receipt?.transactions.forEach(t => {
if (t.transactionId === APPLICATION_VIRTUAL_TRANSACTION_ID) {
if (vTransaction.original_purchase_date_ms) {
t.purchaseDate = new Date(parseInt(vTransaction.original_purchase_date_ms));
localReceiptUpdated = true;
}
}
});
}
}
if (localReceiptUpdated) this.context.listener.receiptsUpdated(Platform.APPLE_APPSTORE, [_receipt]);
}
async requestPayment(payment: PaymentRequest, additionalData?: CdvPurchase.AdditionalData): Promise {
return appStoreError(ErrorCode.UNKNOWN, 'requestPayment not supported', null);
}
async manageSubscriptions(): Promise {
this.bridge.manageSubscriptions();
return;
}
async manageBilling(): Promise {
this.bridge.manageBilling();
return;
}
checkSupport(functionality: PlatformFunctionality): boolean {
if (functionality === 'order') return this._canMakePayments;
const supported: PlatformFunctionality[] = [
'order', 'orderQuantity', 'manageBilling', 'manageSubscriptions', 'getStorefront'
];
return supported.indexOf(functionality) >= 0;
}
restorePurchases(): Promise {
return new Promise(resolve => {
this.onRestoreCompleted = (error) => {
this.onRestoreCompleted = undefined;
this.bridge.refreshReceipts(obj => {
resolve(error)
}, (code, message) => {
resolve(error || appStoreError(code, message, null));
});
}
this.forceReceiptReload = true;
this.bridge.restore();
});
}
presentCodeRedemptionSheet(): Promise {
return new Promise(resolve => {
this.bridge.presentCodeRedemptionSheet(resolve);
});
}
async getStorefront(): Promise {
if (!this.bridge.getStorefront) return undefined;
const countryCode = await this.bridge.getStorefront();
if (!countryCode) return undefined;
// SKStorefront.countryCode typically returns ISO 3166-1 alpha-3 (e.g., "USA").
// The fallback `|| countryCode` handles cases where Apple returns alpha-2 directly
// or uses a non-standard code (e.g., territories not in ISO 3166-1).
return isoAlpha3ToAlpha2(countryCode) || countryCode;
}
}
/**
* Convert ISO 3166-1 alpha-3 country code to alpha-2.
*
* Apple's SKStorefront.countryCode returns alpha-3 codes (e.g., "USA").
* This function converts them to the more common alpha-2 format (e.g., "US")
* for consistency with Google Play which already returns alpha-2.
*/
const ISO_ALPHA3_TO_ALPHA2: { [key: string]: string } = {
AFG: 'AF', ALB: 'AL', DZA: 'DZ', ASM: 'AS', AND: 'AD',
AGO: 'AO', AIA: 'AI', ATA: 'AQ', ATG: 'AG', ARG: 'AR',
ARM: 'AM', ABW: 'AW', AUS: 'AU', AUT: 'AT', AZE: 'AZ',
BHS: 'BS', BHR: 'BH', BGD: 'BD', BRB: 'BB', BLR: 'BY',
BEL: 'BE', BLZ: 'BZ', BEN: 'BJ', BMU: 'BM', BTN: 'BT',
BOL: 'BO', BES: 'BQ', BIH: 'BA', BWA: 'BW', BVT: 'BV',
BRA: 'BR', IOT: 'IO', BRN: 'BN', BGR: 'BG', BFA: 'BF',
BDI: 'BI', CPV: 'CV', KHM: 'KH', CMR: 'CM', CAN: 'CA',
CYM: 'KY', CAF: 'CF', TCD: 'TD', CHL: 'CL', CHN: 'CN',
CXR: 'CX', CCK: 'CC', COL: 'CO', COM: 'KM', COG: 'CG',
COD: 'CD', COK: 'CK', CRI: 'CR', CIV: 'CI', HRV: 'HR',
CUB: 'CU', CUW: 'CW', CYP: 'CY', CZE: 'CZ', DNK: 'DK',
DJI: 'DJ', DMA: 'DM', DOM: 'DO', ECU: 'EC', EGY: 'EG',
SLV: 'SV', GNQ: 'GQ', ERI: 'ER', EST: 'EE', SWZ: 'SZ',
ETH: 'ET', FLK: 'FK', FRO: 'FO', FJI: 'FJ', FIN: 'FI',
FRA: 'FR', GUF: 'GF', PYF: 'PF', ATF: 'TF', GAB: 'GA',
GMB: 'GM', GEO: 'GE', DEU: 'DE', GHA: 'GH', GIB: 'GI',
GRC: 'GR', GRL: 'GL', GRD: 'GD', GLP: 'GP', GUM: 'GU',
GTM: 'GT', GGY: 'GG', GIN: 'GN', GNB: 'GW', GUY: 'GY',
HTI: 'HT', HMD: 'HM', VAT: 'VA', HND: 'HN', HKG: 'HK',
HUN: 'HU', ISL: 'IS', IND: 'IN', IDN: 'ID', IRN: 'IR',
IRQ: 'IQ', IRL: 'IE', IMN: 'IM', ISR: 'IL', ITA: 'IT',
JAM: 'JM', JPN: 'JP', JEY: 'JE', JOR: 'JO', KAZ: 'KZ',
KEN: 'KE', KIR: 'KI', PRK: 'KP', KOR: 'KR', KWT: 'KW',
KGZ: 'KG', LAO: 'LA', LVA: 'LV', LBN: 'LB', LSO: 'LS',
LBR: 'LR', LBY: 'LY', LIE: 'LI', LTU: 'LT', LUX: 'LU',
MAC: 'MO', MDG: 'MG', MWI: 'MW', MYS: 'MY', MDV: 'MV',
MLI: 'ML', MLT: 'MT', MHL: 'MH', MTQ: 'MQ', MRT: 'MR',
MUS: 'MU', MYT: 'YT', MEX: 'MX', FSM: 'FM', MDA: 'MD',
MCO: 'MC', MNG: 'MN', MNE: 'ME', MSR: 'MS', MAR: 'MA',
MOZ: 'MZ', MMR: 'MM', NAM: 'NA', NRU: 'NR', NPL: 'NP',
NLD: 'NL', NCL: 'NC', NZL: 'NZ', NIC: 'NI', NER: 'NE',
NGA: 'NG', NIU: 'NU', NFK: 'NF', MKD: 'MK', MNP: 'MP',
NOR: 'NO', OMN: 'OM', PAK: 'PK', PLW: 'PW', PSE: 'PS',
PAN: 'PA', PNG: 'PG', PRY: 'PY', PER: 'PE', PHL: 'PH',
PCN: 'PN', POL: 'PL', PRT: 'PT', PRI: 'PR', QAT: 'QA',
REU: 'RE', ROU: 'RO', RUS: 'RU', RWA: 'RW', BLM: 'BL',
SHN: 'SH', KNA: 'KN', LCA: 'LC', MAF: 'MF', SPM: 'PM',
VCT: 'VC', WSM: 'WS', SMR: 'SM', STP: 'ST', SAU: 'SA',
SEN: 'SN', SRB: 'RS', SYC: 'SC', SLE: 'SL', SGP: 'SG',
SXM: 'SX', SVK: 'SK', SVN: 'SI', SLB: 'SB', SOM: 'SO',
ZAF: 'ZA', SGS: 'GS', SSD: 'SS', ESP: 'ES', LKA: 'LK',
SDN: 'SD', SUR: 'SR', SJM: 'SJ', SWE: 'SE', CHE: 'CH',
SYR: 'SY', TWN: 'TW', TJK: 'TJ', TZA: 'TZ', THA: 'TH',
TLS: 'TL', TGO: 'TG', TKL: 'TK', TON: 'TO', TTO: 'TT',
TUN: 'TN', TUR: 'TR', TKM: 'TM', TCA: 'TC', TUV: 'TV',
UGA: 'UG', UKR: 'UA', ARE: 'AE', GBR: 'GB', USA: 'US',
UMI: 'UM', URY: 'UY', UZB: 'UZ', VUT: 'VU', VEN: 'VE',
VNM: 'VN', VGB: 'VG', VIR: 'VI', WLF: 'WF', ESH: 'EH',
YEM: 'YE', ZMB: 'ZM', ZWE: 'ZW',
};
function isoAlpha3ToAlpha2(alpha3: string): string | undefined {
return ISO_ALPHA3_TO_ALPHA2[alpha3.toUpperCase()];
}
function appStoreError(code: ErrorCode, message: string, productId: string | null) {
return storeError(code, message, Platform.APPLE_APPSTORE, productId);
}
}
}