Skip to content

Commit 79e7a4c

Browse files
committed
feat: cores iap
1 parent 56927a3 commit 79e7a4c

File tree

5 files changed

+476
-259
lines changed

5 files changed

+476
-259
lines changed

src/common/apple/purchase.ts

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
import type {
2+
Environment,
3+
JWSTransactionDecodedPayload,
4+
ResponseBodyV2DecodedPayload,
5+
} from '@apple/app-store-server-library';
6+
import type { User } from '../../entity/user/User';
7+
import { getAppleTransactionType } from './utils';
8+
import { AppleTransactionType } from './types';
9+
import {
10+
UserTransaction,
11+
UserTransactionProcessor,
12+
UserTransactionStatus,
13+
} from '../../entity/user/UserTransaction';
14+
import createOrGetConnection from '../../db';
15+
16+
export const isCorePurchaseApple = ({
17+
decodedInfo,
18+
}: {
19+
decodedInfo: JWSTransactionDecodedPayload;
20+
}) => {
21+
return (
22+
getAppleTransactionType({ decodedInfo }) ===
23+
AppleTransactionType.Consumable &&
24+
!!decodedInfo.productId?.startsWith('cores_')
25+
);
26+
};
27+
28+
export const handleCoresPurchase = async ({
29+
decodedInfo,
30+
user,
31+
}: {
32+
decodedInfo: JWSTransactionDecodedPayload;
33+
user: User;
34+
environment: Environment;
35+
notification: ResponseBodyV2DecodedPayload;
36+
}): Promise<UserTransaction> => {
37+
if (!decodedInfo.transactionId) {
38+
throw new Error('Missing transactionId in decodedInfo');
39+
}
40+
41+
if (!decodedInfo.productId) {
42+
throw new Error('Missing productId in decodedInfo');
43+
}
44+
45+
const con = await createOrGetConnection();
46+
47+
// TODO feat/cores-iap load from api metadata/new endpoint
48+
const coresValue = Number(decodedInfo.productId.match(/\d+/)?.[0]);
49+
50+
const payload = con.getRepository(UserTransaction).create({
51+
processor: UserTransactionProcessor.AppleStoreKit,
52+
receiverId: user.id,
53+
status: UserTransactionStatus.Success,
54+
productId: null, // no product user is buying cores directly
55+
senderId: null, // no sender, user is buying cores
56+
value: coresValue,
57+
valueIncFees: coresValue,
58+
fee: 0, // no fee when buying cores
59+
request: {},
60+
flags: {
61+
providerId: decodedInfo.transactionId,
62+
},
63+
});
64+
65+
const userTransaction = await con
66+
.getRepository(UserTransaction)
67+
.save(payload);
68+
69+
return userTransaction;
70+
};

