Skip to content

Commit

Permalink
feat(subscription-info): allow hydration of local status
Browse files Browse the repository at this point in the history
  • Loading branch information
stfsy committed Nov 13, 2022
1 parent 8b907cd commit 2afa7dd
Show file tree
Hide file tree
Showing 4 changed files with 323 additions and 4 deletions.
122 changes: 122 additions & 0 deletions lib/subscription-info.js
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,14 @@ class SubscriptionInfo {
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'
}

/**
* @typedef SubscriptionInfos
* @property {SubscriptionInfo} [any] subscription info per subscription plan id
Expand Down Expand Up @@ -408,6 +416,120 @@ class SubscriptionInfo {
_isSubscriptionStatusCurrentlyActive(status) {
return ACTIVE_SUBSCRIPTION_DESCRIPTIONS.includes(status.description)
}

/**
* Fetches the latest subscription status from paddle API and updates the local status accordingly.
*
* This implementation is cautious in that it only updates the status if
* - current status returned by API is active
* - local status' contain only the pre-checkout placeholder
*
* If update of the local subscription status is not necessary - e.g. because
* the webhook was also already received - the method will just silently return.
*
* This method allows us to decouple ourselves from the timely arrival of paddle webhooks. Because
* webhooks are necessary to store a subscription created event in our database. If the webhook
* does not arrive in time, our users need to wait for a finite amount of time which
* is not a convincing user experience.
*
* This method can be called after the first checkout and after the order was processsed
* to already store subscription-related data and let the user already enjoy some goodies.
*
* @param {Array} ids ids pointing to the target subscription object
* @param {Object} subscription the current local subscription instance
* @param {String} checkoutId checkout id of paddle.com
* @throws Error if hydration failed unexepectedly
* @returns
*/
async hydrateSubscriptionCreated(ids, { subscription_id, ...localSubscriptionInstance }, checkoutId) {
if (localSubscriptionInstance.status.length > 1) {
return
}

const subscriptions = await this._api.getSubscription({ subscription_id })
if (!Array.isArray(subscriptions) || subscriptions.length < 1) {
return
}

const subscription = subscriptions.at(0)

const hookPayload = {
alert_id: SubscriptionInfo.HYDRATION_SUBSCRIPTION_CREATED,
alert_name: SubscriptionInfo.HYDRATION_SUBSCRIPTION_CREATED,
currency: subscription.last_payment.currency,
status: subscription.state,
next_bill_date: subscription.next_payment?.date || '',
quantity: subscription.quantity,
event_time: subscription.signup_date,
source: 'pi-hydration',
update_url: subscription.update_url,
subscription_id: subscription.subscription_id,
subscription_plan_id: subscription.plan_id,
cancel_url: subscription.cancel_url,
checkout_id: checkoutId,
user_id: subscription.user_id,
passthrough: JSON.stringify({ ids })
}

if (subscription.state === 'active') {
await this._hookStorage.addSubscriptionCreatedStatus(hookPayload)
}
}

