UNPKG

in-app-purchase-iec-develop

Version:

In-App-Purchase validation and subscription management for iOS, Android, Amazon, and Windows

421 lines (388 loc) 15.9 kB
var async = require('./async'); var verbose = require('./verbose'); var constants = require('../constants'); var responseData = require('./responseData'); var request = require('request'); var errorMap = { 21000: 'The App Store could not read the JSON object you provided.', 21002: 'The data in the receipt-data property was malformed.', 21003: 'The receipt could not be authenticated.', 21004: 'The shared secret you provided does not match the shared secret on file for your account.', 21005: 'The receipt server is not currently available.', 21006: 'This receipt is valid but the subscription has expired. When this status code is returned to your server, the receipt data is also decoded and returned as part of the response.', 21007: 'This receipt is a sandbox receipt, but it was sent to the production service for verification.', 21008: 'This receipt is a production receipt, but it was sent to the sandbox service for verification.', 2: 'The receipt is valid, but purchased nothing.' }; var REC_KEYS = { IN_APP: 'in_app', LRI: 'latest_receipt_info', BUNDLE_ID: 'bundle_id', BID: 'bid', TRANSACTION_ID: 'transaction_id', ORIGINAL_TRANSACTION_ID: 'original_transaction_id', PRODUCT_ID: 'product_id', ITEM_ID: 'item_id', ORIGINAL_PURCHASE_DATE_MS: 'original_purchase_date_ms', EXPIRES_DATE_MS: 'expires_date_ms', EXPIRES_DATE: 'expires_date', EXPIRATION_DATE: 'expiration_date', EXPIRATION_INTENT: 'expiration_intent', CANCELLATION_DATE: 'cancellation_date', PURCHASE_DATE_MS: 'purchase_date_ms', IS_TRIAL: 'is_trial_period' }; var config = null; var sandboxHost = 'sandbox.itunes.apple.com'; var liveHost = 'buy.itunes.apple.com'; var path = '/verifyReceipt'; var testMode = false; function isExpired(responseData) { if (responseData[REC_KEYS.LRI] && responseData[REC_KEYS.LRI][REC_KEYS.EXPIRES_DATE]) { var exp = parseInt(responseData[REC_KEYS.LRI][REC_KEYS.EXPIRES_DATE]); if (exp > Date.now()) { return true; } return false; } // old receipt } function isValidConfigKey(key) { return key.match(/^apple/); } module.exports.readConfig = function (configIn) { if (!configIn) { // no apple iap or password not required return; } // set up verbose logging verbose.setup(configIn); // we do NOT use liveHost if true testMode = configIn.test || false; verbose.log('<Apple> test mode?', testMode); config = {}; var configValueSet = false; // Apply any default settings to Request. if ('requestDefaults' in configIn) { request = request.defaults(configIn.requestDefaults); } Object.keys(configIn).forEach(function (key) { if (isValidConfigKey(key)) { config[key] = configIn[key]; configValueSet = true; } }); if (!configValueSet) { config = null; } }; module.exports.setup = function (cb) { if (!config || !config.applePassword) { if (process.env.APPLE_IAP_PASSWORD) { config = config || {}; config.applePassword = process.env.APPLE_IAP_PASSWORD; } } return cb(); }; module.exports.validatePurchase = function (secret, receipt, cb) { var prodPath = 'https://' + liveHost + path; var sandboxPath = 'https://' + sandboxHost + path; var status; var validatedData; var isValid = false; var content = { 'receipt-data': receipt }; if (config && config.applePassword) { content.password = config.applePassword; } if (config && config.appleExcludeOldTransactions) { content['exclude-old-transactions'] = config.appleExcludeOldTransactions; } // override applePassword from config to allow dynamically fed secret to validate if (secret) { verbose.log('<Apple> Using dynamic applePassword:', secret); content.password = secret; } verbose.log('<Apple> Validatation data:', content); var tryProd = function (next) { if (testMode) { verbose.log('<Apple> test mode: skip production validation'); return next(); } verbose.log('<Apple> Try validate against production:', prodPath); send(prodPath, content, function (error, res, data) { verbose.log('<Apple>', prodPath, 'validation response:', data); // request error if (error) { // 1 is unknown status = data ? data.status : 1; validatedData = { sandbox: false, status: status, message: errorMap[status] || 'Unknown' }; applyResponseData(validatedData, data); verbose.log('<Apple>', prodPath, 'failed:', error, validatedData); error.validatedData = validatedData; return next(error); } // apple responded with error if (data.status > 0 && data.status !== 21007 && data.status !== 21002) { if (data.status === 21006 && !isExpired(data)) { /* valid subscription receipt, but cancelled and it has not been expired status code is 21006 for both expired receipt and cancelled receipt... */ validatedData = data; validatedData.sandbox = false; // force status to be 0 validatedData.status = 0; verbose.log('<Apple> Valid receipt, but has been cancelled (not expired yet)'); isValid = true; return next(); } verbose.log('<Apple>', prodPath, 'failed:', data); status = data.status; var emsg = errorMap[status] || 'Unknown'; var err = new Error(emsg); validatedData = { sandbox: false, status: status, message: emsg }; applyResponseData(validatedData, data); verbose.log('<Apple>', prodPath, 'failed:', validatedData); err.validatedData = validatedData; return next(err); } // try sandbox... if (data.status === 21007 || data.status === 21002) { return next(); } // production validated validatedData = data; validatedData.sandbox = false; verbose.log('<Apple> Production validation successful:', validatedData); isValid = true; next(); }); }; var trySandbox = function (next) { if (isValid) { return next(); } verbose.log('<Apple> Try validate against sandbox:', sandboxPath); send(sandboxPath, content, function (error, res, data) { verbose.log('<Apple>', sandboxPath, 'validation response:', data); if (error) { // 1 is unknown status = data ? data.status : 1; validatedData = { sandbox: true, status: status, message: errorMap[status] || 'Unknown' }; applyResponseData(validatedData, data); verbose.log('<Apple>', sandboxPath, 'failed:', error, validatedData); error.validatedData = validatedData; return next(error); } if (data.status > 0 && data.status !== 21002) { if (data.status === 21006 && !isExpired(data)) { /* valid subscription receipt, but cancelled and it has not been expired status code is 21006 for both expired receipt and cancelled receipt... */ validatedData = data; validatedData.sandbox = true; // force status to be 0 validatedData.status = 0; verbose.log('<Apple> Valid receipt, but has been cancelled (not expired yet)'); isValid = true; return next(); } verbose.log('<Apple>', sandboxPath, 'failed:', data); status = data.status; var emsg = errorMap[status] || 'Unknown'; var err = new Error(emsg); validatedData = { sandbox: true, status: status, message: emsg }; applyResponseData(validatedData, data); verbose.log('<Apple>', sandboxPath, 'failed:', validatedData); err.validatedData = validatedData; return next(err); } // sandbox validated validatedData = data; validatedData.sandbox = true; verbose.log('<Apple> Sandbox validation successful:', validatedData); next(); }); }; var done = function (error) { if (error) { return cb(error, validatedData); } handleResponse(receipt, validatedData, cb); }; var tasks = [ tryProd, trySandbox ]; async.series(tasks, done); }; module.exports.getPurchaseData = function (purchase, options) { if (!purchase || !purchase.receipt) { return null; } var data = []; if (purchase.receipt[REC_KEYS.IN_APP]) { // iOS 6+ var now = Date.now(); var tids = []; var list = purchase.receipt[REC_KEYS.IN_APP]; var lri = purchase[REC_KEYS.LRI] || purchase.receipt[REC_KEYS.LRI]; if (lri && Array.isArray(lri)) { list = list.concat(lri); } /* we sort list by purchase_date_ms to make it easier to weed out duplicates (items with the same original_transaction_id) purchase_date_ms DESC */ list.sort(function (a, b) { return parseInt(b[REC_KEYS.PURCHASE_DATE_MS], 10) - parseInt(a[REC_KEYS.PURCHASE_DATE_MS], 10); }); for (var i = 0, len = list.length; i < len; i++) { var item = list[i]; var tid = item['original_' + REC_KEYS.TRANSACTION_ID]; var exp = getSubscriptionExpireDate(item); if ( options && options.ignoreCanceled && item[REC_KEYS.CANCELLATION_DATE] && item[REC_KEYS.CANCELLATION_DATE].length && /* if a subscription has been cancelled, we need to check if the receipt has expired or not... if it is not subscription (exp is 0 in that case), we ignore right away... */ (!exp || now - exp >= 0) ) { continue; } if (options && options.ignoreExpired && exp && now - exp >= 0) { // we are told to ignore expired item and it is expired continue; } if (tids.indexOf(tid) > -1) { /* avoid duplicate and keep the latest there are cases where we could have the same "time" so we evaludate <= instead of < alone */ continue; } tids.push(tid); var parsed = responseData.parse(item); // transaction ID should be a string: // https://developer.apple.com/documentation/storekit/skpaymenttransaction/1411288-transactionidentifier parsed.transactionId = parsed.transactionId.toString(); // originalTransactionId should also be a string if (parsed.originalTransactionId && !isNaN(parsed.originalTransactionId)) { parsed.originalTransactionId = parsed.originalTransactionId.toString(); } // we need to stick to the name isTrial if (parsed.isTrialPeriod !== undefined) { parsed.isTrial = bool(parsed.isTrialPeriod); } else { parsed.isTrial = false; } parsed.bundleId = purchase.receipt[REC_KEYS.BUNDLE_ID] || purchase.receipt[REC_KEYS.BID]; parsed.expirationDate = exp; data.push(parsed); } return data; } // old and will be deprecated by Apple var receipt = purchase[REC_KEYS.LRI] || purchase.receipt; data.push({ bundleId: receipt[REC_KEYS.BUNDLE_ID] || receipt[REC_KEYS.BID], appItemId: receipt[REC_KEYS.ITEM_ID], originalTransactionId: receipt[REC_KEYS.ORIGINAL_TRANSACTION_ID], transactionId: receipt[REC_KEYS.TRANSACTION_ID], productId: receipt[REC_KEYS.PRODUCT_ID], originalPurchaseDate: receipt[REC_KEYS.ORIGINAL_PURCHASE_DATE_MS], purchaseDate: receipt[REC_KEYS.PURCHASE_DATE_MS], quantity: parseInt(receipt.quantity, 10), expirationDate: getSubscriptionExpireDate(receipt), isTrial: bool(receipt[REC_KEYS.IS_TRIAL]), cancellationDate: receipt[REC_KEYS.CANCELLATION_DATE] || 0 }); return data; }; function bool(val) { return val === 'true' ? true : false; } function getSubscriptionExpireDate(data) { if (!data) { return 0; } if (data[REC_KEYS.EXPIRES_DATE_MS]) { return parseInt(data[REC_KEYS.EXPIRES_DATE_MS], 10); } if (data[REC_KEYS.EXPIRES_DATE]) { return data[REC_KEYS.EXPIRES_DATE]; } if (data[REC_KEYS.EXPIRATION_DATE]) { return data[REC_KEYS.EXPIRATION_DATE]; } if (data[REC_KEYS.EXPIRATION_INTENT]) { return parseInt(data[REC_KEYS.EXPIRATION_INTENT], 10); } return 0; } function handleResponse(receipt, data, cb) { data.service = constants.SERVICES.APPLE; if (data.status === constants.VALIDATION.SUCCESS) { if (data.receipt[REC_KEYS.IN_APP] && !data.receipt[REC_KEYS.IN_APP].length) { // receipt is valid, but the receipt bought nothing // probably hacked: https://forums.developer.apple.com/thread/8954 // https://developer.apple.com/library/mac/technotes/tn2413/_index.html#//apple_ref/doc/uid/DTS40016228-CH1-RECEIPT-HOW_DO_I_USE_THE_CANCELLATION_DATE_FIELD_ data.status = constants.VALIDATION.POSSIBLE_HACK; data.message = errorMap[data.status]; verbose.log( '<Apple>', 'Empty purchased detected: in_app array is empty:', 'consider invalid and does not validate', data ); return cb(new Error('failed to validate for empty purchased list'), data); } // validated successfully return cb(null, data); } else { // error -> add error message data.message = errorMap[data.status] || 'Unkown'; } // failed to validate cb(new Error('failed to validate purchase'), data); } function send(url, content, cb) { var options = { encoding: null, url: url, body: content, json: true }; request.post(options, function (error, res, body) { return cb(error, res, body); }); } function applyResponseData(target, source) { for (var key in source) { if (target[key] === undefined) { target[key] = source[key]; } } }