diff --git a/.infra/Pulumi.prod.yaml b/.infra/Pulumi.prod.yaml index 3d31f6a5dc..ffcf1394af 100644 --- a/.infra/Pulumi.prod.yaml +++ b/.infra/Pulumi.prod.yaml @@ -30,6 +30,12 @@ config: apiConfigFeatureKey: api-config appleAppAppleId: 6740634400 appleAppBundleId: dev.daily.app + appleIssuerId: + secure: AAABAK7J9W93y2dJnNDSiDfzwj2+bSPdo3uOswwQE/vX4RrWSVtPndgGY4EStNzK5dAyV34Osd5Ktekn9wHyEeptNN4= + appleAppStoreServerClientKey: + secure: AAABAPaOTw3J1adzb3RaGgZYE7eywB9UHAw85tagbYBXaIzM6MxQI7vgSFUP5bbvMwuZVaZ2F2aB5WXn0V6ZcoqV/OCGJk/XygbjovFqMlqCEjO4ovRu0Cjck+82nJYk6hPuLqsTUw6C0aJUBour4a1o2Jl04X5XRcUUTTanguOyFlWxSUxVDZ6As9DGuFYsNV3jIhhiAtgC1IHZH/luyURuUkprm7iHKvdlVqMO9ctne+PQ2bMVWCSGvJeXt/oQDWRQT9UHxnbD7LSgWDmMAbd4aaXD/YE/gN220FSzeV7EsiXUb4+NyPtP6dSc4GSKVfa7keGn57KZK+U+duFZjhEqch8uenb0RO28zvyYYcbbcpsd7TvggwbBgqc2cTJXUA== + appleAppStoreServerClientKeyId: + secure: AAABAOxbRgC+Npj/q0uRCmHXjAnMs8W507CAyKb50ndVgQkx1YKyYPZb validLanguagesFeatureKey: valid_languages cdcWorkerMaxMessages: 1 cioApiKey: diff --git a/src/common/apple/purchase.ts b/src/common/apple/purchase.ts index 091c33dc07..7104d22b96 100644 --- a/src/common/apple/purchase.ts +++ b/src/common/apple/purchase.ts @@ -1,15 +1,33 @@ import { + AppStoreServerAPIClient, + ConsumptionStatus, + DeliveryStatus, Environment, JWSTransactionDecodedPayload, + LifetimeDollarsPurchased, + LifetimeDollarsRefunded, + Platform, + PlayTime, ResponseBodyV2DecodedPayload, + UserStatus, + type ConsumptionRequest, } from '@apple/app-store-server-library'; +import { RefundPreference } from '@apple/app-store-server-library/dist/models/RefundPreference'; +import { JsonContains } from 'typeorm'; import type { User } from '../../entity/user/User'; import { + getAccountTenure, getAnalyticsEventFromAppleNotification, getAppleTransactionType, logAppleAnalyticsEvent, } from './utils'; -import { AppleTransactionType } from './types'; +import { + appleAppStoreServerClientKey, + appleAppStoreServerClientKeyId, + appleIssuerId, + AppleTransactionType, + bundleId, +} from './types'; import { UserTransaction, UserTransactionProcessor, @@ -281,3 +299,62 @@ export const handleCoresPurchase = async ({ return transaction; }; + +export const handleCoresConsumptionRequest = async ({ + transactionInfo, + user, + environment, +}: { + transactionInfo: JWSTransactionDecodedPayload; + user: Pick; + environment: Environment; +}) => { + if (!transactionInfo.transactionId) { + throw new Error('Missing transactionId in transactionInfo'); + } + + if (!user.subscriptionFlags?.appAccountToken) { + throw new Error('Missing appAccountToken in user subscription flags'); + } + + const apiClient = new AppStoreServerAPIClient( + appleAppStoreServerClientKey, + appleAppStoreServerClientKeyId, + appleIssuerId, + bundleId, + environment, + ); + + const con = await createOrGetConnection(); + + const deliveryStatus = await con.getRepository(UserTransaction).exists({ + where: { + receiverId: user.id, + flags: JsonContains({ providerId: transactionInfo.transactionId }), + }, + }); + + const consumptionRequest: ConsumptionRequest = { + accountTenure: getAccountTenure(user), + appAccountToken: user.subscriptionFlags.appAccountToken, + consumptionStatus: ConsumptionStatus.FULLY_CONSUMED, // It is assumed that the user has fully consumed the in-app purchase + customerConsented: true, + deliveryStatus: deliveryStatus + ? DeliveryStatus.DELIVERED_AND_WORKING_PROPERLY + : DeliveryStatus.DID_NOT_DELIVER_DUE_TO_SERVER_OUTAGE, + refundPreference: RefundPreference.PREFER_DECLINE, + userStatus: UserStatus.ACTIVE, + + // Values we don't track or care about, but are required by Apple + sampleContentProvided: false, + lifetimeDollarsPurchased: LifetimeDollarsPurchased.UNDECLARED, + lifetimeDollarsRefunded: LifetimeDollarsRefunded.UNDECLARED, + platform: Platform.UNDECLARED, + playTime: PlayTime.UNDECLARED, + }; + + await apiClient.sendConsumptionData( + transactionInfo.transactionId, + consumptionRequest, + ); +}; diff --git a/src/common/apple/types.ts b/src/common/apple/types.ts index 07adcab260..371ba3b35d 100644 --- a/src/common/apple/types.ts +++ b/src/common/apple/types.ts @@ -28,6 +28,11 @@ export const bundleId = isTest ? 'dev.fylla' : env.APPLE_APP_BUNDLE_ID; export const appAppleId = parseInt(env.APPLE_APP_APPLE_ID); export const appleEnableOnlineChecks = true; export const appleEnvironment = getVerifierEnvironment(); +export const appleIssuerId = env.APPLE_ISSUER_ID; +export const appleAppStoreServerClientKey = + env.APPLE_APP_STORE_SERVER_CLIENT_KEY; +export const appleAppStoreServerClientKeyId = + env.APPLE_APP_STORE_SERVER_CLIENT_KEY_ID; export const allowedIPs = [ '127.0.0.1/24', diff --git a/src/common/apple/utils.ts b/src/common/apple/utils.ts index 95389778d9..ef17041494 100644 --- a/src/common/apple/utils.ts +++ b/src/common/apple/utils.ts @@ -1,4 +1,5 @@ import { + AccountTenure, NotificationTypeV2, Subtype, type Environment, @@ -24,6 +25,7 @@ import { readFile } from 'fs/promises'; import { isTest } from '../utils'; import { isCorePurchaseApple } from './purchase'; import { SubscriptionProvider } from '../plus'; +import { differenceInDays } from 'date-fns'; export const verifyAndDecodeAppleSignedData = async ({ notification, @@ -155,3 +157,21 @@ export const getAppleTransactionType = ({ return null; } }; + +export const getAccountTenure = (user: Pick): number => { + if (!user.createdAt) { + return 0; + } + + const difference = differenceInDays(new Date(), user.createdAt); + + if (difference < 3) return AccountTenure.ZERO_TO_THREE_DAYS; + if (difference < 10) return AccountTenure.THREE_DAYS_TO_TEN_DAYS; + if (difference < 30) return AccountTenure.TEN_DAYS_TO_THIRTY_DAYS; + if (difference < 90) return AccountTenure.THIRTY_DAYS_TO_NINETY_DAYS; + if (difference < 180) + return AccountTenure.NINETY_DAYS_TO_ONE_HUNDRED_EIGHTY_DAYS; + if (difference < 365) + return AccountTenure.ONE_HUNDRED_EIGHTY_DAYS_TO_THREE_HUNDRED_SIXTY_FIVE_DAYS; + return AccountTenure.GREATER_THAN_THREE_HUNDRED_SIXTY_FIVE_DAYS; +}; diff --git a/src/routes/webhooks/apple.ts b/src/routes/webhooks/apple.ts index 390fd1fd98..192566c6e8 100644 --- a/src/routes/webhooks/apple.ts +++ b/src/routes/webhooks/apple.ts @@ -26,6 +26,7 @@ import { AppleTransactionType, } from '../../common/apple/types'; import { + handleCoresConsumptionRequest, handleCoresPurchase, isCorePurchaseApple, } from '../../common/apple/purchase'; @@ -82,15 +83,17 @@ const handleNotifcationRequest = async ( } const con = await createOrGetConnection(); - const user: Pick | null = - await con.getRepository(User).findOne({ - select: ['id', 'subscriptionFlags', 'coresRole'], - where: { - subscriptionFlags: JsonContains({ - appAccountToken: transactionInfo.appAccountToken, - }), - }, - }); + const user: Pick< + User, + 'id' | 'subscriptionFlags' | 'coresRole' | 'createdAt' + > | null = await con.getRepository(User).findOne({ + select: ['id', 'subscriptionFlags', 'coresRole', 'createdAt'], + where: { + subscriptionFlags: JsonContains({ + appAccountToken: transactionInfo.appAccountToken, + }), + }, + }); if (!user) { logger.error( @@ -124,12 +127,29 @@ const handleNotifcationRequest = async ( switch (getAppleTransactionType({ transactionInfo })) { case AppleTransactionType.Consumable: if (isCorePurchaseApple({ transactionInfo })) { - await handleCoresPurchase({ - transactionInfo, - user, - environment, - notification, - }); + switch (notification.notificationType) { + case NotificationTypeV2.ONE_TIME_CHARGE: + await handleCoresPurchase({ + transactionInfo, + user, + environment, + notification, + }); + break; + case NotificationTypeV2.CONSUMPTION_REQUEST: + await handleCoresConsumptionRequest({ + transactionInfo, + user, + environment, + }); + break; + case NotificationTypeV2.REFUND_DECLINED: + // No action needed for refund declined on consumables + break; + 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 + default: + throw new Error('Unsupported Apple Consumable notification type'); + } } else { throw new Error('Unsupported Apple Consumable transaction type'); } diff --git a/src/types.ts b/src/types.ts index 1ba189c024..0399cd1f37 100644 --- a/src/types.ts +++ b/src/types.ts @@ -73,6 +73,9 @@ declare global { APPLE_APP_APPLE_ID: string; APPLE_APP_BUNDLE_ID: string; + APPLE_ISSUER_ID: string; + APPLE_APP_STORE_SERVER_CLIENT_KEY: string; + APPLE_APP_STORE_SERVER_CLIENT_KEY_ID: string; GEOIP_PATH?: string; RESUME_BUCKET_NAME: string;