/**
* Fetches the latest subscription status from paddle API and updates the local status accordingly.
*
* If update of the local subscription status is not necessary - e.g. because
* the webhook was also already received - the method will just silently return.
*
* This method allows us to decouple ourselves from the timely arrival of paddle webhooks. Because
* webhooks are necessary to store a subscription created event in our database. If the webhook
* does not arrive in time, our users need to wait for a finite amount of time which
* is not a convincing user experience.
*
* This method can be called after the first checkout and after the order was processsed
* to already store subscription-related data and let the user already enjoy some goodies.
*
* Be cautious with this method because it will immediately cancel the current subscription
* because paddle API does not return when the subscription will actually end.
*
* @param {Array} ids ids pointing to the target subscription object
* @param {Object} subscription the current local subscription instance
* @param {String} checkoutId checkout id of paddle.com^^
* @throws Error if hydration failed unexepectedly
* @returns
*/
async hydrateSubscriptionCancelled(ids, { subscription_id }, checkoutId) {
const subscriptions = await this._api.getSubscription({ subscription_id })

if (!Array.isArray(subscriptions) || subscriptions.length < 1) {
return
}

const subscription = subscriptions.at(0)

const hookPayload = {
alert_id: SubscriptionInfo.HYDRATION_SUBSCRIPTION_CANCELLED,
alert_name: SubscriptionInfo.HYDRATION_SUBSCRIPTION_CANCELLED,
currency: subscription.last_payment.currency,
status: subscription.state,
next_bill_date: subscription.next_payment?.date || '',
quantity: subscription.quantity,
event_time: subscription.signup_date,
update_url: subscription.update_url,
subscription_id: subscription.subscription_id,
subscription_plan_id: subscription.plan_id,
cancel_url: subscription.cancel_url,
checkout_id: checkoutId,
user_id: subscription.user_id,
passthrough: JSON.stringify({ ids })
}

if (subscription.state === 'deleted') {
hookPayload.cancellation_effective_date = new Date().toISOString()
await this._hookStorage.addSubscriptionCancelledStatus(hookPayload)
}
}
}

module.exports = SubscriptionInfo
195 changes: 195 additions & 0 deletions test-e2e/spec/hydration.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,195 @@
'use strict'

const { test, expect } = require('@playwright/test')
const emulatorRunner = require('../../test/emulators-runner')
const hookRunner = require('../hook-server-runner')
const hookTunnelRunner = require('../hook-tunnel-runner')
const testPageRunner = require('../test-page-runner')

const index = require('../../lib/index')
const subscriptions = new index.SubscriptionsHooks('api_client')

const storageResource = require('../../lib/firestore/nested-firestore-resource')
const storage = storageResource({ documentPath: 'api_client', resourceName: 'api_clients' })

let subscriptionInfo
let api

test.beforeAll(emulatorRunner.start)
test.beforeAll(hookRunner.start)
test.beforeAll(hookTunnelRunner.start)
test.beforeAll(testPageRunner.start)
test.afterAll(testPageRunner.stop)

test.beforeAll(async () => {
api = new index.Api({ useSandbox: true, authCode: process.env.AUTH_CODE, vendorId: process.env.VENDOR_ID })
await api.init()

subscriptionInfo = new index.SubscriptionInfo('api_client', { api, hookStorage: subscriptions })
})

test.beforeEach(async () => {
try {
await storage.get(['4815162342'])
await storage.delete(['4815162342'])
} catch (e) {
//
}
await storage.put(['4815162342'], {})
})
test.beforeEach(() => {
return subscriptions.addSubscriptionPlaceholder(['4815162342'])
})

test.afterAll(async () => {
const subscriptions = await api.listSubscriptions()
for (let i = 0; i < subscriptions.length; i++) {
const subscription = subscriptions[i]
await api.cancelSubscription(subscription)
}
})

test.afterAll(async () => {
await new Promise((resolve) => {
setTimeout(resolve, 20000)
})
await hookTunnelRunner.stop()
})

test.afterAll(async () => {
await hookRunner.stop()
})

test.afterAll(emulatorRunner.stop)

