-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat: implement create, update and cancel subscription
- Loading branch information
Showing
3 changed files
with
400 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,307 @@ | ||
'use strict' | ||
|
||
const storage = require('./storage/subscriptions') | ||
const { EQUALS, EQUALS_ANY_OF } = require('./firestore/firestore-queries') | ||
|
||
const SUBSCRIPTION_ACTIVE_STATUS = [ | ||
"active", | ||
"trialing", | ||
"past_due", | ||
] | ||
|
||
/** | ||
* @typedef {Object} SubscriptionCreatedPayload | ||
* @property {String} alert_id - The unique identifier for this Paddle webhook alert. | ||
* @property {String} alert_name - The name of this Paddle webhook alert. | ||
* @property {String} cancel_url - The URL of the 'Cancel Subscription' page. | ||
* @property {String} checkout_id - The checkout id of the order created.. | ||
* @property {String} currency - The three-letter ISO currency code. | ||
* @property {String} email - The email address of the customer. | ||
* @property {String} event_time - The date and time the event was triggered in UTC (Coordinated Universal Time). | ||
* @property {String} marketing_consent - The value of this field `0` or `1` indicates whether the user has agreed to receive marketing messages from the vendor. | ||
* @property {String} next_bill_date - The date the next payment is due on this subscription. | ||
* @property {String} passthrough - This field contains any values that you passed into the checkout using the `passthrough` parameter. See the [Pass Parameters documentation](/guides/how-tos/checkout/pass-parameters#sending-additional-user-data) for more information. | ||
* @property {String} quantity - The number of products or subscription seats sold in the transaction. | ||
* @property {String} source - Referrer website URL(s) from where the traffic originated from. | ||
* @property {String} status - The current status of the subscription. | ||
* @property {String} subscription_id - The unique Subscription ID for this customer’s subscription. | ||
* @property {String} subscription_plan_id - The ID of the Subscription Plan the customer is subscribed to. | ||
* @property {String} unit_price - The price per unit of the subscription. | ||
* @property {String} update_url - The URL of the ‘Update Payment Details’ page. | ||
* @property {String} user_id - The customer user id. | ||
* @property {String} p_signature | ||
*/ | ||
|
||
/** | ||
* @typedef {Object} SubscriptionUpdatedPayload | ||
* @property {String} alert_id - The unique identifier for this Paddle webhook alert. | ||
* @property {String} alert_name - The name of this Paddle webhook alert. | ||
* @property {String} cancel_url - The URL of the 'Cancel Subscription' page. | ||
* @property {String} checkout_id - The checkout id of the order created.. | ||
* @property {String} currency - The three-letter ISO currency code. | ||
* @property {String} email - The email address of the customer. | ||
* @property {String} event_time - The date and time the event was triggered in UTC (Coordinated Universal Time). | ||
* @property {String} marketing_consent - The value of this field `0` or `1` indicates whether the user has agreed to receive marketing messages from the vendor. | ||
* @property {String} new_price - The new price per unit of the subscription. | ||
* @property {String} new_quantity - The new number of products or subscription seats sold in the transaction. | ||
* @property {String} new_unit_price - The new price per unit of the subscription. | ||
* @property {String} next_bill_date - The date the next payment is due on this subscription. | ||
* @property {String} old_next_bill_date - The previous date the next payment was due on this subscription. | ||
* @property {String} old_price - The previous price per unit of the subscription. | ||
* @property {String} old_quantity - The previous number of products or subscription seats sold in the transaction. | ||
* @property {String} old_status - The previous status of the subscription. | ||
* @property {String} old_subscription_plan_id - The previous ID of the Subscription Plan the customer is subscribed to. | ||
* @property {String} old_unit_price - The previous price per unit of the subscription. | ||
* @property {String} passthrough - This field contains any values that you passed into the checkout using the `passthrough` parameter. See the [Pass Parameters documentation](/guides/how-tos/checkout/pass-parameters#sending-additional-user-data) for more information. | ||
* @property {String} status - The status of the subscription. | ||
* @property {String} subscription_id - The unique Subscription ID for this customer’s subscription. | ||
* @property {String} subscription_plan_id - The ID of the Subscription Plan the customer is subscribed to. | ||
* @property {String} update_url - The URL of the ‘Update Payment Details’ page. | ||
* @property {String} user_id - The customer user id. | ||
* @property {String} p_signature | ||
*/ | ||
|
||
/** | ||
* @typedef {Object} SubscriptionCancelledPayload | ||
* @property {String} alert_id - The unique identifier for this Paddle webhook alert. | ||
* @property {String} alert_name - The name of this Paddle webhook alert. | ||
* @property {String} cancellation_effective_date - The date the cancellation should come into effect, taking the customer’s most recent payment into account. | ||
* @property {String} checkout_id - The checkout id of the order created.. | ||
* @property {String} currency - The three-letter ISO currency code. | ||
* @property {String} email - The email address of the customer. | ||
* @property {String} event_time - The date and time the event was triggered in UTC (Coordinated Universal Time). | ||
* @property {String} marketing_consent - The value of this field `0` or `1` indicates whether the user has agreed to receive marketing messages from the vendor. | ||
* @property {String} passthrough - This field contains any values that you passed into the checkout using the `passthrough` parameter. See the [Pass Parameters documentation](/guides/how-tos/checkout/pass-parameters#sending-additional-user-data) for more information. | ||
* @property {String} quantity - The number of products or subscription seats sold in the transaction. | ||
* @property {String} status - The current status of the subscription. | ||
* @property {String} subscription_id - The unique Subscription ID for this customer’s subscription. | ||
* @property {String} subscription_plan_id - The ID of the Subscription Plan the customer is subscribed to. | ||
* @property {String} unit_price - The price per unit of the subscription. | ||
* @property {String} user_id - The customer user id. | ||
* @property {String} p_signature | ||
*/ | ||
|
||
class Subscriptions { | ||
|
||
/** | ||
* | ||
* @param {String} identifier a unique value that can be used to query this subscription | ||
* @param {SubscriptionCreatedPayload} subscription | ||
*/ | ||
async addSubscription(identifier, subscription) { | ||
// if (!userId || !subscription) { | ||
// logger.error(`addSubscription called with falsy arguments ${userId} ${subscription}`) | ||
// return Promise.reject(this.REASON_INVALID_REQUEST) | ||
// } | ||
|
||
const statusModel = { | ||
currency: subscription.currency, | ||
description: subscription.status, | ||
next_bill_date: subscription.next_bill_date, | ||
unit_price: subscription.unit_price, | ||
quantity: subscription.quantity, | ||
start_at: subscription.event_time, | ||
} | ||
|
||
const subscriptionModel = { | ||
cancel_url: subscription.cancel_url, | ||
checkout_id: subscription.checkout_id, | ||
payments: [], | ||
status: [statusModel], | ||
source: subscription.source, | ||
update_url: subscription.update_url, | ||
subscription_id: subscription.subscription_id, | ||
subscription_plan_id: subscription.subscription_plan_id, | ||
vendor_user_id: subscription.user_id, | ||
identifier | ||
} | ||
|
||
await storage.put(subscription.subscription_id, subscriptionModel) | ||
} | ||
|
||
/** | ||
* | ||
* @param {SubscriptionUpdatedPayload} subscription | ||
*/ | ||
async updateSubscription(subscription) { | ||
// if (!userId || !subscriptionId || !subscription) { | ||
// logger.error('updateSubscription called with falsy arguments') | ||
// return Promise.reject(this.REASON_INVALID_REQUEST) | ||
// } | ||
|
||
const statusModel = { | ||
currency: subscription.currency, | ||
description: subscription.status, | ||
next_bill_date: subscription.next_bill_date, | ||
unit_price: subscription.new_unit_price, | ||
quantity: subscription.new_quantity, | ||
start_at: subscription.event_time, | ||
} | ||
|
||
const subscriptionModel = { | ||
cancel_url: subscription.cancel_url, | ||
checkout_id: subscription.checkout_id, | ||
payments: [], | ||
status: storage._arrayUnion(statusModel), | ||
update_url: subscription.update_url, | ||
subscription_id: subscription.subscription_id, | ||
subscription_plan_id: subscription.subscription_plan_id, | ||
vendor_user_id: subscription.user_id, | ||
} | ||
|
||
await storage.update(subscription.subscription_id, subscriptionModel) | ||
} | ||
|
||
/** | ||
* | ||
* @param {SubscriptionCancelledPayload} subscription | ||
*/ | ||
async cancelSubscription(subscription) { | ||
// if (!userId || !subscriptionId || !subscription) { | ||
// logger.error('cancelSubscription called with falsy arguments') | ||
// return Promise.reject(this.REASON_INVALID_REQUEST) | ||
// } | ||
|
||
const statusModel = { | ||
currency: subscription.currency, | ||
description: subscription.status, | ||
unit_price: subscription.unit_price, | ||
quantity: subscription.new_quantity ? subscription.new_quantity : '', | ||
start_at: subscription.cancellation_effective_date, | ||
} | ||
|
||
const subscriptionModel = { | ||
status: storage._arrayUnion(statusModel), | ||
subscription_plan_id: subscription.subscription_plan_id, | ||
} | ||
|
||
await storage.update(subscription.subscription_id, subscriptionModel) | ||
} | ||
|
||
async refundSubscription(userId, subscriptionId, subscription) { | ||
// if (!userId || !subscriptionId || !subscription) { | ||
// logger.error('refundSubscription called with falsy arguments') | ||
// return Promise.reject(this.REASON_INVALID_REQUEST) | ||
// } | ||
|
||
const result = await this._withCollection(async collection => { | ||
const status = await this.encrypt(this._statusBuilder() // | ||
.checkoutId(subscription.checkout_id) // | ||
.currency(subscription.currency) // | ||
.price(subscription.unit_price) // | ||
.description(subscription.status) // | ||
.build()) | ||
|
||
status.quantity = subscription.quantity | ||
status.subscriptionPlanId = subscription.subscription_plan_id | ||
|
||
status.start = Timestamp.fromNumber(Date.now()) | ||
|
||
return this._pushNewStatusAndSort(collection, userId, subscriptionId, status) | ||
}) | ||
|
||
if (result.acknowledged === true && result.modifiedCount) { | ||
return true | ||
} | ||
|
||
return Promise.reject(this.REASON_NOT_EXPECTED) | ||
} | ||
|
||
/** | ||
* | ||
* @param {String} identifier the identifying value passed during creation of the subscription | ||
* @param {Array<String>} subscriptionPlanIds an array of plan ids to search for | ||
* @returns {boolean} true if an active subscription was found | ||
*/ | ||
async hasActiveSubscription(identifier, subscriptionPlanIds) { | ||
const { subscriptions } = await storage.queryAllBy([EQUALS('identifier', identifier), EQUALS_ANY_OF('subscription_plan_id', subscriptionPlanIds)]) | ||
|
||
if (subscriptions.length === 0) { | ||
return false | ||
} | ||
|
||
return this._isOneOfSubscriptionsActive(subscriptions) | ||
} | ||
|
||
/** | ||
* | ||
* @param {Object} subscription | ||
* @returns {boolean} true subscription is active | ||
*/ | ||
async isSubscriptionActive(subscription) { | ||
return this._isOneOfSubscriptionsActive([subscription]) | ||
} | ||
|
||
/** | ||
* | ||
* @param {Array} subscriptions | ||
* @returns {boolean} true if an active subscription was given | ||
*/ | ||
async _isOneOfSubscriptionsActive(subscriptions) { | ||
const now = Date.now() | ||
|
||
const allStatus = subscriptions.map(subscription => { | ||
// filter all status object that have a start date in the past | ||
// and sort by start date descending | ||
return subscription.status | ||
.filter(status => new Date(status.start_at).getTime() < now) | ||
.sort((a, b) => new Date(b.start_at).getTime() - new Date(a.start_at).getTime()) | ||
}) | ||
|
||
// now compare the first status element of each subscription and take the latest one | ||
let latestStatus = null | ||
for (const status of allStatus) { | ||
if (!latestStatus || new Date(latestStatus.start_at).getTime() < new Date(status[0]).getTime()) { | ||
latestStatus = status[0] | ||
} | ||
} | ||
|
||
return this._isSubscriptionStatusCurrentlyActive(latestStatus) | ||
} | ||
|
||
/** | ||
* | ||
* Returns true if the given status has a description that we recognize as active | ||
* | ||
* <strong>Status must be decrypted</strong> | ||
* | ||
* @param {Object} activeStatus | ||
* @returns {Boolean} true or false | ||
*/ | ||
_isSubscriptionStatusCurrentlyActive(status) { | ||
return SUBSCRIPTION_ACTIVE_STATUS.includes(status.description) | ||
} | ||
|
||
async addPayment(subscriptionId, payment) { | ||
// if (!subscriptionId || !payment) { | ||
// logger.error('addPayment called with falsy arguments') | ||
// return Promise.reject(this.REASON_INVALID_REQUEST) | ||
// } | ||
|
||
const result = await this._withCollection(async collection => { | ||
const search = this._modelBuilder() // | ||
.subscriptionId(subscriptionId) // | ||
.build() | ||
|
||
const subscription = await collection.findOne(search) | ||
|
||
if (!subscription) { | ||
logger.error(`Could not addPayment for subscriptionId ${subscriptionId}`) | ||
return Promise.reject(this.REASON_NOT_FOUND) | ||
} | ||
|
||
subscription.payments.push(payment) | ||
|
||
return collection.insertOne(subscription) | ||
}) | ||
|
||
|
||
if (result.acknowledged === true && result.modifiedCount === 1) { | ||
return true | ||
} | ||
|
||
return Promise.reject(this.REASON_NOT_EXPECTED) | ||
} | ||
} | ||
|
||
module.exports = new Subscriptions() |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,2 @@ | ||
const resource = require('../firestore/firestore-resource') | ||
module.exports = resource('subscriptions') |
Oops, something went wrong.