///
///
///
///
///
///
///
///
///
///
namespace CdvPurchase {
export namespace IapticJS {
export type AdapterOptions = ModuleIapticJS.Config;
export class Receipt extends CdvPurchase.Receipt {
public purchases: ModuleIapticJS.Purchase[]; // Store the array of purchases fetched
public accessToken: string; // The KEY for verification/refreshing
// Keep a reference to the context for decorators
private context!: Internal.AdapterContext;
constructor(purchases: ModuleIapticJS.Purchase[], accessToken: string, context: Internal.AdapterContext) {
super(Platform.IAPTIC_JS, context.apiDecorators);
Object.defineProperty(this, 'context', { 'enumerable': false, 'writable': true, value: context });
this.purchases = purchases;
this.accessToken = accessToken;
// Create transactions based on the purchases array
this.transactions = purchases.map(p => new Transaction(this, p, context.apiDecorators));
}
// Add a refresh method if needed to update based on new purchase data
refresh(purchases: ModuleIapticJS.Purchase[]) {
this.purchases = purchases;
// Re-create transactions or update existing ones
this.transactions = purchases.map(p => {
const existing = this.transactions.find(t => (t as Transaction).purchase.purchaseId === p.purchaseId) as Transaction | undefined;
if (existing) {
existing.refresh(p);
return existing;
}
// Pass context's apiDecorators when creating new Transaction
return new Transaction(this, p, this.context.apiDecorators);
});
}
}
export class Transaction extends CdvPurchase.Transaction {
public purchase: ModuleIapticJS.Purchase; // Store the specific purchase info
constructor(receipt: Receipt, purchase: ModuleIapticJS.Purchase, decorator: Internal.TransactionDecorator) {
super(Platform.IAPTIC_JS, receipt, decorator);
this.purchase = purchase;
this.refresh(purchase); // Initial population
}
refresh(purchase: ModuleIapticJS.Purchase) {
this.purchase = purchase;
// Prefix product ID based on the assumption that iaptic-js might return prefixed or non-prefixed IDs
const platformPrefix = purchase.platform ? `${purchase.platform}:` : 'stripe:'; // Default to stripe if platform missing
const productId = purchase.productId.startsWith(platformPrefix) ? purchase.productId : `${platformPrefix}${purchase.productId}`;
this.products = [{ id: productId }];
this.transactionId = purchase.transactionId;
this.purchaseId = purchase.purchaseId;
this.purchaseDate = new Date(purchase.purchaseDate);
this.expirationDate = purchase.expirationDate ? new Date(purchase.expirationDate) : undefined;
this.lastRenewalDate = purchase.lastRenewalDate ? new Date(purchase.lastRenewalDate) : undefined;
this.renewalIntent = purchase.renewalIntent === 'Renew' ? RenewalIntent.RENEW : RenewalIntent.LAPSE;
// this.isTrialPeriod = purchase.isTrialPeriod;
this.state = TransactionState.APPROVED; // Assuming getPurchases only returns valid purchases
this.isAcknowledged = true; // Stripe manages this server-side
this.amountMicros = purchase.amountMicros;
this.currency = purchase.currency;
}
}
export class Adapter implements CdvPurchase.Adapter {
id = Platform.IAPTIC_JS;
name = 'IapticJS';
ready = false;
products: CdvPurchase.Product[] = [];
_receipts: Receipt[] = [];
get receipts(): Receipt[] { return this._receipts; }
private context: Internal.AdapterContext;
private log: Logger;
private options: AdapterOptions;
private iapticAdapterInstance!: ModuleIapticJS.IapticStripe; // Use definite assignment assertion
private backendAdapterType: string; // To store 'stripe' or potentially others
private upsertProduct(product: CdvPurchase.Product) {
this.log.debug(`upsertProduct(${product.id})`);
const existingIndex = this.products.findIndex(p => p.id === product.id);
if (existingIndex >= 0) {
this.products[existingIndex] = product;
} else {
this.products.push(product);
}
}
constructor(context: Internal.AdapterContext, options: AdapterOptions) {
this.context = context;
this.log = context.log.child("IapticJS");
this.options = options;
this.backendAdapterType = options.type;
}
get isSupported(): boolean {
// Check for the global IapticJS object
return typeof window.IapticJS !== 'undefined' && typeof window.IapticJS.createAdapter === 'function';
}
// Let's load products and receipts sequentially for simplicity first.
// If iaptic-js supports parallel, we can change this later.
supportsParallelLoading = false;
async initialize(): Promise {
this.log.info('initialize()');
if (!this.isSupported) {
const msg = 'iaptic-js SDK is not available. Please ensure it is loaded.';
this.log.warn(msg);
return iapticJsError(ErrorCode.SETUP, msg, null);
}
try {
this.log.info(`Creating iaptic-js adapter with options: ${JSON.stringify(this.options)}`);
// Use the globally available IapticJS object
this.iapticAdapterInstance = window.IapticJS.createAdapter(this.options);
this.ready = true;
// Initial load attempt after initialization - receipts first to get token
await this.loadReceipts();
this.log.info('IapticJS Adapter Initialized');
// Products might be loaded on demand or after receipts
// Let's not block initialization for products
this.context.listener.receiptsReady(Platform.IAPTIC_JS); // Indicate readiness even if no receipts initially
return undefined;
} catch (err: any) {
this.ready = false; // Ensure ready is false on error
const message = err?.message || 'Failed to initialize IapticJS adapter';
this.log.error('Initialization failed: ' + message);
return iapticJsError(ErrorCode.SETUP, message, null);
}
}
async loadProducts(products: IRegisterProduct[]): Promise<(CdvPurchase.Product | IError)[]> {
this.log.info(`loadProducts() for ${products.length} registered products`);
if (!this.ready || !this.iapticAdapterInstance) {
return products.map(p => iapticJsError(ErrorCode.SETUP, 'Adapter not initialized', p.id));
}
try {
// Fetch products from iaptic-js
const iapticProducts = await this.iapticAdapterInstance.getProducts();
this.log.debug('Fetched products from iaptic-js: ' + JSON.stringify(iapticProducts.map(p => p.id)));
// Filter and map to CdvPurchase.Product
const results = products.map(registeredProduct => {
const iapticProduct = iapticProducts.find(p => {
// Compare ignoring the platform prefix if present in iapticProduct.id
const iapticIdWithoutPrefix = p.id.includes(':') ? p.id.split(':', 2)[1] : p.id;
// Compare ignoring the platform prefix if present in registeredProduct.id
const registeredIdWithoutPrefix = registeredProduct.id.includes(':') ? registeredProduct.id.split(':', 2)[1] : registeredProduct.id;
return iapticIdWithoutPrefix === registeredIdWithoutPrefix;
});
if (!iapticProduct) {
this.log.warn(`Registered product ID "${registeredProduct.id}" not found in fetched iaptic-js products.`);
return iapticJsError(ErrorCode.PRODUCT_NOT_AVAILABLE, `Product ${registeredProduct.id} not found via iaptic-js`, registeredProduct.id);
}
// Create or update CdvPurchase.Product
const platformProductId = `${Platform.IAPTIC_JS}:${iapticProduct.id.split(':').pop()}`; // Ensure correct prefix
let product = this.products.find(p => p.id === platformProductId);
if (!product) {
product = new CdvPurchase.Product({ ...registeredProduct, platform: Platform.IAPTIC_JS, id: platformProductId }, this.context.apiDecorators);
this.upsertProduct(product);
}
product.title = iapticProduct.title;
product.description = iapticProduct.description ?? '';
product.offers = []; // Clear existing offers before adding new ones
iapticProduct.offers.forEach(o => {
// Ensure offer ID is correctly prefixed
const offerPlatformPrefix = o.platform ? `${o.platform}:` : 'stripe:'; // Default if missing
const offerIdWithoutPrefix = o.id.includes(':') ? o.id.split(':', 2)[1] : o.id;
const fullOfferId = `${offerPlatformPrefix}${offerIdWithoutPrefix}`;
const offer = new CdvPurchase.Offer({
id: fullOfferId,
product: product!,
pricingPhases: o.pricingPhases.map((pp: ModuleIapticJS.PricingPhase) => ({
priceMicros: pp.priceMicros,
currency: pp.currency,
billingPeriod: pp.billingPeriod,
paymentMode: pp.paymentMode as PaymentMode, // Cast might be needed if types differ slightly
recurrenceMode: pp.recurrenceMode as RecurrenceMode,
price: window.IapticJS.Utils.formatCurrency(pp.priceMicros, pp.currency) // Use Utils for formatting
})),
}, this.context.apiDecorators);
product!.addOffer(offer);
});
this.log.debug(`Processed product ${product.id} with ${product.offers.length} offers.`);
return product;
});
// Notify listener about all products (new and updated)
this.context.listener.productsUpdated(Platform.IAPTIC_JS, this.products);
return results;
} catch (err: any) {
this.log.error('Failed to load products: ' + err.message);
return products.map(p => iapticJsError(ErrorCode.LOAD, err.message || 'Failed to load products', p.id));
}
}
async loadReceipts(): Promise {
this.log.info('loadReceipts()');
if (!this.ready || !this.iapticAdapterInstance) {
this.log.warn('Adapter not ready, skipping loadReceipts.');
return this._receipts;
}
try {
const accessToken = this.iapticAdapterInstance.getAccessToken();
if (!accessToken) {
this.log.info('No stored access token found.');
// Clear existing receipts if token is gone
if (this._receipts.length > 0) {
this._receipts = [];
this.context.listener.receiptsUpdated(Platform.IAPTIC_JS, []);
}
return this._receipts;
}
this.log.info('Fetching purchases with stored access token.');
const purchases = await this.iapticAdapterInstance.getPurchases(accessToken); // Fetches AND potentially updates token
const currentToken = this.iapticAdapterInstance.getAccessToken() ?? accessToken; // Use potentially refreshed token
if (purchases.length > 0) {
let receipt = this._receipts.find(r => r.accessToken === currentToken);
if (!receipt) {
this.log.info(`Creating new receipt for token hash ${currentToken.substring(0, 10)}...`);
receipt = new Receipt(purchases, currentToken, this.context);
this._receipts = [receipt]; // Replace old receipts if token changed or was missing
} else {
this.log.info(`Refreshing existing receipt for token hash ${currentToken.substring(0, 10)}...`);
receipt.refresh(purchases);
}
this.context.listener.receiptsUpdated(Platform.IAPTIC_JS, [receipt]);
} else {
// No purchases found for this token. Clear receipts.
if (this._receipts.length > 0) {
this.log.info('No purchases found for token, clearing local receipts.');
this._receipts = [];
this.context.listener.receiptsUpdated(Platform.IAPTIC_JS, []);
}
}
// Let the store know receipts are loaded (even if empty)
// This might have been called during initialize, but it's safe to call again.
this.context.listener.receiptsReady(Platform.IAPTIC_JS);
return this._receipts;
} catch (err: any) {
this.log.warn('Failed to load receipts: ' + err.message);
// If fetching purchases fails due to invalid token, clear local data
if (err.message?.includes('Invalid access token')) { // Adjust based on actual error message
this.log.warn('Invalid access token detected, clearing stored data.');
this.iapticAdapterInstance.clearStoredData();
this._receipts = [];
this.context.listener.receiptsUpdated(Platform.IAPTIC_JS, []);
}
this.context.listener.receiptsReady(Platform.IAPTIC_JS); // Still ready, just failed to load
return [];
}
}
async order(offer: CdvPurchase.Offer, additionalData: CdvPurchase.AdditionalData): Promise {
this.log.info(`order() - Offer ID: ${offer.id}`);
if (!this.ready || !this.iapticAdapterInstance) {
return iapticJsError(ErrorCode.SETUP, 'Adapter not initialized', offer.productId);
}
try {
await this.iapticAdapterInstance.order({
offerId: offer.id,
applicationUsername: additionalData?.applicationUsername || this.context.getApplicationUsername() || '', // Pass username
successUrl: window.location.href, // Use current URL as default
cancelUrl: window.location.href,
accessToken: this.iapticAdapterInstance.getAccessToken(), // Pass existing token
});
// Redirection happens, so success here means initiation.
// We might want to trigger an INITIATED state locally, but it's complex
// as we don't get a transaction object immediately.
this.log.info(`Order initiated for offer ${offer.id}. User will be redirected.`);
return undefined;
} catch (err: any) {
this.log.error('Order failed: ' + err.message);
return iapticJsError(ErrorCode.PURCHASE, err.message || 'Failed to initiate order', offer.productId);
}
}
async finish(transaction: Transaction): Promise {
this.log.info(`finish(${transaction.transactionId}) - No-op for IapticJS/Stripe`);
// Stripe/Iaptic manages entitlement server-side. Mark as finished locally.
transaction.state = TransactionState.FINISHED;
// Notify the store listener that the transaction state might have changed
// Find the parent receipt and notify
const parentReceipt = this._receipts.find(r => r.transactions.indexOf(transaction) >= 0);
if (parentReceipt) {
this.context.listener.receiptsUpdated(Platform.IAPTIC_JS, [parentReceipt]);
}
return undefined;
}
async receiptValidationBody(receipt: Receipt): Promise {
if (receipt.platform !== Platform.IAPTIC_JS) return undefined;
this.log.info(`receiptValidationBody for IapticJS - AccessToken: ${receipt.accessToken ? 'present' : 'missing'}`);
if (!receipt.accessToken) {
this.log.warn('Cannot prepare validation body: IapticJS receipt is missing accessToken.');
return undefined;
}
// Find a representative product ID from the purchases in the receipt, if any
const firstPurchase = receipt.purchases[0];
const product = firstPurchase ? this.context.registeredProducts.find(Platform.IAPTIC_JS, firstPurchase.productId) : undefined;
const productIdForBody = product?.id ?? firstPurchase?.productId ?? 'unknown-product';
const productTypeForBody = product?.type ?? (firstPurchase ? ProductType.PAID_SUBSCRIPTION : ProductType.CONSUMABLE); // Guess type
return {
id: productIdForBody,
type: productTypeForBody,
products: this.products.map(p => ({ // Map to the expected structure for validator
id: p.id,
type: p.type,
offers: p.offers.map(o => ({ id: o.id, pricingPhases: o.pricingPhases }))
})),
transaction: {
type: 'iaptic', // Use 'iaptic' as the generic type
adapter: this.backendAdapterType, // Specify the backend ('stripe')
accessToken: receipt.accessToken,
} as Validator.Request.ApiValidatorBodyTransactionIaptic
};
}
async handleReceiptValidationResponse(receipt: Receipt, response: Validator.Response.Payload): Promise {
this.log.info('handleReceiptValidationResponse for IapticJS');
if (response.ok) {
const validatedData = response.data.transaction;
const collection = response.data.collection;
// Update receipt based on validated collection
if (collection) {
const purchases: ModuleIapticJS.Purchase[] = collection.map((vp: VerifiedPurchase) => ({
purchaseId: vp.purchaseId!,
transactionId: vp.transactionId!,
productId: vp.id!,
platform: 'stripe', // Assuming Stripe for now
purchaseDate: vp.purchaseDate ? new Date(vp.purchaseDate).toISOString() : '',
lastRenewalDate: vp.lastRenewalDate ? new Date(vp.lastRenewalDate).toISOString() : '',
expirationDate: vp.expiryDate ? new Date(vp.expiryDate).toISOString() : '',
renewalIntent: vp.renewalIntent === RenewalIntent.RENEW ? 'Renew' : 'Cancel',
isTrialPeriod: vp.isTrialPeriod ?? false,
amountMicros: 0, // Not typically in VerifiedPurchase, focus is entitlement
currency: '', // Not typically in VerifiedPurchase
}));
receipt.refresh(purchases);
} else {
// If collection is empty or missing, maybe clear local purchases?
receipt.refresh([]);
}
this.context.listener.receiptsUpdated(Platform.IAPTIC_JS, [receipt]);
} else {
this.log.warn(`Receipt validation failed: ${response.message} (Code: ${response.code})`);
// Handle specific error codes if needed, e.g., invalidate token
if (response.code === ErrorCode.COMMUNICATION) {
this.log.info('Clearing potentially invalid access token due to validation failure.');
this.iapticAdapterInstance.clearStoredData();
this._receipts = this._receipts.filter(r => r !== receipt);
this.context.listener.receiptsUpdated(Platform.IAPTIC_JS, []);
}
}
}
async requestPayment(payment: PaymentRequest, additionalData?: CdvPurchase.AdditionalData): Promise {
// Payment Requests are typically handled via `order` with Stripe Checkout
this.log.warn('requestPayment is not directly supported for IapticJS/Stripe. Use order().');
return iapticJsError(ErrorCode.UNKNOWN, 'requestPayment not supported, use order() instead', null);
}
async manageSubscriptions(): Promise {
if (!this.ready || !this.iapticAdapterInstance) {
return iapticJsError(ErrorCode.SETUP, 'Adapter not initialized', null);
}
try {
await this.iapticAdapterInstance.redirectToCustomerPortal({
returnUrl: window.location.href,
});
// Redirection happens, no direct return value indicates success
return undefined;
} catch (err: any) {
this.log.error('Failed to redirect to customer portal: ' + err.message);
return iapticJsError(ErrorCode.UNKNOWN, err.message || 'Failed to open subscription management', null);
}
}
async manageBilling(): Promise {
// For Stripe, billing and subscription management are usually the same portal
return this.manageSubscriptions();
}
checkSupport(functionality: PlatformFunctionality): boolean {
const supported: PlatformFunctionality[] = ['order', 'manageSubscriptions', 'manageBilling'];
return supported.indexOf(functionality) !== -1;
}
async restorePurchases(): Promise {
this.log.info('restorePurchases() - calling loadReceipts()');
if (!this.ready || !this.iapticAdapterInstance) {
return iapticJsError(ErrorCode.SETUP, 'Adapter not initialized', null);
}
try {
await this.loadReceipts(); // Fetches latest purchases based on stored token
return undefined;
} catch (err: any) {
this.log.error('Restore purchases failed during loadReceipts: ' + err.message);
return iapticJsError(ErrorCode.REFRESH, err.message || 'Failed to restore purchases', null);
}
}
}
function iapticJsError(code: ErrorCode, message: string, productId: string | null): IError {
return storeError(code, message, Platform.IAPTIC_JS, productId);
}
}
}