Skip to content

Commit 38c2108

Browse files
authored
feat: handle consumption request of iap cores purchase (#3275)
1 parent 92b47fd commit 38c2108

6 files changed

Lines changed: 147 additions & 16 deletions

File tree

.infra/Pulumi.prod.yaml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,12 @@ config:
3030
apiConfigFeatureKey: api-config
3131
appleAppAppleId: 6740634400
3232
appleAppBundleId: dev.daily.app
33+
appleIssuerId:
34+
secure: AAABAK7J9W93y2dJnNDSiDfzwj2+bSPdo3uOswwQE/vX4RrWSVtPndgGY4EStNzK5dAyV34Osd5Ktekn9wHyEeptNN4=
35+
appleAppStoreServerClientKey:
36+
secure: AAABAPaOTw3J1adzb3RaGgZYE7eywB9UHAw85tagbYBXaIzM6MxQI7vgSFUP5bbvMwuZVaZ2F2aB5WXn0V6ZcoqV/OCGJk/XygbjovFqMlqCEjO4ovRu0Cjck+82nJYk6hPuLqsTUw6C0aJUBour4a1o2Jl04X5XRcUUTTanguOyFlWxSUxVDZ6As9DGuFYsNV3jIhhiAtgC1IHZH/luyURuUkprm7iHKvdlVqMO9ctne+PQ2bMVWCSGvJeXt/oQDWRQT9UHxnbD7LSgWDmMAbd4aaXD/YE/gN220FSzeV7EsiXUb4+NyPtP6dSc4GSKVfa7keGn57KZK+U+duFZjhEqch8uenb0RO28zvyYYcbbcpsd7TvggwbBgqc2cTJXUA==
37+
appleAppStoreServerClientKeyId:
38+
secure: AAABAOxbRgC+Npj/q0uRCmHXjAnMs8W507CAyKb50ndVgQkx1YKyYPZb
3339
validLanguagesFeatureKey: valid_languages
3440
cdcWorkerMaxMessages: 1
3541
cioApiKey:

src/common/apple/purchase.ts

Lines changed: 78 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,33 @@
11
import {
2+
AppStoreServerAPIClient,
3+
ConsumptionStatus,
4+
DeliveryStatus,
25
Environment,
36
JWSTransactionDecodedPayload,
7+
LifetimeDollarsPurchased,
8+
LifetimeDollarsRefunded,
9+
Platform,
10+
PlayTime,
411
ResponseBodyV2DecodedPayload,
12+
UserStatus,
13+
type ConsumptionRequest,
514
} from '@apple/app-store-server-library';
15+
import { RefundPreference } from '@apple/app-store-server-library/dist/models/RefundPreference';
16+
import { JsonContains } from 'typeorm';
617
import type { User } from '../../entity/user/User';
718
import {
19+
getAccountTenure,
820
getAnalyticsEventFromAppleNotification,
921
getAppleTransactionType,
1022
logAppleAnalyticsEvent,
1123
} from './utils';
12-
import { AppleTransactionType } from './types';
24+
import {
25+
appleAppStoreServerClientKey,
26+
appleAppStoreServerClientKeyId,
27+
appleIssuerId,
28+
AppleTransactionType,
29+
bundleId,
30+
} from './types';
1331
import {
1432
UserTransaction,
1533
UserTransactionProcessor,
@@ -281,3 +299,62 @@ export const handleCoresPurchase = async ({
281299

282300
return transaction;
283301
};
302+
303+
export const handleCoresConsumptionRequest = async ({
304+
transactionInfo,
305+
user,
306+
environment,
307+
}: {
308+
transactionInfo: JWSTransactionDecodedPayload;
309+
user: Pick<User, 'id' | 'subscriptionFlags' | 'coresRole' | 'createdAt'>;
310+
environment: Environment;
311+
}) => {
312+
if (!transactionInfo.transactionId) {
313+
throw new Error('Missing transactionId in transactionInfo');
314+
}
315+
316+
if (!user.subscriptionFlags?.appAccountToken) {
317+
throw new Error('Missing appAccountToken in user subscription flags');
318+
}
319+
320+
const apiClient = new AppStoreServerAPIClient(
321+
appleAppStoreServerClientKey,
322+
appleAppStoreServerClientKeyId,
323+
appleIssuerId,
324+
bundleId,
325+
environment,
326+
);
327+
328+
const con = await createOrGetConnection();
329+
330+
const deliveryStatus = await con.getRepository(UserTransaction).exists({
331+
where: {
332+
receiverId: user.id,
333+
flags: JsonContains({ providerId: transactionInfo.transactionId }),
334+
},
335+
});
336+
337+
const consumptionRequest: ConsumptionRequest = {
338+
accountTenure: getAccountTenure(user),
339+
appAccountToken: user.subscriptionFlags.appAccountToken,
340+
consumptionStatus: ConsumptionStatus.FULLY_CONSUMED, // It is assumed that the user has fully consumed the in-app purchase
341+
customerConsented: true,
342+
deliveryStatus: deliveryStatus
343+
? DeliveryStatus.DELIVERED_AND_WORKING_PROPERLY
344+
: DeliveryStatus.DID_NOT_DELIVER_DUE_TO_SERVER_OUTAGE,
345+
refundPreference: RefundPreference.PREFER_DECLINE,
346+
userStatus: UserStatus.ACTIVE,
347+
348+
// Values we don't track or care about, but are required by Apple
349+
sampleContentProvided: false,
350+
lifetimeDollarsPurchased: LifetimeDollarsPurchased.UNDECLARED,
351+
lifetimeDollarsRefunded: LifetimeDollarsRefunded.UNDECLARED,
352+
platform: Platform.UNDECLARED,
353+
playTime: PlayTime.UNDECLARED,
354+
};
355+
356+
await apiClient.sendConsumptionData(
357+
transactionInfo.transactionId,
358+
consumptionRequest,
359+
);
360+
};

src/common/apple/types.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,11 @@ export const bundleId = isTest ? 'dev.fylla' : env.APPLE_APP_BUNDLE_ID;
2828
export const appAppleId = parseInt(env.APPLE_APP_APPLE_ID);
2929
export const appleEnableOnlineChecks = true;
3030
export const appleEnvironment = getVerifierEnvironment();
31+
export const appleIssuerId = env.APPLE_ISSUER_ID;
32+
export const appleAppStoreServerClientKey =
33+
env.APPLE_APP_STORE_SERVER_CLIENT_KEY;
34+
export const appleAppStoreServerClientKeyId =
35+
env.APPLE_APP_STORE_SERVER_CLIENT_KEY_ID;
3136

3237
export const allowedIPs = [
3338
'127.0.0.1/24',

src/common/apple/utils.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import {
2+
AccountTenure,
23
NotificationTypeV2,
34
Subtype,
45
type Environment,
@@ -24,6 +25,7 @@ import { readFile } from 'fs/promises';
2425
import { isTest } from '../utils';
2526
import { isCorePurchaseApple } from './purchase';
2627
import { SubscriptionProvider } from '../plus';
28+
import { differenceInDays } from 'date-fns';
2729

2830
export const verifyAndDecodeAppleSignedData = async ({
2931
notification,
@@ -155,3 +157,21 @@ export const getAppleTransactionType = ({
155157
return null;
156158
}
157159
};
160+
161+
export const getAccountTenure = (user: Pick<User, 'createdAt'>): number => {
162+
if (!user.createdAt) {
163+
return 0;
164+
}
165+
166+
const difference = differenceInDays(new Date(), user.createdAt);
167+
168+
if (difference < 3) return AccountTenure.ZERO_TO_THREE_DAYS;
169+
if (difference < 10) return AccountTenure.THREE_DAYS_TO_TEN_DAYS;
170+
if (difference < 30) return AccountTenure.TEN_DAYS_TO_THIRTY_DAYS;
171+
if (difference < 90) return AccountTenure.THIRTY_DAYS_TO_NINETY_DAYS;
172+
if (difference < 180)
173+
return AccountTenure.NINETY_DAYS_TO_ONE_HUNDRED_EIGHTY_DAYS;
174+
if (difference < 365)
175+
return AccountTenure.ONE_HUNDRED_EIGHTY_DAYS_TO_THREE_HUNDRED_SIXTY_FIVE_DAYS;
176+
return AccountTenure.GREATER_THAN_THREE_HUNDRED_SIXTY_FIVE_DAYS;
177+
};

src/routes/webhooks/apple.ts

Lines changed: 35 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import {
2626
AppleTransactionType,
2727
} from '../../common/apple/types';
2828
import {
29+
handleCoresConsumptionRequest,
2930
handleCoresPurchase,
3031
isCorePurchaseApple,
3132
} from '../../common/apple/purchase';
@@ -82,15 +83,17 @@ const handleNotifcationRequest = async (
8283
}
8384

8485
const con = await createOrGetConnection();
85-
const user: Pick<User, 'id' | 'subscriptionFlags' | 'coresRole'> | null =
86-
await con.getRepository(User).findOne({
87-
select: ['id', 'subscriptionFlags', 'coresRole'],
88-
where: {
89-
subscriptionFlags: JsonContains({
90-
appAccountToken: transactionInfo.appAccountToken,
91-
}),
92-
},
93-
});
86+
const user: Pick<
87+
User,
88+
'id' | 'subscriptionFlags' | 'coresRole' | 'createdAt'
89+
> | null = await con.getRepository(User).findOne({
90+
select: ['id', 'subscriptionFlags', 'coresRole', 'createdAt'],
91+
where: {
92+
subscriptionFlags: JsonContains({
93+
appAccountToken: transactionInfo.appAccountToken,
94+
}),
95+
},
96+
});
9497

9598
if (!user) {
9699
logger.error(
@@ -124,12 +127,29 @@ const handleNotifcationRequest = async (
124127
switch (getAppleTransactionType({ transactionInfo })) {
125128
case AppleTransactionType.Consumable:
126129
if (isCorePurchaseApple({ transactionInfo })) {
127-
await handleCoresPurchase({
128-
transactionInfo,
129-
user,
130-
environment,
131-
notification,
132-
});
130+
switch (notification.notificationType) {
131+
case NotificationTypeV2.ONE_TIME_CHARGE:
132+
await handleCoresPurchase({
133+
transactionInfo,
134+
user,
135+
environment,
136+
notification,
137+
});
138+
break;
139+
case NotificationTypeV2.CONSUMPTION_REQUEST:
140+
await handleCoresConsumptionRequest({
141+
transactionInfo,
142+
user,
143+
environment,
144+
});
145+
break;
146+
case NotificationTypeV2.REFUND_DECLINED:
147+
// No action needed for refund declined on consumables
148+
break;
149+
case NotificationTypeV2.REFUND: // TODO: Handle refunds for consumables if needed - since it is Apple who decides if a refund is given, we may need to revoke the consumable
150+
default:
151+
throw new Error('Unsupported Apple Consumable notification type');
152+
}
133153
} else {
134154
throw new Error('Unsupported Apple Consumable transaction type');
135155
}

src/types.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,9 @@ declare global {
7373

7474
APPLE_APP_APPLE_ID: string;
7575
APPLE_APP_BUNDLE_ID: string;
76+
APPLE_ISSUER_ID: string;
77+
APPLE_APP_STORE_SERVER_CLIENT_KEY: string;
78+
APPLE_APP_STORE_SERVER_CLIENT_KEY_ID: string;
7679

7780
GEOIP_PATH?: string;
7881
RESUME_BUCKET_NAME: string;

0 commit comments

Comments
 (0)