@@ -11,7 +11,7 @@ import {
11
11
type JWSRenewalInfoDecodedPayload ,
12
12
type ResponseBodyV2DecodedPayload ,
13
13
} from '@apple/app-store-server-library' ;
14
- import { isTest } from '../../common' ;
14
+ import { concatText , isTest , webhooks } from '../../common' ;
15
15
import { isInSubnet } from 'is-in-subnet' ;
16
16
import { isNullOrUndefined } from '../../common/object' ;
17
17
import createOrGetConnection from '../../db' ;
@@ -24,6 +24,12 @@ import {
24
24
import { JsonContains } from 'typeorm' ;
25
25
import { SubscriptionCycles } from '../../paddle' ;
26
26
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' ;
27
33
28
34
const certificatesToLoad = isTest
29
35
? [ '__tests__/fixture/testCA.der' ]
@@ -139,15 +145,71 @@ const getSubscriptionStatus = (
139
145
}
140
146
} ;
141
147
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
+
142
203
const handleNotifcationRequest = async (
143
204
verifier : SignedDataVerifier ,
144
205
request : FastifyRequest < { Body : AppleNotificationRequest } > ,
145
206
response : FastifyReply ,
207
+ environment : Environment ,
146
208
) => {
147
209
const { signedPayload } = request . body || { } ;
148
210
149
211
if ( isNullOrUndefined ( signedPayload ) ) {
150
- logger . info (
212
+ logger . error (
151
213
{ body : request . body , provider : SubscriptionProvider . AppleStoreKit } ,
152
214
"Missing 'signedPayload' in request body" ,
153
215
) ;
@@ -158,6 +220,17 @@ const handleNotifcationRequest = async (
158
220
const notification =
159
221
await verifier . verifyAndDecodeNotification ( signedPayload ) ;
160
222
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
161
234
if ( isNullOrUndefined ( notification ?. data ?. signedRenewalInfo ) ) {
162
235
logger . info (
163
236
{ notification, provider : SubscriptionProvider . AppleStoreKit } ,
@@ -188,6 +261,19 @@ const handleNotifcationRequest = async (
188
261
return response . status ( 404 ) . send ( { error : 'Invalid Payload' } ) ;
189
262
}
190
263
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
191
277
if ( user . subscriptionFlags ?. provider === SubscriptionProvider . Paddle ) {
192
278
logger . error (
193
279
{
@@ -200,12 +286,12 @@ const handleNotifcationRequest = async (
200
286
throw new Error ( 'User already has a Paddle subscription' ) ;
201
287
}
202
288
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 ,
206
292
) ;
207
293
208
- const subscriptionStatus = getSubscriptionStatus (
294
+ const eventName = getSubscriptionAnalyticsEvent (
209
295
notification . notificationType ,
210
296
notification . subtype ,
211
297
) ;
@@ -218,9 +304,20 @@ const handleNotifcationRequest = async (
218
304
data : subscriptionFlags ,
219
305
} ) ;
220
306
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 } ) ;
224
321
} catch ( _err ) {
225
322
const err = _err as Error ;
226
323
if ( err instanceof VerificationException ) {
@@ -247,6 +344,91 @@ const handleNotifcationRequest = async (
247
344
}
248
345
} ;
249
346
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
+
250
432
export const apple = async ( fastify : FastifyInstance ) : Promise < void > => {
251
433
let appleRootCAs : Buffer [ ] = [ ] ;
252
434
fastify . addHook ( 'onRequest' , async ( request , res ) => {
@@ -274,7 +456,7 @@ export const apple = async (fastify: FastifyInstance): Promise<void> => {
274
456
appAppleId ,
275
457
) ;
276
458
277
- await handleNotifcationRequest ( verifier , request , response ) ;
459
+ await handleNotifcationRequest ( verifier , request , response , environment ) ;
278
460
} ,
279
461
) ;
280
462
@@ -292,7 +474,12 @@ export const apple = async (fastify: FastifyInstance): Promise<void> => {
292
474
appAppleId ,
293
475
) ;
294
476
295
- await handleNotifcationRequest ( verifier , request , response ) ;
477
+ await handleNotifcationRequest (
478
+ verifier ,
479
+ request ,
480
+ response ,
481
+ Environment . SANDBOX ,
482
+ ) ;
296
483
} ,
297
484
) ;
298
485
} ;
0 commit comments