@discue/paddle-integration-firestore
Version:
Paddle payments integration for Google Cloud Firestore
492 lines (429 loc) • 20.4 kB
JavaScript
const resource = require('./firestore/nested-firestore-resource')
const { PLACEHOLDER_DESCRIPTION, UPCOMING_PAYMENTS_DESCRIPTION, ACTIVE_SUBSCRIPTION_DESCRIPTIONS } = require('./subscription-descriptions')
const { HYDRATION_SUBSCRIPTION_CREATED } = require('./subscription-hydration.js')
class SubscriptionInfo {
/**
* @typedef ConstructorOptions
* @property {import('./paddle/api.js')} api
* @property {string} resourceName
*/
/**
*
* @param {String} storagePath Path to the target collection
* @param {ConstructorOptions} options
*/
constructor(storagePath, { api = {}, resourceName }) {
/** @private */ this._resourceName = resourceName || 'subscription'
/** @private */ this._storage = resource({ documentPath: storagePath, resourceName: this._resourceName })
/** @private */ this._api = api
}
static get ERROR_SUBSCRIPTION_NOT_FOUND() {
return 'NOT_FOUND'
}
static get ERROR_SUBSCRIPTION_ALREADY_CANCELLED() {
return 'ERROR_SUBSCRIPTION_ALREADY_CANCELLED'
}
static get HYDRATION_SUBSCRIPTION_CREATED() {
return 'pi-hydration/subscription_created'
}
static get HYDRATION_SUBSCRIPTION_CANCELLED() {
return 'pi-hydration/subscription_cancelled'
}
static get HYDRATION_UNAUTHORIZED() {
return 'pi-hydration/unauthorized'
}
static get HYDRATION_BAD_REQUEST() {
return 'pi-hydration/bad_request'
}
/**
* @typedef SubscriptionInfos
* @property {SubscriptionInfo} [any] subscription info per subscription plan id
*/
/**
* @typedef SubscriptionInfo
* @property {Boolean} active indicates whether the subscription is currently active
* @property {String} [start] ISO-formatted start date
* @property {String} [end=undefined] ISO-formatted end date
* @property {Array} [status_trail] a list of subscription status updates
* @property {Array} [payments_trail] a list of payments
*/
/**
* Reads and returns subscription related information. This method returns also future subscription info.
*
* @param {Array<String>} ids ids necessary to lookup possibly nested subscription object
* @param {Date} [validBeforeMillis=new Date(2099, 0)] timestamp indicating until which time info should be included. Defaults to year 2099.
*
* @returns {SubscriptionInfos}
*/
async getSubscriptionInfo(ids, validBeforeMillis = new Date(2099, 0)) {
const subscription = await this._getOrReadSubscription(ids)
const status = await this.getStartAndEndDates(subscription, validBeforeMillis)
const statusTrail = await this.getStatusTrail(subscription, validBeforeMillis)
const paymentsTrail = await this.getPaymentsTrail(subscription, validBeforeMillis)
return Object.keys(status).reduce((context, subscriptionPlanId) => {
const subscriptionPlanInfo = Object.assign(status[subscriptionPlanId], {
status_trail: statusTrail[subscriptionPlanId] || [],
payments_trail: paymentsTrail[subscriptionPlanId] || []
})
const hasStarted = new Date(subscriptionPlanInfo.start).getTime() < Date.now()
const hasEnded = subscriptionPlanInfo.end ? new Date(subscriptionPlanInfo.end).getTime() <= Date.now() : false
// if we have an end date and end date is in the past, then subscription was cancelled
subscriptionPlanInfo.active = hasStarted && !hasEnded
if (subscriptionPlanInfo.payments_trail.length && subscriptionPlanInfo.payments_trail.at(0).description === UPCOMING_PAYMENTS_DESCRIPTION) {
// was subscription already cancelled?
if (subscriptionPlanInfo.end) {
// and is the computed upcoming payment after the end date?
// then remove the upcoming payment
if (new Date(subscriptionPlanInfo.end).getTime() <= new Date(subscriptionPlanInfo.payments_trail.at(0).event_time).getTime()) {
subscriptionPlanInfo.payments_trail.splice(0, 1)
}
}
}
context[subscriptionPlanId] = subscriptionPlanInfo
return context
}, {})
}
/**
* Will cancel the subscription plan of the given subscription. The actual subscription id
* will be looked at at runtime by peaking at all subscription events.
*
* @param {Object|Array<String>} subscriptionOrIds subscription object or array of ids if subscription should be read from database
* @throws SubscriptionInfo.ERROR_SUBSCRIPTION_ALREADY_CANCELLED if already cancelled
* @throws SubscriptionInfo.ERROR_SUBSCRIPTION_NOT_FOUND if not found
* @returns
*/
async cancelSubscription(subscriptionOrIds, subscriptionPlanId) {
const subscription = await this._getOrReadSubscription(subscriptionOrIds)
const subscriptionId = await this._findActiveSubscriptionIdByPlanId(subscription, subscriptionPlanId)
try {
const cancelled = await this._api.cancelSubscription({ subscription_id: subscriptionId })
return cancelled !== false && cancelled !== 'false'
} catch (e) {
console.error(`Failed to cancel subscription because of: ${e}`)
}
return false
}
/**
* Will cancel the subscription plan of the given subscription. The actual subscription id
* will be looked at at runtime by peaking at all subscription events.
*
* @private
* @param {Object|Array<String>} subscriptionOrIds subscription object or array of ids if subscription should be read from database
* @throws SubscriptionInfo.ERROR_SUBSCRIPTION_NOT_FOUND if not found
* @returns
*/
async _getOrReadSubscription(subscriptionOrIds) {
if (Array.isArray(subscriptionOrIds)) {
try {
const sub = await this._storage.get(subscriptionOrIds, true)
const res = sub[this._resourceName]
return res
} catch (e) {
if (e.message == 'Not Found') {
throw new Error(SubscriptionInfo.ERROR_SUBSCRIPTION_NOT_FOUND)
} else {
throw e
}
}
} else {
return subscriptionOrIds
}
}
/**
* Will update the subscription plan of the given subscription. The actual subscription id
* will be looked at at runtime by peaking at all subscription events. The current plan will
* be cancelled in favor of the new one.
*
* @param {Object|Array<String>} subscriptionOrIds subscription object or array of ids if subscription should be read from database
* @param {String} currentSubscriptionPlanId the current plan id to be terminated
* @param {String} newSubscriptionPlanId the new plan id
* @throws SubscriptionInfo.ERROR_SUBSCRIPTION_ALREADY_CANCELLED if already cancelled
* @throws SubscriptionInfo.ERROR_SUBSCRIPTION_NOT_FOUND if not found
* @returns
*/
async updateSubscription(subscriptionOrIds, currentSubscriptionPlanId, newSubscriptionPlanId) {
const subscription = await this._getOrReadSubscription(subscriptionOrIds)
const subscriptionId = await this._findActiveSubscriptionIdByPlanId(subscription, currentSubscriptionPlanId)
try {
const response = await this._api.updateSubscriptionPlan({ subscription_id: subscriptionId }, newSubscriptionPlanId)
return response.subscription_id !== undefined
} catch (e) {
console.error(`Failed to update subscription because of: ${e}`)
}
return false
}
/**
* Finds the id of an active subscription by peaking at the status events.
*
* @private
* @param {Object} subscription
* @param {String} subscriptionPlanId
* @throws SubscriptionInfo.ERROR_SUBSCRIPTION_ALREADY_CANCELLED if already cancelled
* @throws SubscriptionInfo.ERROR_SUBSCRIPTION_NOT_FOUND if not found
* @returns
*/
async _findActiveSubscriptionIdByPlanId(subscription, subscriptionPlanId) {
const future = new Date(2099, 1).getTime()
const statusByPlanId = this._bySubscriptionId(subscription.status, future)
const startAndEndDates = await this.getStartAndEndDates(subscription, future)
// check whether plan id exists
if (!statusByPlanId[subscriptionPlanId] || statusByPlanId.length < 1) {
throw new Error(SubscriptionInfo.ERROR_SUBSCRIPTION_NOT_FOUND)
}
// check whether subscription was already cancelled
if (startAndEndDates[subscriptionPlanId].end) {
throw new Error(SubscriptionInfo.ERROR_SUBSCRIPTION_ALREADY_CANCELLED)
}
const statusArray = statusByPlanId[subscriptionPlanId]
return statusArray.at(0).subscription_id
}
/**
* @typedef {Object} StartAndEndDateBySubscription
* @property {StartAndEndDate} [any] start and end date of a subscription plan
*/
/**
* @typedef {Object} StartAndEndDate
* @property {String} start - subscription start date as ISO formatted string
* @property {String} start - subscription end date as ISO formatted string
*/
/**
* Returns start and end dates for all subscription plans found in the document.
*
* @param {Object|Array<String>} subscriptionOrIds subscription object or array of ids if subscription should be read from database
* @returns {StartAndEndDateBySubscription} containing the start and end date
*/
async getStartAndEndDates(subscriptionOrIds, validBeforeMillis = Date.now()) {
const subscription = await this._getOrReadSubscription(subscriptionOrIds)
const statusByPlanId = this._bySubscriptionId(subscription.status, validBeforeMillis)
return Object.entries(statusByPlanId).reduce((context, [subscriptionPlanId, status]) => {
context[subscriptionPlanId] = this._getStartAndEndDates(status)
return context
}, {})
}
/**
* Returns start and end dates for the given list of status objects.
*
* @private
* @param {Object} subscription
* @returns {StartAndEndDate} containing the start and end date
*/
_getStartAndEndDates(status) {
const statusArray = status
.sort((a, b) => new Date(a.event_time).getTime() - new Date(b.event_time).getTime())
const first = statusArray.find((s) => s.description !== PLACEHOLDER_DESCRIPTION)
let last = statusArray.at(-1)
if (first.alert_id === last.alert_id) {
last == null
}
const start = first.event_time
let end = null
if (last && !this._isSubscriptionStatusCurrentlyActive(statusArray.at(-1))) {
end = statusArray.at(-1).event_time
}
return { start, end }
}
/**
* Returns a list of payments sorted by date ascending for all subscription plans found in the document.
*
* <strong>Note:</strong> We also add an upcoming payment event to the list which may or may not happen
* according to the users' subscription status. For the sake of efficiency we don't check the subscription
* status in this method. So please check the status in your application before also showing the upcoming
* payments.
*
* @param {Object|Array<String>} subscriptionOrIds subscription object or array of ids if subscription should be read from database
* @returns {Object}
*/
async getPaymentsTrail(subscriptionOrIds, validBeforeMillis = Date.now()) {
const subscription = await this._getOrReadSubscription(subscriptionOrIds)
const paymentsByPlanId = this._bySubscriptionId(subscription.payments, validBeforeMillis)
return Object.entries(paymentsByPlanId).reduce((context, [subscriptionPlanId, payments]) => {
context[subscriptionPlanId] = this._getPaymentsTrail(payments)
return context
}, {})
}
/**
* Returns a list of payments sorted by event_time descending.
*
* @private
* @param {Object} subscription
* @returns {Array<Object>} containing the start and end date
*/
_getPaymentsTrail(payments) {
const sortedPayments = payments //
.sort((a, b) => new Date(b.event_time).getTime() - new Date(a.event_time).getTime())
let latestPaymentInfo = null
for (let i = 0, n = sortedPayments.length; i < n; i++) {
const payment = sortedPayments.at(i)
const { alert_name } = payment
if (alert_name === 'subscription_payment_succeeded' || alert_name === 'subscription_payment_failed') {
latestPaymentInfo = payment
break
}
}
if (latestPaymentInfo) {
const upcomingPayment = {
event_time: latestPaymentInfo.next_bill_date || latestPaymentInfo.next_retry_date,
alert_name: UPCOMING_PAYMENTS_DESCRIPTION,
currency: latestPaymentInfo.currency,
amount: latestPaymentInfo.next_payment_amount || latestPaymentInfo.amount,
quantity: latestPaymentInfo.quantity,
unit_price: latestPaymentInfo.unit_price,
subscription_plan_id: latestPaymentInfo.subscription_plan_id
}
payments.splice(0, 0, upcomingPayment)
}
return sortedPayments
.map(payment => {
const result = {
event_time: payment.event_time,
description: payment.alert_name,
amount: {
currency: payment.currency,
total: payment.amount,
quantity: payment.quantity,
unit_price: payment.unit_price,
},
subscription_plan_id: payment.subscription_plan_id,
}
if (payment.alert_name === 'subscription_payment_failed') {
Object.assign(result, {
next_try: {
date: payment.next_retry_date
},
instalments: payment.instalments,
})
} else if (payment.alert_name === 'subscription_payment_refunded') {
Object.assign(result, {
refund: {
reason: payment.refund_reason,
type: payment.refund_type
},
instalments: payment.instalments
})
result.amount.total = payment.gross_refund
} else if (payment.alert_name === 'subscription_payment_succeeded' ||
payment.alert_name === HYDRATION_SUBSCRIPTION_CREATED) {
Object.assign(result, {
next_payment: {
date: payment.next_bill_date,
amount: {
currency: payment.currency,
total: payment.next_payment_amount
}
},
receipt_url: payment.receipt_url,
instalments: payment.instalments,
})
result.amount.total = payment.sale_gross
result.amount.method = payment.payment_method
}
return result
})
}
/**
* Returns a list of update and changes events for all subscription plans found in the document.
*
* @param {Object|Array<String>} subscriptionOrIds subscription object or array of ids if subscription should be read from database
* @returns {Object}
*/
async getStatusTrail(subscriptionOrIds, validBeforeMillis = Date.now()) {
const subscription = await this._getOrReadSubscription(subscriptionOrIds)
const statusByPlanId = this._bySubscriptionId(subscription.status, validBeforeMillis)
return Object.entries(statusByPlanId).reduce((context, [subscriptionPlanId, status]) => {
context[subscriptionPlanId] = this._getStatusTrail(status)
return context
}, {})
}
/**
* Returns a list of update events sorted by event_time descending.
*
* @private
* @param {Array<Object>} status
* @returns {Array<Object>} containing the start and end date
*/
_getStatusTrail(status) {
return status //
.sort((a, b) => new Date(b.event_time).getTime() - new Date(a.event_time).getTime())
.map(s => {
return {
event_time: s.cancellation_effective_date || s.event_time,
cancel_url: s.cancel_url,
update_url: s.update_url,
description: s.description,
type: s.alert_name
}
})
}
/**
* Returns the status of each subscription found in the document.
*
* For each found subscription the method will add a boolean value
* indicating the subscription plan is active (true) or not active (false).
*
* Unless the second parameter is passed, the status will always be calculated
* using the current time
*
* @param {Object|Array<String>} subscriptionOrIds subscription object or array of ids if subscription should be read from database
* @param {Date} [atDate=new Date()] date for the calculation
* @returns {Object} true if an active subscription was given
*/
async getAllSubscriptionsStatus(subscriptionOrIds, atDate = new Date()) {
const result = {}
const now = atDate.getTime() + 10_000
const subscriptions = await this._getOrReadSubscription(subscriptionOrIds)
const allStatusBySubscriptionPlan = this._bySubscriptionId(subscriptions.status, now)
Object.entries(allStatusBySubscriptionPlan).forEach(([subscriptionPlanId, list]) => {
list.sort((a, b) => new Date(b.event_time).getTime() - new Date(a.event_time).getTime())
result[subscriptionPlanId] = this._isSubscriptionStatusCurrentlyActive(list.at(0))
})
return result
}
/**
*
* @private
* @param {Array<Object>} statusOrPayments
* @param {Number} validBeforeMillis
* @returns
*/
_bySubscriptionId(statusOrPayments, validBeforeMillis = Date.now()) {
return statusOrPayments.reduce((context, next) => {
if (new Date(next.event_time).getTime() < validBeforeMillis) {
if (!Array.isArray(context[next.subscription_plan_id])) {
context[next.subscription_plan_id] = []
}
context[next.subscription_plan_id].push(next)
}
return context
}, {})
}
/**
* Returns true if the given status has a description that we recognize as active
*
* @private
* @param {Object} activeStatus
* @returns {Boolean} true or false
*/
_isSubscriptionStatusCurrentlyActive(status) {
return ACTIVE_SUBSCRIPTION_DESCRIPTIONS.includes(status.description)
}
/**
* Reads the target subscription from the database, or uses the given subscription object
* to find the subscription id.
*
* @param {Object|Array<String>} subscriptionOrIds subscription object or array of ids if subscription should be read from database
* @param {String} planId the target plan
* @returns {String}
*/
async findSubscriptionIdByPlanId(subscriptionOrIds, planId) {
const subscription = await this._getOrReadSubscription(subscriptionOrIds)
const { status } = subscription
const subscriptionStatus = status.find(({ subscription_plan_id }) => subscription_plan_id == planId)
if (subscriptionStatus) {
return subscriptionStatus.subscription_id
} else {
return null
}
}
}
module.exports = SubscriptionInfo