cc.fovea.cordova.purchase
Version:
Cordova Purchase plugin for iOS, Android, Windows (AppStore, Play, UWP)
449 lines (390 loc) • 20.4 kB
JavaScript
(function() {
function defer(thisArg, cb, delay) {
setTimeout(function() {
cb.call(thisArg);
}, delay || 1);
}
var delay = defer;
/// ## <a name="product"></a>*store.Product* object ##
///
/// Most events methods give you access to a `product` object.
store.Product = function(options) {
if (!options)
options = {};
///
/// Products object have the following fields and methods.
///
/// ### *store.Product* public attributes
///
/// - `product.id` - Identifier of the product on the store
this.id = options.id || null;
/// - `product.alias` - Alias that can be used for more explicit [queries](#queries)
this.alias = options.alias || options.id || null;
/// - `product.type` - Family of product, should be one of the defined [product types](#product-types).
var type = this.type = options.type || null;
if (type !== store.CONSUMABLE && type !== store.NON_CONSUMABLE && type !== store.PAID_SUBSCRIPTION && type !== store.FREE_SUBSCRIPTION && type !== store.NON_RENEWING_SUBSCRIPTION && type !== store.APPLICATION)
throw new TypeError("Invalid product type");
/// - `product.group` - Name of the group your subscription product is a member of (default to `"default"`). If you don't set anything, all subscription will be members of the same group.
var defaultGroup = this.type === store.PAID_SUBSCRIPTION ? "default" : "";
this.group = options.group || defaultGroup;
/// - `product.state` - Current state the product is in (see [life-cycle](#life-cycle) below). Should be one of the defined [product states](#product-states)
this.state = options.state || "";
/// - `product.title` - Localized name or short description
this.title = options.title || options.localizedTitle || null;
/// - `product.description` - Localized longer description
this.description = options.description || options.localizedDescription || null;
/// - `product.priceMicros` - Price in micro-units (divide by 1000000 to get numeric price)
this.priceMicros = options.priceMicros || null;
/// - `product.price` - Localized price, with currency symbol
this.price = options.price || null;
/// - `product.currency` - Currency code (optionaly)
this.currency = options.currency || null;
/// - `product.countryCode` - Country code. Available only on iOS
this.countryCode = options.countryCode || null;
// - `product.localizedTitle` - Localized name or short description ready for display
// this.localizedTitle = options.localizedTitle || options.title || null;
// - `product.localizedDescription` - Localized longer description ready for display
// this.localizedDescription = options.localizedDescription || options.description || null;
// - `product.localizedPrice` - Localized price (with currency) ready for display
// this.localizedPrice = options.localizedPrice || null;
/// - `product.loaded` - Product has been loaded from server, however it can still be either `valid` or not
this.loaded = options.loaded;
/// - `product.valid` - Product has been loaded and is a valid product
/// - when product definitions can't be loaded from the store, you should display instead a warning like: "You cannot make purchases at this stage. Try again in a moment. Make sure you didn't enable In-App-Purchases restrictions on your phone."
this.valid = options.valid;
/// - `product.canPurchase` - Product is in a state where it can be purchased
this.canPurchase = options.canPurchase;
/// - `product.owned` - Product is owned
this.owned = options.owned;
/// - `product.deferred` - Purchase has been initiated but is waiting for external action (for example, Ask to Buy on iOS)
this.deferred = options.deferred;
/// - `product.introPrice` - Localized introductory price, with currency symbol
this.introPrice = options.introPrice || null;
/// - `product.introPriceMicros` - Introductory price in micro-units (divide by 1000000 to get numeric price)
this.introPriceMicros = options.introPriceMicros || null;
/// - `product.introPricePeriod` - Duration the introductory price is available (in period-unit)
this.introPricePeriod = options.introPricePeriod || null;
this.introPriceNumberOfPeriods = options.introPriceNumberOfPeriods || null; // legacy
/// - `product.introPricePeriodUnit` - Period for the introductory price ("Day", "Week", "Month" or "Year")
this.introPricePeriodUnit = options.introPricePeriodUnit || null;
this.introPriceSubscriptionPeriod = options.introPriceSubscriptionPeriod || null; // legacy
/// - `product.introPricePaymentMode` - Payment mode for the introductory price ("PayAsYouGo", "UpFront", or "FreeTrial")
this.introPricePaymentMode = options.introPricePaymentMode || null;
/// - `product.ineligibleForIntroPrice` - True when a trial or introductory price has been applied to a subscription. Only available after [receipt validation](#validator). Available only on iOS
this.ineligibleForIntroPrice = options.ineligibleForIntroPrice || null;
/// - `product.discounts` - Array of discounts available for the product. Each discount exposes the following fields:
/// - `id` - The discount identifier
/// - `price` - Localized price, with currency symbol
/// - `priceMicros` - Price in micro-units (divide by 1000000 to get numeric price)
/// - `period` - Number of subscription periods
/// - `periodUnit` - Unit of the subcription period ("Day", "Week", "Month" or "Year")
/// - `paymentMode` - "PayAsYouGo", "UpFront", or "FreeTrial"
/// - `eligible` - True if the user is deemed eligible for this discount by the platform
this.discounts = [];
/// - `product.downloading` - Product is downloading non-consumable content
this.downloading = options.downloading;
/// - `product.downloaded` - Non-consumable content has been successfully downloaded for this product
this.downloaded = options.downloaded;
/// - `product.additionalData` - additional data possibly required for product purchase
this.additionalData = options.additionalData || null;
/// - `product.transaction` - Latest transaction data for this product (see [transactions](#transactions)).
this.transaction = null;
/// - `product.expiryDate` - Latest known expiry date for a subscription (a javascript Date)
/// - `product.lastRenewalDate` - Latest date a subscription was renewed (a javascript Date)
/// - `product.billingPeriod` - Duration of the billing period for a subscription, in the units specified by the `billingPeriodUnit` property. (_not available on iOS < 11.2_)
/// - `product.billingPeriodUnit` - Units of the billing period for a subscription. Possible values: Minute, Hour, Day, Week, Month, Year. (_not available on iOS < 11.2_)
/// - `product.trialPeriod` - Duration of the trial period for the subscription, in the units specified by the `trialPeriodUnit` property (windows only)
/// - `product.trialPeriodUnit` - Units of the trial period for a subscription (windows only)
// Some more fields set by [Fovea.Billing](https://billing.fovea.cc) receipt validator.
// - `product.isBillingRetryPeriod` -
// - `product.isTrialPeriod` -
// - `product.isIntroPeriod` -
// - `product.discountId` -
// - `product.priceConsentStatus` -
// - `product.renewalIntent` -
// - `product.renewalIntentChangeDate` -
// - `product.purchaseDate` -
// - `product.cancelationReason` -
this.stateChanged();
};
///
/// ### *store.Product* public methods
///
/// #### <a name="finish"></a>`product.finish()` ##
///
/// Call `product.finish()` to confirm to the store that an approved order has been delivered.
/// This will change the product state from `APPROVED` to `FINISHED` (see [life-cycle](#life-cycle)).
///
/// As long as you keep the product in state `APPROVED`:
///
/// - the money may not be in your account (i.e. user isn't charged)
/// - you will receive the `approved` event each time the application starts,
/// where you should try again to finish the pending transaction.
///
/// ##### example use
/// ```js
/// store.when("product.id").approved(function(product){
/// // synchronous
/// app.unlockFeature();
/// product.finish();
/// });
/// ```
///
/// ```js
/// store.when("product.id").approved(function(product){
/// // asynchronous
/// app.downloadFeature(function() {
/// product.finish();
/// });
/// });
/// ```
store.Product.prototype.finish = function() {
store.log.debug("product -> defer finishing " + this.id);
defer(this, function() {
store.log.debug("product -> finishing " + this.id);
if (this.state !== store.FINISHED) {
this.set('state', store.FINISHED);
// The platform store should now handle the FINISHED event
// and change the product status to VALID or OWNED.
}
});
};
/// #### <a name="verify"></a>`product.verify()` ##
///
/// Initiate purchase validation as defined by the [`store.validator`](#validator).
///
store.Product.prototype.verify = function() {
var that = this;
var nRetry = 0;
// Callbacks set by the Promise
var noop = function() {};
var doneCb = noop;
var successCb = noop;
var expiredCb = noop;
var errorCb = noop;
var tryValidation = function() {
function getData(data, key) {
if (!data)
return null;
return data.data && data.data[key] || data[key];
}
// No need to verify a which status isn't approved
// It means it already has been
if (that.state !== store.APPROVED)
return;
store._validator(that, function(success, data) {
if (!data) data = {};
store.log.debug("verify -> " + JSON.stringify({
success: success,
data: data
}));
var dataTransaction = getData(data, 'transaction');
if (dataTransaction) {
that.transaction = Object.assign({}, that.transaction || {}, dataTransaction);
store._extractTransactionFields(that);
that.trigger("updated");
}
if (success) {
store.log.debug("verify -> success: " + JSON.stringify(data));
// Process the list of products that are ineligible
// for introductory prices.
if (data && data.ineligible_for_intro_price &&
data.ineligible_for_intro_price.forEach) {
var ineligibleGroups = {};
data.ineligible_for_intro_price.forEach(function(pid) {
var p = store.get(pid);
if (p && p.group)
ineligibleGroups[p.group] = true;
});
store.products.forEach(function(p) {
if (data.ineligible_for_intro_price.indexOf(p.id) >= 0) {
store.log.debug('verify -> ' + p.id + ' ineligibleForIntroPrice:true');
p.set('ineligibleForIntroPrice', true);
}
else {
if (p.group && ineligibleGroups[p.group]) {
store.log.debug('verify -> ' + p.id + ' ineligibleForIntroPrice:true');
p.set('ineligibleForIntroPrice', true);
}
else {
store.log.debug('verify -> ' + p.id + ' ineligibleForIntroPrice:false');
p.set('ineligibleForIntroPrice', false);
}
}
});
}
if (data && data.collection && data.collection.forEach) {
// new behavior: the validator sets products state in the collection
// (including expiry status)
data.collection.forEach(function(purchase) {
var p = store.get(purchase.id);
if (p) {
p.set(purchase);
}
});
}
else if (that.expired) {
// old behavior: a valid receipt means the subscription isn't expired.
that.set("expired", false);
}
store.utils.callExternal('verify.success', successCb, that, data);
store.utils.callExternal('verify.done', doneCb, that);
that.trigger("verified");
}
else {
store.log.debug("verify -> error: " + JSON.stringify(data));
var msg = data && data.error && data.error.message ? data.error.message : '';
var err = new store.Error({
code: store.ERR_VERIFICATION_FAILED,
message: "Transaction verification failed: " + msg
});
if (data.code === store.PURCHASE_EXPIRED) {
err = new store.Error({
code: store.ERR_PAYMENT_EXPIRED,
message: "Transaction expired: " + msg
});
that.set("expired", true);
store.error(err);
store.utils.callExternal('verify.error', errorCb, err);
store.utils.callExternal('verify.done', doneCb, that);
that.trigger("expired");
that.set("state", store.VALID);
store.utils.callExternal('verify.expired', expiredCb, that);
}
else if (nRetry < 4) {
// It failed... let's try one more time. Maybe the appStoreReceipt wasn't updated yet.
nRetry += 1;
delay(this, tryValidation, 1500 * nRetry * nRetry);
}
else {
store.log.debug("validation failed, no retrying, trigger an error");
store.error(err);
store.utils.callExternal('verify.error', errorCb, err);
store.utils.callExternal('verify.done', doneCb, that);
that.trigger("unverified");
}
}
});
};
defer(this, function() {
if (that.state !== store.APPROVED) {
if (that.type !== store.APPLICATION) {
var err = new store.Error({
code: store.ERR_VERIFICATION_FAILED,
message: "Product isn't in the APPROVED state"
});
store.error(err);
store.utils.callExternal('verify.error', errorCb, err);
}
store.utils.callExternal('verify.done', doneCb, that);
return;
}
});
// For some reason, the appStoreReceipt isn't always immediately available.
delay(this, tryValidation, 1000);
/// ##### return value
/// A Promise with the following methods:
///
var ret = {
/// - `done(function(product){})`
/// - called whether verification failed or succeeded.
done: function(cb) { doneCb = cb; return this; },
/// - `expired(function(product){})`
/// - called if the purchase expired.
expired: function(cb) { expiredCb = cb; return this; },
/// - `success(function(product, purchaseData){})`
/// - called if the purchase is valid and verified.
/// - `purchaseData` is the device dependent transaction details
/// returned by the validator, which you can most probably ignore.
success: function(cb) { successCb = cb; return this; },
/// - `error(function(err){})`
/// - validation failed, either because of expiry or communication
/// failure.
/// - `err` is a [store.Error object](#errors), with a code expected to be
/// `store.ERR_PAYMENT_EXPIRED` or `store.ERR_VERIFICATION_FAILED`.
error: function(cb) { errorCb = cb; return this; }
};
///
return ret;
};
store._extractTransactionFields = function(that, t) {
t = t || that.transaction;
store.log.debug('transaction fields for ' + that.id);
// using legacy transactions (platform specific)
if (t.type === 'ios-appstore' && t.expires_date_ms) {
that.lastRenewalDate = new Date(parseInt(t.purchase_date_ms));
that.expiryDate = new Date(parseInt(t.expires_date_ms));
store.log.debug('expiryDate: ' + that.expiryDate.toISOString());
}
else if (t.type === 'android-playstore' && t.expiryTimeMillis > 0) {
that.lastRenewalDate = new Date(parseInt(t.startTimeMillis));
that.expiryDate = new Date(parseInt(t.expiryTimeMillis));
store.log.debug('expiryDate: ' + that.expiryDate.toISOString());
}
// using unified transaction fields
if (t.expiryDate)
that.expiryDate = new Date(t.expiryDate);
if (t.lastRenewalDate)
that.lastRenewalDate = new Date(t.lastRenewalDate);
if (t.renewalIntent)
that.renewalIntent = t.renewalIntent;
// owned?
if (that.type === store.PAID_SUBSCRIPTION && +that.expiryDate) {
var now = +new Date();
if (now > that.expiryDate.getTime() + 60000) {
window.setTimeout(function() {
if (that.state === store.OWNED) {
that.set('state', store.APPROVED);
that.verify();
}
}, 30000);
}
}
return t;
};
///
/// ### life-cycle
///
/// A product will change state during the application execution.
///
/// Find below a diagram of the different states a product can pass by.
///
/// REGISTERED +--> INVALID
/// |
/// +--> VALID +--> REQUESTED +--> INITIATED +-+
/// |
/// ^ +------------------------------+
/// | |
/// | | +--> DOWNLOADING +--> DOWNLOADED +
/// | | | |
/// | +--> APPROVED +--------------------------------+--> FINISHED +--> OWNED
/// | |
/// +-------------------------------------------------------------+
///
/// #### states definitions
///
/// - `REGISTERED`: right after being declared to the store using [`store.register()`](#register)
/// - `INVALID`: the server didn't recognize this product, it cannot be used.
/// - `VALID`: the server sent extra information about the product (`title`, `price` and such).
/// - `REQUESTED`: order (purchase) requested by the user
/// - `INITIATED`: order transmitted to the server
/// - `APPROVED`: purchase approved by server
/// - `FINISHED`: purchase delivered by the app (see [Finish a Purchase](#finish-a-purchase))
/// - `OWNED`: purchase is owned (only for non-consumable and subscriptions)
/// - `DOWNLOADING` purchased content is downloading (only for non-consumable)
/// - `DOWNLOADED` purchased content is downloaded (only for non-consumable)
///
/// #### Notes
///
/// - When finished, a consumable product will get back to the `VALID` state, while other will enter the `OWNED` state.
/// - Any error in the purchase process will bring a product back to the `VALID` state.
/// - During application startup, products may go instantly from `REGISTERED` to `APPROVED` or `OWNED`, for example if they are purchased non-consumables or non-expired subscriptions.
/// - Non-Renewing Subscriptions are iOS products only. Please see the [iOS Non Renewing Subscriptions documentation](https://github.com/j3k0/cordova-plugin-purchase/blob/master/doc/ios.md#non-renewing) for a detailed explanation.
///
/// #### state changes
///
/// Each time the product changes state, appropriate events is triggered.
///
/// Learn more about events [here](#events) and about listening to events [here](#when).
///
})();