Skip to content

Commit d516062

Browse files
authored
feat: add analytics logging and slack notifications (#2704)
1 parent 2c9396a commit d516062

File tree

4 files changed

+205
-14
lines changed

4 files changed

+205
-14
lines changed

src/common/utils.ts

+3
Original file line numberDiff line numberDiff line change
@@ -259,3 +259,6 @@ export const isSpecialUser = ({
259259
}): boolean => {
260260
return !!userId && [ghostUser.id, systemUser.id].includes(userId);
261261
};
262+
263+
export const concatText = (a?: string, b?: string) =>
264+
[a, b].filter(Boolean).join(`\n`);

src/remoteConfig.ts

+1
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ type RemoteConfigValue = {
2020
transfer: number;
2121
}>;
2222
enableBalance: boolean;
23+
approvedStoreKitSandboxUsers: string[];
2324
};
2425

2526
class RemoteConfig {

src/routes/webhooks/apple.ts

+198-11
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import {
1111
type JWSRenewalInfoDecodedPayload,
1212
type ResponseBodyV2DecodedPayload,
1313
} from '@apple/app-store-server-library';
14-
import { isTest } from '../../common';
14+
import { concatText, isTest, webhooks } from '../../common';
1515
import { isInSubnet } from 'is-in-subnet';
1616
import { isNullOrUndefined } from '../../common/object';
1717
import createOrGetConnection from '../../db';
@@ -24,6 +24,12 @@ import {
2424
import { JsonContains } from 'typeorm';
2525
import { SubscriptionCycles } from '../../paddle';
2626
import { updateStoreKitUserSubscription } from '../../plusSubscription';
27+
import {
28+
AnalyticsEventName,
29+
sendAnalyticsEvent,
30+
} from '../../integrations/analytics';
31+
import type { Block, KnownBlock } from '@slack/web-api';
32+
import { remoteConfig } from '../../remoteConfig';
2733

2834
const certificatesToLoad = isTest
2935
? ['__tests__/fixture/testCA.der']
@@ -139,15 +145,71 @@ const getSubscriptionStatus = (
139145
}
140146
};
141147

148+
const getSubscriptionAnalyticsEvent = (
149+
notificationType: ResponseBodyV2DecodedPayload['notificationType'],
150+
subtype?: ResponseBodyV2DecodedPayload['subtype'],
151+
): AnalyticsEventName | null => {
152+
switch (notificationType) {
153+
case NotificationTypeV2.SUBSCRIBED:
154+
case NotificationTypeV2.DID_RENEW:
155+
return AnalyticsEventName.ReceivePayment;
156+
case NotificationTypeV2.DID_CHANGE_RENEWAL_PREF:
157+
return AnalyticsEventName.ChangeBillingCycle;
158+
case NotificationTypeV2.DID_CHANGE_RENEWAL_STATUS: // Disable/Enable Auto-Renew
159+
return subtype === Subtype.AUTO_RENEW_ENABLED
160+
? null
161+
: AnalyticsEventName.CancelSubscription;
162+
case NotificationTypeV2.DID_FAIL_TO_RENEW:
163+
// When user fails to renew and there is no grace period
164+
if (isNullOrUndefined(subtype)) {
165+
return AnalyticsEventName.CancelSubscription;
166+
}
167+
default:
168+
return null;
169+
}
170+
};
171+
172+
export const logAppleAnalyticsEvent = async (
173+
data: JWSRenewalInfoDecodedPayload,
174+
eventName: AnalyticsEventName,
175+
user: User,
176+
) => {
177+
if (!data || isTest) {
178+
return;
179+
}
180+
181+
const cycle =
182+
productIdToCycle[data?.autoRenewProductId as keyof typeof productIdToCycle];
183+
const cost = data?.renewalPrice;
184+
185+
const extra = {
186+
cycle,
187+
localCost: cost ? cost / 100 : undefined,
188+
localCurrency: data?.currency,
189+
};
190+
191+
await sendAnalyticsEvent([
192+
{
193+
event_name: eventName,
194+
event_timestamp: new Date(data?.signedDate || ''),
195+
event_id: data.appTransactionId,
196+
app_platform: 'api',
197+
user_id: user.id,
198+
extra: JSON.stringify(extra),
199+
},
200+
]);
201+
};
202+
142203
const handleNotifcationRequest = async (
143204
verifier: SignedDataVerifier,
144205
request: FastifyRequest<{ Body: AppleNotificationRequest }>,
145206
response: FastifyReply,
207+
environment: Environment,
146208
) => {
147209
const { signedPayload } = request.body || {};
148210

149211
if (isNullOrUndefined(signedPayload)) {
150-
logger.info(
212+
logger.error(
151213
{ body: request.body, provider: SubscriptionProvider.AppleStoreKit },
152214
"Missing 'signedPayload' in request body",
153215
);
@@ -158,6 +220,17 @@ const handleNotifcationRequest = async (
158220
const notification =
159221
await verifier.verifyAndDecodeNotification(signedPayload);
160222

223+
// Don't proceed any further if it's a test notification
224+
if (notification.notificationType === NotificationTypeV2.TEST) {
225+
logger.info(
226+
{ notification, provider: SubscriptionProvider.AppleStoreKit },
227+
'Received Test Notification',
228+
);
229+
return response.status(200).send({ received: true });
230+
}
231+
232+
// Check if the event is a subscription event
233+
// NOTE: When adding support for purchasing cores, we must remove this check as it's not a subscription event
161234
if (isNullOrUndefined(notification?.data?.signedRenewalInfo)) {
162235
logger.info(
163236
{ notification, provider: SubscriptionProvider.AppleStoreKit },
@@ -188,6 +261,19 @@ const handleNotifcationRequest = async (
188261
return response.status(404).send({ error: 'Invalid Payload' });
189262
}
190263

264+
// Only allow sandbox requests from approved users
265+
if (
266+
environment === Environment.SANDBOX &&
267+
!remoteConfig.vars.approvedStoreKitSandboxUsers?.includes(user.id)
268+
) {
269+
logger.error(
270+
{ user, provider: SubscriptionProvider.AppleStoreKit },
271+
'User not approved for sandbox',
272+
);
273+
return response.status(403).send({ error: 'Invalid Payload' });
274+
}
275+
276+
// Prevent double subscription
191277
if (user.subscriptionFlags?.provider === SubscriptionProvider.Paddle) {
192278
logger.error(
193279
{
@@ -200,12 +286,12 @@ const handleNotifcationRequest = async (
200286
throw new Error('User already has a Paddle subscription');
201287
}
202288

203-
logger.info(
204-
{ renewalInfo, user, provider: SubscriptionProvider.AppleStoreKit },
205-
'Received Apple App Store Server Notification',
289+
const subscriptionStatus = getSubscriptionStatus(
290+
notification.notificationType,
291+
notification.subtype,
206292
);
207293

208-
const subscriptionStatus = getSubscriptionStatus(
294+
const eventName = getSubscriptionAnalyticsEvent(
209295
notification.notificationType,
210296
notification.subtype,
211297
);
@@ -218,9 +304,20 @@ const handleNotifcationRequest = async (
218304
data: subscriptionFlags,
219305
});
220306

221-
return {
222-
received: true,
223-
};
307+
if (eventName) {
308+
await logAppleAnalyticsEvent(renewalInfo, eventName, user);
309+
}
310+
311+
if (notification.notificationType === NotificationTypeV2.SUBSCRIBED) {
312+
await notifyNewStoreKitSubscription(renewalInfo, user);
313+
}
314+
315+
logger.info(
316+
{ renewalInfo, user, provider: SubscriptionProvider.AppleStoreKit },
317+
'Received Apple App Store Server Notification',
318+
);
319+
320+
return response.status(200).send({ received: true });
224321
} catch (_err) {
225322
const err = _err as Error;
226323
if (err instanceof VerificationException) {
@@ -247,6 +344,91 @@ const handleNotifcationRequest = async (
247344
}
248345
};
249346

347+
export const notifyNewStoreKitSubscription = async (
348+
data: JWSRenewalInfoDecodedPayload,
349+
user: User,
350+
) => {
351+
if (isTest) {
352+
return;
353+
}
354+
355+
const blocks: (KnownBlock | Block)[] = [
356+
{
357+
type: 'header',
358+
text: {
359+
type: 'plain_text',
360+
text: 'New Plus subscriber :moneybag: :apple-ico:',
361+
emoji: true,
362+
},
363+
},
364+
{
365+
type: 'section',
366+
fields: [
367+
{
368+
type: 'mrkdwn',
369+
text: concatText('*Transaction ID:*', data.appTransactionId),
370+
},
371+
],
372+
},
373+
{
374+
type: 'section',
375+
fields: [
376+
{
377+
type: 'mrkdwn',
378+
text: concatText('*Type:*', data.autoRenewProductId),
379+
},
380+
{
381+
type: 'mrkdwn',
382+
text: concatText(
383+
'*Purchased by:*',
384+
`<https://app.daily.dev/${user.id}|${user.id}>`,
385+
),
386+
},
387+
],
388+
},
389+
// {
390+
// type: 'section',
391+
// fields: [
392+
// {
393+
// type: 'mrkdwn',
394+
// text: concatText(
395+
// '*Cost:*',
396+
// new Intl.NumberFormat('en-US', {
397+
// style: 'currency',
398+
// currency: currencyCode,
399+
// }).format((parseFloat(total) || 0) / 100),
400+
// ),
401+
// },
402+
// {
403+
// type: 'mrkdwn',
404+
// text: concatText('*Currency:*', currencyCode),
405+
// },
406+
// ],
407+
// },
408+
{
409+
type: 'section',
410+
fields: [
411+
{
412+
type: 'mrkdwn',
413+
text: concatText(
414+
'*Cost (local):*',
415+
new Intl.NumberFormat('en-US', {
416+
style: 'currency',
417+
currency: data.currency,
418+
}).format((data.renewalPrice || 0) / 1000),
419+
),
420+
},
421+
{
422+
type: 'mrkdwn',
423+
text: concatText('*Currency (local):*', data.currency),
424+
},
425+
],
426+
},
427+
];
428+
429+
await webhooks.transactions.send({ blocks });
430+
};
431+
250432
export const apple = async (fastify: FastifyInstance): Promise<void> => {
251433
let appleRootCAs: Buffer[] = [];
252434
fastify.addHook('onRequest', async (request, res) => {
@@ -274,7 +456,7 @@ export const apple = async (fastify: FastifyInstance): Promise<void> => {
274456
appAppleId,
275457
);
276458

277-
await handleNotifcationRequest(verifier, request, response);
459+
await handleNotifcationRequest(verifier, request, response, environment);
278460
},
279461
);
280462

@@ -292,7 +474,12 @@ export const apple = async (fastify: FastifyInstance): Promise<void> => {
292474
appAppleId,
293475
);
294476

295-
await handleNotifcationRequest(verifier, request, response);
477+
await handleNotifcationRequest(
478+
verifier,
479+
request,
480+
response,
481+
Environment.SANDBOX,
482+
);
296483
},
297484
);
298485
};

src/routes/webhooks/paddle.ts

+3-3
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
} from '@paddle/paddle-node-sdk';
1111
import createOrGetConnection from '../../db';
1212
import {
13+
concatText,
1314
updateFlagsStatement,
1415
updateSubscriptionFlags,
1516
webhooks,
@@ -239,7 +240,6 @@ const logPaddleAnalyticsEvent = async (
239240
]);
240241
};
241242

242-
const concatText = (a: string, b: string) => [a, b].filter(Boolean).join(`\n`);
243243
const notifyNewPaddleTransaction = async ({
244244
event: { data },
245245
}: {
@@ -284,10 +284,10 @@ const notifyNewPaddleTransaction = async ({
284284

285285
const headerText = (() => {
286286
if (gifter_id) {
287-
return 'Gift subscription :gift:';
287+
return 'Gift subscription :gift: :paddle:';
288288
}
289289

290-
return 'New Plus subscriber :moneybag:';
290+
return 'New Plus subscriber :moneybag: :paddle:';
291291
})();
292292

293293
const blocks = [

0 commit comments

Comments
 (0)