Skip to content

Commit

Permalink
feat: implement create, update and cancel subscription
Browse files Browse the repository at this point in the history
  • Loading branch information
stfsy committed Aug 23, 2022
1 parent 0d41cc2 commit bbb716b
Show file tree
Hide file tree
Showing 3 changed files with 400 additions and 0 deletions.
307 changes: 307 additions & 0 deletions lib/index.js
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()
2 changes: 2 additions & 0 deletions lib/storage/subscriptions.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
const resource = require('../firestore/firestore-resource')
module.exports = resource('subscriptions')

0 comments on commit bbb716b

Please sign in to comment.