Skip to content

Commit 7a71780

Browse files
committed
feat(subscription-info): use custom data to verify api client
1 parent 2765fcc commit 7a71780

File tree

3 files changed

+132
-0
lines changed

3 files changed

+132
-0
lines changed

lib/subscription-info.js

+89
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,14 @@ class SubscriptionInfo {
3333
return 'pi-hydration/subscription_cancelled'
3434
}
3535

36+
static get HYDRATION_UNAUTHORIZED() {
37+
return 'pi-hydration/unauthorized'
38+
}
39+
40+
static get HYDRATION_BAD_REQUEST() {
41+
return 'pi-hydration/bad_request'
42+
}
43+
3644
/**
3745
* @typedef SubscriptionInfos
3846
* @property {SubscriptionInfo} [any] subscription info per subscription plan id
@@ -439,9 +447,90 @@ class SubscriptionInfo {
439447
* @param {Object} subscription the current local subscription status instance
440448
* @param {String} checkoutId checkout id of paddle.com
441449
* @throws Error if hydration failed unexepectedly
450+
* @throws SubscriptionInfo.HYDRATION_BAD_REQUEST if the subscription going to be hydrated contains no client information in custom_data
451+
* @throws SubscriptionInfo.HYDRATION_UNAUTHORIZED if ids do not match the ids found in the subscription
442452
* @returns
443453
*/
444454
async hydrateSubscriptionCreated(ids, { subscription_id }, checkoutId) {
455+
{
456+
const { subscription } = await this._storage.get(ids)
457+
if (subscription?.status?.length > 1) {
458+
return
459+
}
460+
}
461+
462+
const subscriptions = await this._api.getSubscription({ subscription_id })
463+
if (!Array.isArray(subscriptions) || subscriptions.length < 1) {
464+
return
465+
}
466+
467+
// more sanity checks here
468+
const subscription = subscriptions.at(0)
469+
if (!subscription.custom_data?._pi?.ids) {
470+
throw new Error(SubscriptionInfo.HYDRATION_BAD_REQUEST)
471+
}
472+
473+
const idsFromCustomData = subscription.custom_data._pi.ids
474+
if (!Array.isArray(idsFromCustomData)) {
475+
throw new Error(SubscriptionInfo.HYDRATION_UNAUTHORIZED)
476+
}
477+
478+
const isForTargetId = ids.length === idsFromCustomData.length && idsFromCustomData.every((id, index) => id === ids[index])
479+
if (!isForTargetId) {
480+
throw new Error(SubscriptionInfo.HYDRATION_UNAUTHORIZED)
481+
}
482+
483+
const hookPayload = {
484+
alert_id: SubscriptionInfo.HYDRATION_SUBSCRIPTION_CREATED,
485+
alert_name: SubscriptionInfo.HYDRATION_SUBSCRIPTION_CREATED,
486+
currency: subscription.last_payment.currency,
487+
status: subscription.state,
488+
next_bill_date: subscription.next_payment?.date || '',
489+
quantity: subscription.quantity,
490+
event_time: subscription.signup_date,
491+
source: 'pi-hydration',
492+
update_url: subscription.update_url,
493+
subscription_id: subscription.subscription_id,
494+
subscription_plan_id: subscription.plan_id,
495+
cancel_url: subscription.cancel_url,
496+
checkout_id: checkoutId,
497+
user_id: subscription.user_id,
498+
// use the ids from paddle api here
499+
// these were initially passed during checkout and cannot be updated
500+
// thus we ensure that user do not hydrate other subscriptions too
501+
passthrough: JSON.stringify({ ids: idsFromCustomData })
502+
}
503+
504+
if (subscription.state === 'active') {
505+
await this._hookStorage.addSubscriptionCreatedStatus(hookPayload)
506+
}
507+
}
508+
509+
/**
510+
* Fetches the latest subscription status from paddle API and updates the local status accordingly.
511+
*
512+
* This implementation is cautious in that it only updates the status if
513+
* - current status returned by API is active
514+
* - local status' contain only the pre-checkout placeholder
515+
*
516+
* If update of the local subscription status is not necessary - e.g. because
517+
* the webhook was also already received - the method will just silently return.
518+
*
519+
* This method allows us to decouple ourselves from the timely arrival of paddle webhooks. Because
520+
* webhooks are necessary to store a subscription created event in our database. If the webhook
521+
* does not arrive in time, our users need to wait for a finite amount of time which
522+
* is not a convincing user experience.
523+
*
524+
* This method can be called after the first checkout and after the order was processsed
525+
* to already store subscription-related data and let the user already enjoy some goodies.
526+
*
527+
* @param {Array} ids ids pointing to the target subscription object
528+
* @param {Object} subscription the current local subscription status instance
529+
* @param {String} checkoutId checkout id of paddle.com
530+
* @throws Error if hydration failed unexepectedly
531+
* @returns
532+
*/
533+
async hydrateSubscriptionCreated2(ids, { orderId, email }) {
445534
{
446535
const { subscription } = await this._storage.get(ids)
447536

test-e2e/spec/hydration.spec.js

+38
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ const index = require('../../lib/index')
77
const subscriptions = new index.SubscriptionsHooks('api_client')
88

99
const storageResource = require('../../lib/firestore/nested-firestore-resource')
10+
const SubscriptionInfo = require('../../lib/subscription-info')
1011
const storage = storageResource({ documentPath: 'api_client', resourceName: 'api_clients' })
1112

1213
let subscriptionInfo
@@ -94,6 +95,43 @@ test('hydrate an active subscription', async ({ page }) => {
9495
expect(sub['33590']).toBeTruthy()
9596
})
9697

98+
test('throws if subscription was created for another client', async ({ page }) => {
99+
// create new subscription and ...
100+
await createNewSubscription(page, apiClientId)
101+
102+
let { subscription } = await storage.get([apiClientId])
103+
const subscriptionId = subscription.status[1].subscription_id
104+
105+
// .. and check subscription is active to make sure setup was correct
106+
let sub = await subscriptionInfo.getAllSubscriptionsStatus(subscription)
107+
expect(sub['33590']).toBeTruthy()
108+
109+
// remove status and payments to verify hydration process
110+
await storage.update([apiClientId], {
111+
'subscription.status': [],
112+
'subscription.payments': []
113+
});
114+
115+
({ subscription } = await storage.get([apiClientId]))
116+
// .. expect sub to be not active anymore after we reset all status and payments
117+
sub = await subscriptionInfo.getAllSubscriptionsStatus(subscription)
118+
expect(sub['33590']).toBeFalsy()
119+
120+
try {
121+
// add dummy client here
122+
await storage.put(['123'], {
123+
subscription: {
124+
status: []
125+
}
126+
})
127+
await subscriptionInfo.hydrateSubscriptionCreated(['123'], { subscription_id: subscriptionId }, 'checkoutId')
128+
throw new Error('Must throw')
129+
} catch (e) {
130+
const message = e.message
131+
expect(message).toEqual(SubscriptionInfo.HYDRATION_UNAUTHORIZED)
132+
}
133+
})
134+
97135
test('does not hydrate if status created was already received', async ({ page }) => {
98136
// create new subscription and ...
99137
await createNewSubscription(page, apiClientId)

test-e2e/test-page/fragments/checkout/checkout-page.html

+5
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,11 @@ <h2 class="text-3xl text-center">
160160
email: 'a@b.com',
161161
title: 'This is the title',
162162
message: 'This is a message',
163+
customData: {
164+
_pi: {
165+
ids: [clientId]
166+
}
167+
},
163168
allowQuantity: false,
164169
disableLogout: false,
165170
passthrough: `{"ids": ["${clientId}"]}`,

0 commit comments

Comments
 (0)