async function createNewSubscription(page) {
await page.goto('http://localhost:3333/checkout.html')
await page.frameLocator('iframe[name="paddle_frame"]').locator('[data-testid="postcodeInput"]').click()
await page.frameLocator('iframe[name="paddle_frame"]').locator('[data-testid="postcodeInput"]').fill('12345')
await page.frameLocator('iframe[name="paddle_frame"]').locator('[data-testid="postcodeInput"]').press('Enter')
await page.frameLocator('iframe[name="paddle_frame"]').locator('[data-testid="CARD_PaymentSelectionButton"]').click()
await page.frameLocator('iframe[name="paddle_frame"]').locator('[data-testid="cardNumberInput"]').click()
await page.frameLocator('iframe[name="paddle_frame"]').locator('[data-testid="cardNumberInput"]').fill('4242 4242 4242 4242')
await page.frameLocator('iframe[name="paddle_frame"]').locator('[data-testid="cardNumberInput"]').press('Tab')
await page.frameLocator('iframe[name="paddle_frame"]').locator('[data-testid="cardholderNameInput"]').fill('Muller')
await page.frameLocator('iframe[name="paddle_frame"]').locator('[data-testid="cardholderNameInput"]').press('Tab')
await page.frameLocator('iframe[name="paddle_frame"]').locator('[data-testid="expiryMonthInput"]').fill('120')
await page.frameLocator('iframe[name="paddle_frame"]').locator('[data-testid="expiryMonthInput"]').press('Tab')
await page.frameLocator('iframe[name="paddle_frame"]').locator('[data-testid="expiryYearInput"]').fill('2025')
await page.frameLocator('iframe[name="paddle_frame"]').locator('[data-testid="expiryYearInput"]').press('Tab')
await page.frameLocator('iframe[name="paddle_frame"]').locator('[data-testid="cardVerificationValueInput"]').fill('123')
await page.frameLocator('iframe[name="paddle_frame"]').locator('[data-testid="cardVerificationValueInput"]').press('Enter')

await page.waitForSelector('#paddleSuccess', { state: 'visible', timeout: 50000 })
await page.waitForSelector('.paddle-loader', { state: 'hidden', timeout: 50000 })
await page.waitForTimeout(20000)
}

test('hydrate an active subscription', async ({ page }) => {
// create new subscription and ...
await createNewSubscription(page)

let { subscription } = await storage.get(['4815162342'])

// .. and check subscription is active to make sure setup was correct
let sub = await subscriptionInfo.getAllSubscriptionsStatus(subscription)
expect(sub['33590']).toBeTruthy()

// remove status and payments to verify hydration process
await storage.update(['4815162342'], {
'subscription.status': [],
'subscription.payments': []
});

({ subscription } = await storage.get(['4815162342']))
// .. expect sub to be not active anymore after we reset all status and payments
sub = await subscriptionInfo.getAllSubscriptionsStatus(subscription)
expect(sub['33590']).toBeFalsy()

// .. now hydrate status again ..
await subscriptionInfo.hydrateSubscriptionCreated(['4815162342'], subscription, 'checkoutId');

// .. and expect subscription to be active again
({ subscription } = await storage.get(['4815162342']))
sub = await subscriptionInfo.getAllSubscriptionsStatus(subscription)
expect(sub['33590']).toBeTruthy()
})

test('does not hydrate if status created was already received', async ({ page }) => {
// create new subscription and ...
await createNewSubscription(page)

let { subscription } = await storage.get(['4815162342'])

// .. and check subscription is active to make sure setup was correct
let sub = await subscriptionInfo.getAllSubscriptionsStatus(subscription)
expect(sub['33590']).toBeTruthy()

// .. now hydrate status again ..
await subscriptionInfo.hydrateSubscriptionCreated(['4815162342'], subscription, 'checkoutId');

// .. and expect subscription to be active again
({ subscription } = await storage.get(['4815162342']))
expect(subscription.status).toHaveLength(2)
})

