Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add subscription deferral for Google play #59

Open
wants to merge 23 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 43 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,14 +33,28 @@ var payment = {

### Purchase verification ( all platforms )

A single method is exposed to verify purchase receipts:
A method is exposed to verify purchase receipts:

```javascript
iap.verifyPayment(platform, payment, function (error, response) {
/* your code */
});
```

Or, if you prefer a promise-based alternative:

```javascript
iap.verifyPayment(platform, payment)
.then(
response => {
/* your code */
},
error => {
/* your code */
}
)
```

The receipt you pass must conform to the requirements of the backend you are verifying with. Read
the next chapter for more information on the format.

Expand All @@ -57,6 +71,34 @@ iap.cancelSubscription("google", payment, function (error, response) {
});
```

Or, if you prefer a promise-based alternative:

```javascript
iap.cancelSubscription(platform, payment)
.then(
response => {
/* your code */
},
error => {
/* your code */
}
)
```

### Subscription deferral ( Google Play only )

Google exposes [an API for deferral](https://developers.google.com/android-publisher/api-ref/purchases/subscriptions/defer) of recurring suscriptions. This might be used to extend a user's subscription purchase until a specified future expiration time ( useful to grant your users some free days or months ).

```javascript
var deferralInfo = {
expectedExpiryTimeMillis: 1546616722237,
desiredExpiryTimeMillis: 1547716722237,
};
iap.deferSubscription("google", payment, deferralInfo, function (error, response) {
/* your code */
});
```

## Supported platforms

### Amazon
Expand Down
96 changes: 92 additions & 4 deletions index.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,17 @@ const platforms = {
roku: require('./lib/roku')
};

const promisify = (fn) => {
return (...args) => {
return new Promise((resolve, reject) => {
fn(...args, (err, res) => {
return (err ? reject(err) : resolve(res));
});
});
};
};

exports.verifyPayment = function (platform, payment, cb) {
function verifyPayment(platform, payment, cb) {
function syncError(error) {
process.nextTick(function () {
cb(error);
Expand All @@ -34,10 +43,9 @@ exports.verifyPayment = function (platform, payment, cb) {

cb(null, result);
});
};
}


exports.cancelSubscription = function (platform, payment, cb) {
function cancelSubscription(platform, payment, cb) {
function syncError(error) {
process.nextTick(function () {
cb(error);
Expand All @@ -64,6 +72,86 @@ exports.cancelSubscription = function (platform, payment, cb) {
return cb(error);
}

cb(null, result);
});
}

exports.verifyPayment = (platform, payment, cb) => {
return (cb ? verifyPayment(platform, payment, cb) : promisify(verifyPayment)(platform, payment));
};

exports.cancelSubscription = (platform, payment, cb) => {
return (cb ? cancelSubscription(platform, payment, cb) : promisify(cancelSubscription)(platform, payment));
};


exports.deferSubscription = function (platform, payment, deferralInfo, cb) {
function syncError(error) {
process.nextTick(function () {
cb(error);
});
}

if (!payment) {
return syncError(new Error('No payment given'));
}

if (!deferralInfo) {
return syncError(new Error('No deferralInfo given'));
}

const engine = platforms[platform];

if (!engine) {
return syncError(new Error(`Platform ${platform} not recognized`));
}

if (!engine.deferSubscription) {
return syncError(new Error(`Platform ${platform
} does not have deferSubscription method`));
}

engine.deferSubscription(payment, deferralInfo, function (error, result) {
if (error) {
return cb(error);
}

cb(null, result);
});
};


exports.deferSubscription = function (platform, payment, deferralInfo, cb) {
function syncError(error) {
process.nextTick(function () {
cb(error);
});
}

if (!payment) {
return syncError(new Error('No payment given'));
}

if (!deferralInfo) {
return syncError(new Error('No deferralInfo given'));
}

const engine = platforms[platform];

if (!engine) {
return syncError(new Error(`Platform ${platform} not recognized`));
}

if (!engine.deferSubscription) {
return syncError(new Error(`Platform ${platform
} does not have deferSubscription method`));
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Was the line too long for lint's liking?

}

engine.deferSubscription(payment, deferralInfo, function (error, result) {
if (error) {
return cb(error);
}

cb(null, result);
});
};
22 changes: 19 additions & 3 deletions lib/apple/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,9 @@ const responses = {
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 from the test environment, but it was sent to the production service for verification. Send it to the test environment service instead.',
21008: 'This receipt is from the production receipt, but it was sent to the test environment service for verification. Send it to the production environment service instead.'
21008: 'This receipt is from the production receipt, but it was sent to the test environment service for verification. Send it to the production environment service instead.',
21009: 'Internal data access error. Try again later.',
21010: 'The user account cannot be found or has been deleted.'
};

function getReceiptFieldValue(receipt, field) {
Expand Down Expand Up @@ -78,19 +80,33 @@ function parseResult(result) {
productId = lastReceipt.product_id;
transactionId = lastReceipt.transaction_id;
purchaseDate = parseTime(lastReceipt.purchase_date_ms);
expirationDate = parseTime(lastReceipt.expires_date_ms || lastReceipt.expires_date);
expirationDate = parseTime(
lastReceipt.expires_date_ms ||
lastReceipt.expires_date
);
} else if (result.latest_receipt_info && result.latest_receipt_info.transaction_id) {
latestReceiptInfo = [result.latest_receipt_info];

productId = result.latest_receipt_info.product_id;
transactionId = result.latest_receipt_info.transaction_id;
purchaseDate = parseTime(result.latest_receipt_info.purchase_date_ms);
expirationDate = parseTime(
result.latest_receipt_info.expires_date_ms || result.latest_receipt_info.expires_date
result.latest_receipt_info.expires_date_ms ||
result.latest_receipt_info.expires_date
);
}
}

// If cancellation date is available it means that Apple Customer Support refunded subscription payment.
// From Apple:
// To check whether a purchase has been canceled by Apple Customer Support, look for the
// Cancellation Date field in the receipt. If the field contains a date, regardless of the
// subscription’s expiration date, the purchase has been canceled. With respect to providing
// content or service, treat a canceled transaction the same as if no purchase had ever been made.
if (latestExpiredReceiptInfo && latestExpiredReceiptInfo.cancellation_date_ms) {
expirationDate = parseTime(latestExpiredReceiptInfo.cancellation_date_ms);
}

return {
receipt: result.receipt,
latestReceiptInfo,
Expand Down
60 changes: 60 additions & 0 deletions lib/google/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,17 @@ function validatePaymentAndParseKeyObject(payment) {
return keyObject;
}

function validateDeferralInfo(deferralInfo) {
assert.equal(typeof deferralInfo, 'object', 'deferralInfo must be an object');
assert.equal(typeof deferralInfo.expectedExpiryTimeMillis, 'number', 'expectedExpiryTimeMillis must be a number');
assert.equal(typeof deferralInfo.desiredExpiryTimeMillis, 'number', 'desiredExpiryTimeMillis must be a number');

assert(deferralInfo.desiredExpiryTimeMillis > deferralInfo.expectedExpiryTimeMillis, 'desiredExpiryTimeMillis must be greater than expectedExpiryTimeMillis');

return deferralInfo;
}


exports.verifyPayment = function (payment, cb) {
let keyObject;

Expand Down Expand Up @@ -133,3 +144,52 @@ exports.cancelSubscription = function (payment, cb) {
});
});
};


exports.deferSubscription = function (payment, deferralInfo, cb) {
let keyObject;
const options = {};

try {
keyObject = validatePaymentAndParseKeyObject(payment);
options.json = {
deferralInfo: validateDeferralInfo(deferralInfo)
};
} catch (error) {
return process.nextTick(function () {
cb(error);
});
}

jwt.getToken(keyObject.client_email, keyObject.private_key, apiUrls.publisherScope, function (error, token) {
if (error) {
return cb(error);
}

const requestUrl = apiUrls.purchasesSubscriptionsDefer(
payment.packageName,
payment.productId,
payment.receipt,
token
);

https.post(requestUrl, options, function (error, res, resultString) {
if (error) {
return cb(error);
}

if (res.statusCode !== 200) {
return cb(new Error(`Received ${res.statusCode} status code with body: ${resultString}`));
}

var resultObject;
try {
resultObject = JSON.parse(resultString);
} catch (e) {
return cb(e);
}

return cb(null, resultObject);
});
});
};
15 changes: 13 additions & 2 deletions lib/google/urls.js
Original file line number Diff line number Diff line change
Expand Up @@ -38,8 +38,6 @@ exports.purchasesSubscriptionsGet = function (packageName, productId, receipt, a
);
};


// Android Subscriptions URLs & generators
exports.purchasesSubscriptionsCancel = function (packageName, productId, receipt, accessToken) {
const urlFormat = 'https://www.googleapis.com/androidpublisher/v3/applications/%s/purchases/subscriptions/%s/tokens/%s:cancel?access_token=%s';

Expand All @@ -51,3 +49,16 @@ exports.purchasesSubscriptionsCancel = function (packageName, productId, receipt
encodeURIComponent(accessToken) // API access token
);
};

// Android Subscriptions URLs & generators
exports.purchasesSubscriptionsDefer = function (packageName, productId, receipt, accessToken) {
const urlFormat = 'https://www.googleapis.com/androidpublisher/v3/applications/%s/purchases/subscriptions/%s/tokens/%s:defer?access_token=%s';

return util.format(
urlFormat,
encodeURIComponent(packageName), // application package name
encodeURIComponent(productId), // productId
encodeURIComponent(receipt), // purchase token
encodeURIComponent(accessToken) // API access token
);
};
Loading