src/common/apple/subscription.ts

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
import {
2+
NotificationTypeV2,
3+
Subtype,
4+
type Environment,
5+
type JWSRenewalInfoDecodedPayload,
6+
type ResponseBodyV2DecodedPayload,
7+
} from '@apple/app-store-server-library';
8+
import { logger } from '../../logger';
9+
import {
10+
SubscriptionProvider,
11+
UserSubscriptionStatus,
12+
type User,
13+
type UserSubscriptionFlags,
14+
} from '../../entity/user/User';
15+
import { convertCurrencyToUSD } from '../../integrations/openExchangeRates';
16+
import { updateStoreKitUserSubscription } from '../../plusSubscription';
17+
import { isNullOrUndefined } from '../object';
18+
import {
19+
getAnalyticsEventFromAppleNotification,
20+
logAppleAnalyticsEvent,
21+
} from './utils';
22+
import { notifyNewStoreKitSubscription } from '../../routes/webhooks/apple';
23+
import { productIdToCycle } from './types';
24+
25+
const getSubscriptionStatus = (
26+
notificationType: ResponseBodyV2DecodedPayload['notificationType'],
27+
subtype?: ResponseBodyV2DecodedPayload['subtype'],
28+
): UserSubscriptionStatus => {
29+
switch (notificationType) {
30+
case NotificationTypeV2.SUBSCRIBED:
31+
case NotificationTypeV2.DID_RENEW:
32+
case NotificationTypeV2.DID_CHANGE_RENEWAL_PREF: // Upgrade/Downgrade
33+
case NotificationTypeV2.REFUND_REVERSED:
34+
return UserSubscriptionStatus.Active;
35+
case NotificationTypeV2.DID_CHANGE_RENEWAL_STATUS: // Disable/Enable Auto-Renew
36+
return subtype === Subtype.AUTO_RENEW_ENABLED
37+
? UserSubscriptionStatus.Active
38+
: UserSubscriptionStatus.Cancelled;
39+
case NotificationTypeV2.DID_FAIL_TO_RENEW:
40+
// When user fails to renew and there is no grace period
41+
if (isNullOrUndefined(subtype)) {
42+
return UserSubscriptionStatus.Cancelled;
43+
}
44+
case NotificationTypeV2.GRACE_PERIOD_EXPIRED:
45+
return UserSubscriptionStatus.Cancelled;
46+
case NotificationTypeV2.REFUND:
47+
case NotificationTypeV2.EXPIRED:
48+
case NotificationTypeV2.REVOKE: // We don't support Family Sharing, but to be on the safe side
49+
return UserSubscriptionStatus.Expired;
50+
default:
51+
logger.error(
52+
{
53+
notificationType,
54+
subtype,
55+
provider: SubscriptionProvider.AppleStoreKit,
56+
},
57+
'Unknown notification type',
58+
);
59+
throw new Error('Unknown notification type');
60+
}
61+
};
62+
63+
const renewalInfoToSubscriptionFlags = (
64+
data: JWSRenewalInfoDecodedPayload,
65+
): UserSubscriptionFlags => {
66+
const cycle =
67+
productIdToCycle[data.autoRenewProductId as keyof typeof productIdToCycle];
68+
69+
if (isNullOrUndefined(cycle)) {
70+
logger.error(
71+
{ data, provider: SubscriptionProvider.AppleStoreKit },
72+
'Invalid auto renew product ID',
73+
);
74+
throw new Error('Invalid auto renew product ID');
75+
}
76+
77+
return {
78+
cycle,
79+
subscriptionId: data.originalTransactionId,
80+
createdAt: new Date(data.recentSubscriptionStartDate!),
81+
expiresAt: new Date(data.renewalDate!),
82+
provider: SubscriptionProvider.AppleStoreKit,
83+
};
84+
};
85+
86+
export const handleAppleSubscription = async ({
87+
decodedInfo,
88+
user,
89+
environment,
90+
notification,
91+
}: {
92+
decodedInfo: JWSRenewalInfoDecodedPayload;
93+
user: User;
94+
environment: Environment;
95+
notification: ResponseBodyV2DecodedPayload;
96+
}) => {
97+
// Prevent double subscription
98+
if (user.subscriptionFlags?.provider === SubscriptionProvider.Paddle) {
99+
logger.error(
100+
{
101+
user,
102+
environment,
103+
notification,
104+
provider: SubscriptionProvider.AppleStoreKit,
105+
},
106+
'User already has a Paddle subscription',
107+
);
108+
throw new Error('User already has a Paddle subscription');
109+
}
110+
111+
const subscriptionStatus = getSubscriptionStatus(
112+
notification.notificationType,
113+
notification.subtype,
114+
);
115+
116+
const subscriptionFlags = renewalInfoToSubscriptionFlags(decodedInfo);
117+
118+
await updateStoreKitUserSubscription({
119+
userId: user.id,
120+
status: subscriptionStatus,
121+
data: subscriptionFlags,
122+
});
123+
124+
const currencyInUSD = await convertCurrencyToUSD(
125+
(decodedInfo.renewalPrice || 0) / 1000,
126+
decodedInfo.currency || 'USD',
127+
);
128+
129+
const eventName = getAnalyticsEventFromAppleNotification(
130+
notification.notificationType,
131+
notification.subtype,
132+
);
133+
134+
if (eventName) {
135+
await logAppleAnalyticsEvent(decodedInfo, eventName, user, currencyInUSD);
136+
}
137+
138+
if (notification.notificationType === NotificationTypeV2.SUBSCRIBED) {
139+
await notifyNewStoreKitSubscription(decodedInfo, user, currencyInUSD);
140+
}
141+
};

src/common/apple/types.ts

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import { Environment } from '@apple/app-store-server-library';
2+
import { env } from 'node:process';
3+
import { isTest } from '../utils';
4+
import { SubscriptionCycles } from '../../paddle';
5+
6+
export const certificatesToLoad = isTest
7+
? ['__tests__/fixture/testCA.der']
8+
: [
9+
'/usr/local/share/ca-certificates/AppleIncRootCertificate.cer',
10+
'/usr/local/share/ca-certificates/AppleRootCA-G2.cer',
11+
'/usr/local/share/ca-certificates/AppleRootCA-G3.cer',
12+
];
13+
14+
const getVerifierEnvironment = (): Environment => {
15+
switch (env.NODE_ENV) {
16+
case 'production':
17+
return Environment.PRODUCTION;
18+
case 'development':
19+
return Environment.SANDBOX;
20+
case 'test':
21+
return Environment.LOCAL_TESTING;
22+
default:
23+
throw new Error("Invalid 'NODE_ENV' value");
24+
}
25+
};
26+
27+
export const bundleId = isTest ? 'dev.fylla' : env.APPLE_APP_BUNDLE_ID;
28+
export const appAppleId = parseInt(env.APPLE_APP_APPLE_ID);
29+
export const appleEnableOnlineChecks = true;
30+
export const appleEnvironment = getVerifierEnvironment();
31+
32+
export const allowedIPs = [
33+
'127.0.0.1/24',
34+
'192.168.0.0/16',
35+
'172.16.0.0/12',
36+
'10.0.0.0/8',
37+
38+
// Production IPs. These are the IPs that Apple uses to send notifications.
39+
// https://developer.apple.com/documentation/appstoreservernotifications/enabling-app-store-server-notifications#Configure-an-allow-list
40+
'17.0.0.0/8',
41+
];
42+
43+
export interface AppleNotificationRequest {
44+
signedPayload: string;
45+
}
46+
47+
export const productIdToCycle = {
48+
annualSpecial: SubscriptionCycles.Yearly,
49+
annual: SubscriptionCycles.Yearly,
50+
monthly: SubscriptionCycles.Monthly,
51+
};
52+
53+
export enum AppleTransactionType {
54+
AutoRenewableSubscription = 'Auto-Renewable Subscription',
55+
NonConsumable = 'Non-Consumable',
56+
Consumable = 'Consumable',
57+
NonRenewingSubscription = 'Non-Renewing Subscription',
58+
}

0 commit comments

Comments
 (0)