test('hydrate a deleted subscription', async ({ page }) => {
// create new subscription and ...
await createNewSubscription(page)

let { subscription } = await storage.get(['4815162342'])

// .. and check subscription is active to make sure setup was correct
let sub = await subscriptionInfo.getAllSubscriptionsStatus(subscription)
expect(sub['33590']).toBeTruthy()

try {
await subscriptionInfo.cancelSubscription(subscription, '33590')
await page.waitForTimeout(10000)
} catch (e) {
if (e.message !== index.SubscriptionInfo.ERROR_SUBSCRIPTION_ALREADY_CANCELLED) {
throw e
}
}

({ subscription } = await storage.get(['4815162342']))
// .. expect sub to be not active anymore in the future
sub = await subscriptionInfo.getAllSubscriptionsStatus(subscription, new Date(new Date().getTime() + 1000 * 3600 * 24 * 35))
expect(sub['33590']).toBeFalsy()

// remove status and payments to verify hydration process
await storage.update(['4815162342'], {
'subscription.status': [],
'subscription.payments': []
})

// .. now hydrate status again ..
await subscriptionInfo.hydrateSubscriptionCancelled(['4815162342'], subscription, 'checkoutId');

// .. and expect subscription to be active again
({ subscription } = await storage.get(['4815162342']))
sub = await subscriptionInfo.getAllSubscriptionsStatus(subscription)
expect(sub['33590']).toBeFalsy()

const { status: subscriptionStatus } = subscription
expect(subscriptionStatus).toHaveLength(1)

const subscriptionsFromApi = await api.getSubscription(subscription.status.at(0))
const subscriptionFromApi = subscriptionsFromApi.at(0)

const status = subscriptionStatus.at(0)

expect(status.alert_id).toEqual(index.SubscriptionInfo.HYDRATION_SUBSCRIPTION_CANCELLED)
expect(status.alert_name).toEqual(index.SubscriptionInfo.HYDRATION_SUBSCRIPTION_CANCELLED)
expect(status.currency).toEqual(subscriptionFromApi.last_payment.currency)
expect(status.description).toEqual('deleted')
expect(status.next_bill_date).toBeUndefined()
expect(status.quantity).toEqual('')
expect(new Date(status.event_time).getTime()).toBeGreaterThanOrEqual(Date.now() - 2000)
expect(status.update_url).toBeUndefined()
expect(status.subscription_id).toEqual(subscriptionFromApi.subscription_id)
expect(status.subscription_plan_id).toEqual(subscriptionFromApi.plan_id)
expect(status.cancel_url).toBeUndefined()
expect(status.checkout_id).toEqual('checkoutId')
expect(status.vendor_user_id).toEqual(subscriptionFromApi.user_id)
})
2 changes: 1 addition & 1 deletion test-e2e/spec/subscription.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -184,7 +184,7 @@ test('test cancel via api', async ({ page }) => {
// cancel subscription ...
const success = await api.cancelSubscription(subscription.status[1])
expect(success).toBeTruthy()
await page.waitForTimeout(10000);
await page.waitForTimeout(20000);

// ... verify subscription still active today ...
({ subscription } = await storage.get(['4815162342']))
Expand Down
8 changes: 5 additions & 3 deletions test/spec/subscription-info.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -220,7 +220,7 @@ describe('SubscriptionInfo', () => {
}
})
})

describe('.updateSubscription', () => {
beforeEach(async () => {
const subscriptionId = uuid()
Expand Down Expand Up @@ -635,10 +635,12 @@ describe('SubscriptionInfo', () => {
})
})

describe('.getStatusTrail', () => {
describe.only('.getStatusTrail', () => {
beforeEach(async () => {
const subscriptionId = uuid()

await subscriptions.addSubscriptionPlaceholder(ids)

const createPayload = Object.assign({}, subscriptionCreated,
{
event_time: new Date().toISOString(),
Expand Down Expand Up @@ -669,7 +671,7 @@ describe('SubscriptionInfo', () => {
const { subscription: sub } = await storage.get(ids)
const trail = await subscriptionInfo.getStatusTrail(sub)
const subscriptionTrail = trail[subscriptionCreated.subscription_plan_id]

expect(subscriptionTrail).to.have.length(3)
// expect(subscriptionTrail).to.have.length(3)
expect(subscriptionTrail[2].type).to.equal('subscription_created')
expect(subscriptionTrail[2].description).to.equal('active')
Expand Down

0 comments on commit 2afa7dd

Please sign in to comment.