From e8cb167dbf5a41fade4ab58951c3aa3c0c3180b0 Mon Sep 17 00:00:00 2001 From: Hampus Date: Wed, 30 Sep 2026 01:55:19 +0200 Subject: [PATCH] feat(premium): add App Store and Google Play purchases (#3052) --- fluxer_admin/openapi-admin.json | 306 +++++ fluxer_api/src/api/Config.ts | 28 + fluxer_api/src/api/RateLimitConfig.ts | 2 + fluxer_api/src/api/Tables.ts | 31 + fluxer_api/src/api/admin/AdminAuditActions.ts | 1 + fluxer_api/src/api/admin/AdminService.ts | 3 + .../StoreBillingAdminController.ts | 91 ++ fluxer_api/src/api/admin/controllers/index.ts | 2 + .../services/AdminUserDeletionService.ts | 3 + .../api/admin/services/AdminUserService.ts | 3 + .../AdminAuditRouteCoverage.test.ts | 2 + .../StoreBillingAdminAuditCases.ts | 42 + .../StoreBillingAdminAuditCoverage.test.ts | 6 + fluxer_api/src/api/app/ControllerRegistry.ts | 2 + fluxer_api/src/api/config/APIConfig.ts | 32 +- .../src/api/database/types/PaymentTypes.ts | 2 + .../api/database/types/StoreBillingTypes.ts | 124 ++ .../api/middleware/ContentFilterMiddleware.ts | 1 + .../src/api/middleware/ServiceMiddleware.ts | 19 + .../src/api/middleware/ServiceSingletons.ts | 12 + .../src/api/middleware/UserMiddleware.ts | 8 +- fluxer_api/src/api/models/GiftCode.ts | 3 + fluxer_api/src/api/openapi/openapi.json | 644 +++++++++- .../StoreBillingRateLimitConfig.ts | 35 + .../api/store_billing/GooglePlaySettlement.ts | 133 ++ .../api/store_billing/StoreBillingConfig.ts | 119 ++ .../store_billing/StoreBillingController.ts | 278 ++++ .../api/store_billing/StoreBillingMappers.ts | 134 ++ .../store_billing/StoreBillingRepository.ts | 159 +++ .../api/store_billing/StoreBillingTypes.ts | 107 ++ .../store_billing/StoreEntitlementService.ts | 1045 ++++++++++++++++ .../StoreEntitlementServiceFactory.ts | 42 + .../store_billing/StoreEntitlementWriter.ts | 189 +++ .../api/store_billing/StoreGiftFulfilment.ts | 162 +++ .../store_billing/StoreNotificationClaims.ts | 65 + .../api/store_billing/StorePurchaseRefresh.ts | 84 ++ .../app_store/AppStoreJwsVerifier.ts | 503 ++++++++ .../app_store/AppStorePurchaseSync.ts | 253 ++++ .../app_store/AppStoreServerApiClient.ts | 415 ++++++ .../app_store/AppleRootCertificates.ts | 35 + .../GooglePlayAccessTokenProvider.ts | 258 ++++ .../google_play/GooglePlayApiError.ts | 48 + .../GooglePlayDeveloperApiClient.ts | 381 ++++++ .../google_play/GooglePlayPurchaseSync.ts | 234 ++++ .../google_play/GooglePlayPushVerifier.ts | 237 ++++ .../tests/AppStoreJwsVerifier.test.ts | 654 ++++++++++ .../tests/AppStorePurchaseSync.test.ts | 383 ++++++ .../tests/AppStoreServerApiClient.test.ts | 493 ++++++++ .../tests/AppleProductionChain.ts | 12 + .../api/store_billing/tests/AppleTestPki.ts | 244 ++++ .../GooglePlayAccessTokenProvider.test.ts | 204 +++ .../GooglePlayDeveloperApiClient.test.ts | 288 +++++ .../tests/GooglePlayPurchaseSync.test.ts | 448 +++++++ .../tests/GooglePlayPushVerifier.test.ts | 284 +++++ .../tests/PremiumSubscriptionProvider.test.ts | 156 +++ .../tests/StoreBillingAppStore.test.ts | 1113 +++++++++++++++++ .../tests/StoreBillingConfig.test.ts | 164 +++ .../tests/StoreBillingGooglePlay.test.ts | 778 ++++++++++++ .../tests/StoreBillingRepository.test.ts | 211 ++++ .../StoreBillingStripeCoexistence.test.ts | 344 +++++ .../tests/StoreBillingTestUtils.ts | 149 +++ .../tests/StoreBillingTypes.test.ts | 20 + .../tests/StoreBillingUnconfigured.test.ts | 210 ++++ .../tests/StoreEntitlementUserWrite.test.ts | 96 ++ .../tests/StoreNotificationClaims.test.ts | 30 + fluxer_api/src/api/stripe/StripeService.ts | 8 + .../stripe/services/PremiumStateService.ts | 39 + .../stripe/services/StripeCheckoutService.ts | 8 + .../services/StripeGiftReversalHandler.ts | 213 +++- .../api/stripe/services/StripeGiftService.ts | 3 + .../services/StripeSubscriptionService.ts | 3 + .../StripeSubscriptionWebhookHandler.ts | 13 +- .../stripe/services/StripeWebhookService.ts | 5 +- .../tests/SelfHostedPremiumRoutes.test.ts | 44 +- .../tests/StripeGiftReversalHandler.test.ts | 128 ++ fluxer_api/src/api/test/Setup.ts | 49 +- .../msw/handlers/AppStoreServerApiHandlers.ts | 389 ++++++ .../GooglePlayDeveloperApiHandlers.ts | 646 ++++++++++ fluxer_api/src/api/types/HonoEnv.ts | 2 + .../user/controllers/UserAccountController.ts | 1 + .../user/repositories/GiftCodeRepository.ts | 33 +- .../repositories/IUserContentRepository.ts | 3 + .../repositories/UserContentRepository.ts | 12 + .../api/user/repositories/UserRepository.ts | 12 + .../api/user/services/UserDeletionService.ts | 4 + .../api/user/tests/UserPremiumReset.test.ts | 24 + .../src/api/worker/WorkerDependencies.ts | 12 + fluxer_api/src/api/worker/WorkerLaneConfig.ts | 6 + fluxer_api/src/api/worker/WorkerMain.ts | 9 + .../src/api/worker/WorkerTaskRegistry.ts | 12 + .../api/worker/tasks/HarvestUserData.test.ts | 38 +- .../src/api/worker/tasks/HarvestUserData.ts | 11 +- .../tasks/PollAppStoreNotificationHistory.ts | 57 + .../tasks/PollGooglePlayVoidedPurchases.ts | 52 + .../tasks/ProcessAppStoreNotification.ts | 112 ++ .../tasks/ProcessExpiredPremiumSweep.ts | 76 +- .../tasks/ProcessGooglePlayNotification.ts | 115 ++ .../ProcessPremiumStateReconciliationQueue.ts | 18 +- .../tasks/ProcessStorePurchaseRefreshQueue.ts | 44 + .../api/worker/tasks/ProcessStripeWebhook.ts | 3 + .../api/worker/tasks/RefreshStorePurchase.ts | 30 + .../tasks/admin_bulk/AdminBulkServices.ts | 1 + .../tests/BulkScheduleUserDeletion.test.ts | 2 + .../tests/ProcessExpiredPremiumSweep.test.ts | 162 +++ ...essPremiumStateReconciliationQueue.test.ts | 58 +- fluxer_docs/astro.config.ts | 8 +- fluxer_docs/scripts/VerifyDocsCoverage.ts | 18 +- .../src/content/docs/admin-api/index.mdx | 4 +- .../src/content/docs/admin-api/users.mdx | 151 +++ .../src/content/docs/http-api/billing.mdx | 1 + .../docs/http-api/deployment-availability.md | 7 + .../src/content/docs/http-api/errors.md | 24 + .../docs/http-api/in-app-purchases.mdx | 455 +++++++ .../src/content/docs/http-api/premium.mdx | 143 +++ packages/config/src/ConfigLoader.ts | 62 +- packages/config/src/MasterConfig.ts | 30 + .../config/src/__tests__/ConfigLoader.test.ts | 80 ++ .../__tests__/EnvironmentOverrides.test.ts | 90 ++ .../SelfHostingComposeEnvironment.test.ts | 5 + .../src/config_loader/EnvironmentOverrides.ts | 24 + packages/constants/src/ApiErrorCodes.ts | 6 + .../errors/src/__tests__/DomainErrors.test.ts | 67 + .../payment/PremiumPurchaseBlockedError.ts | 8 +- .../payment/StoreBillingUnavailableError.ts | 12 + .../StoreNotificationUnauthorizedError.ts | 13 + .../payment/StorePurchaseInvalidError.ts | 12 + .../StorePurchaseOwnedByOtherAccountError.ts | 12 + .../StorePurchaseSandboxNotEntitledError.ts | 12 + .../payment/UnknownStorePurchaseError.ts | 12 + packages/errors/src/i18n/ErrorCodeMappings.ts | 6 + packages/errors/src/i18n/ErrorI18nMessages.ts | 6 + packages/errors/src/i18n/locales/ar.ts | 6 + packages/errors/src/i18n/locales/bg.ts | 6 + packages/errors/src/i18n/locales/cs.ts | 6 + packages/errors/src/i18n/locales/da.ts | 6 + packages/errors/src/i18n/locales/de.ts | 6 + packages/errors/src/i18n/locales/el.ts | 6 + packages/errors/src/i18n/locales/en-GB.ts | 6 + packages/errors/src/i18n/locales/es-419.ts | 6 + packages/errors/src/i18n/locales/es-ES.ts | 6 + packages/errors/src/i18n/locales/fi.ts | 6 + packages/errors/src/i18n/locales/fr.ts | 6 + packages/errors/src/i18n/locales/he.ts | 6 + packages/errors/src/i18n/locales/hi.ts | 6 + packages/errors/src/i18n/locales/hr.ts | 6 + packages/errors/src/i18n/locales/hu.ts | 6 + packages/errors/src/i18n/locales/id.ts | 6 + packages/errors/src/i18n/locales/it.ts | 6 + packages/errors/src/i18n/locales/ja.ts | 6 + packages/errors/src/i18n/locales/ko.ts | 6 + packages/errors/src/i18n/locales/lt.ts | 6 + packages/errors/src/i18n/locales/nl.ts | 6 + packages/errors/src/i18n/locales/no.ts | 6 + packages/errors/src/i18n/locales/pl.ts | 6 + packages/errors/src/i18n/locales/pt-BR.ts | 6 + packages/errors/src/i18n/locales/ro.ts | 6 + packages/errors/src/i18n/locales/ru.ts | 6 + packages/errors/src/i18n/locales/sv-SE.ts | 6 + packages/errors/src/i18n/locales/th.ts | 6 + packages/errors/src/i18n/locales/tr.ts | 6 + packages/errors/src/i18n/locales/uk.ts | 6 + packages/errors/src/i18n/locales/vi.ts | 6 + packages/errors/src/i18n/locales/zh-CN.ts | 6 + packages/errors/src/i18n/locales/zh-TW.ts | 6 + .../errors/src/i18n/weblate/locales/ar.json | 6 + .../errors/src/i18n/weblate/locales/bg.json | 6 + .../errors/src/i18n/weblate/locales/cs.json | 6 + .../errors/src/i18n/weblate/locales/da.json | 6 + .../errors/src/i18n/weblate/locales/de.json | 6 + .../errors/src/i18n/weblate/locales/el.json | 6 + .../src/i18n/weblate/locales/en-GB.json | 6 + .../src/i18n/weblate/locales/es-419.json | 6 + .../src/i18n/weblate/locales/es-ES.json | 6 + .../errors/src/i18n/weblate/locales/fi.json | 6 + .../errors/src/i18n/weblate/locales/fr.json | 6 + .../errors/src/i18n/weblate/locales/he.json | 6 + .../errors/src/i18n/weblate/locales/hi.json | 6 + .../errors/src/i18n/weblate/locales/hr.json | 6 + .../errors/src/i18n/weblate/locales/hu.json | 6 + .../errors/src/i18n/weblate/locales/id.json | 6 + .../errors/src/i18n/weblate/locales/it.json | 6 + .../errors/src/i18n/weblate/locales/ja.json | 6 + .../errors/src/i18n/weblate/locales/ko.json | 6 + .../errors/src/i18n/weblate/locales/lt.json | 6 + .../errors/src/i18n/weblate/locales/nl.json | 6 + .../errors/src/i18n/weblate/locales/no.json | 6 + .../errors/src/i18n/weblate/locales/pl.json | 6 + .../src/i18n/weblate/locales/pt-BR.json | 6 + .../errors/src/i18n/weblate/locales/ro.json | 6 + .../errors/src/i18n/weblate/locales/ru.json | 6 + .../src/i18n/weblate/locales/sv-SE.json | 6 + .../errors/src/i18n/weblate/locales/th.json | 6 + .../errors/src/i18n/weblate/locales/tr.json | 6 + .../errors/src/i18n/weblate/locales/uk.json | 6 + .../errors/src/i18n/weblate/locales/vi.json | 6 + .../src/i18n/weblate/locales/zh-CN.json | 6 + .../src/i18n/weblate/locales/zh-TW.json | 6 + .../errors/src/i18n/weblate/messages.json | 6 + .../domains/admin/AdminStoreBillingSchemas.ts | 47 + .../src/domains/premium/PremiumSchemas.ts | 16 +- .../domains/premium/StoreBillingSchemas.ts | 166 +++ tools/dev/cassandra_target_schema.json | 224 ++++ 202 files changed, 17095 insertions(+), 49 deletions(-) create mode 100644 fluxer_api/src/api/admin/controllers/StoreBillingAdminController.ts create mode 100644 fluxer_api/src/api/admin/tests/audit_coverage/StoreBillingAdminAuditCases.ts create mode 100644 fluxer_api/src/api/admin/tests/audit_coverage/StoreBillingAdminAuditCoverage.test.ts create mode 100644 fluxer_api/src/api/database/types/StoreBillingTypes.ts create mode 100644 fluxer_api/src/api/rate_limit_configs/StoreBillingRateLimitConfig.ts create mode 100644 fluxer_api/src/api/store_billing/GooglePlaySettlement.ts create mode 100644 fluxer_api/src/api/store_billing/StoreBillingConfig.ts create mode 100644 fluxer_api/src/api/store_billing/StoreBillingController.ts create mode 100644 fluxer_api/src/api/store_billing/StoreBillingMappers.ts create mode 100644 fluxer_api/src/api/store_billing/StoreBillingRepository.ts create mode 100644 fluxer_api/src/api/store_billing/StoreBillingTypes.ts create mode 100644 fluxer_api/src/api/store_billing/StoreEntitlementService.ts create mode 100644 fluxer_api/src/api/store_billing/StoreEntitlementServiceFactory.ts create mode 100644 fluxer_api/src/api/store_billing/StoreEntitlementWriter.ts create mode 100644 fluxer_api/src/api/store_billing/StoreGiftFulfilment.ts create mode 100644 fluxer_api/src/api/store_billing/StoreNotificationClaims.ts create mode 100644 fluxer_api/src/api/store_billing/StorePurchaseRefresh.ts create mode 100644 fluxer_api/src/api/store_billing/app_store/AppStoreJwsVerifier.ts create mode 100644 fluxer_api/src/api/store_billing/app_store/AppStorePurchaseSync.ts create mode 100644 fluxer_api/src/api/store_billing/app_store/AppStoreServerApiClient.ts create mode 100644 fluxer_api/src/api/store_billing/app_store/AppleRootCertificates.ts create mode 100644 fluxer_api/src/api/store_billing/google_play/GooglePlayAccessTokenProvider.ts create mode 100644 fluxer_api/src/api/store_billing/google_play/GooglePlayApiError.ts create mode 100644 fluxer_api/src/api/store_billing/google_play/GooglePlayDeveloperApiClient.ts create mode 100644 fluxer_api/src/api/store_billing/google_play/GooglePlayPurchaseSync.ts create mode 100644 fluxer_api/src/api/store_billing/google_play/GooglePlayPushVerifier.ts create mode 100644 fluxer_api/src/api/store_billing/tests/AppStoreJwsVerifier.test.ts create mode 100644 fluxer_api/src/api/store_billing/tests/AppStorePurchaseSync.test.ts create mode 100644 fluxer_api/src/api/store_billing/tests/AppStoreServerApiClient.test.ts create mode 100644 fluxer_api/src/api/store_billing/tests/AppleProductionChain.ts create mode 100644 fluxer_api/src/api/store_billing/tests/AppleTestPki.ts create mode 100644 fluxer_api/src/api/store_billing/tests/GooglePlayAccessTokenProvider.test.ts create mode 100644 fluxer_api/src/api/store_billing/tests/GooglePlayDeveloperApiClient.test.ts create mode 100644 fluxer_api/src/api/store_billing/tests/GooglePlayPurchaseSync.test.ts create mode 100644 fluxer_api/src/api/store_billing/tests/GooglePlayPushVerifier.test.ts create mode 100644 fluxer_api/src/api/store_billing/tests/PremiumSubscriptionProvider.test.ts create mode 100644 fluxer_api/src/api/store_billing/tests/StoreBillingAppStore.test.ts create mode 100644 fluxer_api/src/api/store_billing/tests/StoreBillingConfig.test.ts create mode 100644 fluxer_api/src/api/store_billing/tests/StoreBillingGooglePlay.test.ts create mode 100644 fluxer_api/src/api/store_billing/tests/StoreBillingRepository.test.ts create mode 100644 fluxer_api/src/api/store_billing/tests/StoreBillingStripeCoexistence.test.ts create mode 100644 fluxer_api/src/api/store_billing/tests/StoreBillingTestUtils.ts create mode 100644 fluxer_api/src/api/store_billing/tests/StoreBillingTypes.test.ts create mode 100644 fluxer_api/src/api/store_billing/tests/StoreBillingUnconfigured.test.ts create mode 100644 fluxer_api/src/api/store_billing/tests/StoreEntitlementUserWrite.test.ts create mode 100644 fluxer_api/src/api/store_billing/tests/StoreNotificationClaims.test.ts create mode 100644 fluxer_api/src/api/stripe/tests/StripeGiftReversalHandler.test.ts create mode 100644 fluxer_api/src/api/test/msw/handlers/AppStoreServerApiHandlers.ts create mode 100644 fluxer_api/src/api/test/msw/handlers/GooglePlayDeveloperApiHandlers.ts create mode 100644 fluxer_api/src/api/worker/tasks/PollAppStoreNotificationHistory.ts create mode 100644 fluxer_api/src/api/worker/tasks/PollGooglePlayVoidedPurchases.ts create mode 100644 fluxer_api/src/api/worker/tasks/ProcessAppStoreNotification.ts create mode 100644 fluxer_api/src/api/worker/tasks/ProcessGooglePlayNotification.ts create mode 100644 fluxer_api/src/api/worker/tasks/ProcessStorePurchaseRefreshQueue.ts create mode 100644 fluxer_api/src/api/worker/tasks/RefreshStorePurchase.ts create mode 100644 fluxer_docs/src/content/docs/http-api/in-app-purchases.mdx create mode 100644 packages/errors/src/domains/payment/StoreBillingUnavailableError.ts create mode 100644 packages/errors/src/domains/payment/StoreNotificationUnauthorizedError.ts create mode 100644 packages/errors/src/domains/payment/StorePurchaseInvalidError.ts create mode 100644 packages/errors/src/domains/payment/StorePurchaseOwnedByOtherAccountError.ts create mode 100644 packages/errors/src/domains/payment/StorePurchaseSandboxNotEntitledError.ts create mode 100644 packages/errors/src/domains/payment/UnknownStorePurchaseError.ts create mode 100644 packages/schema/src/domains/admin/AdminStoreBillingSchemas.ts create mode 100644 packages/schema/src/domains/premium/StoreBillingSchemas.ts diff --git a/fluxer_admin/openapi-admin.json b/fluxer_admin/openapi-admin.json index 627c38254..3b0701172 100644 --- a/fluxer_admin/openapi-admin.json +++ b/fluxer_admin/openapi-admin.json @@ -7727,6 +7727,130 @@ ] } }, + "/admin/users/{user_id}/store-purchases": { + "get": { + "operationId": "list_admin_user_store_purchases", + "summary": "List user store purchases", + "tags": ["Admin"], + "responses": { + "200": { + "description": "Success", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/AdminStorePurchaseListResponse"}}} + }, + "400": { + "description": "Bad Request - The request was malformed or contained invalid data", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "401": { + "description": "Unauthorized - Authentication is required or the token is invalid", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "403": { + "description": "Forbidden - You do not have permission to perform this action", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "429": { + "description": "Too Many Requests - You are being rate limited", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ThrottledError"}}}, + "headers": { + "Retry-After": { + "description": "Number of seconds to wait before retrying (only on 429)", + "schema": {"type": "integer"} + }, + "X-RateLimit-Limit": { + "description": "The number of requests that can be made in the current window", + "schema": {"type": "integer"} + }, + "X-RateLimit-Remaining": { + "description": "The number of remaining requests that can be made", + "schema": {"type": "integer"} + }, + "X-RateLimit-Reset": { + "description": "Unix timestamp when the rate limit resets", + "schema": {"type": "integer"} + } + } + }, + "500": { + "description": "Internal Server Error - An unexpected error occurred", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + } + }, + "description": "Lists the App Store and Google Play purchases bound to a user, newest first, with their store state. Only available on hosted instances. Requires USER_LOOKUP permission.", + "security": [{"adminApiKey": []}], + "parameters": [ + { + "name": "user_id", + "in": "path", + "required": true, + "schema": {"description": "The ID of the user", "allOf": [{"$ref": "#/components/schemas/SnowflakeType"}]}, + "description": "The ID of the user" + } + ] + } + }, + "/admin/users/{user_id}/store-purchases/refresh": { + "post": { + "operationId": "refresh_admin_user_store_purchases", + "summary": "Refresh user store purchases", + "tags": ["Admin"], + "responses": { + "200": { + "description": "Success", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/AdminStorePurchaseListResponse"}}} + }, + "400": { + "description": "Bad Request - The request was malformed or contained invalid data", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "401": { + "description": "Unauthorized - Authentication is required or the token is invalid", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "403": { + "description": "Forbidden - You do not have permission to perform this action", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "429": { + "description": "Too Many Requests - You are being rate limited", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ThrottledError"}}}, + "headers": { + "Retry-After": { + "description": "Number of seconds to wait before retrying (only on 429)", + "schema": {"type": "integer"} + }, + "X-RateLimit-Limit": { + "description": "The number of requests that can be made in the current window", + "schema": {"type": "integer"} + }, + "X-RateLimit-Remaining": { + "description": "The number of remaining requests that can be made", + "schema": {"type": "integer"} + }, + "X-RateLimit-Reset": { + "description": "Unix timestamp when the rate limit resets", + "schema": {"type": "integer"} + } + } + }, + "500": { + "description": "Internal Server Error - An unexpected error occurred", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + } + }, + "description": "Reads every store purchase bound to a user again from the App Store or Google Play, applies the result to the account and returns the updated purchases. Creates audit log entry. Only available on hosted instances. Requires USER_UPDATE_FLAGS permission.", + "security": [{"adminApiKey": []}], + "parameters": [ + { + "name": "user_id", + "in": "path", + "required": true, + "schema": {"description": "The ID of the user", "allOf": [{"$ref": "#/components/schemas/SnowflakeType"}]}, + "description": "The ID of the user" + } + ] + } + }, "/admin/users/{user_id}/suspicious-activity-disablement": { "put": { "operationId": "disable_admin_user_suspicious", @@ -9407,6 +9531,18 @@ }, "required": ["flags"] }, + "AdminStorePurchaseListResponse": { + "type": "object", + "properties": { + "purchases": { + "type": "array", + "items": {"$ref": "#/components/schemas/AdminStorePurchaseResponse"}, + "description": "Store purchases bound to the user" + } + }, + "required": ["purchases"], + "additionalProperties": false + }, "TerminateSessionsResponse": { "type": "object", "properties": {"terminated_count": {"$ref": "#/components/schemas/Int32Type"}}, @@ -15790,6 +15926,176 @@ "required": ["target_user_id", "category", "nickname", "since", "target"], "additionalProperties": false }, + "AdminStorePurchaseResponse": { + "type": "object", + "properties": { + "id": { + "description": "The unique identifier (snowflake) for this store purchase", + "allOf": [{"$ref": "#/components/schemas/SnowflakeStringType"}] + }, + "user_id": { + "nullable": true, + "description": "ID of the user the purchase is bound to, null when unbound", + "allOf": [{"$ref": "#/components/schemas/SnowflakeStringType"}] + }, + "provider": { + "type": "string", + "enum": ["app_store", "google_play"], + "description": "Store the purchase was made in" + }, + "kind": { + "type": "string", + "enum": ["subscription", "gift"], + "description": "Whether the purchase is a subscription or a gift" + }, + "slot": { + "type": "string", + "enum": ["monthly", "yearly", "gift_1_month", "gift_1_year"], + "description": "Fluxer product the purchase is for" + }, + "environment": { + "type": "string", + "enum": ["production", "sandbox"], + "description": "Whether the purchase was real or a test purchase" + }, + "app_id": { + "type": "string", + "description": "Bundle identifier or package name of the app that made the purchase" + }, + "product_id": {"type": "string", "description": "Store product identifier"}, + "base_plan_id": { + "nullable": true, + "description": "Google Play base plan identifier, null otherwise", + "type": "string" + }, + "latest_transaction_id": { + "nullable": true, + "description": "Latest App Store transaction ID or Google Play order ID for the purchase", + "type": "string" + }, + "ownership_type": { + "nullable": true, + "description": "Store ownership type, such as PURCHASED or FAMILY_SHARED", + "type": "string" + }, + "state": {"type": "string", "description": "Normalized state of the purchase"}, + "store_state": {"nullable": true, "description": "Raw state reported by the store", "type": "string"}, + "entitled": {"type": "boolean", "description": "Whether the purchase currently grants Plutonium"}, + "expires_at": { + "nullable": true, + "description": "When the paid period ends", + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z))$" + }, + "grace_ends_at": { + "nullable": true, + "description": "When the billing grace period ends", + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z))$" + }, + "auto_renew": { + "nullable": true, + "description": "Whether the subscription renews automatically", + "type": "boolean" + }, + "started_at": { + "nullable": true, + "description": "When the subscription or purchase started", + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z))$" + }, + "purchased_at": { + "nullable": true, + "description": "When the latest payment was made", + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z))$" + }, + "revoked_at": { + "nullable": true, + "description": "When the store revoked or refunded the purchase", + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z))$" + }, + "revocation_reason": { + "nullable": true, + "description": "Reason the store gave for the revocation", + "type": "string" + }, + "superseded": {"type": "boolean", "description": "Whether a newer purchase replaced this one"}, + "acknowledged": {"type": "boolean", "description": "Whether the purchase was acknowledged with the store"}, + "gift_code": {"nullable": true, "description": "Gift code minted by a gift purchase", "type": "string"}, + "bound_at": { + "nullable": true, + "description": "When the purchase was bound to its user", + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z))$" + }, + "last_event_at": { + "nullable": true, + "description": "Time of the latest store event applied to the purchase", + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z))$" + }, + "synced_at": { + "nullable": true, + "description": "When the purchase was last synced with the store", + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z))$" + }, + "created_at": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z))$", + "description": "When Fluxer first saw the purchase" + }, + "updated_at": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z))$", + "description": "When the purchase record last changed" + } + }, + "required": [ + "id", + "user_id", + "provider", + "kind", + "slot", + "environment", + "app_id", + "product_id", + "base_plan_id", + "latest_transaction_id", + "ownership_type", + "state", + "store_state", + "entitled", + "expires_at", + "grace_ends_at", + "auto_renew", + "started_at", + "purchased_at", + "revoked_at", + "revocation_reason", + "superseded", + "acknowledged", + "gift_code", + "bound_at", + "last_event_at", + "synced_at", + "created_at", + "updated_at" + ], + "additionalProperties": false + }, "DiscriminatorType": {"anyOf": [{"type": "string"}, {"type": "number"}]}, "UsernameType": {"type": "string"}, "WebAuthnCredentialResponse": { diff --git a/fluxer_api/src/api/Config.ts b/fluxer_api/src/api/Config.ts index f9cf88602..dce0b9451 100644 --- a/fluxer_api/src/api/Config.ts +++ b/fluxer_api/src/api/Config.ts @@ -415,6 +415,34 @@ export function buildAPIConfigFromMaster(master: MasterConfig): APIConfig { apps: mapApnsApps(master.integrations.push.apns.apps), }, }, + appStore: { + enabled: master.integrations.app_store.enabled, + issuerId: master.integrations.app_store.issuer_id, + keyId: master.integrations.app_store.key_id, + privateKey: master.integrations.app_store.private_key, + privateKeyPath: master.integrations.app_store.private_key_path, + apps: (master.integrations.app_store.apps ?? []).map((app) => ({ + bundleId: app.bundle_id, + appAppleId: app.app_apple_id, + })), + products: master.integrations.app_store.products ?? {}, + }, + googlePlay: { + enabled: master.integrations.google_play.enabled, + packages: master.integrations.google_play.packages ?? [], + clientEmail: master.integrations.google_play.client_email, + privateKey: master.integrations.google_play.private_key, + privateKeyPath: master.integrations.google_play.private_key_path, + serviceAccountJsonPath: master.integrations.google_play.service_account_json_path, + tokenUri: master.integrations.google_play.token_uri ?? 'https://oauth2.googleapis.com/token', + products: master.integrations.google_play.products ?? {}, + pushAudience: master.integrations.google_play.push_audience, + pushServiceAccountEmail: master.integrations.google_play.push_service_account_email, + }, + storeBilling: { + sandboxUserIds: master.integrations.store_billing.sandbox_user_ids ?? [], + sandboxEntitlesAll: master.integrations.store_billing.sandbox_entitles_all, + }, worker: { mode: apiWorkerConfig?.mode ?? 'all_lanes', laneName: apiWorkerConfig?.lane, diff --git a/fluxer_api/src/api/RateLimitConfig.ts b/fluxer_api/src/api/RateLimitConfig.ts index 2a17a0f1f..782c677eb 100644 --- a/fluxer_api/src/api/RateLimitConfig.ts +++ b/fluxer_api/src/api/RateLimitConfig.ts @@ -12,6 +12,7 @@ import {MiscRateLimitConfigs} from '@app/api/rate_limit_configs/MiscRateLimitCon import {OAuthRateLimitConfigs} from '@app/api/rate_limit_configs/OAuthRateLimitConfig'; import type {RateLimitSection} from '@app/api/rate_limit_configs/RateLimitHelpers'; import {mergeRateLimitSections} from '@app/api/rate_limit_configs/RateLimitHelpers'; +import {StoreBillingRateLimitConfigs} from '@app/api/rate_limit_configs/StoreBillingRateLimitConfig'; import {UserRateLimitConfigs} from '@app/api/rate_limit_configs/UserRateLimitConfig'; import {WebhookRateLimitConfigs} from '@app/api/rate_limit_configs/WebhookRateLimitConfig'; @@ -26,6 +27,7 @@ const rateLimitSections = [ InviteRateLimitConfigs, WebhookRateLimitConfigs, IntegrationRateLimitConfigs, + StoreBillingRateLimitConfigs, AdminRateLimitConfigs, MiscRateLimitConfigs, ] satisfies ReadonlyArray; diff --git a/fluxer_api/src/api/Tables.ts b/fluxer_api/src/api/Tables.ts index 4bc90e8b3..07db8efa1 100644 --- a/fluxer_api/src/api/Tables.ts +++ b/fluxer_api/src/api/Tables.ts @@ -250,6 +250,16 @@ import { MESSAGE_REPORT_SUBMISSION_BY_REPORTER_COLUMNS, type MessageReportSubmissionByReporterRow, } from '@app/api/database/types/ReportTypes'; +import { + STORE_ACCOUNT_TOKEN_BY_USER_COLUMNS, + STORE_ACCOUNT_TOKEN_COLUMNS, + STORE_PURCHASE_BY_USER_COLUMNS, + STORE_PURCHASE_COLUMNS, + type StoreAccountTokenByUserRow, + type StoreAccountTokenRow, + type StorePurchaseByUserRow, + type StorePurchaseRow, +} from '@app/api/database/types/StoreBillingTypes'; import { FAVORITE_MEME_COLUMNS, type FavoriteMemeRow, @@ -703,6 +713,27 @@ export const GiftCodesByRedeemer = defineTable({ + name: 'store_purchases', + columns: STORE_PURCHASE_COLUMNS, + primaryKey: ['store_key'], +}); +export const StorePurchasesByUser = defineTable({ + name: 'store_purchases_by_user', + columns: STORE_PURCHASE_BY_USER_COLUMNS, + primaryKey: ['user_id', 'store_key'], + partitionKey: ['user_id'], +}); +export const StoreAccountTokens = defineTable({ + name: 'store_account_tokens', + columns: STORE_ACCOUNT_TOKEN_COLUMNS, + primaryKey: ['token_'], +}); +export const StoreAccountTokensByUser = defineTable({ + name: 'store_account_tokens_by_user', + columns: STORE_ACCOUNT_TOKEN_BY_USER_COLUMNS, + primaryKey: ['user_id'], +}); export const AdminArchivesBySubject = defineTable({ name: 'admin_archives_by_subject', columns: ADMIN_ARCHIVE_COLUMNS, diff --git a/fluxer_api/src/api/admin/AdminAuditActions.ts b/fluxer_api/src/api/admin/AdminAuditActions.ts index e84f20a4c..f0e8ba910 100644 --- a/fluxer_api/src/api/admin/AdminAuditActions.ts +++ b/fluxer_api/src/api/admin/AdminAuditActions.ts @@ -44,6 +44,7 @@ export const AdminAuditReadActions = { LIST_USER_GUILDS: 'list_user_guilds', LIST_USER_RELATIONSHIPS: 'list_user_relationships', LIST_USER_SESSIONS: 'list_user_sessions', + LIST_USER_STORE_PURCHASES: 'list_user_store_purchases', LIST_VOICE_REGIONS: 'list_voice_regions', LIST_VOICE_SERVERS: 'list_voice_servers', LIST_WEBAUTHN_CREDENTIALS: 'list_webauthn_credentials', diff --git a/fluxer_api/src/api/admin/AdminService.ts b/fluxer_api/src/api/admin/AdminService.ts index 588f82acc..43342de27 100644 --- a/fluxer_api/src/api/admin/AdminService.ts +++ b/fluxer_api/src/api/admin/AdminService.ts @@ -37,6 +37,7 @@ import { } from '@app/api/middleware/ServiceSingletons'; import type {IApplicationRepository} from '@app/api/oauth/repositories/IApplicationRepository'; import type {ReportService} from '@app/api/report/ReportService'; +import type {StoreEntitlementService} from '@app/api/store_billing/StoreEntitlementService'; import type {UserService} from '@app/api/user/services/UserService'; import type {VoiceRepository} from '@app/api/voice/VoiceRepository'; import type {SendSystemDmResponse} from '@fluxer/schema/src/domains/admin/AdminSchemas'; @@ -81,6 +82,7 @@ export class AdminService { private readonly stripe: Stripe | null = null, private readonly jobLedger: IJobLedgerRepository, private readonly ipInfoService: IpInfoService, + private readonly storeEntitlementService: StoreEntitlementService, ) { const {users, gateway, worker, snowflake} = this.apiContext.services; this.auditService = new AdminAuditService(this.adminRepository, snowflake, { @@ -107,6 +109,7 @@ export class AdminService { bulkMessageDeletionQueue: this.bulkMessageDeletionQueue, stripe: this.stripe, reportService: this.reportService, + storeEntitlementService: this.storeEntitlementService, }); this.guildServiceAggregate = new AdminGuildService({ guildRepository: this.guildRepository, diff --git a/fluxer_api/src/api/admin/controllers/StoreBillingAdminController.ts b/fluxer_api/src/api/admin/controllers/StoreBillingAdminController.ts new file mode 100644 index 000000000..0d4738c64 --- /dev/null +++ b/fluxer_api/src/api/admin/controllers/StoreBillingAdminController.ts @@ -0,0 +1,91 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import {AdminAuditReadActions} from '@app/api/admin/AdminAuditActions'; +import {recordAdminRead, recordAdminWrite} from '@app/api/admin/AdminAuditRecorder'; +import {createUserID, type UserID} from '@app/api/BrandedTypes'; +import {requireAdminACL} from '@app/api/middleware/AdminMiddleware'; +import {RateLimitMiddleware} from '@app/api/middleware/RateLimitMiddleware'; +import {OpenAPI} from '@app/api/middleware/ResponseTypeMiddleware'; +import {RateLimitConfigs} from '@app/api/RateLimitConfig'; +import {HostedOnlyRoute} from '@app/api/store_billing/StoreBillingController'; +import {mapStorePurchaseToAdminResponse} from '@app/api/store_billing/StoreBillingMappers'; +import type {HonoApp, HonoEnv} from '@app/api/types/HonoEnv'; +import {Validator} from '@app/api/Validator'; +import {AdminACLs} from '@fluxer/constants/src/AdminACLs'; +import {UnknownUserError} from '@fluxer/errors/src/domains/user/UnknownUserError'; +import {AdminStorePurchaseListResponse} from '@fluxer/schema/src/domains/admin/AdminStoreBillingSchemas'; +import {UserIdParam} from '@fluxer/schema/src/domains/common/CommonParamSchemas'; +import type {Context} from 'hono'; + +async function requireUser(ctx: Context, userId: UserID): Promise { + if (!(await ctx.get('userRepository').findUnique(userId))) { + throw new UnknownUserError(); + } +} + +export function StoreBillingAdminController(app: HonoApp) { + app.get( + '/admin/users/:user_id/store-purchases', + HostedOnlyRoute, + RateLimitMiddleware(RateLimitConfigs.ADMIN_LOOKUP), + requireAdminACL(AdminACLs.USER_LOOKUP), + Validator('param', UserIdParam), + OpenAPI({ + operationId: 'list_admin_user_store_purchases', + summary: 'List user store purchases', + responseSchema: AdminStorePurchaseListResponse, + statusCode: 200, + security: 'adminApiKey', + tags: 'Admin', + description: + 'Lists the App Store and Google Play purchases bound to a user, newest first, with their store state. Only available on hosted instances. Requires USER_LOOKUP permission.', + }), + async (ctx) => { + const userId = createUserID(ctx.req.valid('param').user_id); + await requireUser(ctx, userId); + const rows = await ctx.get('storeEntitlementService').listStorePurchases(userId); + await recordAdminRead(ctx, { + targetType: 'user', + targetId: userId, + action: AdminAuditReadActions.LIST_USER_STORE_PURCHASES, + metadata: {result_count: rows.length}, + }); + return ctx.json({purchases: rows.map(mapStorePurchaseToAdminResponse)}); + }, + ); + app.post( + '/admin/users/:user_id/store-purchases/refresh', + HostedOnlyRoute, + RateLimitMiddleware(RateLimitConfigs.ADMIN_USER_MODIFY), + requireAdminACL(AdminACLs.USER_UPDATE_FLAGS), + Validator('param', UserIdParam), + OpenAPI({ + operationId: 'refresh_admin_user_store_purchases', + summary: 'Refresh user store purchases', + responseSchema: AdminStorePurchaseListResponse, + statusCode: 200, + security: 'adminApiKey', + tags: 'Admin', + description: + 'Reads every store purchase bound to a user again from the App Store or Google Play, applies the result to the account and returns the updated purchases. Creates audit log entry. Only available on hosted instances. Requires USER_UPDATE_FLAGS permission.', + }), + async (ctx) => { + const userId = createUserID(ctx.req.valid('param').user_id); + await requireUser(ctx, userId); + const storeEntitlementService = ctx.get('storeEntitlementService'); + const rows = await storeEntitlementService.listStorePurchases(userId); + for (const row of rows) { + await storeEntitlementService.refreshStorePurchase(row.store_key); + } + await storeEntitlementService.applyStoreEntitlementToUser(userId); + const refreshed = await storeEntitlementService.listStorePurchases(userId); + await recordAdminWrite(ctx, { + targetType: 'user', + targetId: userId, + action: 'refresh_store_purchases', + metadata: {purchase_count: rows.length}, + }); + return ctx.json({purchases: refreshed.map(mapStorePurchaseToAdminResponse)}); + }, + ); +} diff --git a/fluxer_api/src/api/admin/controllers/index.ts b/fluxer_api/src/api/admin/controllers/index.ts index 460bd565d..5c2726b98 100644 --- a/fluxer_api/src/api/admin/controllers/index.ts +++ b/fluxer_api/src/api/admin/controllers/index.ts @@ -17,6 +17,7 @@ import {LimitConfigAdminController} from '@app/api/admin/controllers/LimitConfig import {MessageAdminController} from '@app/api/admin/controllers/MessageAdminController'; import {ReportAdminController} from '@app/api/admin/controllers/ReportAdminController'; import {SearchAdminController} from '@app/api/admin/controllers/SearchAdminController'; +import {StoreBillingAdminController} from '@app/api/admin/controllers/StoreBillingAdminController'; import {SystemDmAdminController} from '@app/api/admin/controllers/SystemDmAdminController'; import {UserAdminController} from '@app/api/admin/controllers/UserAdminController'; import {VoiceAdminController} from '@app/api/admin/controllers/VoiceAdminController'; @@ -26,6 +27,7 @@ export function registerAdminControllers(app: HonoApp) { AdminApiKeyAdminController(app); ApplicationAdminController(app); UserAdminController(app); + StoreBillingAdminController(app); CodesAdminController(app); GuildAdminController(app); AssetAdminController(app); diff --git a/fluxer_api/src/api/admin/services/AdminUserDeletionService.ts b/fluxer_api/src/api/admin/services/AdminUserDeletionService.ts index a18ee09a6..90f37958a 100644 --- a/fluxer_api/src/api/admin/services/AdminUserDeletionService.ts +++ b/fluxer_api/src/api/admin/services/AdminUserDeletionService.ts @@ -17,6 +17,7 @@ import type {OAuth2TokenRepository} from '@app/api/oauth/repositories/OAuth2Toke import {ReportStatus} from '@app/api/report/IReportRepository'; import type {ReportService} from '@app/api/report/ReportService'; import {getReportSearchService} from '@app/api/SearchFactory'; +import type {StoreEntitlementService} from '@app/api/store_billing/StoreEntitlementService'; import {clearPendingDeletion, reschedulePendingDeletion} from '@app/api/user/services/PendingDeletionCoordinator'; import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes'; import {DeletionReasons} from '@fluxer/constants/src/Core'; @@ -41,6 +42,7 @@ interface AdminUserDeletionServiceDeps { stripe: Stripe | null; billingRepository: BillingRepository; oauth2Tokens: Pick; + storeEntitlementService: StoreEntitlementService; } const minUserRequestedDeletionDays = 14; @@ -206,6 +208,7 @@ export class AdminUserDeletionService { ); } } + await this.deps.storeEntitlementService.revokeForBannedUser(userId); const email = user.email; const notificationTemplate = scheduledDeletionEmailTemplate(data.reason_code); const notificationAttempted = Boolean(data.notify_user && email); diff --git a/fluxer_api/src/api/admin/services/AdminUserService.ts b/fluxer_api/src/api/admin/services/AdminUserService.ts index 7a4438446..0f0961f6c 100644 --- a/fluxer_api/src/api/admin/services/AdminUserService.ts +++ b/fluxer_api/src/api/admin/services/AdminUserService.ts @@ -21,6 +21,7 @@ import type {UserCacheService} from '@app/api/infrastructure/UserCacheService'; import {getBillingRepository} from '@app/api/middleware/ServiceRegistry'; import {OAuth2TokenRepository} from '@app/api/oauth/repositories/OAuth2TokenRepository'; import type {ReportService} from '@app/api/report/ReportService'; +import type {StoreEntitlementService} from '@app/api/store_billing/StoreEntitlementService'; import type {IUserRepository} from '@app/api/user/IUserRepository'; import type {UserContactChangeLogService} from '@app/api/user/services/UserContactChangeLogService'; import {AdminACLs} from '@fluxer/constants/src/AdminACLs'; @@ -47,6 +48,7 @@ interface AdminUserServiceDeps { bulkMessageDeletionQueue: KVBulkMessageDeletionQueueService; stripe: Stripe | null; reportService: ReportService; + storeEntitlementService: StoreEntitlementService; } export class AdminUserService { @@ -109,6 +111,7 @@ export class AdminUserService { stripe: deps.stripe, billingRepository: getBillingRepository(), oauth2Tokens: new OAuth2TokenRepository(), + storeEntitlementService: deps.storeEntitlementService, }); this.contactChangeLogService = contactChangeLog; } diff --git a/fluxer_api/src/api/admin/tests/audit_coverage/AdminAuditRouteCoverage.test.ts b/fluxer_api/src/api/admin/tests/audit_coverage/AdminAuditRouteCoverage.test.ts index e84112c45..50898c17f 100644 --- a/fluxer_api/src/api/admin/tests/audit_coverage/AdminAuditRouteCoverage.test.ts +++ b/fluxer_api/src/api/admin/tests/audit_coverage/AdminAuditRouteCoverage.test.ts @@ -19,6 +19,7 @@ import {LimitConfigAdminAuditCases} from '@app/api/admin/tests/audit_coverage/Li import {MessageAdminAuditCases} from '@app/api/admin/tests/audit_coverage/MessageAdminAuditCases'; import {ReportAdminAuditCases} from '@app/api/admin/tests/audit_coverage/ReportAdminAuditCases'; import {SearchAdminAuditCases} from '@app/api/admin/tests/audit_coverage/SearchAdminAuditCases'; +import {StoreBillingAdminAuditCases} from '@app/api/admin/tests/audit_coverage/StoreBillingAdminAuditCases'; import {SystemDmAdminAuditCases} from '@app/api/admin/tests/audit_coverage/SystemDmAdminAuditCases'; import {UserAdminAuditCases} from '@app/api/admin/tests/audit_coverage/UserAdminAuditCases'; import {UserWriteAdminAuditCases} from '@app/api/admin/tests/audit_coverage/UserWriteAdminAuditCases'; @@ -45,6 +46,7 @@ const ALL_CASES = [ ...MessageAdminAuditCases, ...ReportAdminAuditCases, ...SearchAdminAuditCases, + ...StoreBillingAdminAuditCases, ...SystemDmAdminAuditCases, ...UserAdminAuditCases, ...UserWriteAdminAuditCases, diff --git a/fluxer_api/src/api/admin/tests/audit_coverage/StoreBillingAdminAuditCases.ts b/fluxer_api/src/api/admin/tests/audit_coverage/StoreBillingAdminAuditCases.ts new file mode 100644 index 000000000..1ade058c6 --- /dev/null +++ b/fluxer_api/src/api/admin/tests/audit_coverage/StoreBillingAdminAuditCases.ts @@ -0,0 +1,42 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import type {AdminAuditCoverageCase} from '@app/api/admin/tests/audit_coverage/AdminAuditCoverage'; +import {createTestAccount} from '@app/api/auth/tests/AuthTestUtils'; +import {createUserID} from '@app/api/BrandedTypes'; +import {seedStorePurchase} from '@app/api/store_billing/tests/StoreBillingTestUtils'; + +export const StoreBillingAdminAuditCases: ReadonlyArray = [ + { + method: 'GET', + route: '/admin/users/:user_id/store-purchases', + async prepare({harness}) { + const target = await createTestAccount(harness); + await seedStorePurchase(createUserID(BigInt(target.userId))); + return { + request: {path: `/admin/users/${target.userId}/store-purchases`}, + expected: { + action: 'list_user_store_purchases', + targetType: 'user', + targetId: target.userId, + metadata: {result_count: '1'}, + }, + }; + }, + }, + { + method: 'POST', + route: '/admin/users/:user_id/store-purchases/refresh', + async prepare({harness}) { + const target = await createTestAccount(harness); + return { + request: {path: `/admin/users/${target.userId}/store-purchases/refresh`}, + expected: { + action: 'refresh_store_purchases', + targetType: 'user', + targetId: target.userId, + metadata: {purchase_count: '0'}, + }, + }; + }, + }, +]; diff --git a/fluxer_api/src/api/admin/tests/audit_coverage/StoreBillingAdminAuditCoverage.test.ts b/fluxer_api/src/api/admin/tests/audit_coverage/StoreBillingAdminAuditCoverage.test.ts new file mode 100644 index 000000000..70ba6d315 --- /dev/null +++ b/fluxer_api/src/api/admin/tests/audit_coverage/StoreBillingAdminAuditCoverage.test.ts @@ -0,0 +1,6 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import {describeAdminAuditCoverage} from '@app/api/admin/tests/audit_coverage/AdminAuditCoverage'; +import {StoreBillingAdminAuditCases} from '@app/api/admin/tests/audit_coverage/StoreBillingAdminAuditCases'; + +describeAdminAuditCoverage('StoreBillingAdminController', StoreBillingAdminAuditCases); diff --git a/fluxer_api/src/api/app/ControllerRegistry.ts b/fluxer_api/src/api/app/ControllerRegistry.ts index ed423b8d7..74517f913 100644 --- a/fluxer_api/src/api/app/ControllerRegistry.ts +++ b/fluxer_api/src/api/app/ControllerRegistry.ts @@ -29,6 +29,7 @@ import {ReadStateController} from '@app/api/read_state/ReadStateController'; import {ReportController} from '@app/api/report/ReportController'; import {InternalRpcController} from '@app/api/rpc/InternalRpcController'; import {SearchController} from '@app/api/search/controllers/SearchController'; +import {StoreBillingController} from '@app/api/store_billing/StoreBillingController'; import {StripeController} from '@app/api/stripe/StripeController'; import {TestHarnessController} from '@app/api/test/TestHarnessController'; import {ThemeController} from '@app/api/theme/ThemeController'; @@ -69,6 +70,7 @@ export function registerControllers(routes: HonoApp, config: APIConfig): void { } UserController(routes); installSmsWebhookForwarder(routes, getActivityJetStream); + StoreBillingController(routes); WebhookController(routes); OAuth2Controller(routes); OAuth2ApplicationsController(routes); diff --git a/fluxer_api/src/api/config/APIConfig.ts b/fluxer_api/src/api/config/APIConfig.ts index c23b8688e..25da7d75e 100644 --- a/fluxer_api/src/api/config/APIConfig.ts +++ b/fluxer_api/src/api/config/APIConfig.ts @@ -1,7 +1,7 @@ // SPDX-License-Identifier: AGPL-3.0-or-later import type {WorkerTaskName} from '@app/api/worker/WorkerLaneConfig'; -import type {CachePurgeAdapterName} from '@fluxer/config/src/MasterConfig'; +import type {CachePurgeAdapterName, StoreProductSlotName} from '@fluxer/config/src/MasterConfig'; export type APIWorkerMode = 'all_lanes' | 'single_lane' | 'single_task'; export type APIWorkerLaneName = 'realtime' | 'unfurl' | 'lifecycle' | 'batch'; @@ -13,6 +13,11 @@ export interface PushProviderAppConfig { environment?: PushProviderEnvironment; } +export interface AppStoreAppConfig { + bundleId: string; + appAppleId: number; +} + export interface APICachePurgeConfig { adapter: CachePurgeAdapterName; http: { @@ -305,6 +310,31 @@ export interface APIConfig { apps: Array; }; }; + appStore: { + enabled: boolean; + issuerId?: string; + keyId?: string; + privateKey?: string; + privateKeyPath?: string; + apps: Array; + products: Record; + }; + googlePlay: { + enabled: boolean; + packages: Array; + clientEmail?: string; + privateKey?: string; + privateKeyPath?: string; + serviceAccountJsonPath?: string; + tokenUri: string; + products: Record; + pushAudience?: string; + pushServiceAccountEmail?: string; + }; + storeBilling: { + sandboxUserIds: Array; + sandboxEntitlesAll: boolean; + }; worker: { mode: APIWorkerMode; laneName?: APIWorkerLaneName; diff --git a/fluxer_api/src/api/database/types/PaymentTypes.ts b/fluxer_api/src/api/database/types/PaymentTypes.ts index 8fc1d820f..0fbb21cee 100644 --- a/fluxer_api/src/api/database/types/PaymentTypes.ts +++ b/fluxer_api/src/api/database/types/PaymentTypes.ts @@ -18,6 +18,7 @@ export interface GiftCodeRow { visionary_sequence_number: Nullish; checkout_session_id: Nullish; revoked_at?: Nullish; + premium_reversed_seconds?: Nullish; version: number; } @@ -105,6 +106,7 @@ export const GIFT_CODE_COLUMNS = [ 'visionary_sequence_number', 'checkout_session_id', 'revoked_at', + 'premium_reversed_seconds', 'version', ] as const; export const GIFT_CODE_BY_CREATOR_COLUMNS = ['created_by_user_id', 'code'] as const; diff --git a/fluxer_api/src/api/database/types/StoreBillingTypes.ts b/fluxer_api/src/api/database/types/StoreBillingTypes.ts new file mode 100644 index 000000000..beca37cc1 --- /dev/null +++ b/fluxer_api/src/api/database/types/StoreBillingTypes.ts @@ -0,0 +1,124 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import type {UserID} from '@app/api/BrandedTypes'; +import type { + StoreEnvironment, + StoreProvider, + StorePurchaseKind, + StorePurchaseState, + StoreSlot, +} from '@app/api/store_billing/StoreBillingTypes'; + +type Nullish = T | null; + +export interface StorePurchaseRow { + store_key: string; + id: bigint; + provider: StoreProvider; + kind: StorePurchaseKind; + slot: StoreSlot; + environment: StoreEnvironment; + app_id: string; + product_id: string; + base_plan_id: Nullish; + store_reference: string; + latest_transaction_id: Nullish; + app_transaction_id: Nullish; + user_id: Nullish; + bound_at: Nullish; + released_at: Nullish; + account_token: Nullish; + ownership_type: Nullish; + state: StorePurchaseState; + store_state: Nullish; + entitled: boolean; + expires_at: Nullish; + grace_ends_at: Nullish; + auto_renew: Nullish; + auto_renew_product_id: Nullish; + started_at: Nullish; + purchased_at: Nullish; + revoked_at: Nullish; + revocation_reason: Nullish; + linked_store_key: Nullish; + superseded_by_store_key: Nullish; + acknowledged: boolean; + gift_code: Nullish; + region: Nullish; + last_event_at: Nullish; + synced_at: Nullish; + created_at: Date; + updated_at: Date; + version: number; +} + +export interface StorePurchaseByUserRow { + user_id: UserID; + store_key: string; + created_at: Date; +} + +export interface StoreAccountTokenRow { + token_: string; + user_id: UserID; + created_at: Date; +} + +export interface StoreAccountTokenByUserRow { + user_id: UserID; + token_: string; + created_at: Date; +} + +export const STORE_PURCHASE_COLUMNS = [ + 'store_key', + 'id', + 'provider', + 'kind', + 'slot', + 'environment', + 'app_id', + 'product_id', + 'base_plan_id', + 'store_reference', + 'latest_transaction_id', + 'app_transaction_id', + 'user_id', + 'bound_at', + 'released_at', + 'account_token', + 'ownership_type', + 'state', + 'store_state', + 'entitled', + 'expires_at', + 'grace_ends_at', + 'auto_renew', + 'auto_renew_product_id', + 'started_at', + 'purchased_at', + 'revoked_at', + 'revocation_reason', + 'linked_store_key', + 'superseded_by_store_key', + 'acknowledged', + 'gift_code', + 'region', + 'last_event_at', + 'synced_at', + 'created_at', + 'updated_at', + 'version', +] as const satisfies ReadonlyArray; + +export const STORE_PURCHASE_BY_USER_COLUMNS = ['user_id', 'store_key', 'created_at'] as const satisfies ReadonlyArray< + keyof StorePurchaseByUserRow +>; + +export const STORE_ACCOUNT_TOKEN_COLUMNS = ['token_', 'user_id', 'created_at'] as const satisfies ReadonlyArray< + keyof StoreAccountTokenRow +>; + +export const STORE_ACCOUNT_TOKEN_BY_USER_COLUMNS = ['user_id', 'token_', 'created_at'] as const satisfies ReadonlyArray< + keyof StoreAccountTokenByUserRow +>; diff --git a/fluxer_api/src/api/middleware/ContentFilterMiddleware.ts b/fluxer_api/src/api/middleware/ContentFilterMiddleware.ts index a6d737da5..a78e6318a 100644 --- a/fluxer_api/src/api/middleware/ContentFilterMiddleware.ts +++ b/fluxer_api/src/api/middleware/ContentFilterMiddleware.ts @@ -75,6 +75,7 @@ const SKIP_CONTENT_FILTER_PATH_PARTS = [ '/admin/blocklists/phrase/', '/auth/', '/oauth2/', + '/premium/store/', '/reports/dsa/email/', '/users/@me/authorized-ips', '/users/@me/email-change/', diff --git a/fluxer_api/src/api/middleware/ServiceMiddleware.ts b/fluxer_api/src/api/middleware/ServiceMiddleware.ts index 6c0ebf88e..2e0abc153 100644 --- a/fluxer_api/src/api/middleware/ServiceMiddleware.ts +++ b/fluxer_api/src/api/middleware/ServiceMiddleware.ts @@ -98,6 +98,7 @@ import { getReadStateService, getReportRepository, getStorageService, + getStoreBillingRepository, getStreamPreviewService, getSweegoWebhookService, getThemeService, @@ -119,6 +120,8 @@ import {ReportService} from '@app/api/report/ReportService'; import {RpcService} from '@app/api/rpc/RpcService'; import {getReportSearchService} from '@app/api/SearchFactory'; import {SearchService} from '@app/api/search/SearchService'; +import type {StoreEntitlementService} from '@app/api/store_billing/StoreEntitlementService'; +import {createStoreEntitlementService} from '@app/api/store_billing/StoreEntitlementServiceFactory'; import {StripeService} from '@app/api/stripe/StripeService'; import {AgeVerificationService} from '@app/api/stripe/services/AgeVerificationService'; import type {HonoEnv} from '@app/api/types/HonoEnv'; @@ -254,6 +257,7 @@ class RequestServices implements RequestScopedServices { private cachedRpcService: RpcService | undefined; private cachedSearchService: SearchService | undefined; private cachedStripeService: StripeService | undefined; + private cachedStoreEntitlementService: StoreEntitlementService | undefined; private cachedAgeVerificationService: AgeVerificationService | undefined; private cachedDonationService: DonationService | undefined; private cachedUserService: UserService | undefined; @@ -541,6 +545,7 @@ class RequestServices implements RequestScopedServices { this.stripeService.getStripe(), new JobLedgerRepository(), getIpInfoService(), + this.storeEntitlementService, ); return this.cachedAdminService; } @@ -767,10 +772,24 @@ class RequestServices implements RequestScopedServices { this.guildService, getCacheService(), getBillingRepository(), + getStoreBillingRepository(), + this.storeEntitlementService, ); return this.cachedStripeService; } + get storeEntitlementService(): StoreEntitlementService { + this.cachedStoreEntitlementService ??= createStoreEntitlementService({ + userRepository: getUserRepository(), + userCacheService: getUserCacheService(), + gatewayService: this.gatewayService, + kvClient: getKVClient(), + snowflakeService: getSnowflakeService(), + premiumStateReconciliationQueueService: getPremiumStateReconciliationQueueService(), + }); + return this.cachedStoreEntitlementService; + } + get ageVerificationService(): AgeVerificationService | undefined { if (Config.instance.selfHosted) { return undefined; diff --git a/fluxer_api/src/api/middleware/ServiceSingletons.ts b/fluxer_api/src/api/middleware/ServiceSingletons.ts index 9e73bb959..80867c926 100644 --- a/fluxer_api/src/api/middleware/ServiceSingletons.ts +++ b/fluxer_api/src/api/middleware/ServiceSingletons.ts @@ -72,6 +72,11 @@ import {ReadStateRequestService} from '@app/api/read_state/ReadStateRequestServi import {ReadStateService} from '@app/api/read_state/ReadStateService'; import {ReportRepository} from '@app/api/report/ReportRepository'; import {getGuildSearchService} from '@app/api/SearchFactory'; +import {AppStoreServerApiClient} from '@app/api/store_billing/app_store/AppStoreServerApiClient'; +import {GooglePlayAccessTokenProvider} from '@app/api/store_billing/google_play/GooglePlayAccessTokenProvider'; +import {GooglePlayDeveloperApiClient} from '@app/api/store_billing/google_play/GooglePlayDeveloperApiClient'; +import {GooglePlayPushVerifier} from '@app/api/store_billing/google_play/GooglePlayPushVerifier'; +import {StoreBillingRepository} from '@app/api/store_billing/StoreBillingRepository'; import {ThemeService} from '@app/api/theme/ThemeService'; import {EntranceSoundPlayService} from '@app/api/user/entrance_sound/EntranceSoundPlayService'; import {EntranceSoundRepository} from '@app/api/user/entrance_sound/EntranceSoundRepository'; @@ -119,6 +124,13 @@ export const getEmailChangeRepository = singleton(() => new EmailChangeRepositor export const getPasswordChangeRepository = singleton(() => new PasswordChangeRepository()); const getUserContactChangeLogRepository = singleton(() => new UserContactChangeLogRepository()); export const getDonationRepository = singleton(() => new DonationRepository()); +export const getStoreBillingRepository = singleton(() => new StoreBillingRepository()); +export const getAppStoreServerApiClient = singleton(() => new AppStoreServerApiClient()); +const getGooglePlayAccessTokenProvider = singleton(() => new GooglePlayAccessTokenProvider()); +export const getGooglePlayDeveloperApiClient = singleton( + () => new GooglePlayDeveloperApiClient({accessTokenProvider: getGooglePlayAccessTokenProvider()}), +); +export const getGooglePlayPushVerifier = singleton(() => new GooglePlayPushVerifier()); const getAdminApiKeyRepository = singleton(() => new AdminApiKeyRepository()); let instanceConfigRepositoryInstance: InstanceConfigRepository | null = null; export const getInstanceConfigRepository = singleton( diff --git a/fluxer_api/src/api/middleware/UserMiddleware.ts b/fluxer_api/src/api/middleware/UserMiddleware.ts index 908128fbe..45e5f9fe0 100644 --- a/fluxer_api/src/api/middleware/UserMiddleware.ts +++ b/fluxer_api/src/api/middleware/UserMiddleware.ts @@ -18,7 +18,13 @@ interface ParsedAuthHeader { type: TokenType; } -const SKIP_PATHS = new Set(['/_health', '/webhooks/livekit', '/webhooks/sweego']); +const SKIP_PATHS = new Set([ + '/_health', + '/webhooks/livekit', + '/webhooks/sweego', + '/webhooks/app-store', + '/webhooks/google-play', +]); const SESSION_TOKEN_PATTERN = /^flx_[A-Za-z0-9]{36}$/; function parseAuthHeader(authHeader?: string | null): ParsedAuthHeader | null { diff --git a/fluxer_api/src/api/models/GiftCode.ts b/fluxer_api/src/api/models/GiftCode.ts index c1a2d409f..416430228 100644 --- a/fluxer_api/src/api/models/GiftCode.ts +++ b/fluxer_api/src/api/models/GiftCode.ts @@ -114,6 +114,7 @@ export class GiftCode { readonly visionarySequenceNumber: number | null; readonly checkoutSessionId: string | null; readonly revokedAt: Date | null; + readonly premiumReversedSeconds: number | null; readonly version: number; constructor(row: GiftCodeRow) { @@ -130,6 +131,7 @@ export class GiftCode { this.visionarySequenceNumber = row.visionary_sequence_number ?? null; this.checkoutSessionId = row.checkout_session_id ?? null; this.revokedAt = row.revoked_at ?? null; + this.premiumReversedSeconds = row.premium_reversed_seconds ?? null; this.version = row.version; } @@ -147,6 +149,7 @@ export class GiftCode { visionary_sequence_number: this.visionarySequenceNumber, checkout_session_id: this.checkoutSessionId, revoked_at: this.revokedAt, + premium_reversed_seconds: this.premiumReversedSeconds, version: this.version, }; } diff --git a/fluxer_api/src/api/openapi/openapi.json b/fluxer_api/src/api/openapi/openapi.json index 8fc43e112..e0e00801e 100644 --- a/fluxer_api/src/api/openapi/openapi.json +++ b/fluxer_api/src/api/openapi/openapi.json @@ -11641,6 +11641,289 @@ ] } }, + "/premium/store": { + "get": { + "operationId": "get_store_billing_context", + "summary": "Get in-app purchase context", + "tags": ["Premium"], + "responses": { + "200": { + "description": "Success", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/StoreBillingContextResponse"}}} + }, + "400": { + "description": "Bad Request - The request was malformed or contained invalid data", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "401": { + "description": "Unauthorized - Authentication is required or the token is invalid", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "403": { + "description": "Forbidden - You do not have permission to perform this action", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "429": { + "description": "Too Many Requests - You are being rate limited", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ThrottledError"}}}, + "headers": { + "Retry-After": { + "description": "Number of seconds to wait before retrying (only on 429)", + "schema": {"type": "integer"} + }, + "X-RateLimit-Limit": { + "description": "The number of requests that can be made in the current window", + "schema": {"type": "integer"} + }, + "X-RateLimit-Remaining": { + "description": "The number of remaining requests that can be made", + "schema": {"type": "integer"} + }, + "X-RateLimit-Reset": { + "description": "Unix timestamp when the rate limit resets", + "schema": {"type": "integer"} + } + } + }, + "500": { + "description": "Internal Server Error - An unexpected error occurred", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + } + }, + "description": "Returns the account token and the App Store and Google Play products the mobile apps may sell, plus the reason a new subscription is blocked, if any.", + "security": [{"sessionToken": []}] + } + }, + "/premium/store/app-store/transactions": { + "post": { + "operationId": "claim_app_store_transaction", + "summary": "Claim App Store transaction", + "tags": ["Premium"], + "responses": { + "200": { + "description": "Success", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/StorePurchaseClaimResponse"}}} + }, + "400": { + "description": "Bad Request - The request was malformed or contained invalid data", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "401": { + "description": "Unauthorized - Authentication is required or the token is invalid", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "403": { + "description": "Forbidden - You do not have permission to perform this action", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "429": { + "description": "Too Many Requests - You are being rate limited", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ThrottledError"}}}, + "headers": { + "Retry-After": { + "description": "Number of seconds to wait before retrying (only on 429)", + "schema": {"type": "integer"} + }, + "X-RateLimit-Limit": { + "description": "The number of requests that can be made in the current window", + "schema": {"type": "integer"} + }, + "X-RateLimit-Remaining": { + "description": "The number of remaining requests that can be made", + "schema": {"type": "integer"} + }, + "X-RateLimit-Reset": { + "description": "Unix timestamp when the rate limit resets", + "schema": {"type": "integer"} + } + } + }, + "500": { + "description": "Internal Server Error - An unexpected error occurred", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + } + }, + "description": "Verifies a StoreKit 2 signed transaction, links the purchase to the authenticated account and applies it. Calling it again for the same purchase returns the same result. Finish the transaction after a 200 or a 400 or 403 error. Leave it unfinished after a 429, a 5xx or a network failure.", + "security": [{"sessionToken": []}], + "requestBody": { + "required": true, + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ClaimAppStoreTransactionRequest"}}} + } + } + }, + "/premium/store/google-play/purchases": { + "post": { + "operationId": "claim_google_play_purchase", + "summary": "Claim Google Play purchase", + "tags": ["Premium"], + "responses": { + "200": { + "description": "Success", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/StorePurchaseClaimResponse"}}} + }, + "400": { + "description": "Bad Request - The request was malformed or contained invalid data", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "401": { + "description": "Unauthorized - Authentication is required or the token is invalid", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "403": { + "description": "Forbidden - You do not have permission to perform this action", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "429": { + "description": "Too Many Requests - You are being rate limited", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ThrottledError"}}}, + "headers": { + "Retry-After": { + "description": "Number of seconds to wait before retrying (only on 429)", + "schema": {"type": "integer"} + }, + "X-RateLimit-Limit": { + "description": "The number of requests that can be made in the current window", + "schema": {"type": "integer"} + }, + "X-RateLimit-Remaining": { + "description": "The number of remaining requests that can be made", + "schema": {"type": "integer"} + }, + "X-RateLimit-Reset": { + "description": "Unix timestamp when the rate limit resets", + "schema": {"type": "integer"} + } + } + }, + "500": { + "description": "Internal Server Error - An unexpected error occurred", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + } + }, + "description": "Verifies a Google Play purchase token, links the purchase to the authenticated account, acknowledges it and applies it. Calling it again for the same purchase returns the same result.", + "security": [{"sessionToken": []}], + "requestBody": { + "required": true, + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ClaimGooglePlayPurchaseRequest"}}} + } + } + }, + "/premium/store/purchases": { + "get": { + "operationId": "list_store_purchases", + "summary": "List in-app purchases", + "tags": ["Premium"], + "responses": { + "200": { + "description": "Success", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/StorePurchaseListResponse"}}} + }, + "400": { + "description": "Bad Request - The request was malformed or contained invalid data", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "401": { + "description": "Unauthorized - Authentication is required or the token is invalid", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "403": { + "description": "Forbidden - You do not have permission to perform this action", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "429": { + "description": "Too Many Requests - You are being rate limited", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ThrottledError"}}}, + "headers": { + "Retry-After": { + "description": "Number of seconds to wait before retrying (only on 429)", + "schema": {"type": "integer"} + }, + "X-RateLimit-Limit": { + "description": "The number of requests that can be made in the current window", + "schema": {"type": "integer"} + }, + "X-RateLimit-Remaining": { + "description": "The number of remaining requests that can be made", + "schema": {"type": "integer"} + }, + "X-RateLimit-Reset": { + "description": "Unix timestamp when the rate limit resets", + "schema": {"type": "integer"} + } + } + }, + "500": { + "description": "Internal Server Error - An unexpected error occurred", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + } + }, + "description": "Returns the App Store and Google Play purchases linked to the authenticated account.", + "security": [{"sessionToken": []}] + } + }, + "/premium/store/purchases/{purchase_id}": { + "delete": { + "operationId": "release_store_purchase", + "summary": "Release in-app subscription", + "tags": ["Premium"], + "responses": { + "204": {"description": "No Content"}, + "400": { + "description": "Bad Request - The request was malformed or contained invalid data", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "401": { + "description": "Unauthorized - Authentication is required or the token is invalid", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "403": { + "description": "Forbidden - You do not have permission to perform this action", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "429": { + "description": "Too Many Requests - You are being rate limited", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ThrottledError"}}}, + "headers": { + "Retry-After": { + "description": "Number of seconds to wait before retrying (only on 429)", + "schema": {"type": "integer"} + }, + "X-RateLimit-Limit": { + "description": "The number of requests that can be made in the current window", + "schema": {"type": "integer"} + }, + "X-RateLimit-Remaining": { + "description": "The number of remaining requests that can be made", + "schema": {"type": "integer"} + }, + "X-RateLimit-Reset": { + "description": "Unix timestamp when the rate limit resets", + "schema": {"type": "integer"} + } + } + }, + "500": { + "description": "Internal Server Error - An unexpected error occurred", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + } + }, + "description": "Unlinks an App Store or Google Play subscription from the authenticated account so another account can claim it. Requires sudo mode.", + "security": [{"sessionToken": []}], + "parameters": [ + { + "name": "purchase_id", + "in": "path", + "required": true, + "schema": {"description": "The ID of the store purchase", "$ref": "#/components/schemas/SnowflakeType"}, + "description": "The ID of the store purchase" + } + ], + "requestBody": { + "required": false, + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/SudoVerificationSchema"}}} + } + } + }, "/premium/switch-to-list-price": { "post": { "operationId": "switch_subscription_to_list_price", @@ -25837,6 +26120,162 @@ {"$ref": "#/components/schemas/IneligibleSwitchToListPriceResponse"} ] }, + "StorePurchaseListResponse": {"type": "array", "items": {"$ref": "#/components/schemas/StorePurchaseResponse"}}, + "ClaimGooglePlayPurchaseRequest": { + "type": "object", + "properties": { + "purchase_token": {"description": "Purchase token from Google Play Billing", "type": "string"}, + "product_id": {"description": "Google Play product identifier of the purchase", "type": "string"}, + "package_name": { + "description": "Package name of the app that made the purchase. Must be one of the accepted package names", + "type": "string" + } + }, + "required": ["purchase_token", "product_id"] + }, + "StorePurchaseClaimResponse": { + "type": "object", + "properties": { + "purchase": { + "properties": { + "id": { + "description": "The unique identifier (snowflake) for this store purchase", + "$ref": "#/components/schemas/SnowflakeStringType" + }, + "provider": { + "description": "Store the purchase was made in", + "$ref": "#/components/schemas/StoreProvider" + }, + "kind": { + "description": "Whether the purchase is a subscription or a gift", + "$ref": "#/components/schemas/StorePurchaseKind" + }, + "slot": {"description": "Fluxer product the purchase is for", "$ref": "#/components/schemas/StoreSlot"}, + "product_id": {"type": "string", "description": "Store product identifier"}, + "environment": { + "description": "Whether the purchase was a real purchase or a test purchase", + "$ref": "#/components/schemas/StoreEnvironment" + }, + "state": { + "description": "Current state of the purchase", + "$ref": "#/components/schemas/StorePurchaseState" + }, + "entitled": {"type": "boolean", "description": "Whether the purchase currently grants Plutonium"}, + "expires_at": { + "anyOf": [ + { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z))$" + }, + {"type": "null"} + ], + "description": "When the paid period ends, null for gifts" + }, + "entitled_until": { + "anyOf": [ + { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z))$" + }, + {"type": "null"} + ], + "description": "When access from this purchase ends, including any grace period, null when not entitled" + }, + "will_renew": { + "description": "Whether the subscription renews automatically, null for gifts", + "type": ["boolean", "null"] + }, + "gift_code": { + "description": "Gift code minted by a gift purchase, null otherwise", + "type": ["string", "null"] + }, + "created_at": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z))$", + "description": "When Fluxer first saw the purchase" + } + }, + "description": "The claimed purchase", + "$ref": "#/components/schemas/StorePurchaseResponse" + }, + "gift_code": { + "description": "Gift code minted by a gift purchase, null otherwise", + "type": ["string", "null"] + } + }, + "required": ["purchase", "gift_code"], + "additionalProperties": false + }, + "ClaimAppStoreTransactionRequest": { + "type": "object", + "properties": { + "signed_transaction": { + "description": "JWS signed transaction from StoreKit 2 (Transaction.jwsRepresentation)", + "type": "string" + } + }, + "required": ["signed_transaction"] + }, + "StoreBillingContextResponse": { + "type": "object", + "properties": { + "app_account_token": { + "type": "string", + "description": "Lowercase UUID for this account. Pass it as appAccountToken on App Store purchases and as obfuscatedAccountId on Google Play purchases" + }, + "app_store": { + "type": "object", + "properties": { + "enabled": {"type": "boolean", "description": "Whether App Store purchases are accepted"}, + "bundle_ids": { + "type": "array", + "items": {"type": "string"}, + "description": "App bundle identifiers whose purchases are accepted" + }, + "products": { + "type": "array", + "items": {"$ref": "#/components/schemas/StoreBillingAppStoreProductResponse"}, + "description": "App Store products on sale" + } + }, + "required": ["enabled", "bundle_ids", "products"], + "additionalProperties": false, + "description": "App Store purchase settings" + }, + "google_play": { + "type": "object", + "properties": { + "enabled": {"type": "boolean", "description": "Whether Google Play purchases are accepted"}, + "package_names": { + "type": "array", + "items": {"type": "string"}, + "description": "App package names whose purchases are accepted" + }, + "products": { + "type": "array", + "items": {"$ref": "#/components/schemas/StoreBillingGooglePlayProductResponse"}, + "description": "Google Play products on sale" + } + }, + "required": ["enabled", "package_names", "products"], + "additionalProperties": false, + "description": "Google Play purchase settings" + }, + "purchase_blocked_reason": { + "anyOf": [{"$ref": "#/components/schemas/StorePurchaseBlockedReason"}, {"type": "null"}], + "description": "Why this account cannot buy a subscription right now, null when it can" + }, + "blocking_provider": { + "anyOf": [{"$ref": "#/components/schemas/StoreBlockingProvider"}, {"type": "null"}], + "description": "Provider of the active subscription that blocks a new one, null when nothing blocks" + } + }, + "required": ["app_account_token", "app_store", "google_play", "purchase_blocked_reason", "blocking_provider"], + "additionalProperties": false + }, "PremiumStateResponse": { "type": "object", "properties": { @@ -26008,9 +26447,17 @@ ], "additionalProperties": false }, - "pricing": {"$ref": "#/components/schemas/PremiumPricingState"} + "pricing": {"$ref": "#/components/schemas/PremiumPricingState"}, + "store": { + "description": "Active App Store or Google Play subscription, null when no store subscription is active", + "anyOf": [{"$ref": "#/components/schemas/PremiumStoreSubscriptionState"}, {"type": "null"}] + }, + "subscription_provider": { + "anyOf": [{"$ref": "#/components/schemas/PremiumSubscriptionProvider"}, {"type": "null"}], + "description": "Billing platform that owns the current recurring subscription, null for gift, lifetime or no subscription. When a Stripe and a store subscription are both active, the one paid through later" + } }, - "required": ["actual", "effective", "billing", "pricing"], + "required": ["actual", "effective", "billing", "pricing", "subscription_provider"], "additionalProperties": false }, "SelfServeRefundResponse": { @@ -32852,6 +33299,68 @@ "type": "string", "enum": ["no_refundable_purchase", "outside_refund_window", "cooldown_active", "feature_unavailable"] }, + "PremiumSubscriptionProvider": {"type": "string", "enum": ["stripe", "app_store", "google_play"]}, + "PremiumStoreSubscriptionState": { + "type": "object", + "properties": { + "provider": { + "description": "Store that bills the subscription", + "$ref": "#/components/schemas/StoreProvider" + }, + "purchase_id": { + "description": "ID of the store purchase that grants the subscription", + "$ref": "#/components/schemas/SnowflakeStringType" + }, + "slot": {"description": "Fluxer subscription product", "$ref": "#/components/schemas/StoreSubscriptionSlot"}, + "billing_cycle": { + "description": "Billing cycle of the subscription", + "$ref": "#/components/schemas/StoreBillingCycle" + }, + "state": { + "description": "Current state of the store subscription", + "$ref": "#/components/schemas/StorePurchaseState" + }, + "expires_at": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z))$", + "description": "When the paid period ends" + }, + "grace_ends_at": { + "anyOf": [ + { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z))$" + }, + {"type": "null"} + ], + "description": "When the billing grace period ends, null outside grace" + }, + "will_renew": {"type": "boolean", "description": "Whether the subscription renews automatically"}, + "manage_url": { + "type": "string", + "description": "Store page where the subscription can be managed or canceled" + }, + "environment": { + "description": "Whether the subscription is a real purchase or a test purchase", + "$ref": "#/components/schemas/StoreEnvironment" + } + }, + "required": [ + "provider", + "purchase_id", + "slot", + "billing_cycle", + "state", + "expires_at", + "grace_ends_at", + "will_renew", + "manage_url", + "environment" + ], + "additionalProperties": false + }, "PremiumPricingState": { "type": "object", "properties": { @@ -33117,6 +33626,137 @@ "switch_in_progress" ] }, + "StoreEnvironment": {"type": "string", "enum": ["production", "sandbox"]}, + "StorePurchaseState": { + "type": "string", + "enum": [ + "pending", + "active", + "grace", + "billing_retry", + "on_hold", + "paused", + "canceled", + "expired", + "revoked", + "superseded", + "purchased", + "fulfilled", + "refunded" + ] + }, + "StoreBillingCycle": {"type": "string", "enum": ["monthly", "yearly"]}, + "StoreSubscriptionSlot": {"type": "string", "enum": ["monthly", "yearly"]}, + "StoreProvider": {"type": "string", "enum": ["app_store", "google_play"]}, + "StoreBlockingProvider": {"type": "string", "enum": ["stripe", "app_store", "google_play"]}, + "StorePurchaseBlockedReason": { + "type": "string", + "enum": ["lifetime", "existing_subscription", "purchase_disabled"] + }, + "StoreBillingGooglePlayProductResponse": { + "type": "object", + "properties": { + "product_id": {"type": "string", "description": "Google Play product identifier"}, + "base_plan_id": { + "description": "Google Play base plan identifier, null for one-time products", + "type": ["string", "null"] + }, + "slot": { + "description": "Fluxer product this Google Play product sells", + "$ref": "#/components/schemas/StoreSlot" + } + }, + "required": ["product_id", "base_plan_id", "slot"], + "additionalProperties": false + }, + "StoreBillingAppStoreProductResponse": { + "type": "object", + "properties": { + "product_id": {"type": "string", "description": "App Store product identifier"}, + "slot": { + "description": "Fluxer product this App Store product sells", + "$ref": "#/components/schemas/StoreSlot" + } + }, + "required": ["product_id", "slot"], + "additionalProperties": false + }, + "StoreSlot": {"type": "string", "enum": ["monthly", "yearly", "gift_1_month", "gift_1_year"]}, + "StorePurchaseResponse": { + "type": "object", + "properties": { + "id": { + "description": "The unique identifier (snowflake) for this store purchase", + "$ref": "#/components/schemas/SnowflakeStringType" + }, + "provider": {"description": "Store the purchase was made in", "$ref": "#/components/schemas/StoreProvider"}, + "kind": { + "description": "Whether the purchase is a subscription or a gift", + "$ref": "#/components/schemas/StorePurchaseKind" + }, + "slot": {"description": "Fluxer product the purchase is for", "$ref": "#/components/schemas/StoreSlot"}, + "product_id": {"type": "string", "description": "Store product identifier"}, + "environment": { + "description": "Whether the purchase was a real purchase or a test purchase", + "$ref": "#/components/schemas/StoreEnvironment" + }, + "state": {"description": "Current state of the purchase", "$ref": "#/components/schemas/StorePurchaseState"}, + "entitled": {"type": "boolean", "description": "Whether the purchase currently grants Plutonium"}, + "expires_at": { + "anyOf": [ + { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z))$" + }, + {"type": "null"} + ], + "description": "When the paid period ends, null for gifts" + }, + "entitled_until": { + "anyOf": [ + { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z))$" + }, + {"type": "null"} + ], + "description": "When access from this purchase ends, including any grace period, null when not entitled" + }, + "will_renew": { + "description": "Whether the subscription renews automatically, null for gifts", + "type": ["boolean", "null"] + }, + "gift_code": { + "description": "Gift code minted by a gift purchase, null otherwise", + "type": ["string", "null"] + }, + "created_at": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z))$", + "description": "When Fluxer first saw the purchase" + } + }, + "required": [ + "id", + "provider", + "kind", + "slot", + "product_id", + "environment", + "state", + "entitled", + "expires_at", + "entitled_until", + "will_renew", + "gift_code", + "created_at" + ], + "additionalProperties": false + }, + "StorePurchaseKind": {"type": "string", "enum": ["subscription", "gift"]}, "IneligibleSwitchToListPriceResponse": { "type": "object", "properties": { diff --git a/fluxer_api/src/api/rate_limit_configs/StoreBillingRateLimitConfig.ts b/fluxer_api/src/api/rate_limit_configs/StoreBillingRateLimitConfig.ts new file mode 100644 index 000000000..3f41fab3e --- /dev/null +++ b/fluxer_api/src/api/rate_limit_configs/StoreBillingRateLimitConfig.ts @@ -0,0 +1,35 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import type {RouteRateLimitConfig} from '@app/api/middleware/RateLimitMiddleware'; +import {ms} from 'itty-time'; + +export const StoreBillingRateLimitConfigs = { + STORE_BILLING_CONTEXT: { + bucket: 'store:context', + config: {limit: 30, windowMs: ms('10 seconds')}, + } as RouteRateLimitConfig, + STORE_BILLING_CLAIM_APP_STORE: { + bucket: 'store:claim:app_store', + config: {limit: 20, windowMs: ms('1 minute')}, + } as RouteRateLimitConfig, + STORE_BILLING_CLAIM_GOOGLE_PLAY: { + bucket: 'store:claim:google_play', + config: {limit: 20, windowMs: ms('1 minute')}, + } as RouteRateLimitConfig, + STORE_BILLING_PURCHASES_LIST: { + bucket: 'store:purchases:list', + config: {limit: 30, windowMs: ms('10 seconds')}, + } as RouteRateLimitConfig, + STORE_BILLING_PURCHASE_RELEASE: { + bucket: 'store:purchases:release', + config: {limit: 5, windowMs: ms('1 minute')}, + } as RouteRateLimitConfig, + STORE_BILLING_APP_STORE_WEBHOOK: { + bucket: 'store:webhook:app_store', + config: {limit: 300, windowMs: ms('1 minute'), exemptFromGlobal: true}, + } as RouteRateLimitConfig, + STORE_BILLING_GOOGLE_PLAY_WEBHOOK: { + bucket: 'store:webhook:google_play', + config: {limit: 300, windowMs: ms('1 minute'), exemptFromGlobal: true}, + } as RouteRateLimitConfig, +} as const; diff --git a/fluxer_api/src/api/store_billing/GooglePlaySettlement.ts b/fluxer_api/src/api/store_billing/GooglePlaySettlement.ts new file mode 100644 index 000000000..f141cb220 --- /dev/null +++ b/fluxer_api/src/api/store_billing/GooglePlaySettlement.ts @@ -0,0 +1,133 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import type {StorePurchaseRow} from '@app/api/database/types/StoreBillingTypes'; +import {Logger} from '@app/api/Logger'; +import type {User} from '@app/api/models/User'; +import {isGooglePlayApiError} from '@app/api/store_billing/google_play/GooglePlayApiError'; +import type {GooglePlayDeveloperApiClient} from '@app/api/store_billing/google_play/GooglePlayDeveloperApiClient'; +import type {StoreBillingRepository} from '@app/api/store_billing/StoreBillingRepository'; +import { + LIFETIME_REFUND_REASON, + PAID_SUBSCRIPTION_STATES, + redactStoreKey, +} from '@app/api/store_billing/StoreBillingTypes'; +import {UserPremiumTypes} from '@fluxer/constants/src/UserConstants'; + +export class GooglePlaySettlement { + constructor( + private readonly repository: StoreBillingRepository, + private readonly googlePlayClient: GooglePlayDeveloperApiClient, + ) {} + + async settleSubscription(row: StorePurchaseRow, owner: User | null): Promise { + if (row.kind !== 'subscription' || row.user_id === null || !owner) { + return row; + } + if (owner.premiumType === UserPremiumTypes.LIFETIME) { + return this.refundForLifetimeUser(row); + } + if (row.acknowledged || !PAID_SUBSCRIPTION_STATES.has(row.state)) { + return row; + } + let acknowledged = false; + try { + await this.googlePlayClient.acknowledgeSubscription(row.app_id, row.product_id, row.store_reference); + acknowledged = true; + } catch (error) { + if (isGooglePlayApiError(error) && error.status === 400) { + try { + const purchase = await this.googlePlayClient.getSubscription(row.app_id, row.store_reference); + acknowledged = purchase.acknowledgementState === 'ACKNOWLEDGEMENT_STATE_ACKNOWLEDGED'; + } catch (readError) { + Logger.warn( + {error: readError, storeKey: redactStoreKey(row.store_key)}, + 'Failed to re-read a Google Play subscription', + ); + } + } else { + Logger.warn( + {error, storeKey: redactStoreKey(row.store_key)}, + 'Failed to acknowledge a Google Play subscription', + ); + } + } + if (!acknowledged) { + return row; + } + const updated = await this.repository.updatePurchase(row, {acknowledged: true}); + return updated ?? row; + } + + async consumeProduct(row: StorePurchaseRow): Promise { + try { + await this.googlePlayClient.consumeProduct(row.app_id, row.product_id, row.store_reference); + return true; + } catch (error) { + if (isGooglePlayApiError(error) && error.status === 400) { + try { + const purchase = await this.googlePlayClient.getProduct(row.app_id, row.store_reference); + const lineItem = (purchase.productLineItem ?? []).find((item) => item.productId === row.product_id); + return lineItem?.productOfferDetails?.consumptionState === 'CONSUMPTION_STATE_CONSUMED'; + } catch (readError) { + Logger.warn( + {error: readError, storeKey: redactStoreKey(row.store_key)}, + 'Failed to re-read a Google Play product', + ); + return false; + } + } + Logger.warn({error, storeKey: redactStoreKey(row.store_key)}, 'Failed to consume a Google Play gift purchase'); + return false; + } + } + + async refundUnsupportedGiftQuantity(row: StorePurchaseRow): Promise { + if (row.provider !== 'google_play' || row.revoked_at || !row.latest_transaction_id) { + return row; + } + try { + await this.googlePlayClient.refundOrder(row.app_id, row.latest_transaction_id); + } catch (error) { + Logger.warn( + {error, storeKey: redactStoreKey(row.store_key)}, + 'Failed to refund a Google Play gift bought in a quantity above one', + ); + return row; + } + Logger.info( + {storeKey: redactStoreKey(row.store_key)}, + 'Refunded a Google Play gift bought in a quantity above one', + ); + const updated = await this.repository.updatePurchase(row, {revoked_at: new Date()}); + return updated ?? row; + } + + private async refundForLifetimeUser(row: StorePurchaseRow): Promise { + if ( + row.revocation_reason === LIFETIME_REFUND_REASON || + !PAID_SUBSCRIPTION_STATES.has(row.state) || + !row.latest_transaction_id + ) { + return row; + } + try { + await this.googlePlayClient.refundOrder(row.app_id, row.latest_transaction_id); + } catch (error) { + Logger.warn( + {error, storeKey: redactStoreKey(row.store_key)}, + 'Failed to refund a Google Play purchase for a lifetime user', + ); + return row; + } + Logger.info( + {storeKey: redactStoreKey(row.store_key)}, + 'Refunded a Google Play subscription bought by a lifetime user', + ); + const updated = await this.repository.updatePurchase(row, { + revocation_reason: LIFETIME_REFUND_REASON, + revoked_at: new Date(), + entitled: false, + }); + return updated ?? row; + } +} diff --git a/fluxer_api/src/api/store_billing/StoreBillingConfig.ts b/fluxer_api/src/api/store_billing/StoreBillingConfig.ts new file mode 100644 index 000000000..4c4df9166 --- /dev/null +++ b/fluxer_api/src/api/store_billing/StoreBillingConfig.ts @@ -0,0 +1,119 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import type {UserID} from '@app/api/BrandedTypes'; +import {Config} from '@app/api/Config'; +import type {APIConfig, AppStoreAppConfig} from '@app/api/config/APIConfig'; +import type {StorePurchaseKind, StoreSlot} from '@app/api/store_billing/StoreBillingTypes'; +import type {RecurringBillingCycle} from '@app/api/stripe/ProductRegistry'; + +interface StoreProductSlotShape { + kind: StorePurchaseKind; + billingCycle: RecurringBillingCycle | null; + durationMonths: number; +} + +export const STORE_PRODUCT_SLOTS: Record = { + monthly: {kind: 'subscription', billingCycle: 'monthly', durationMonths: 1}, + yearly: {kind: 'subscription', billingCycle: 'yearly', durationMonths: 12}, + gift_1_month: {kind: 'gift', billingCycle: null, durationMonths: 1}, + gift_1_year: {kind: 'gift', billingCycle: null, durationMonths: 12}, +}; + +export interface GooglePlayProductEntry { + productId: string; + basePlanId: string | null; + slot: StoreSlot; +} + +export function getAppStoreConfig(): APIConfig['appStore'] { + return Config.appStore; +} + +export function getGooglePlayConfig(): APIConfig['googlePlay'] { + return Config.googlePlay; +} + +function hasText(value: string | undefined): boolean { + return value !== undefined && value.trim().length > 0; +} + +export function isAppStoreConfigured(): boolean { + const cfg = Config.appStore; + return ( + !Config.instance.selfHosted && + cfg.enabled && + hasText(cfg.issuerId) && + hasText(cfg.keyId) && + (hasText(cfg.privateKey) || hasText(cfg.privateKeyPath)) && + cfg.apps.length > 0 && + Object.keys(cfg.products).length > 0 + ); +} + +export function isGooglePlayConfigured(): boolean { + const cfg = Config.googlePlay; + const hasCredentials = + hasText(cfg.serviceAccountJsonPath) || + (hasText(cfg.clientEmail) && (hasText(cfg.privateKey) || hasText(cfg.privateKeyPath))); + return ( + !Config.instance.selfHosted && + cfg.enabled && + hasCredentials && + cfg.packages.length > 0 && + Object.keys(cfg.products).length > 0 + ); +} + +export function getAppStoreApp(bundleId: string): AppStoreAppConfig | null { + return Config.appStore.apps.find((app) => app.bundleId === bundleId) ?? null; +} + +export function getAppStoreProductSlot(productId: string): StoreSlot | null { + return Object.hasOwn(Config.appStore.products, productId) ? Config.appStore.products[productId] : null; +} + +export function listAppStoreProducts(): Array<{productId: string; slot: StoreSlot}> { + return Object.entries(Config.appStore.products).map(([productId, slot]) => ({productId, slot})); +} + +export function isGooglePlayPackageConfigured(packageName: string): boolean { + return Config.googlePlay.packages.includes(packageName); +} + +export function listGooglePlayProducts(): Array { + return Object.entries(Config.googlePlay.products).map(([key, slot]) => { + const separator = key.indexOf(':'); + if (separator === -1) { + return {productId: key, basePlanId: null, slot}; + } + return {productId: key.slice(0, separator), basePlanId: key.slice(separator + 1), slot}; + }); +} + +export function getGooglePlaySubscriptionSlot(productId: string, basePlanId: string): StoreSlot | null { + const key = `${productId}:${basePlanId}`; + if (!Object.hasOwn(Config.googlePlay.products, key)) { + return null; + } + const slot = Config.googlePlay.products[key]; + return STORE_PRODUCT_SLOTS[slot].kind === 'subscription' ? slot : null; +} + +export function getGooglePlayOneTimeProductSlot(productId: string): StoreSlot | null { + if (!Object.hasOwn(Config.googlePlay.products, productId)) { + return null; + } + const slot = Config.googlePlay.products[productId]; + return STORE_PRODUCT_SLOTS[slot].kind === 'gift' ? slot : null; +} + +export function isGooglePlaySubscriptionProduct(productId: string): boolean { + return listGooglePlayProducts().some( + (entry) => entry.productId === productId && STORE_PRODUCT_SLOTS[entry.slot].kind === 'subscription', + ); +} + +export function isSandboxEntitlementAllowed(userId: UserID): boolean { + const cfg = Config.storeBilling; + return cfg.sandboxEntitlesAll || cfg.sandboxUserIds.includes(userId.toString()); +} diff --git a/fluxer_api/src/api/store_billing/StoreBillingController.ts b/fluxer_api/src/api/store_billing/StoreBillingController.ts new file mode 100644 index 000000000..7d826c8e0 --- /dev/null +++ b/fluxer_api/src/api/store_billing/StoreBillingController.ts @@ -0,0 +1,278 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import {requireSudoMode} from '@app/api/auth/services/SudoVerificationService'; +import {Config} from '@app/api/Config'; +import {Logger} from '@app/api/Logger'; +import {DefaultUserOnly, LoginRequired} from '@app/api/middleware/AuthMiddleware'; +import {RateLimitMiddleware} from '@app/api/middleware/RateLimitMiddleware'; +import {OpenAPI} from '@app/api/middleware/ResponseTypeMiddleware'; +import {getGooglePlayPushVerifier} from '@app/api/middleware/ServiceSingletons'; +import {SudoModeMiddleware} from '@app/api/middleware/SudoModeMiddleware'; +import type {User} from '@app/api/models/User'; +import {RateLimitConfigs} from '@app/api/RateLimitConfig'; +import {AppStoreJwsVerificationError, verifyNotification} from '@app/api/store_billing/app_store/AppStoreJwsVerifier'; +import {parseGooglePlayPushEnvelope} from '@app/api/store_billing/google_play/GooglePlayPushVerifier'; +import { + isAppStoreConfigured, + isGooglePlayConfigured, + listAppStoreProducts, + listGooglePlayProducts, +} from '@app/api/store_billing/StoreBillingConfig'; +import {mapStorePurchaseToResponse} from '@app/api/store_billing/StoreBillingMappers'; +import type {StoreEntitlementService} from '@app/api/store_billing/StoreEntitlementService'; +import {isStripeSubscriptionActive} from '@app/api/store_billing/StoreEntitlementWriter'; +import type {HonoApp, HonoEnv} from '@app/api/types/HonoEnv'; +import {Validator} from '@app/api/Validator'; +import {PremiumFlags, UserPremiumTypes} from '@fluxer/constants/src/UserConstants'; +import {AppNotFoundHandler} from '@fluxer/errors/src/domains/core/ErrorHandlers'; +import {StoreBillingUnavailableError} from '@fluxer/errors/src/domains/payment/StoreBillingUnavailableError'; +import {StoreNotificationUnauthorizedError} from '@fluxer/errors/src/domains/payment/StoreNotificationUnauthorizedError'; +import {SudoVerificationSchema} from '@fluxer/schema/src/domains/auth/AuthSchemas'; +import { + ClaimAppStoreTransactionRequest, + ClaimGooglePlayPurchaseRequest, + StoreBillingContextResponse, + StorePurchaseClaimResponse, + StorePurchaseIdParam, + StorePurchaseListResponse, +} from '@fluxer/schema/src/domains/premium/StoreBillingSchemas'; +import {createMiddleware} from 'hono/factory'; +import {z} from 'zod'; + +function routeAvailableWhen(isAvailable: () => boolean) { + return createMiddleware(async (ctx, next) => { + if (Config.instance.selfHosted && !isAvailable()) { + return AppNotFoundHandler(ctx); + } + return next(); + }); +} + +export const HostedOnlyRoute = routeAvailableWhen(() => false); + +const AppStoreNotificationBody = z.object({signedPayload: z.string().min(1)}); + +async function buildStoreBillingContext( + user: User, + storeEntitlementService: StoreEntitlementService, +): Promise { + const appStoreEnabled = isAppStoreConfigured(); + const googlePlayEnabled = isGooglePlayConfigured(); + const [appAccountToken, storeEntitlement] = await Promise.all([ + storeEntitlementService.getOrCreateAccountToken(user.id), + storeEntitlementService.getActiveStoreEntitlement(user.id), + ]); + let purchaseBlockedReason: StoreBillingContextResponse['purchase_blocked_reason'] = null; + let blockingProvider: StoreBillingContextResponse['blocking_provider'] = null; + if (user.premiumType === UserPremiumTypes.LIFETIME) { + purchaseBlockedReason = 'lifetime'; + } else if ((user.premiumFlags & PremiumFlags.PURCHASE_DISABLED) !== 0) { + purchaseBlockedReason = 'purchase_disabled'; + } else if (storeEntitlement) { + purchaseBlockedReason = 'existing_subscription'; + blockingProvider = storeEntitlement.provider; + } else if (isStripeSubscriptionActive(user, new Date())) { + purchaseBlockedReason = 'existing_subscription'; + blockingProvider = 'stripe'; + } + return { + app_account_token: appAccountToken, + app_store: { + enabled: appStoreEnabled, + bundle_ids: appStoreEnabled ? Config.appStore.apps.map((app) => app.bundleId) : [], + products: appStoreEnabled + ? listAppStoreProducts().map((product) => ({product_id: product.productId, slot: product.slot})) + : [], + }, + google_play: { + enabled: googlePlayEnabled, + package_names: googlePlayEnabled ? [...Config.googlePlay.packages] : [], + products: googlePlayEnabled + ? listGooglePlayProducts().map((product) => ({ + product_id: product.productId, + base_plan_id: product.basePlanId, + slot: product.slot, + })) + : [], + }, + purchase_blocked_reason: purchaseBlockedReason, + blocking_provider: blockingProvider, + }; +} + +export function StoreBillingController(app: HonoApp) { + app.get( + '/premium/store', + HostedOnlyRoute, + RateLimitMiddleware(RateLimitConfigs.STORE_BILLING_CONTEXT), + LoginRequired, + DefaultUserOnly, + OpenAPI({ + operationId: 'get_store_billing_context', + summary: 'Get in-app purchase context', + description: + 'Returns the account token and the App Store and Google Play products the mobile apps may sell, plus the reason a new subscription is blocked, if any.', + responseSchema: StoreBillingContextResponse, + statusCode: 200, + security: ['bearerToken', 'sessionToken'], + tags: 'Premium', + }), + async (ctx) => { + return ctx.json(await buildStoreBillingContext(ctx.get('user'), ctx.get('storeEntitlementService'))); + }, + ); + app.post( + '/premium/store/app-store/transactions', + HostedOnlyRoute, + RateLimitMiddleware(RateLimitConfigs.STORE_BILLING_CLAIM_APP_STORE), + LoginRequired, + DefaultUserOnly, + Validator('json', ClaimAppStoreTransactionRequest), + OpenAPI({ + operationId: 'claim_app_store_transaction', + summary: 'Claim App Store transaction', + description: + 'Verifies a StoreKit 2 signed transaction, links the purchase to the authenticated account and applies it. Calling it again for the same purchase returns the same result. Finish the transaction after a 200 or a 400 or 403 error. Leave it unfinished after a 429, a 5xx or a network failure.', + responseSchema: StorePurchaseClaimResponse, + statusCode: 200, + security: ['bearerToken', 'sessionToken'], + tags: 'Premium', + }), + async (ctx) => { + const {signed_transaction} = ctx.req.valid('json'); + const result = await ctx + .get('storeEntitlementService') + .claimAppStoreTransaction(ctx.get('user').id, signed_transaction); + return ctx.json({purchase: mapStorePurchaseToResponse(result.purchase), gift_code: result.giftCode}); + }, + ); + app.post( + '/premium/store/google-play/purchases', + HostedOnlyRoute, + RateLimitMiddleware(RateLimitConfigs.STORE_BILLING_CLAIM_GOOGLE_PLAY), + LoginRequired, + DefaultUserOnly, + Validator('json', ClaimGooglePlayPurchaseRequest), + OpenAPI({ + operationId: 'claim_google_play_purchase', + summary: 'Claim Google Play purchase', + description: + 'Verifies a Google Play purchase token, links the purchase to the authenticated account, acknowledges it and applies it. Calling it again for the same purchase returns the same result.', + responseSchema: StorePurchaseClaimResponse, + statusCode: 200, + security: ['bearerToken', 'sessionToken'], + tags: 'Premium', + }), + async (ctx) => { + const body = ctx.req.valid('json'); + const result = await ctx.get('storeEntitlementService').claimGooglePlayPurchase(ctx.get('user').id, { + purchaseToken: body.purchase_token, + productId: body.product_id, + packageName: body.package_name, + }); + return ctx.json({purchase: mapStorePurchaseToResponse(result.purchase), gift_code: result.giftCode}); + }, + ); + app.get( + '/premium/store/purchases', + HostedOnlyRoute, + RateLimitMiddleware(RateLimitConfigs.STORE_BILLING_PURCHASES_LIST), + LoginRequired, + DefaultUserOnly, + OpenAPI({ + operationId: 'list_store_purchases', + summary: 'List in-app purchases', + description: 'Returns the App Store and Google Play purchases linked to the authenticated account.', + responseSchema: StorePurchaseListResponse, + statusCode: 200, + security: ['bearerToken', 'sessionToken'], + tags: 'Premium', + }), + async (ctx) => { + const rows = await ctx.get('storeEntitlementService').listStorePurchases(ctx.get('user').id); + return ctx.json(rows.map(mapStorePurchaseToResponse)); + }, + ); + app.delete( + '/premium/store/purchases/:purchase_id', + HostedOnlyRoute, + RateLimitMiddleware(RateLimitConfigs.STORE_BILLING_PURCHASE_RELEASE), + LoginRequired, + DefaultUserOnly, + SudoModeMiddleware, + Validator('param', StorePurchaseIdParam), + Validator('json', SudoVerificationSchema), + OpenAPI({ + operationId: 'release_store_purchase', + summary: 'Release in-app subscription', + description: + 'Unlinks an App Store or Google Play subscription from the authenticated account so another account can claim it. Requires sudo mode.', + requestSchema: SudoVerificationSchema, + requestBodyRequired: false, + responseSchema: null, + statusCode: 204, + security: ['bearerToken', 'sessionToken'], + tags: 'Premium', + }), + async (ctx) => { + const user = ctx.get('user'); + const {purchase_id} = ctx.req.valid('param'); + await requireSudoMode(ctx, user, ctx.req.valid('json')); + await ctx.get('storeEntitlementService').releaseStorePurchase(user.id, purchase_id); + return ctx.body(null, 204); + }, + ); + app.post( + '/webhooks/app-store', + HostedOnlyRoute, + RateLimitMiddleware(RateLimitConfigs.STORE_BILLING_APP_STORE_WEBHOOK), + async (ctx) => { + if (!isAppStoreConfigured()) { + throw new StoreBillingUnavailableError(); + } + let signedPayload: string; + try { + signedPayload = AppStoreNotificationBody.parse(await ctx.req.json()).signedPayload; + } catch { + throw new StoreNotificationUnauthorizedError(); + } + try { + await verifyNotification(signedPayload); + } catch (error) { + if (error instanceof AppStoreJwsVerificationError) { + Logger.warn({reason: error.reason}, 'Rejected App Store notification'); + throw new StoreNotificationUnauthorizedError(); + } + throw error; + } + await ctx.get('workerService').addJob('processAppStoreNotification', {signedPayload}); + return ctx.body(null, 200); + }, + ); + app.post( + '/webhooks/google-play', + HostedOnlyRoute, + RateLimitMiddleware(RateLimitConfigs.STORE_BILLING_GOOGLE_PLAY_WEBHOOK), + async (ctx) => { + if (!isGooglePlayConfigured()) { + throw new StoreBillingUnavailableError(); + } + await getGooglePlayPushVerifier().verifyAuthorizationHeader(ctx.req.header('Authorization')); + let body: unknown = null; + try { + body = await ctx.req.json(); + } catch { + body = null; + } + const message = parseGooglePlayPushEnvelope(body); + if (!message) { + Logger.warn('Ignored a malformed Google Play push message'); + return ctx.body(null, 204); + } + await ctx + .get('workerService') + .addJob('processGooglePlayNotification', {messageId: message.messageId, data: message.data}); + return ctx.body(null, 204); + }, + ); +} diff --git a/fluxer_api/src/api/store_billing/StoreBillingMappers.ts b/fluxer_api/src/api/store_billing/StoreBillingMappers.ts new file mode 100644 index 000000000..967546d79 --- /dev/null +++ b/fluxer_api/src/api/store_billing/StoreBillingMappers.ts @@ -0,0 +1,134 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import type {StorePurchaseRow} from '@app/api/database/types/StoreBillingTypes'; +import {STORE_PRODUCT_SLOTS} from '@app/api/store_billing/StoreBillingConfig'; +import {PREMIUM_GRACE_PERIOD_MS} from '@app/api/user/UserHelpers'; +import type {AdminStorePurchaseResponse} from '@fluxer/schema/src/domains/admin/AdminStoreBillingSchemas'; +import type { + PremiumStoreSubscriptionState, + StorePurchaseResponse, +} from '@fluxer/schema/src/domains/premium/StoreBillingSchemas'; + +const APP_STORE_MANAGE_URL = 'https://apps.apple.com/account/subscriptions'; +const GOOGLE_PLAY_MANAGE_URL = 'https://play.google.com/store/account/subscriptions'; + +function toIso(value: Date | null | undefined): string | null { + return value ? value.toISOString() : null; +} + +export function resolveStoreAccessEnd(row: StorePurchaseRow): Date | null { + if (row.state === 'grace' && row.grace_ends_at) { + return row.grace_ends_at; + } + return row.expires_at ?? null; +} + +function isActiveStoreSubscription(row: StorePurchaseRow, now: Date): boolean { + if (row.kind !== 'subscription' || !row.entitled || row.user_id === null) { + return false; + } + const accessEnd = resolveStoreAccessEnd(row); + if (!accessEnd) { + return false; + } + if (row.state === 'grace') { + return accessEnd.getTime() >= now.getTime(); + } + return accessEnd.getTime() + PREMIUM_GRACE_PERIOD_MS >= now.getTime(); +} + +export function selectActiveStoreSubscription( + rows: ReadonlyArray, + now: Date, +): StorePurchaseRow | null { + let best: StorePurchaseRow | null = null; + for (const row of rows) { + if (!isActiveStoreSubscription(row, now)) { + continue; + } + if (!best || (resolveStoreAccessEnd(row)?.getTime() ?? 0) > (resolveStoreAccessEnd(best)?.getTime() ?? 0)) { + best = row; + } + } + return best; +} + +export function mapStorePurchaseToResponse(row: StorePurchaseRow): StorePurchaseResponse { + const isSubscription = row.kind === 'subscription'; + return { + id: row.id.toString(), + provider: row.provider, + kind: row.kind, + slot: row.slot, + product_id: row.product_id, + environment: row.environment, + state: row.state, + entitled: row.entitled, + expires_at: isSubscription ? toIso(row.expires_at) : null, + entitled_until: isSubscription && row.entitled ? toIso(resolveStoreAccessEnd(row)) : null, + will_renew: isSubscription ? (row.auto_renew ?? null) : null, + gift_code: row.gift_code ?? null, + created_at: row.created_at.toISOString(), + }; +} + +export function mapStorePurchaseToAdminResponse(row: StorePurchaseRow): AdminStorePurchaseResponse { + return { + id: row.id.toString(), + user_id: row.user_id?.toString() ?? null, + provider: row.provider, + kind: row.kind, + slot: row.slot, + environment: row.environment, + app_id: row.app_id, + product_id: row.product_id, + base_plan_id: row.base_plan_id ?? null, + latest_transaction_id: row.latest_transaction_id ?? null, + ownership_type: row.ownership_type ?? null, + state: row.state, + store_state: row.store_state ?? null, + entitled: row.entitled, + expires_at: toIso(row.expires_at), + grace_ends_at: toIso(row.grace_ends_at), + auto_renew: row.auto_renew ?? null, + started_at: toIso(row.started_at), + purchased_at: toIso(row.purchased_at), + revoked_at: toIso(row.revoked_at), + revocation_reason: row.revocation_reason ?? null, + superseded: row.superseded_by_store_key != null, + acknowledged: row.acknowledged, + gift_code: row.gift_code ?? null, + bound_at: toIso(row.bound_at), + last_event_at: toIso(row.last_event_at), + synced_at: toIso(row.synced_at), + created_at: row.created_at.toISOString(), + updated_at: row.updated_at.toISOString(), + }; +} + +function buildManageUrl(row: StorePurchaseRow): string { + if (row.provider === 'app_store') { + return APP_STORE_MANAGE_URL; + } + const params = new URLSearchParams({sku: row.product_id, package: row.app_id}); + return `${GOOGLE_PLAY_MANAGE_URL}?${params.toString()}`; +} + +export function mapPremiumStoreSubscriptionState(row: StorePurchaseRow): PremiumStoreSubscriptionState | null { + const billingCycle = STORE_PRODUCT_SLOTS[row.slot].billingCycle; + if (!billingCycle || !row.expires_at || (row.slot !== 'monthly' && row.slot !== 'yearly')) { + return null; + } + return { + provider: row.provider, + purchase_id: row.id.toString(), + slot: row.slot, + billing_cycle: billingCycle, + state: row.state, + expires_at: row.expires_at.toISOString(), + grace_ends_at: row.state === 'grace' ? toIso(row.grace_ends_at) : null, + will_renew: row.auto_renew === true, + manage_url: buildManageUrl(row), + environment: row.environment, + }; +} diff --git a/fluxer_api/src/api/store_billing/StoreBillingRepository.ts b/fluxer_api/src/api/store_billing/StoreBillingRepository.ts new file mode 100644 index 000000000..edb301242 --- /dev/null +++ b/fluxer_api/src/api/store_billing/StoreBillingRepository.ts @@ -0,0 +1,159 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import {randomUUID} from 'node:crypto'; +import type {UserID} from '@app/api/BrandedTypes'; +import { + deleteOneOrMany, + executeConditional, + fetchMany, + fetchOne, + upsertOne, +} from '@app/api/database/CassandraQueryExecution'; +import {Db, type DbOp, nextVersion} from '@app/api/database/CassandraTypes'; +import {buildPatchFromData} from '@app/api/database/CassandraVersionedUpdate'; +import { + STORE_PURCHASE_COLUMNS, + type StoreAccountTokenByUserRow, + type StoreAccountTokenRow, + type StorePurchaseByUserRow, + type StorePurchaseRow, +} from '@app/api/database/types/StoreBillingTypes'; +import {StoreAccountTokens, StoreAccountTokensByUser, StorePurchases, StorePurchasesByUser} from '@app/api/Tables'; + +type StorePurchaseChanges = Partial>; + +type StorePurchasePatch = Partial<{ + [K in Exclude]: DbOp; +}>; + +const ACCOUNT_TOKEN_CREATE_ATTEMPTS = 3; + +const FETCH_PURCHASE_QUERY = StorePurchases.selectCql({ + where: StorePurchases.where.eq('store_key'), + limit: 1, +}); +const FETCH_PURCHASE_KEYS_BY_USER_QUERY = StorePurchasesByUser.selectCql({ + where: StorePurchasesByUser.where.eq('user_id'), +}); +const FETCH_ACCOUNT_TOKEN_QUERY = StoreAccountTokens.selectCql({ + where: StoreAccountTokens.where.eq('token_'), + limit: 1, +}); +const FETCH_ACCOUNT_TOKEN_BY_USER_QUERY = StoreAccountTokensByUser.selectCql({ + where: StoreAccountTokensByUser.where.eq('user_id'), + limit: 1, +}); + +export class StoreBillingRepository { + async findPurchase(storeKey: string): Promise { + return fetchOne(FETCH_PURCHASE_QUERY, {store_key: storeKey}); + } + + async listPurchasesForUser(userId: UserID): Promise> { + const refs = await fetchMany(FETCH_PURCHASE_KEYS_BY_USER_QUERY, {user_id: userId}); + const rows = await Promise.all(refs.map((ref) => this.findPurchase(ref.store_key))); + return rows.filter((row): row is StorePurchaseRow => row !== null && row.user_id === userId); + } + + async insertPurchase(row: StorePurchaseRow): Promise { + if (row.user_id !== null) { + await this.addUserIndex(row.user_id, row); + } + return executeConditional(StorePurchases.insertIfNotExists(row)); + } + + async updatePurchase(current: StorePurchaseRow, changes: StorePurchaseChanges): Promise { + const candidate: StorePurchaseRow = {...current, ...changes}; + const patch = buildPatchFromData(candidate, current, STORE_PURCHASE_COLUMNS, ['store_key']) as StorePurchasePatch; + if (Object.keys(patch).length === 0) { + return current; + } + const updatedAt = changes.updated_at ?? new Date(); + const version = nextVersion(current.version); + const next: StorePurchaseRow = {...candidate, updated_at: updatedAt, version}; + if (next.user_id !== null && next.user_id !== current.user_id) { + await this.addUserIndex(next.user_id, next); + } + const applied = await executeConditional( + StorePurchases.conditionalPatchByPk( + {store_key: current.store_key}, + {...patch, updated_at: Db.set(updatedAt), version: Db.set(version)}, + {version: current.version}, + ), + ); + if (!applied) { + return null; + } + if (current.user_id !== null && current.user_id !== next.user_id) { + await deleteOneOrMany(StorePurchasesByUser.deleteByPk({user_id: current.user_id, store_key: current.store_key})); + } + return next; + } + + async bindPurchase(current: StorePurchaseRow, userId: UserID, boundAt: Date): Promise { + return this.updatePurchase(current, {user_id: userId, bound_at: boundAt}); + } + + async unbindPurchase(current: StorePurchaseRow): Promise { + return this.updatePurchase(current, {user_id: null, bound_at: null}); + } + + async releasePurchase(current: StorePurchaseRow): Promise { + return this.updatePurchase(current, {user_id: null, bound_at: null, released_at: new Date()}); + } + + async setGiftCode(storeKey: string, giftCode: string): Promise { + return executeConditional( + StorePurchases.conditionalPatchByPk( + {store_key: storeKey}, + {gift_code: Db.set(giftCode), updated_at: Db.set(new Date())}, + {kind: 'gift', gift_code: null}, + ), + ); + } + + async findUserIdByAccountToken(token: string): Promise { + const row = await fetchOne(FETCH_ACCOUNT_TOKEN_QUERY, {token_: token.toLowerCase()}); + return row?.user_id ?? null; + } + + async findAccountTokenForUser(userId: UserID): Promise { + const row = await fetchOne(FETCH_ACCOUNT_TOKEN_BY_USER_QUERY, {user_id: userId}); + return row?.token_ ?? null; + } + + async getOrCreateAccountToken(userId: UserID): Promise { + const existing = await this.findAccountTokenForUser(userId); + if (existing) { + return existing; + } + for (let attempt = 0; attempt < ACCOUNT_TOKEN_CREATE_ATTEMPTS; attempt++) { + const token = randomUUID().toLowerCase(); + const createdAt = new Date(); + const claimed = await executeConditional( + StoreAccountTokens.insertIfNotExists({token_: token, user_id: userId, created_at: createdAt}), + ); + if (!claimed) { + continue; + } + const assigned = await executeConditional( + StoreAccountTokensByUser.insertIfNotExists({user_id: userId, token_: token, created_at: createdAt}), + ); + if (assigned) { + return token; + } + await deleteOneOrMany(StoreAccountTokens.deleteByPk({token_: token})); + const winner = await this.findAccountTokenForUser(userId); + if (winner) { + return winner; + } + } + throw new Error('Could not allocate a store account token'); + } + + private async addUserIndex(userId: UserID, row: StorePurchaseRow): Promise { + await upsertOne( + StorePurchasesByUser.upsertAll({user_id: userId, store_key: row.store_key, created_at: row.created_at}), + ); + } +} diff --git a/fluxer_api/src/api/store_billing/StoreBillingTypes.ts b/fluxer_api/src/api/store_billing/StoreBillingTypes.ts new file mode 100644 index 000000000..70232c348 --- /dev/null +++ b/fluxer_api/src/api/store_billing/StoreBillingTypes.ts @@ -0,0 +1,107 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import {createHash} from 'node:crypto'; +import type {StorePurchaseRow} from '@app/api/database/types/StoreBillingTypes'; +import type {StoreProductSlotName} from '@fluxer/config/src/MasterConfig'; + +export type StoreProvider = 'app_store' | 'google_play'; + +export type StorePurchaseKind = 'subscription' | 'gift'; + +export type StoreSlot = StoreProductSlotName; + +export type StoreEnvironment = 'production' | 'sandbox'; + +export type StorePurchaseState = + | 'pending' + | 'active' + | 'grace' + | 'billing_retry' + | 'on_hold' + | 'paused' + | 'canceled' + | 'expired' + | 'revoked' + | 'superseded' + | 'purchased' + | 'fulfilled' + | 'refunded'; + +const TERMINAL_STORE_PURCHASE_STATES: ReadonlySet = new Set([ + 'expired', + 'revoked', + 'superseded', + 'fulfilled', + 'refunded', +]); + +export const PAID_SUBSCRIPTION_STATES: ReadonlySet = new Set(['active', 'canceled', 'grace']); + +export const LIFETIME_REFUND_REASON = 'lifetime_refund'; + +export const UNSUPPORTED_QUANTITY_REASON = 'unsupported_quantity'; + +export const GOOGLE_PLAY_VOIDED_REASON = 'google_play_voided'; + +export function isTerminalStorePurchaseState(state: StorePurchaseState): boolean { + return TERMINAL_STORE_PURCHASE_STATES.has(state); +} + +export function isVoidedGooglePlaySubscriptionStillRenewing(row: StorePurchaseRow): boolean { + return ( + row.provider === 'google_play' && + row.kind === 'subscription' && + row.state === 'revoked' && + row.revocation_reason === GOOGLE_PLAY_VOIDED_REASON && + row.auto_renew === true + ); +} + +export function buildAppStoreStoreKey(environment: StoreEnvironment, originalTransactionId: string): string { + return `app_store:${environment}:${originalTransactionId}`; +} + +export function buildGooglePlayStoreKey(purchaseToken: string): string { + return `google_play:${purchaseToken}`; +} + +export function redactStoreKey(storeKey: string): string { + if (!storeKey.startsWith('google_play:')) { + return storeKey; + } + return `google_play:${createHash('sha256').update(storeKey).digest('hex').slice(0, 16)}`; +} + +export interface StorePurchaseSnapshot { + storeKey: string; + provider: StoreProvider; + kind: StorePurchaseKind; + slot: StoreSlot; + environment: StoreEnvironment; + appId: string; + productId: string; + basePlanId: string | null; + storeReference: string; + latestTransactionId: string | null; + appTransactionId: string | null; + accountToken: string | null; + ownershipType: string | null; + state: StorePurchaseState; + storeState: string | null; + providerEntitled: boolean; + expiresAt: Date | null; + graceEndsAt: Date | null; + autoRenew: boolean | null; + autoRenewProductId: string | null; + startedAt: Date | null; + purchasedAt: Date | null; + revokedAt: Date | null; + revocationReason: string | null; + linkedStoreKey: string | null; + expiredStoreKey: string | null; + expiredAccountToken: string | null; + acknowledged: boolean; + quantity: number; + region: string | null; + lastEventAt: Date; +} diff --git a/fluxer_api/src/api/store_billing/StoreEntitlementService.ts b/fluxer_api/src/api/store_billing/StoreEntitlementService.ts new file mode 100644 index 000000000..aac61fece --- /dev/null +++ b/fluxer_api/src/api/store_billing/StoreEntitlementService.ts @@ -0,0 +1,1045 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import {randomUUID} from 'node:crypto'; +import {setTimeout as sleep} from 'node:timers/promises'; +import type {UserID} from '@app/api/BrandedTypes'; +import {Config} from '@app/api/Config'; +import type {StorePurchaseRow} from '@app/api/database/types/StoreBillingTypes'; +import type {IGatewayService} from '@app/api/infrastructure/IGatewayService'; +import type {ISnowflakeService} from '@app/api/infrastructure/ISnowflakeService'; +import type {UserCacheService} from '@app/api/infrastructure/UserCacheService'; +import {Logger} from '@app/api/Logger'; +import { + AppStoreJwsVerificationError, + toStoreEnvironment, + verifyTransaction, +} from '@app/api/store_billing/app_store/AppStoreJwsVerifier'; +import {AppStorePurchaseSync, AppStorePurchaseSyncError} from '@app/api/store_billing/app_store/AppStorePurchaseSync'; +import { + type AppStoreServerApiClient, + AppStoreServerApiError, + isAppStoreTransactionNotFound, +} from '@app/api/store_billing/app_store/AppStoreServerApiClient'; +import {GooglePlaySettlement} from '@app/api/store_billing/GooglePlaySettlement'; +import {isGooglePlayApiError} from '@app/api/store_billing/google_play/GooglePlayApiError'; +import type {GooglePlayDeveloperApiClient} from '@app/api/store_billing/google_play/GooglePlayDeveloperApiClient'; +import { + buildGooglePlayProductSnapshot, + buildGooglePlaySubscriptionSnapshot, +} from '@app/api/store_billing/google_play/GooglePlayPurchaseSync'; +import { + getAppStoreProductSlot, + getGooglePlayOneTimeProductSlot, + isAppStoreConfigured, + isGooglePlayConfigured, + isGooglePlayPackageConfigured, + isGooglePlaySubscriptionProduct, + isSandboxEntitlementAllowed, +} from '@app/api/store_billing/StoreBillingConfig'; +import {resolveStoreAccessEnd, selectActiveStoreSubscription} from '@app/api/store_billing/StoreBillingMappers'; +import type {StoreBillingRepository} from '@app/api/store_billing/StoreBillingRepository'; +import { + buildGooglePlayStoreKey, + GOOGLE_PLAY_VOIDED_REASON, + isTerminalStorePurchaseState, + isVoidedGooglePlaySubscriptionStillRenewing, + LIFETIME_REFUND_REASON, + PAID_SUBSCRIPTION_STATES, + redactStoreKey, + type StoreEnvironment, + type StoreProvider, + type StorePurchaseSnapshot, + type StoreSlot, + UNSUPPORTED_QUANTITY_REASON, +} from '@app/api/store_billing/StoreBillingTypes'; +import {StoreEntitlementWriter} from '@app/api/store_billing/StoreEntitlementWriter'; +import {StoreGiftFulfilment} from '@app/api/store_billing/StoreGiftFulfilment'; +import { + REFRESH_WATCH_DELAY_MS, + STORE_PURCHASE_REFRESH_QUEUE_KEY, + scheduleStorePurchaseRefresh, +} from '@app/api/store_billing/StorePurchaseRefresh'; +import type {StripeGiftReversalHandler} from '@app/api/stripe/services/StripeGiftReversalHandler'; +import type {IUserRepository} from '@app/api/user/IUserRepository'; +import {UserFlags, UserPremiumTypes} from '@fluxer/constants/src/UserConstants'; +import {PremiumPurchaseBlockedError} from '@fluxer/errors/src/domains/payment/PremiumPurchaseBlockedError'; +import {StoreBillingUnavailableError} from '@fluxer/errors/src/domains/payment/StoreBillingUnavailableError'; +import {StorePurchaseInvalidError} from '@fluxer/errors/src/domains/payment/StorePurchaseInvalidError'; +import {StorePurchaseOwnedByOtherAccountError} from '@fluxer/errors/src/domains/payment/StorePurchaseOwnedByOtherAccountError'; +import {StorePurchaseSandboxNotEntitledError} from '@fluxer/errors/src/domains/payment/StorePurchaseSandboxNotEntitledError'; +import {UnknownStorePurchaseError} from '@fluxer/errors/src/domains/payment/UnknownStorePurchaseError'; +import type {IKVProvider} from '@pkgs/kv_client/src/IKVProvider'; +import {seconds} from 'itty-time'; + +const PURCHASE_LOCK_TTL_SECONDS = seconds('30 seconds'); +const PURCHASE_LOCK_ATTEMPTS = 100; +const PURCHASE_LOCK_RETRY_MS = 100; +const PURCHASE_WRITE_ATTEMPTS = 4; +const FAMILY_SHARED_OWNERSHIP = 'FAMILY_SHARED'; + +interface StoreEntitlementServiceDeps { + repository: StoreBillingRepository; + userRepository: IUserRepository; + userCacheService: UserCacheService; + gatewayService: IGatewayService; + kvClient: IKVProvider; + snowflakeService: ISnowflakeService; + appStoreClient: AppStoreServerApiClient; + googlePlayClient: GooglePlayDeveloperApiClient; + giftReversalHandler: StripeGiftReversalHandler; +} + +interface ActiveStoreEntitlement { + provider: StoreProvider; + storePurchaseId: bigint; + entitledUntil: Date; + expiresAt: Date; + graceEndsAt: Date | null; + autoRenew: boolean; + slot: StoreSlot; +} + +interface StorePurchaseClaimResult { + purchase: StorePurchaseRow; + giftCode: string | null; +} + +interface ClaimGooglePlayPurchaseParams { + purchaseToken: string; + productId: string; + packageName?: string; +} + +interface SyncAppStorePurchaseParams { + environment: StoreEnvironment | null; + bundleId: string; + productId: string; + transactionId: string; + originalTransactionId: string; + hintAccountToken: string | null; +} + +interface SyncGooglePlaySubscriptionParams { + packageName: string; + purchaseToken: string; +} + +interface SyncGooglePlayProductParams { + packageName: string; + purchaseToken: string; + productId: string | null; +} + +interface ApplyGooglePlayVoidedParams { + packageName: string; + purchaseToken: string; + orderId: string | null; + productType: 'subscription' | 'one_time' | null; + voidedAt: Date | null; +} + +interface SyncContext { + claimerId: UserID | null; + hintAccountToken: string | null; +} + +interface PersistResult { + row: StorePurchaseRow; + previous: StorePurchaseRow | null; +} + +const NOTIFICATION_CONTEXT: SyncContext = {claimerId: null, hintAccountToken: null}; + +class StorePurchaseRejectedByStoreError extends StorePurchaseInvalidError {} + +function normalizeAccountToken(token: string | null | undefined): string | null { + return token ? token.toLowerCase() : null; +} + +function toStoreBillingError(error: unknown): unknown { + if (error instanceof AppStoreJwsVerificationError || error instanceof AppStorePurchaseSyncError) { + return new StorePurchaseInvalidError(); + } + if (error instanceof AppStoreServerApiError) { + if (isAppStoreTransactionNotFound(error)) { + return new StorePurchaseRejectedByStoreError(); + } + const status = error.status ?? 0; + if (!error.retryable && status >= 400 && status < 500 && status !== 401 && status !== 403) { + return new StorePurchaseInvalidError(); + } + return new StoreBillingUnavailableError(); + } + if (isGooglePlayApiError(error)) { + return error.kind === 'invalid_token' + ? new StorePurchaseRejectedByStoreError() + : new StoreBillingUnavailableError(); + } + return error; +} + +export class StoreEntitlementService { + private readonly appStoreSync: AppStorePurchaseSync; + private readonly entitlementWriter: StoreEntitlementWriter; + private readonly googlePlaySettlement: GooglePlaySettlement; + private readonly giftFulfilment: StoreGiftFulfilment; + + constructor(private readonly deps: StoreEntitlementServiceDeps) { + this.appStoreSync = new AppStorePurchaseSync(deps.appStoreClient); + this.entitlementWriter = new StoreEntitlementWriter(deps); + this.googlePlaySettlement = new GooglePlaySettlement(deps.repository, deps.googlePlayClient); + this.giftFulfilment = new StoreGiftFulfilment({ + repository: deps.repository, + userRepository: deps.userRepository, + giftReversalHandler: deps.giftReversalHandler, + googlePlaySettlement: this.googlePlaySettlement, + entitlementWriter: this.entitlementWriter, + }); + } + + async getOrCreateAccountToken(userId: UserID): Promise { + return this.deps.repository.getOrCreateAccountToken(userId); + } + + async getActiveStoreEntitlement(userId: UserID): Promise { + if (Config.instance.selfHosted) { + return null; + } + const rows = await this.deps.repository.listPurchasesForUser(userId); + const row = selectActiveStoreSubscription(rows, new Date()); + const entitledUntil = row ? resolveStoreAccessEnd(row) : null; + if (!row || !entitledUntil || !row.expires_at) { + return null; + } + return { + provider: row.provider, + storePurchaseId: row.id, + entitledUntil, + expiresAt: row.expires_at, + graceEndsAt: row.state === 'grace' ? (row.grace_ends_at ?? null) : null, + autoRenew: row.auto_renew === true, + slot: row.slot, + }; + } + + async hasStorePurchases(userId: UserID): Promise { + if (Config.instance.selfHosted) { + return false; + } + const rows = await this.deps.repository.listPurchasesForUser(userId); + return rows.length > 0; + } + + async applyStoreEntitlementToUser(userId: UserID): Promise { + await this.entitlementWriter.applyEntitlement(userId, []); + } + + async reapplyAfterStripeChange(userId: UserID): Promise { + try { + if (!(await this.hasStorePurchases(userId))) { + return false; + } + await this.applyStoreEntitlementToUser(userId); + return true; + } catch (error) { + Logger.warn({error, userId: userId.toString()}, 'Failed to reapply the store entitlement after a Stripe change'); + return false; + } + } + + async claimAppStoreTransaction(userId: UserID, signedTransaction: string): Promise { + if (!isAppStoreConfigured()) { + throw new StoreBillingUnavailableError(); + } + let transaction: Awaited>; + try { + transaction = await verifyTransaction(signedTransaction); + } catch (error) { + throw toStoreBillingError(error); + } + if (!getAppStoreProductSlot(transaction.productId)) { + throw new StorePurchaseInvalidError(); + } + const row = await this.syncFromAppStore( + { + environment: toStoreEnvironment(transaction.environment), + bundleId: transaction.bundleId, + productId: transaction.productId, + transactionId: transaction.transactionId, + originalTransactionId: transaction.originalTransactionId, + hintAccountToken: transaction.appAccountToken ?? null, + }, + {claimerId: userId, hintAccountToken: normalizeAccountToken(transaction.appAccountToken)}, + ); + return this.finishClaim(userId, row); + } + + async claimGooglePlayPurchase( + userId: UserID, + params: ClaimGooglePlayPurchaseParams, + ): Promise { + if (!isGooglePlayConfigured()) { + throw new StoreBillingUnavailableError(); + } + const packageName = params.packageName ?? Config.googlePlay.packages[0]; + if (!packageName || !isGooglePlayPackageConfigured(packageName)) { + throw new StorePurchaseInvalidError(); + } + const context: SyncContext = {claimerId: userId, hintAccountToken: null}; + let row: StorePurchaseRow | null; + if (isGooglePlaySubscriptionProduct(params.productId)) { + row = await this.syncFromGooglePlaySubscription(packageName, params.purchaseToken, context, params.productId); + } else if (getGooglePlayOneTimeProductSlot(params.productId)) { + row = await this.syncFromGooglePlayProduct(packageName, params.purchaseToken, params.productId, context); + } else { + throw new StorePurchaseInvalidError(); + } + if (!row) { + throw new StorePurchaseInvalidError(); + } + return this.finishClaim(userId, row); + } + + async syncAppStorePurchase(params: SyncAppStorePurchaseParams): Promise { + if (!isAppStoreConfigured()) { + return null; + } + return this.syncFromAppStore(params, { + claimerId: null, + hintAccountToken: normalizeAccountToken(params.hintAccountToken), + }); + } + + async syncGooglePlaySubscription(params: SyncGooglePlaySubscriptionParams): Promise { + if (!isGooglePlayConfigured() || !isGooglePlayPackageConfigured(params.packageName)) { + return null; + } + return this.syncFromGooglePlaySubscription(params.packageName, params.purchaseToken, NOTIFICATION_CONTEXT, null); + } + + async syncGooglePlayProduct(params: SyncGooglePlayProductParams): Promise { + if (!isGooglePlayConfigured() || !isGooglePlayPackageConfigured(params.packageName)) { + return null; + } + return this.syncFromGooglePlayProduct( + params.packageName, + params.purchaseToken, + params.productId, + NOTIFICATION_CONTEXT, + ); + } + + async refreshStorePurchase(storeKey: string): Promise { + if (Config.instance.selfHosted) { + return null; + } + const row = await this.deps.repository.findPurchase(storeKey); + if (!row) { + await this.deps.kvClient.zrem(STORE_PURCHASE_REFRESH_QUEUE_KEY, storeKey); + return null; + } + const configured = + row.provider === 'app_store' + ? isAppStoreConfigured() + : isGooglePlayConfigured() && isGooglePlayPackageConfigured(row.app_id); + if (!configured) { + await this.deps.kvClient.zrem(STORE_PURCHASE_REFRESH_QUEUE_KEY, storeKey); + return row; + } + let result: StorePurchaseRow | null; + try { + result = await this.syncStoredPurchase(row); + } catch (error) { + if (error instanceof StorePurchaseRejectedByStoreError) { + return this.markPurchaseUnverifiable(storeKey); + } + if (!(error instanceof StorePurchaseInvalidError)) { + throw error; + } + result = null; + } + if (result === null) { + Logger.error( + {storeKey: redactStoreKey(storeKey)}, + 'A stored purchase could not be refreshed with the current store config', + ); + await this.deps.kvClient.zadd(STORE_PURCHASE_REFRESH_QUEUE_KEY, Date.now() + REFRESH_WATCH_DELAY_MS, storeKey); + return row; + } + return result; + } + + async applyGooglePlayVoided(params: ApplyGooglePlayVoidedParams): Promise { + if (!isGooglePlayConfigured() || !isGooglePlayPackageConfigured(params.packageName)) { + return null; + } + const storeKey = buildGooglePlayStoreKey(params.purchaseToken); + const known = await this.deps.repository.findPurchase(storeKey); + const kind = + known?.kind ?? + (params.productType === 'subscription' ? 'subscription' : params.productType === 'one_time' ? 'gift' : null); + try { + if (kind === 'subscription') { + await this.syncGooglePlaySubscription({packageName: params.packageName, purchaseToken: params.purchaseToken}); + } else if (kind === 'gift') { + await this.syncGooglePlayProduct({ + packageName: params.packageName, + purchaseToken: params.purchaseToken, + productId: known?.product_id ?? null, + }); + } + } catch (error) { + if (!(error instanceof StorePurchaseInvalidError)) { + throw error; + } + } + if (!(await this.deps.repository.findPurchase(storeKey))) { + Logger.info({storeKey: redactStoreKey(storeKey)}, 'Ignored a voided Google Play purchase that is not stored'); + return null; + } + return this.withPurchaseLock(storeKey, () => + this.markGooglePlayVoided(storeKey, params.orderId, params.voidedAt ?? new Date()), + ); + } + + async listStorePurchases(userId: UserID): Promise> { + if (Config.instance.selfHosted) { + return []; + } + const rows = await this.deps.repository.listPurchasesForUser(userId); + return rows.sort((left, right) => right.created_at.getTime() - left.created_at.getTime()); + } + + async releaseStorePurchase(userId: UserID, purchaseId: bigint): Promise { + const rows = await this.listStorePurchases(userId); + const row = rows.find((entry) => entry.id === purchaseId); + if (!row) { + throw new UnknownStorePurchaseError(); + } + if (row.kind !== 'subscription') { + throw new StorePurchaseInvalidError(); + } + const released = await this.withPurchaseLock(row.store_key, async () => { + for (let attempt = 0; attempt < PURCHASE_WRITE_ATTEMPTS; attempt++) { + const current = await this.deps.repository.findPurchase(row.store_key); + if (!current || current.user_id !== userId) { + throw new UnknownStorePurchaseError(); + } + const unbound = await this.deps.repository.releasePurchase(current); + if (unbound) { + return unbound; + } + } + throw new StoreBillingUnavailableError(); + }); + Logger.info( + {userId: userId.toString(), storeKey: redactStoreKey(row.store_key)}, + 'Store purchase released by its owner', + ); + await this.entitlementWriter.applyEntitlement(userId, [released]); + } + + async unbindAllForUser(userId: UserID): Promise { + if (Config.instance.selfHosted) { + return; + } + const rows = await this.deps.repository.listPurchasesForUser(userId); + for (const row of rows) { + await this.withPurchaseLock(row.store_key, async () => { + for (let attempt = 0; attempt < PURCHASE_WRITE_ATTEMPTS; attempt++) { + const current = await this.deps.repository.findPurchase(row.store_key); + if (!current || current.user_id !== userId) { + return; + } + if (await this.deps.repository.unbindPurchase(current)) { + return; + } + } + throw new StoreBillingUnavailableError(); + }); + } + } + + async stopBillingForDeletedUser(userId: UserID): Promise { + try { + const rows = await this.listGooglePlaySubscriptionsToStop(userId); + for (const row of rows) { + try { + await this.deps.googlePlayClient.cancelSubscription( + row.app_id, + row.store_reference, + 'DEVELOPER_REQUESTED_STOP_PAYMENTS', + ); + } catch (error) { + Logger.warn( + {error, userId: userId.toString(), storeKey: redactStoreKey(row.store_key)}, + 'Failed to stop Google Play billing for a deleted user', + ); + } + } + } catch (error) { + Logger.warn({error, userId: userId.toString()}, 'Failed to stop store billing for a deleted user'); + } + } + + async revokeForBannedUser(userId: UserID): Promise { + try { + const rows = await this.listGooglePlaySubscriptionsToStop(userId); + for (const row of rows) { + try { + await this.deps.googlePlayClient.revokeSubscription(row.app_id, row.store_reference); + } catch (error) { + Logger.warn( + {error, userId: userId.toString(), storeKey: redactStoreKey(row.store_key)}, + 'Failed to revoke a Google Play subscription for a banned user', + ); + } + } + } catch (error) { + Logger.warn({error, userId: userId.toString()}, 'Failed to revoke store subscriptions for a banned user'); + } + } + + async scheduleRefresh(row: StorePurchaseRow): Promise { + await scheduleStorePurchaseRefresh(this.deps.kvClient, row); + } + + private async syncStoredPurchase(row: StorePurchaseRow): Promise { + if (row.provider === 'app_store') { + return this.syncAppStorePurchase({ + environment: row.environment, + bundleId: row.app_id, + productId: row.product_id, + transactionId: row.latest_transaction_id ?? row.store_reference, + originalTransactionId: row.store_reference, + hintAccountToken: null, + }); + } + if (row.kind === 'subscription') { + return this.syncGooglePlaySubscription({packageName: row.app_id, purchaseToken: row.store_reference}); + } + return this.syncGooglePlayProduct({ + packageName: row.app_id, + purchaseToken: row.store_reference, + productId: row.product_id, + }); + } + + private async listGooglePlaySubscriptionsToStop(userId: UserID): Promise> { + if (Config.instance.selfHosted || !isGooglePlayConfigured()) { + return []; + } + const rows = await this.deps.repository.listPurchasesForUser(userId); + return rows.filter( + (row) => + row.provider === 'google_play' && + row.kind === 'subscription' && + (!isTerminalStorePurchaseState(row.state) || isVoidedGooglePlaySubscriptionStillRenewing(row)) && + isGooglePlayPackageConfigured(row.app_id), + ); + } + + private async finishClaim(userId: UserID, row: StorePurchaseRow): Promise { + if (row.environment === 'sandbox' && !isSandboxEntitlementAllowed(userId)) { + throw new StorePurchaseSandboxNotEntitledError(); + } + if (row.revocation_reason === UNSUPPORTED_QUANTITY_REASON) { + throw new StorePurchaseInvalidError(); + } + if (row.kind === 'subscription') { + const user = await this.deps.userRepository.findUnique(userId); + if (user?.premiumType === UserPremiumTypes.LIFETIME) { + throw new PremiumPurchaseBlockedError('lifetime', {provider: row.provider}); + } + } + return {purchase: row, giftCode: row.gift_code ?? null}; + } + + private async syncFromAppStore(params: SyncAppStorePurchaseParams, context: SyncContext): Promise { + let snapshot: StorePurchaseSnapshot; + try { + snapshot = await this.appStoreSync.loadPurchase({ + environment: params.environment, + bundleId: params.bundleId, + productId: params.productId, + transactionId: params.transactionId, + originalTransactionId: params.originalTransactionId, + }); + } catch (error) { + throw toStoreBillingError(error); + } + return this.withPurchaseLock(snapshot.storeKey, () => this.applySnapshot(snapshot, context)); + } + + private async syncFromGooglePlaySubscription( + packageName: string, + purchaseToken: string, + context: SyncContext, + expectedProductId: string | null, + ): Promise { + const storeKey = buildGooglePlayStoreKey(purchaseToken); + let snapshot: StorePurchaseSnapshot | null; + try { + const purchase = await this.deps.googlePlayClient.getSubscription(packageName, purchaseToken); + snapshot = buildGooglePlaySubscriptionSnapshot({packageName, purchaseToken, purchase, now: new Date()}); + } catch (error) { + if (context.claimerId === null && isGooglePlayApiError(error, 'invalid_token')) { + return this.markPurchaseUnverifiable(storeKey); + } + throw toStoreBillingError(error); + } + if (!snapshot || (expectedProductId !== null && snapshot.productId !== expectedProductId)) { + if (context.claimerId !== null) { + throw new StorePurchaseInvalidError(); + } + Logger.warn({packageName}, 'Ignored a Google Play subscription with no configured product'); + return null; + } + const resolved = snapshot; + return this.withPurchaseLock(storeKey, () => this.applySnapshot(resolved, context)); + } + + private async syncFromGooglePlayProduct( + packageName: string, + purchaseToken: string, + productId: string | null, + context: SyncContext, + ): Promise { + const storeKey = buildGooglePlayStoreKey(purchaseToken); + let snapshot: StorePurchaseSnapshot | null; + try { + const purchase = await this.deps.googlePlayClient.getProduct(packageName, purchaseToken); + snapshot = buildGooglePlayProductSnapshot({ + packageName, + purchaseToken, + expectedProductId: productId ?? undefined, + purchase, + now: new Date(), + }); + } catch (error) { + if (context.claimerId === null && isGooglePlayApiError(error, 'invalid_token')) { + return this.markPurchaseUnverifiable(storeKey); + } + throw toStoreBillingError(error); + } + if (!snapshot) { + if (context.claimerId !== null) { + throw new StorePurchaseInvalidError(); + } + Logger.warn({packageName, productId}, 'Ignored a Google Play purchase with no configured product'); + return null; + } + const resolved = snapshot; + return this.withPurchaseLock(storeKey, () => this.applySnapshot(resolved, context)); + } + + private async markPurchaseUnverifiable(storeKey: string): Promise { + return this.withPurchaseLock(storeKey, async () => { + for (let attempt = 0; attempt < PURCHASE_WRITE_ATTEMPTS; attempt++) { + const current = await this.deps.repository.findPurchase(storeKey); + if (!current) { + await this.deps.kvClient.zrem(STORE_PURCHASE_REFRESH_QUEUE_KEY, storeKey); + return null; + } + if (current.kind !== 'subscription') { + await this.deps.kvClient.zrem(STORE_PURCHASE_REFRESH_QUEUE_KEY, storeKey); + return current; + } + const updated = await this.deps.repository.updatePurchase(current, { + state: 'expired', + entitled: false, + synced_at: new Date(), + }); + if (updated) { + Logger.warn({storeKey: redactStoreKey(storeKey)}, 'The store can no longer verify a stored purchase'); + await this.scheduleRefresh(updated); + if (updated.user_id !== null) { + await this.entitlementWriter.applyEntitlement(updated.user_id, [current]); + } + return updated; + } + } + throw new StoreBillingUnavailableError(); + }); + } + + private async markGooglePlayVoided( + storeKey: string, + orderId: string | null, + voidedAt: Date, + ): Promise { + for (let attempt = 0; attempt < PURCHASE_WRITE_ATTEMPTS; attempt++) { + const current = await this.deps.repository.findPurchase(storeKey); + if (!current) { + return null; + } + const isGift = current.kind === 'gift'; + if (!isGift && (!orderId || orderId !== current.latest_transaction_id)) { + return current; + } + if (current.revocation_reason === GOOGLE_PLAY_VOIDED_REASON || (isGift && current.state === 'refunded')) { + return isGift ? this.giftFulfilment.settleGift(current) : current; + } + const updated = await this.deps.repository.updatePurchase(current, { + state: isGift ? 'refunded' : 'revoked', + entitled: false, + revoked_at: voidedAt, + revocation_reason: GOOGLE_PLAY_VOIDED_REASON, + }); + if (!updated) { + continue; + } + Logger.info({storeKey: redactStoreKey(storeKey)}, 'Revoked a Google Play purchase that Google voided'); + await this.scheduleRefresh(updated); + if (isGift) { + return this.giftFulfilment.settleGift(updated); + } + if (updated.user_id !== null) { + await this.entitlementWriter.applyEntitlement(updated.user_id, [current]); + } + return updated; + } + throw new StoreBillingUnavailableError(); + } + + private async withPurchaseLock(storeKey: string, run: () => Promise): Promise { + const lockKey = `store:purchase:lock:${storeKey}`; + const token = randomUUID(); + for (let attempt = 0; attempt < PURCHASE_LOCK_ATTEMPTS; attempt++) { + if (await this.deps.kvClient.acquireLock(lockKey, token, PURCHASE_LOCK_TTL_SECONDS)) { + try { + return await run(); + } finally { + try { + await this.deps.kvClient.releaseLock(lockKey, token); + } catch (error) { + Logger.warn({error, storeKey: redactStoreKey(storeKey)}, 'Failed to release a store purchase lock'); + } + } + } + await sleep(PURCHASE_LOCK_RETRY_MS); + } + Logger.warn({storeKey: redactStoreKey(storeKey)}, 'Timed out waiting for a store purchase lock'); + throw new StoreBillingUnavailableError(); + } + + private async applySnapshot(snapshot: StorePurchaseSnapshot, context: SyncContext): Promise { + const persisted = await this.persistSnapshot(snapshot, context); + let row = persisted.row; + const previous = persisted.previous; + const affectedUsers = new Map>(); + if (row.user_id !== null) { + affectedUsers.set(row.user_id, previous && previous.user_id === row.user_id ? [previous] : []); + } + if (previous?.user_id != null && previous.user_id !== row.user_id) { + affectedUsers.set(previous.user_id, [previous]); + } + if ( + snapshot.linkedStoreKey && + snapshot.provider === 'google_play' && + snapshot.state !== 'pending' && + snapshot.storeState !== 'SUBSCRIPTION_STATE_PENDING_PURCHASE_CANCELED' + ) { + const superseded = await this.supersedeLinkedPurchase(snapshot.linkedStoreKey, row); + if (superseded?.user_id != null && !affectedUsers.has(superseded.user_id)) { + affectedUsers.set(superseded.user_id, []); + } + } + if (context.claimerId !== null && row.user_id === context.claimerId && row.provider === 'app_store') { + row = await this.ensureAppStoreAccountToken(row, snapshot); + } + const owner = row.user_id !== null ? await this.deps.userRepository.findUnique(row.user_id) : null; + if (row.kind === 'gift') { + row = await this.giftFulfilment.settleGift(row); + } else if (row.provider === 'google_play') { + row = await this.googlePlaySettlement.settleSubscription(row, owner); + } + await this.scheduleRefresh(row); + for (const [userId, departedRows] of affectedUsers) { + await this.entitlementWriter.applyEntitlement(userId, departedRows); + } + return row; + } + + private async persistSnapshot(snapshot: StorePurchaseSnapshot, context: SyncContext): Promise { + for (let attempt = 0; attempt < PURCHASE_WRITE_ATTEMPTS; attempt++) { + const existing = await this.deps.repository.findPurchase(snapshot.storeKey); + const ownerId = await this.resolveOwner(existing, snapshot, context); + const now = new Date(); + const fields = this.buildRowFields(snapshot, existing, ownerId, context, now); + const boundAt = ownerId === null ? null : ownerId === existing?.user_id ? existing.bound_at : now; + const releasedAt = ownerId === null ? (existing?.released_at ?? null) : null; + if (!existing) { + const row: StorePurchaseRow = { + store_key: snapshot.storeKey, + id: await this.deps.snowflakeService.generate(), + ...fields, + user_id: ownerId, + bound_at: boundAt, + released_at: releasedAt, + gift_code: null, + created_at: now, + updated_at: now, + version: 1, + }; + if (await this.deps.repository.insertPurchase(row)) { + return {row, previous: null}; + } + continue; + } + const updated = await this.deps.repository.updatePurchase(existing, { + ...fields, + user_id: ownerId, + bound_at: boundAt, + released_at: releasedAt, + }); + if (updated) { + return {row: updated, previous: existing}; + } + } + throw new StoreBillingUnavailableError(); + } + + private buildRowFields( + snapshot: StorePurchaseSnapshot, + existing: StorePurchaseRow | null, + ownerId: UserID | null, + context: SyncContext, + now: Date, + ): Omit< + StorePurchaseRow, + 'store_key' | 'id' | 'user_id' | 'bound_at' | 'released_at' | 'gift_code' | 'created_at' | 'updated_at' | 'version' + > { + const stale = existing?.last_event_at != null && snapshot.lastEventAt.getTime() < existing.last_event_at.getTime(); + const accountToken = snapshot.accountToken ?? context.hintAccountToken ?? existing?.account_token ?? null; + if (stale && existing) { + const { + store_key: _storeKey, + id: _id, + user_id: _userId, + bound_at: _boundAt, + released_at: _releasedAt, + gift_code: _giftCode, + created_at: _createdAt, + updated_at: _updatedAt, + version: _version, + ...kept + } = existing; + const entitled = + ownerId === existing.user_id + ? existing.entitled && ownerId !== null + : this.isStoredRowEntitled(existing, ownerId); + return {...kept, entitled, synced_at: now}; + } + let state = snapshot.state; + let entitled = this.isEntitled(snapshot, ownerId); + const voided = + existing?.revocation_reason === GOOGLE_PLAY_VOIDED_REASON && + (snapshot.kind === 'gift' || snapshot.latestTransactionId === existing.latest_transaction_id); + const unsupportedQuantity = snapshot.kind === 'gift' && snapshot.quantity > 1; + if (voided) { + state = snapshot.kind === 'gift' ? 'refunded' : 'revoked'; + entitled = false; + } + if (existing?.superseded_by_store_key) { + state = 'superseded'; + entitled = false; + } + if (snapshot.kind === 'gift' && existing?.gift_code && state === 'purchased') { + state = 'fulfilled'; + } + if (existing?.revocation_reason === LIFETIME_REFUND_REASON || unsupportedQuantity) { + entitled = false; + } + const keepRevocation = + voided || + existing?.revocation_reason === LIFETIME_REFUND_REASON || + (unsupportedQuantity && existing?.revocation_reason === UNSUPPORTED_QUANTITY_REASON); + const keptRevocationReason = keepRevocation ? (existing?.revocation_reason ?? null) : null; + return { + provider: snapshot.provider, + kind: snapshot.kind, + slot: snapshot.slot, + environment: snapshot.environment, + app_id: snapshot.appId, + product_id: snapshot.productId, + base_plan_id: snapshot.basePlanId, + store_reference: snapshot.storeReference, + latest_transaction_id: snapshot.latestTransactionId, + app_transaction_id: snapshot.appTransactionId ?? existing?.app_transaction_id ?? null, + account_token: accountToken, + ownership_type: snapshot.ownershipType, + state, + store_state: snapshot.storeState, + entitled, + expires_at: snapshot.expiresAt, + grace_ends_at: snapshot.graceEndsAt, + auto_renew: snapshot.autoRenew, + auto_renew_product_id: snapshot.autoRenewProductId, + started_at: snapshot.startedAt, + purchased_at: snapshot.purchasedAt, + revoked_at: snapshot.revokedAt ?? (keepRevocation ? (existing?.revoked_at ?? null) : null), + revocation_reason: + snapshot.revocationReason ?? keptRevocationReason ?? (unsupportedQuantity ? UNSUPPORTED_QUANTITY_REASON : null), + linked_store_key: snapshot.linkedStoreKey ?? existing?.linked_store_key ?? null, + superseded_by_store_key: existing?.superseded_by_store_key ?? null, + acknowledged: snapshot.acknowledged || (existing?.acknowledged ?? false), + region: snapshot.region, + last_event_at: snapshot.lastEventAt, + synced_at: now, + }; + } + + private isEntitled(snapshot: StorePurchaseSnapshot, ownerId: UserID | null): boolean { + if (!snapshot.providerEntitled || ownerId === null) { + return false; + } + return snapshot.environment === 'production' || isSandboxEntitlementAllowed(ownerId); + } + + private isStoredRowEntitled(row: StorePurchaseRow, ownerId: UserID | null): boolean { + if (ownerId === null || row.superseded_by_store_key || row.revocation_reason != null) { + return false; + } + const paid = + row.kind === 'gift' + ? row.state === 'purchased' || row.state === 'fulfilled' + : PAID_SUBSCRIPTION_STATES.has(row.state); + return paid && (row.environment === 'production' || isSandboxEntitlementAllowed(ownerId)); + } + + private async resolveOwner( + existing: StorePurchaseRow | null, + snapshot: StorePurchaseSnapshot, + context: SyncContext, + ): Promise { + const claimerId = context.claimerId; + if (existing?.user_id != null) { + const signedToken = + snapshot.provider === 'app_store' ? (snapshot.accountToken ?? context.hintAccountToken) : null; + if (signedToken && signedToken !== existing.account_token && !existing.entitled) { + const tokenOwner = await this.findLiveTokenOwner(signedToken); + if (tokenOwner !== null && tokenOwner !== existing.user_id) { + return this.acceptOwnerCandidate(tokenOwner, claimerId); + } + } + if (claimerId === null || existing.user_id === claimerId) { + return existing.user_id; + } + if ( + existing.environment === 'sandbox' && + !isSandboxEntitlementAllowed(existing.user_id) && + isSandboxEntitlementAllowed(claimerId) + ) { + return claimerId; + } + if (await this.isLiveUser(existing.user_id)) { + throw new StorePurchaseOwnedByOtherAccountError(); + } + return claimerId; + } + if (snapshot.ownershipType === FAMILY_SHARED_OWNERSHIP && claimerId === null) { + return null; + } + const token = snapshot.accountToken ?? context.hintAccountToken; + if (token && !(existing && existing.account_token === token)) { + const tokenOwner = await this.findLiveTokenOwner(token); + if (tokenOwner !== null) { + return this.acceptOwnerCandidate(tokenOwner, claimerId); + } + } + if (snapshot.provider === 'google_play' && !existing?.released_at) { + for (const linkedKey of [snapshot.linkedStoreKey, snapshot.expiredStoreKey]) { + if (!linkedKey) { + continue; + } + const linked = await this.deps.repository.findPurchase(linkedKey); + if (linked?.user_id != null && (await this.isLiveUser(linked.user_id))) { + return this.acceptOwnerCandidate(linked.user_id, claimerId); + } + } + if (snapshot.expiredAccountToken) { + const expiredOwner = await this.findLiveTokenOwner(snapshot.expiredAccountToken); + if (expiredOwner !== null) { + return this.acceptOwnerCandidate(expiredOwner, claimerId); + } + } + } + return claimerId; + } + + private acceptOwnerCandidate(candidate: UserID, claimerId: UserID | null): UserID { + if (claimerId !== null && candidate !== claimerId) { + throw new StorePurchaseOwnedByOtherAccountError(); + } + return candidate; + } + + private async findLiveTokenOwner(token: string): Promise { + const userId = await this.deps.repository.findUserIdByAccountToken(token); + if (userId === null || !(await this.isLiveUser(userId))) { + return null; + } + return userId; + } + + private async isLiveUser(userId: UserID): Promise { + const user = await this.deps.userRepository.findUnique(userId); + return user !== null && (user.flags & UserFlags.DELETED) === 0n; + } + + private async supersedeLinkedPurchase(linkedKey: string, row: StorePurchaseRow): Promise { + if (linkedKey === row.store_key) { + return null; + } + for (let attempt = 0; attempt < PURCHASE_WRITE_ATTEMPTS; attempt++) { + const linked = await this.deps.repository.findPurchase(linkedKey); + if (!linked || linked.superseded_by_store_key === row.store_key) { + return null; + } + const updated = await this.deps.repository.updatePurchase(linked, { + state: 'superseded', + entitled: false, + superseded_by_store_key: row.store_key, + }); + if (updated) { + await this.scheduleRefresh(updated); + Logger.info( + {storeKey: redactStoreKey(row.store_key), supersededStoreKey: redactStoreKey(linkedKey)}, + 'Google Play purchase superseded by a linked purchase', + ); + return updated; + } + } + return null; + } + + private async ensureAppStoreAccountToken( + row: StorePurchaseRow, + snapshot: StorePurchaseSnapshot, + ): Promise { + if (row.user_id === null || snapshot.ownershipType === FAMILY_SHARED_OWNERSHIP) { + return row; + } + const token = await this.deps.repository.getOrCreateAccountToken(row.user_id); + if (snapshot.accountToken === token) { + return row; + } + try { + await this.deps.appStoreClient.setAppAccountToken({ + environment: row.environment, + bundleId: row.app_id, + originalTransactionId: row.store_reference, + appAccountToken: token, + }); + } catch (error) { + Logger.warn( + {error, storeKey: redactStoreKey(row.store_key)}, + 'Failed to set the App Store account token on a claimed purchase', + ); + return row; + } + const updated = await this.deps.repository.updatePurchase(row, {account_token: token}); + return updated ?? row; + } +} diff --git a/fluxer_api/src/api/store_billing/StoreEntitlementServiceFactory.ts b/fluxer_api/src/api/store_billing/StoreEntitlementServiceFactory.ts new file mode 100644 index 000000000..2c1452cc7 --- /dev/null +++ b/fluxer_api/src/api/store_billing/StoreEntitlementServiceFactory.ts @@ -0,0 +1,42 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import type {IGatewayService} from '@app/api/infrastructure/IGatewayService'; +import type {ISnowflakeService} from '@app/api/infrastructure/ISnowflakeService'; +import type {PremiumStateReconciliationQueueService} from '@app/api/infrastructure/PremiumStateReconciliationQueueService'; +import type {UserCacheService} from '@app/api/infrastructure/UserCacheService'; +import { + getAppStoreServerApiClient, + getGooglePlayDeveloperApiClient, + getStoreBillingRepository, +} from '@app/api/middleware/ServiceSingletons'; +import {StoreEntitlementService} from '@app/api/store_billing/StoreEntitlementService'; +import {StripeGiftReversalHandler} from '@app/api/stripe/services/StripeGiftReversalHandler'; +import type {IUserRepository} from '@app/api/user/IUserRepository'; +import type {IKVProvider} from '@pkgs/kv_client/src/IKVProvider'; + +interface StoreEntitlementServiceFactoryParams { + userRepository: IUserRepository; + userCacheService: UserCacheService; + gatewayService: IGatewayService; + kvClient: IKVProvider; + snowflakeService: ISnowflakeService; + premiumStateReconciliationQueueService: PremiumStateReconciliationQueueService; +} + +export function createStoreEntitlementService(params: StoreEntitlementServiceFactoryParams): StoreEntitlementService { + return new StoreEntitlementService({ + repository: getStoreBillingRepository(), + userRepository: params.userRepository, + userCacheService: params.userCacheService, + gatewayService: params.gatewayService, + kvClient: params.kvClient, + snowflakeService: params.snowflakeService, + appStoreClient: getAppStoreServerApiClient(), + googlePlayClient: getGooglePlayDeveloperApiClient(), + giftReversalHandler: new StripeGiftReversalHandler( + params.userRepository, + params.gatewayService, + params.premiumStateReconciliationQueueService, + ), + }); +} diff --git a/fluxer_api/src/api/store_billing/StoreEntitlementWriter.ts b/fluxer_api/src/api/store_billing/StoreEntitlementWriter.ts new file mode 100644 index 000000000..381acfd3e --- /dev/null +++ b/fluxer_api/src/api/store_billing/StoreEntitlementWriter.ts @@ -0,0 +1,189 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import type {UserID} from '@app/api/BrandedTypes'; +import {Config} from '@app/api/Config'; +import type {StorePurchaseRow} from '@app/api/database/types/StoreBillingTypes'; +import type {UserRow} from '@app/api/database/types/UserTypes'; +import type {IGatewayService} from '@app/api/infrastructure/IGatewayService'; +import type {UserCacheService} from '@app/api/infrastructure/UserCacheService'; +import {Logger} from '@app/api/Logger'; +import type {User} from '@app/api/models/User'; +import {selectActiveStoreSubscription} from '@app/api/store_billing/StoreBillingMappers'; +import type {StoreBillingRepository} from '@app/api/store_billing/StoreBillingRepository'; +import type {IUserRepository} from '@app/api/user/IUserRepository'; +import {clearPerksSanitizedFlag} from '@app/api/user/UserHelpers'; +import {mapUserToPrivateResponse} from '@app/api/user/UserMappers'; +import {UserFlags, UserPremiumTypes} from '@fluxer/constants/src/UserConstants'; + +const USER_WRITE_ATTEMPTS = 3; + +interface StoreEntitlementWriterDeps { + repository: StoreBillingRepository; + userRepository: IUserRepository; + userCacheService: UserCacheService; + gatewayService: IGatewayService; +} + +function sameTime(left: Date | null | undefined, right: Date | null | undefined): boolean { + return (left?.getTime() ?? null) === (right?.getTime() ?? null); +} + +export function isStripeSubscriptionActive(user: User, now: Date): boolean { + return ( + Boolean(user.stripeSubscriptionId) && + user.premiumBillingCycle !== null && + user.premiumUntil !== null && + user.premiumUntil > now + ); +} + +function setIfChanged( + patch: Partial, + key: K, + current: UserRow[K], + next: UserRow[K], +): void { + if (current instanceof Date || next instanceof Date) { + if (!sameTime(current as Date | null, next as Date | null)) { + patch[key] = next; + } + return; + } + if ((current ?? null) !== (next ?? null)) { + patch[key] = next; + } +} + +function buildGrantPatch(user: User, row: StorePurchaseRow, now: Date): Partial { + const patch: Partial = {}; + const expiresAt = row.expires_at; + if (!expiresAt) { + return patch; + } + const oldUntil = user.premiumUntil; + if (isStripeSubscriptionActive(user, now)) { + if (oldUntil && oldUntil.getTime() >= expiresAt.getTime()) { + return patch; + } + setIfChanged(patch, 'premium_type', user.premiumType, UserPremiumTypes.SUBSCRIPTION); + setIfChanged(patch, 'premium_until', oldUntil, expiresAt); + setIfChanged(patch, 'has_ever_purchased', user.hasEverPurchased, true); + return patch; + } + const inStoreGrace = + oldUntil !== null && + user.premiumGraceEndsAt !== null && + user.premiumGraceEndsAt.getTime() > oldUntil.getTime() && + user.premiumGraceEndsAt.getTime() > now.getTime(); + const anchorMs = inStoreGrace ? oldUntil.getTime() : Math.max(now.getTime(), oldUntil?.getTime() ?? 0); + const giftEnd = user.premiumGiftExtensionEndsAt; + const shiftMs = expiresAt.getTime() - anchorMs; + const shiftedGiftEnd = + giftEnd && shiftMs > 0 && giftEnd.getTime() > anchorMs ? new Date(giftEnd.getTime() + shiftMs) : giftEnd; + const startedAt = row.started_at ?? now; + const premiumSince = user.premiumSince && user.premiumSince <= startedAt ? user.premiumSince : startedAt; + setIfChanged(patch, 'premium_type', user.premiumType, UserPremiumTypes.SUBSCRIPTION); + setIfChanged(patch, 'premium_since', user.premiumSince, premiumSince); + setIfChanged(patch, 'premium_until', oldUntil, expiresAt); + setIfChanged(patch, 'premium_gift_extension_ends_at', giftEnd, shiftedGiftEnd); + setIfChanged( + patch, + 'premium_grace_ends_at', + user.premiumGraceEndsAt, + row.state === 'grace' ? (row.grace_ends_at ?? null) : null, + ); + setIfChanged(patch, 'premium_will_cancel', user.premiumWillCancel, row.auto_renew !== true); + setIfChanged(patch, 'premium_billing_cycle', user.premiumBillingCycle, null); + setIfChanged(patch, 'has_ever_purchased', user.hasEverPurchased, true); + setIfChanged(patch, 'premium_flags', user.premiumFlags, clearPerksSanitizedFlag(user.premiumFlags)); + return patch; +} + +function buildCutPatch( + user: User, + rows: Array, + departedRows: Array, + now: Date, +): Partial { + const patch: Partial = {}; + const premiumUntil = user.premiumUntil; + if (user.premiumType !== UserPremiumTypes.SUBSCRIPTION || !premiumUntil || isStripeSubscriptionActive(user, now)) { + return patch; + } + const candidates = [ + ...rows.filter((row) => row.kind === 'subscription' && !row.entitled), + ...departedRows.filter((row) => row.kind === 'subscription'), + ]; + const untilMs = premiumUntil.getTime(); + const owner = candidates.find((row) => + [row.expires_at, row.grace_ends_at, row.revoked_at].some((value) => value?.getTime() === untilMs), + ); + if (!owner) { + return patch; + } + let cutMs = Math.min(untilMs, now.getTime()); + if (owner.revoked_at) { + cutMs = Math.min(untilMs, owner.revoked_at.getTime()); + } + const cutAt = new Date(cutMs); + setIfChanged(patch, 'premium_until', premiumUntil, cutAt); + setIfChanged(patch, 'premium_grace_ends_at', user.premiumGraceEndsAt, cutAt); + const giftEnd = user.premiumGiftExtensionEndsAt; + if (giftEnd && giftEnd.getTime() > untilMs && cutMs < untilMs) { + setIfChanged(patch, 'premium_gift_extension_ends_at', giftEnd, new Date(giftEnd.getTime() - (untilMs - cutMs))); + } + return patch; +} + +export class StoreEntitlementWriter { + constructor(private readonly deps: StoreEntitlementWriterDeps) {} + + async applyEntitlement(userId: UserID, departedRows: Array): Promise { + if (Config.instance.selfHosted) { + return; + } + for (let attempt = 1; attempt <= USER_WRITE_ATTEMPTS; attempt++) { + try { + await this.writeEntitlement(userId, departedRows); + return; + } catch (error) { + if (attempt === USER_WRITE_ATTEMPTS) { + throw error; + } + Logger.warn({error, userId: userId.toString(), attempt}, 'Retrying a store entitlement write'); + } + } + } + + async patchUserAndDispatch(user: User, patch: Partial): Promise { + const updatedUser = await this.deps.userRepository.patchUpsert(user.id, patch, user.toRow()); + this.deps.userCacheService.setUserPartialResponseFromUserInBackground(updatedUser); + await this.deps.gatewayService.dispatchPresence({ + userId: updatedUser.id, + event: 'USER_UPDATE', + data: mapUserToPrivateResponse(updatedUser), + }); + return updatedUser; + } + + private async writeEntitlement(userId: UserID, departedRows: Array): Promise { + const user = await this.deps.userRepository.findUnique(userId); + if ( + !user || + user.isBot || + user.premiumType === UserPremiumTypes.LIFETIME || + (user.flags & UserFlags.DELETED) !== 0n + ) { + return; + } + const rows = await this.deps.repository.listPurchasesForUser(userId); + const now = new Date(); + const best = selectActiveStoreSubscription(rows, now); + const patch = best ? buildGrantPatch(user, best, now) : buildCutPatch(user, rows, departedRows, now); + if (Object.keys(patch).length === 0) { + return; + } + await this.patchUserAndDispatch(user, patch); + Logger.debug({userId: userId.toString(), fields: Object.keys(patch)}, 'Applied store entitlement to user'); + } +} diff --git a/fluxer_api/src/api/store_billing/StoreGiftFulfilment.ts b/fluxer_api/src/api/store_billing/StoreGiftFulfilment.ts new file mode 100644 index 000000000..5361920f3 --- /dev/null +++ b/fluxer_api/src/api/store_billing/StoreGiftFulfilment.ts @@ -0,0 +1,162 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import type {UserID} from '@app/api/BrandedTypes'; +import type {StorePurchaseRow} from '@app/api/database/types/StoreBillingTypes'; +import {Logger} from '@app/api/Logger'; +import {type GiftCode, mapGiftDurationMonthsToFields} from '@app/api/models/GiftCode'; +import type {GooglePlaySettlement} from '@app/api/store_billing/GooglePlaySettlement'; +import {STORE_PRODUCT_SLOTS} from '@app/api/store_billing/StoreBillingConfig'; +import {selectActiveStoreSubscription} from '@app/api/store_billing/StoreBillingMappers'; +import type {StoreBillingRepository} from '@app/api/store_billing/StoreBillingRepository'; +import {redactStoreKey, type StoreSlot, UNSUPPORTED_QUANTITY_REASON} from '@app/api/store_billing/StoreBillingTypes'; +import type {StoreEntitlementWriter} from '@app/api/store_billing/StoreEntitlementWriter'; +import type {StripeGiftReversalHandler} from '@app/api/stripe/services/StripeGiftReversalHandler'; +import type {IUserRepository} from '@app/api/user/IUserRepository'; +import * as RandomUtils from '@app/api/utils/RandomUtils'; + +const PURCHASE_WRITE_ATTEMPTS = 4; +const GIFT_REFUND_REVERSAL_REASON = 'store_refund'; + +interface StoreGiftFulfilmentDeps { + repository: StoreBillingRepository; + userRepository: IUserRepository; + giftReversalHandler: StripeGiftReversalHandler; + googlePlaySettlement: GooglePlaySettlement; + entitlementWriter: StoreEntitlementWriter; +} + +export class StoreGiftFulfilment { + constructor(private readonly deps: StoreGiftFulfilmentDeps) {} + + async settleGift(row: StorePurchaseRow): Promise { + if (row.state === 'refunded') { + if (row.gift_code) { + await this.reverseStoreGift(row.gift_code, row); + } + return row; + } + if (row.revocation_reason === UNSUPPORTED_QUANTITY_REASON) { + return this.deps.googlePlaySettlement.refundUnsupportedGiftQuantity(row); + } + if (!row.entitled || row.user_id === null || (row.state !== 'purchased' && row.state !== 'fulfilled')) { + return row; + } + let current = row; + if (!current.gift_code) { + const candidate = await this.generateUniqueGiftCode(); + await this.deps.repository.setGiftCode(current.store_key, candidate); + const reloaded = await this.deps.repository.findPurchase(current.store_key); + if (!reloaded?.gift_code) { + return current; + } + current = reloaded; + } + const giftCode = current.gift_code; + if (!giftCode || current.user_id === null) { + return current; + } + const existingGift = await this.deps.userRepository.findGiftCode(giftCode); + if (!existingGift) { + await this.createStoreGiftCode(giftCode, current.user_id, current.slot); + } else if (existingGift.revokedAt) { + await this.restoreStoreGift(existingGift, current); + } + let acknowledged = current.acknowledged; + if (current.provider === 'google_play' && !acknowledged) { + acknowledged = await this.deps.googlePlaySettlement.consumeProduct(current); + } + if (current.state !== 'fulfilled' || acknowledged !== current.acknowledged) { + for (let attempt = 0; attempt < PURCHASE_WRITE_ATTEMPTS; attempt++) { + const updated = await this.deps.repository.updatePurchase(current, {state: 'fulfilled', acknowledged}); + if (updated) { + return updated; + } + const reloaded = await this.deps.repository.findPurchase(current.store_key); + if (!reloaded) { + break; + } + current = reloaded; + } + } + return current; + } + + private async createStoreGiftCode(code: string, purchaserId: UserID, slot: StoreSlot): Promise { + const duration = mapGiftDurationMonthsToFields(STORE_PRODUCT_SLOTS[slot].durationMonths); + await this.deps.userRepository.createGiftCode({ + code, + duration_months: null, + duration_type: duration.durationType, + duration_quantity: duration.durationQuantity, + created_at: new Date(), + created_by_user_id: purchaserId, + redeemed_at: null, + redeemed_by_user_id: null, + stripe_payment_intent_id: null, + visionary_sequence_number: null, + checkout_session_id: null, + version: 1, + }); + const purchaser = await this.deps.userRepository.findUnique(purchaserId); + if (!purchaser) { + return; + } + await this.deps.entitlementWriter.patchUserAndDispatch(purchaser, { + gift_inventory_server_seq: (purchaser.giftInventoryServerSeq ?? 0) + 1, + ...(purchaser.hasEverPurchased ? {} : {has_ever_purchased: true}), + }); + Logger.info({purchaserId: purchaserId.toString(), slot}, 'Minted a gift code from a store purchase'); + } + + private async reverseStoreGift(code: string, row: StorePurchaseRow): Promise { + const gift = await this.deps.userRepository.findGiftCode(code); + if (!gift || gift.revokedAt) { + return; + } + const redeemerId = gift.redeemedByUserId; + if (redeemerId !== null) { + await this.reverseRedeemedStoreGift(gift, redeemerId); + } + await this.deps.userRepository.revokeGiftCode(code); + Logger.info( + {storeKey: redactStoreKey(row.store_key), redeemerId: redeemerId?.toString() ?? null}, + 'Revoked a gift code after a store refund', + ); + } + + private async reverseRedeemedStoreGift(gift: GiftCode, redeemerId: UserID): Promise { + const redeemerRows = await this.deps.repository.listPurchasesForUser(redeemerId); + if (!selectActiveStoreSubscription(redeemerRows, new Date())) { + await this.deps.giftReversalHandler.handleGiftPremiumReversal(gift, {reason: GIFT_REFUND_REVERSAL_REASON}); + return; + } + const redeemer = await this.deps.userRepository.findUnique(redeemerId); + if (!redeemer) { + return; + } + await this.deps.giftReversalHandler.reverseStackedGift(redeemer, gift); + await this.deps.entitlementWriter.applyEntitlement(redeemerId, []); + } + + private async restoreStoreGift(gift: GiftCode, row: StorePurchaseRow): Promise { + const redeemerId = gift.redeemedByUserId; + if (redeemerId !== null) { + await this.deps.giftReversalHandler.restoreReversedGift(gift); + await this.deps.entitlementWriter.applyEntitlement(redeemerId, []); + } + await this.deps.userRepository.unrevokeGiftCode(gift.code); + Logger.info( + {storeKey: redactStoreKey(row.store_key), redeemerId: redeemerId?.toString() ?? null}, + 'Reinstated a gift code after a store refund was reversed', + ); + } + + private async generateUniqueGiftCode(): Promise { + for (;;) { + const code = RandomUtils.randomString(32); + if (!(await this.deps.userRepository.findGiftCode(code))) { + return code; + } + } + } +} diff --git a/fluxer_api/src/api/store_billing/StoreNotificationClaims.ts b/fluxer_api/src/api/store_billing/StoreNotificationClaims.ts new file mode 100644 index 000000000..2fc8f3f03 --- /dev/null +++ b/fluxer_api/src/api/store_billing/StoreNotificationClaims.ts @@ -0,0 +1,65 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import type {IKVProvider} from '@pkgs/kv_client/src/IKVProvider'; +import type {WorkerTaskHelpers} from '@pkgs/worker/src/contracts/WorkerTask'; +import {ms, seconds} from 'itty-time'; + +const NOTIFICATION_IN_FLIGHT_TTL_SECONDS = seconds('90 seconds'); +const NOTIFICATION_PROCESSED_TTL_SECONDS = seconds('7 days'); +const STORE_NOTIFICATION_MAX_RETRIES = 12; +const STORE_NOTIFICATION_RETRY_BASE_MS = ms('5 minutes'); +const STORE_NOTIFICATION_RETRY_MAX_MS = ms('12 hours'); + +function storeNotificationRetryDelayMs(retry: number): number { + return Math.min(STORE_NOTIFICATION_RETRY_BASE_MS * 2 ** retry, STORE_NOTIFICATION_RETRY_MAX_MS); +} + +export async function rescheduleStoreNotification( + helpers: WorkerTaskHelpers, + taskType: 'processAppStoreNotification' | 'processGooglePlayNotification', + payload: Record, + retry: number, + error: unknown, +): Promise { + if (!helpers.attempt?.isLastAttempt || retry >= STORE_NOTIFICATION_MAX_RETRIES) { + return false; + } + await helpers.addJob( + taskType, + {...payload, retry: retry + 1}, + {runAt: new Date(Date.now() + storeNotificationRetryDelayMs(retry))}, + ); + helpers.logger.warn({error, retry: retry + 1}, 'Rescheduled a store notification after repeated failures'); + return true; +} + +type StoreNotificationClaimResult = 'claimed' | 'already_processed' | 'in_flight'; + +export function appStoreNotificationClaimKey(notificationUUID: string): string { + return `app-store-notification:${notificationUUID}`; +} + +export function googlePlayMessageClaimKey(messageId: string): string { + return `google-play-message:${messageId}`; +} + +export class StoreNotificationClaims { + constructor(private readonly kv: IKVProvider) {} + + async tryClaim(key: string): Promise { + const claimed = await this.kv.setnx(key, 'in_flight', NOTIFICATION_IN_FLIGHT_TTL_SECONDS); + if (claimed) { + return 'claimed'; + } + const current = await this.kv.get(key); + return current === 'processed' ? 'already_processed' : 'in_flight'; + } + + async markProcessed(key: string): Promise { + await this.kv.setex(key, NOTIFICATION_PROCESSED_TTL_SECONDS, 'processed'); + } + + async releaseClaim(key: string): Promise { + await this.kv.del(key); + } +} diff --git a/fluxer_api/src/api/store_billing/StorePurchaseRefresh.ts b/fluxer_api/src/api/store_billing/StorePurchaseRefresh.ts new file mode 100644 index 000000000..4696abe04 --- /dev/null +++ b/fluxer_api/src/api/store_billing/StorePurchaseRefresh.ts @@ -0,0 +1,84 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import {Config} from '@app/api/Config'; +import type {StorePurchaseRow} from '@app/api/database/types/StoreBillingTypes'; +import { + isTerminalStorePurchaseState, + isVoidedGooglePlaySubscriptionStillRenewing, + LIFETIME_REFUND_REASON, + PAID_SUBSCRIPTION_STATES, + type StorePurchaseState, + UNSUPPORTED_QUANTITY_REASON, +} from '@app/api/store_billing/StoreBillingTypes'; +import type {IKVProvider} from '@pkgs/kv_client/src/IKVProvider'; +import {ms} from 'itty-time'; + +export const STORE_PURCHASE_REFRESH_QUEUE_KEY = 'store:purchase:refresh'; + +export const REFRESH_WATCH_DELAY_MS = ms('6 hours'); + +const REFRESH_AFTER_EXPIRY_MS = ms('10 minutes'); +const REFRESH_MAX_DELAY_MS = ms('7 days'); +const REFRESH_MIN_DELAY_MS = ms('5 minutes'); +const REFRESH_UNSETTLED_DELAY_MS = ms('1 hour'); +const REFRESH_UNSETTLED_SANDBOX_DELAY_MS = ms('1 minute'); +const REFRESH_LAPSED_DELAY_MS = ms('1 hour'); +const WATCHED_STATES: ReadonlySet = new Set([ + 'grace', + 'billing_retry', + 'on_hold', + 'paused', + 'pending', +]); + +function needsGooglePlaySettle(row: StorePurchaseRow): boolean { + if (row.provider !== 'google_play') { + return false; + } + if (row.kind === 'gift') { + if (row.revocation_reason === UNSUPPORTED_QUANTITY_REASON) { + return !row.revoked_at && row.state !== 'refunded'; + } + return ( + !row.acknowledged && + row.user_id !== null && + row.entitled && + (row.state === 'purchased' || row.state === 'fulfilled') + ); + } + return ( + !row.acknowledged && + row.user_id !== null && + row.revocation_reason !== LIFETIME_REFUND_REASON && + PAID_SUBSCRIPTION_STATES.has(row.state) + ); +} + +export async function scheduleStorePurchaseRefresh(kv: IKVProvider, row: StorePurchaseRow): Promise { + if (Config.instance.selfHosted) { + return; + } + const needsSettle = needsGooglePlaySettle(row); + const keepWatching = + needsSettle || + (row.kind === 'subscription' + ? !isTerminalStorePurchaseState(row.state) || isVoidedGooglePlaySubscriptionStillRenewing(row) + : row.state === 'pending'); + if (!keepWatching) { + await kv.zrem(STORE_PURCHASE_REFRESH_QUEUE_KEY, row.store_key); + return; + } + const now = Date.now(); + let dueAt = now + REFRESH_WATCH_DELAY_MS; + if (needsSettle) { + dueAt = now + (row.environment === 'sandbox' ? REFRESH_UNSETTLED_SANDBOX_DELAY_MS : REFRESH_UNSETTLED_DELAY_MS); + } else if (!WATCHED_STATES.has(row.state) && row.expires_at) { + if (row.expires_at.getTime() <= now) { + dueAt = now + REFRESH_LAPSED_DELAY_MS; + } else { + dueAt = Math.min(row.expires_at.getTime() + REFRESH_AFTER_EXPIRY_MS, now + REFRESH_MAX_DELAY_MS); + dueAt = Math.max(dueAt, now + REFRESH_MIN_DELAY_MS); + } + } + await kv.zadd(STORE_PURCHASE_REFRESH_QUEUE_KEY, dueAt, row.store_key); +} diff --git a/fluxer_api/src/api/store_billing/app_store/AppStoreJwsVerifier.ts b/fluxer_api/src/api/store_billing/app_store/AppStoreJwsVerifier.ts new file mode 100644 index 000000000..5e4c9135c --- /dev/null +++ b/fluxer_api/src/api/store_billing/app_store/AppStoreJwsVerifier.ts @@ -0,0 +1,503 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import {type KeyObject, X509Certificate} from 'node:crypto'; +import type {AppStoreAppConfig} from '@app/api/config/APIConfig'; +import {getAppleRootCertificates} from '@app/api/store_billing/app_store/AppleRootCertificates'; +import {getAppStoreApp} from '@app/api/store_billing/StoreBillingConfig'; +import type {StoreEnvironment} from '@app/api/store_billing/StoreBillingTypes'; +import {ms} from 'itty-time'; +import {compactVerify, decodeProtectedHeader} from 'jose'; +import {z} from 'zod'; + +const APPLE_RECEIPT_SIGNING_EXTENSION_OID = '1.2.840.113635.100.6.11.1'; +const APPLE_WWDR_INTERMEDIATE_EXTENSION_OID = '1.2.840.113635.100.6.2.1'; +const APPLE_CERTIFICATE_CHAIN_LENGTH = 3; +const CERTIFICATE_DATE_SKEW_MS = ms('1 minute'); +const STANDARD_BASE64_PATTERN = /^[A-Za-z0-9+/]+={0,2}$/; +const BASE64URL_SEGMENT_PATTERN = /^[A-Za-z0-9_-]+$/; +const DER_SEQUENCE = 0x30; +const DER_OBJECT_IDENTIFIER = 0x06; +const DER_EXTENSIONS_CONTEXT_TAG = 0xa3; + +export type AppStoreEnvironmentName = 'Production' | 'Sandbox'; + +export type AppStoreJwsFailureReason = + | 'malformed' + | 'unsupported_algorithm' + | 'invalid_chain_length' + | 'invalid_certificate' + | 'untrusted_chain' + | 'missing_certificate_extension' + | 'certificate_expired' + | 'invalid_signature' + | 'invalid_payload' + | 'invalid_app_identifier' + | 'invalid_environment'; + +export class AppStoreJwsVerificationError extends Error { + constructor( + readonly reason: AppStoreJwsFailureReason, + options?: {cause?: unknown}, + ) { + super(`App Store signed data rejected: ${reason}`, options); + this.name = 'AppStoreJwsVerificationError'; + } +} + +const AppStoreTransactionPayloadSchema = z.object({ + transactionId: z.string().min(1), + originalTransactionId: z.string().min(1), + bundleId: z.string().min(1), + productId: z.string().min(1), + type: z.string().min(1), + purchaseDate: z.number(), + signedDate: z.number(), + environment: z.string(), + originalPurchaseDate: z.number().nullish(), + expiresDate: z.number().nullish(), + webOrderLineItemId: z.string().nullish(), + subscriptionGroupIdentifier: z.string().nullish(), + quantity: z.number().nullish(), + appAccountToken: z.string().nullish(), + appTransactionId: z.string().nullish(), + inAppOwnershipType: z.string().nullish(), + revocationDate: z.number().nullish(), + revocationReason: z.number().nullish(), + revocationType: z.string().nullish(), + revocationPercentage: z.number().nullish(), + isUpgraded: z.boolean().nullish(), + offerType: z.number().nullish(), + offerIdentifier: z.string().nullish(), + offerDiscountType: z.string().nullish(), + offerPeriod: z.string().nullish(), + storefront: z.string().nullish(), + storefrontId: z.string().nullish(), + transactionReason: z.string().nullish(), + currency: z.string().nullish(), + price: z.number().nullish(), +}); + +const AppStoreRenewalInfoPayloadSchema = z.object({ + originalTransactionId: z.string().min(1), + productId: z.string().min(1), + signedDate: z.number(), + environment: z.string(), + autoRenewStatus: z.number().nullish(), + autoRenewProductId: z.string().nullish(), + expirationIntent: z.number().nullish(), + gracePeriodExpiresDate: z.number().nullish(), + isInBillingRetryPeriod: z.boolean().nullish(), + priceIncreaseStatus: z.number().nullish(), + recentSubscriptionStartDate: z.number().nullish(), + renewalDate: z.number().nullish(), + renewalPrice: z.number().nullish(), + currency: z.string().nullish(), + offerType: z.number().nullish(), + offerIdentifier: z.string().nullish(), + offerDiscountType: z.string().nullish(), + offerPeriod: z.string().nullish(), + eligibleWinBackOfferIds: z.array(z.string()).nullish(), + appAccountToken: z.string().nullish(), + appTransactionId: z.string().nullish(), +}); + +const AppStoreNotificationDataSchema = z.object({ + bundleId: z.string().min(1), + environment: z.string(), + appAppleId: z.number().nullish(), + bundleVersion: z.string().nullish(), + signedTransactionInfo: z.string().nullish(), + signedRenewalInfo: z.string().nullish(), + status: z.number().nullish(), + consumptionRequestReason: z.string().nullish(), +}); + +const AppStoreNotificationSummarySchema = z.object({ + bundleId: z.string().min(1), + environment: z.string(), + appAppleId: z.number().nullish(), + requestIdentifier: z.string().nullish(), + productId: z.string().nullish(), + storefrontCountryCodes: z.array(z.string()).nullish(), + failedCount: z.number().nullish(), + succeededCount: z.number().nullish(), +}); + +const AppStoreNotificationAppDataSchema = z.object({ + bundleId: z.string().min(1), + environment: z.string(), + appAppleId: z.number().nullish(), + signedAppTransactionInfo: z.string().nullish(), +}); + +const AppStoreExternalPurchaseTokenSchema = z.object({ + bundleId: z.string().min(1), + appAppleId: z.number().nullish(), + externalPurchaseId: z.string().nullish(), + tokenCreationDate: z.number().nullish(), +}); + +const AppStoreNotificationPayloadSchema = z.object({ + notificationType: z.string().min(1), + notificationUUID: z.string().min(1), + signedDate: z.number(), + subtype: z.string().nullish(), + version: z.string().nullish(), + data: AppStoreNotificationDataSchema.nullish(), + summary: AppStoreNotificationSummarySchema.nullish(), + appData: AppStoreNotificationAppDataSchema.nullish(), + externalPurchaseToken: AppStoreExternalPurchaseTokenSchema.nullish(), +}); + +type WithEnvironment = Omit & {environment: AppStoreEnvironmentName}; + +export type AppStoreTransactionPayload = WithEnvironment>; + +export type AppStoreRenewalInfoPayload = WithEnvironment>; + +export type AppStoreNotificationPayload = z.infer; + +export interface AppStoreVerifiedNotification { + notificationType: string; + subtype: string | null; + notificationUUID: string; + signedDate: number; + environment: AppStoreEnvironmentName; + bundleId: string; + appAppleId: number | null; + status: number | null; + transaction: AppStoreTransactionPayload | null; + renewalInfo: AppStoreRenewalInfoPayload | null; + payload: AppStoreNotificationPayload; +} + +export interface AppStoreVerifyOptions { + expectedEnvironment?: StoreEnvironment | null; +} + +export function toStoreEnvironment(environment: AppStoreEnvironmentName): StoreEnvironment { + return environment === 'Production' ? 'production' : 'sandbox'; +} + +function fail(reason: AppStoreJwsFailureReason, cause?: unknown): never { + throw new AppStoreJwsVerificationError(reason, cause === undefined ? undefined : {cause}); +} + +interface DerElement { + tag: number; + contentStart: number; + end: number; +} + +function readDerElement(buffer: Buffer, offset: number, limit: number): DerElement { + if (offset + 2 > limit) { + throw new RangeError('Truncated DER element'); + } + const tag = buffer[offset]; + if ((tag & 0x1f) === 0x1f) { + throw new RangeError('Unsupported DER tag'); + } + const firstLengthByte = buffer[offset + 1]; + let position = offset + 2; + let length = firstLengthByte; + if (firstLengthByte >= 0x80) { + const lengthBytes = firstLengthByte & 0x7f; + if (lengthBytes === 0 || lengthBytes > 4 || position + lengthBytes > limit) { + throw new RangeError('Invalid DER length'); + } + length = 0; + for (let i = 0; i < lengthBytes; i++) { + length = length * 256 + buffer[position + i]; + } + position += lengthBytes; + } + const end = position + length; + if (end > limit) { + throw new RangeError('DER element exceeds its parent'); + } + return {tag, contentStart: position, end}; +} + +function readDerChildren(buffer: Buffer, parent: DerElement): Array { + const children: Array = []; + let offset = parent.contentStart; + while (offset < parent.end) { + const child = readDerElement(buffer, offset, parent.end); + children.push(child); + offset = child.end; + } + return children; +} + +function encodeObjectIdentifier(oid: string): Buffer { + const arcs = oid.split('.').map((arc) => Number.parseInt(arc, 10)); + const bytes: Array = [arcs[0] * 40 + arcs[1]]; + for (const arc of arcs.slice(2)) { + const encoded = [arc & 0x7f]; + let remaining = Math.floor(arc / 128); + while (remaining > 0) { + encoded.unshift((remaining & 0x7f) | 0x80); + remaining = Math.floor(remaining / 128); + } + bytes.push(...encoded); + } + return Buffer.from(bytes); +} + +function listCertificateExtensionOids(der: Buffer): Array { + const certificate = readDerElement(der, 0, der.length); + if (certificate.tag !== DER_SEQUENCE || certificate.end !== der.length) { + throw new RangeError('Certificate is not a DER sequence'); + } + const [tbsCertificate] = readDerChildren(der, certificate); + if (tbsCertificate?.tag !== DER_SEQUENCE) { + throw new RangeError('Certificate has no TBSCertificate'); + } + const extensionsWrapper = readDerChildren(der, tbsCertificate).find( + (element) => element.tag === DER_EXTENSIONS_CONTEXT_TAG, + ); + if (!extensionsWrapper) { + return []; + } + const [extensions] = readDerChildren(der, extensionsWrapper); + if (extensions?.tag !== DER_SEQUENCE) { + throw new RangeError('Certificate extensions are not a sequence'); + } + return readDerChildren(der, extensions).map((extension) => { + const [extensionId] = readDerChildren(der, extension); + if (extension.tag !== DER_SEQUENCE || extensionId?.tag !== DER_OBJECT_IDENTIFIER) { + throw new RangeError('Certificate extension has no identifier'); + } + return der.subarray(extensionId.contentStart, extensionId.end); + }); +} + +export function certificateHasExtension(certificate: X509Certificate, oid: string): boolean { + const expected = encodeObjectIdentifier(oid); + try { + return listCertificateExtensionOids(certificate.raw).some((extensionOid) => extensionOid.equals(expected)); + } catch { + return false; + } +} + +function parseChainCertificate(encoded: unknown): X509Certificate { + if (typeof encoded !== 'string' || encoded.length % 4 !== 0 || !STANDARD_BASE64_PATTERN.test(encoded)) { + fail('invalid_certificate'); + } + try { + return new X509Certificate(Buffer.from(encoded, 'base64')); + } catch (error) { + fail('invalid_certificate', error); + } +} + +function safeVerify(certificate: X509Certificate, issuer: X509Certificate): boolean { + try { + return certificate.verify(issuer.publicKey); + } catch { + return false; + } +} + +function isValidAt(certificate: X509Certificate, effectiveDate: Date): boolean { + const time = effectiveDate.getTime(); + return ( + certificate.validFromDate.getTime() <= time + CERTIFICATE_DATE_SKEW_MS && + certificate.validToDate.getTime() >= time - CERTIFICATE_DATE_SKEW_MS + ); +} + +export function verifyAppStoreCertificateChain( + x5c: ReadonlyArray, + effectiveDate: Date, + roots: ReadonlyArray = getAppleRootCertificates(), +): KeyObject { + if (x5c.length !== APPLE_CERTIFICATE_CHAIN_LENGTH) { + fail('invalid_chain_length'); + } + const leaf = parseChainCertificate(x5c[0]); + const intermediate = parseChainCertificate(x5c[1]); + const root = roots.find( + (candidate) => intermediate.issuer === candidate.subject && safeVerify(intermediate, candidate), + ); + if (!root || !intermediate.ca) { + fail('untrusted_chain'); + } + if (leaf.ca || leaf.issuer !== intermediate.subject || !safeVerify(leaf, intermediate)) { + fail('untrusted_chain'); + } + if ( + !certificateHasExtension(leaf, APPLE_RECEIPT_SIGNING_EXTENSION_OID) || + !certificateHasExtension(intermediate, APPLE_WWDR_INTERMEDIATE_EXTENSION_OID) + ) { + fail('missing_certificate_extension'); + } + if (!isValidAt(leaf, effectiveDate) || !isValidAt(intermediate, effectiveDate) || !isValidAt(root, effectiveDate)) { + fail('certificate_expired'); + } + return leaf.publicKey; +} + +function decodeUnverifiedPayload(segment: string): Record { + let decoded: unknown; + try { + decoded = JSON.parse(Buffer.from(segment, 'base64url').toString('utf8')); + } catch (error) { + fail('malformed', error); + } + if (decoded === null || typeof decoded !== 'object' || Array.isArray(decoded)) { + fail('malformed'); + } + return decoded as Record; +} + +export async function verifyAppStoreSignedData( + signedData: string, + roots: ReadonlyArray = getAppleRootCertificates(), +): Promise { + if (typeof signedData !== 'string') { + fail('malformed'); + } + const segments = signedData.split('.'); + if (segments.length !== 3 || !segments.every((segment) => BASE64URL_SEGMENT_PATTERN.test(segment))) { + fail('malformed'); + } + let header: ReturnType; + try { + header = decodeProtectedHeader(signedData); + } catch (error) { + fail('malformed', error); + } + if (header.alg !== 'ES256') { + fail('unsupported_algorithm'); + } + if (!Array.isArray(header.x5c)) { + fail('invalid_chain_length'); + } + const unverified = decodeUnverifiedPayload(segments[1]); + const signedDate = unverified.signedDate; + if (typeof signedDate !== 'number' || !Number.isFinite(signedDate)) { + fail('invalid_payload'); + } + const leafKey = verifyAppStoreCertificateChain(header.x5c, new Date(signedDate), roots); + let verifiedBytes: Uint8Array; + try { + const result = await compactVerify(signedData, leafKey, {algorithms: ['ES256']}); + verifiedBytes = result.payload; + } catch (error) { + fail('invalid_signature', error); + } + try { + return JSON.parse(Buffer.from(verifiedBytes).toString('utf8')); + } catch (error) { + fail('malformed', error); + } +} + +function parsePayload(schema: z.ZodType, payload: unknown): T { + const result = schema.safeParse(payload); + if (!result.success) { + fail('invalid_payload', result.error); + } + return result.data; +} + +function resolveEnvironment( + environment: string, + expected: StoreEnvironment | null | undefined, +): AppStoreEnvironmentName { + if (environment !== 'Production' && environment !== 'Sandbox') { + fail('invalid_environment'); + } + if (expected && toStoreEnvironment(environment) !== expected) { + fail('invalid_environment'); + } + return environment; +} + +function resolveApp(bundleId: string): AppStoreAppConfig { + const app = getAppStoreApp(bundleId); + if (!app) { + fail('invalid_app_identifier'); + } + return app; +} + +export async function verifyTransaction( + signedTransaction: string, + options: AppStoreVerifyOptions = {}, +): Promise { + const payload = parsePayload(AppStoreTransactionPayloadSchema, await verifyAppStoreSignedData(signedTransaction)); + resolveApp(payload.bundleId); + const environment = resolveEnvironment(payload.environment, options.expectedEnvironment); + return {...payload, environment}; +} + +export async function verifyRenewalInfo( + signedRenewalInfo: string, + options: AppStoreVerifyOptions = {}, +): Promise { + const payload = parsePayload(AppStoreRenewalInfoPayloadSchema, await verifyAppStoreSignedData(signedRenewalInfo)); + const environment = resolveEnvironment(payload.environment, options.expectedEnvironment); + return {...payload, environment}; +} + +interface NotificationIdentity { + bundleId: string; + appAppleId: number | null; + environment: string; +} + +function resolveNotificationIdentity(payload: AppStoreNotificationPayload): NotificationIdentity { + const block = payload.data ?? payload.summary ?? payload.appData; + if (block) { + return {bundleId: block.bundleId, appAppleId: block.appAppleId ?? null, environment: block.environment}; + } + const token = payload.externalPurchaseToken; + if (token) { + return { + bundleId: token.bundleId, + appAppleId: token.appAppleId ?? null, + environment: token.externalPurchaseId?.startsWith('SANDBOX') ? 'Sandbox' : 'Production', + }; + } + fail('invalid_payload'); +} + +export async function verifyNotification( + signedPayload: string, + options: AppStoreVerifyOptions = {}, +): Promise { + const payload = parsePayload(AppStoreNotificationPayloadSchema, await verifyAppStoreSignedData(signedPayload)); + const identity = resolveNotificationIdentity(payload); + const app = resolveApp(identity.bundleId); + const environment = resolveEnvironment(identity.environment, options.expectedEnvironment); + if (environment === 'Production' && identity.appAppleId !== app.appAppleId) { + fail('invalid_app_identifier'); + } + const nestedOptions: AppStoreVerifyOptions = {expectedEnvironment: toStoreEnvironment(environment)}; + const signedTransactionInfo = payload.data?.signedTransactionInfo; + const signedRenewalInfo = payload.data?.signedRenewalInfo; + const transaction = signedTransactionInfo ? await verifyTransaction(signedTransactionInfo, nestedOptions) : null; + const renewalInfo = signedRenewalInfo ? await verifyRenewalInfo(signedRenewalInfo, nestedOptions) : null; + if (transaction && transaction.bundleId !== identity.bundleId) { + fail('invalid_app_identifier'); + } + if (transaction && renewalInfo && transaction.originalTransactionId !== renewalInfo.originalTransactionId) { + fail('invalid_payload'); + } + return { + notificationType: payload.notificationType, + subtype: payload.subtype ?? null, + notificationUUID: payload.notificationUUID, + signedDate: payload.signedDate, + environment, + bundleId: identity.bundleId, + appAppleId: identity.appAppleId, + status: payload.data?.status ?? null, + transaction, + renewalInfo, + payload, + }; +} diff --git a/fluxer_api/src/api/store_billing/app_store/AppStorePurchaseSync.ts b/fluxer_api/src/api/store_billing/app_store/AppStorePurchaseSync.ts new file mode 100644 index 000000000..3b4f8e5dd --- /dev/null +++ b/fluxer_api/src/api/store_billing/app_store/AppStorePurchaseSync.ts @@ -0,0 +1,253 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import { + type AppStoreRenewalInfoPayload, + type AppStoreTransactionPayload, + toStoreEnvironment, +} from '@app/api/store_billing/app_store/AppStoreJwsVerifier'; +import type { + AppStoreLastTransaction, + AppStoreServerApiClient, +} from '@app/api/store_billing/app_store/AppStoreServerApiClient'; +import {getAppStoreProductSlot, STORE_PRODUCT_SLOTS} from '@app/api/store_billing/StoreBillingConfig'; +import { + buildAppStoreStoreKey, + type StoreEnvironment, + type StorePurchaseSnapshot, + type StorePurchaseState, + type StoreSlot, +} from '@app/api/store_billing/StoreBillingTypes'; + +export const APP_STORE_SUBSCRIPTION_STATUS_ACTIVE = 1; +export const APP_STORE_SUBSCRIPTION_STATUS_EXPIRED = 2; +export const APP_STORE_SUBSCRIPTION_STATUS_BILLING_RETRY = 3; +export const APP_STORE_SUBSCRIPTION_STATUS_GRACE = 4; +export const APP_STORE_SUBSCRIPTION_STATUS_REVOKED = 5; +const APP_STORE_AUTO_RENEWABLE_TYPE = 'Auto-Renewable Subscription'; +const APP_STORE_AUTO_RENEW_OFF = 0; +const APP_STORE_AUTO_RENEW_ON = 1; + +export type AppStorePurchaseSyncFailureReason = 'unknown_product' | 'not_found' | 'mismatch' | 'missing_expiry'; + +export class AppStorePurchaseSyncError extends Error { + constructor(readonly reason: AppStorePurchaseSyncFailureReason) { + super(`App Store purchase could not be synced: ${reason}`); + this.name = 'AppStorePurchaseSyncError'; + } +} + +export interface AppStorePurchaseLookup { + environment: StoreEnvironment | null; + bundleId: string; + productId: string; + transactionId: string; + originalTransactionId: string; +} + +function toDate(value: number | null | undefined): Date | null { + return value === null || value === undefined ? null : new Date(value); +} + +function resolveSlot(productId: string): StoreSlot { + const slot = getAppStoreProductSlot(productId); + if (!slot) { + throw new AppStorePurchaseSyncError('unknown_product'); + } + return slot; +} + +function normalizeAccountToken(token: string | null | undefined): string | null { + return token ? token.toLowerCase() : null; +} + +function revocationReason(transaction: AppStoreTransactionPayload): string | null { + if (transaction.revocationType) { + return transaction.revocationType; + } + return transaction.revocationReason === null || transaction.revocationReason === undefined + ? null + : String(transaction.revocationReason); +} + +export function resolveAppStoreSubscriptionState( + status: number, + transaction: AppStoreTransactionPayload, + renewalInfo: AppStoreRenewalInfoPayload | null, +): StorePurchaseState { + if (status === APP_STORE_SUBSCRIPTION_STATUS_REVOKED || transaction.revocationDate) { + return 'revoked'; + } + switch (status) { + case APP_STORE_SUBSCRIPTION_STATUS_ACTIVE: + return renewalInfo?.autoRenewStatus === APP_STORE_AUTO_RENEW_OFF ? 'canceled' : 'active'; + case APP_STORE_SUBSCRIPTION_STATUS_GRACE: + return 'grace'; + case APP_STORE_SUBSCRIPTION_STATUS_BILLING_RETRY: + return 'billing_retry'; + case APP_STORE_SUBSCRIPTION_STATUS_EXPIRED: + return 'expired'; + default: + return 'pending'; + } +} + +export function buildAppStoreSubscriptionSnapshot(item: AppStoreLastTransaction): StorePurchaseSnapshot { + const {transaction, renewalInfo, status} = item; + if (transaction.originalTransactionId !== item.originalTransactionId) { + throw new AppStorePurchaseSyncError('mismatch'); + } + if (transaction.type !== APP_STORE_AUTO_RENEWABLE_TYPE) { + throw new AppStorePurchaseSyncError('mismatch'); + } + const slot = resolveSlot(transaction.productId); + if (STORE_PRODUCT_SLOTS[slot].kind !== 'subscription') { + throw new AppStorePurchaseSyncError('mismatch'); + } + if (transaction.expiresDate === null || transaction.expiresDate === undefined) { + throw new AppStorePurchaseSyncError('missing_expiry'); + } + const environment = toStoreEnvironment(transaction.environment); + const state = resolveAppStoreSubscriptionState(status, transaction, renewalInfo); + const graceEndsAt = + state === 'grace' ? new Date(renewalInfo?.gracePeriodExpiresDate ?? transaction.expiresDate) : null; + const autoRenewStatus = renewalInfo?.autoRenewStatus; + const lastEventMs = Math.max(transaction.signedDate, renewalInfo?.signedDate ?? 0); + return { + storeKey: buildAppStoreStoreKey(environment, item.originalTransactionId), + provider: 'app_store', + kind: 'subscription', + slot, + environment, + appId: transaction.bundleId, + productId: transaction.productId, + basePlanId: null, + storeReference: item.originalTransactionId, + latestTransactionId: transaction.transactionId, + appTransactionId: transaction.appTransactionId ?? renewalInfo?.appTransactionId ?? null, + accountToken: normalizeAccountToken(renewalInfo?.appAccountToken ?? transaction.appAccountToken), + ownershipType: transaction.inAppOwnershipType ?? null, + state, + storeState: String(status), + providerEntitled: state === 'active' || state === 'canceled' || state === 'grace', + expiresAt: new Date(transaction.expiresDate), + graceEndsAt, + autoRenew: + autoRenewStatus === null || autoRenewStatus === undefined ? null : autoRenewStatus === APP_STORE_AUTO_RENEW_ON, + autoRenewProductId: renewalInfo?.autoRenewProductId ?? null, + startedAt: new Date(renewalInfo?.recentSubscriptionStartDate ?? transaction.purchaseDate), + purchasedAt: new Date(transaction.purchaseDate), + revokedAt: toDate(transaction.revocationDate), + revocationReason: revocationReason(transaction), + linkedStoreKey: null, + expiredStoreKey: null, + expiredAccountToken: null, + acknowledged: true, + quantity: 1, + region: transaction.storefront ?? null, + lastEventAt: new Date(lastEventMs), + }; +} + +export function buildAppStoreGiftSnapshot(transaction: AppStoreTransactionPayload): StorePurchaseSnapshot { + if (transaction.type === APP_STORE_AUTO_RENEWABLE_TYPE) { + throw new AppStorePurchaseSyncError('mismatch'); + } + const slot = resolveSlot(transaction.productId); + if (STORE_PRODUCT_SLOTS[slot].kind !== 'gift') { + throw new AppStorePurchaseSyncError('mismatch'); + } + const environment = toStoreEnvironment(transaction.environment); + const revoked = Boolean(transaction.revocationDate); + return { + storeKey: buildAppStoreStoreKey(environment, transaction.originalTransactionId), + provider: 'app_store', + kind: 'gift', + slot, + environment, + appId: transaction.bundleId, + productId: transaction.productId, + basePlanId: null, + storeReference: transaction.originalTransactionId, + latestTransactionId: transaction.transactionId, + appTransactionId: transaction.appTransactionId ?? null, + accountToken: normalizeAccountToken(transaction.appAccountToken), + ownershipType: transaction.inAppOwnershipType ?? null, + state: revoked ? 'refunded' : 'purchased', + storeState: transaction.revocationType ?? null, + providerEntitled: !revoked, + expiresAt: null, + graceEndsAt: null, + autoRenew: null, + autoRenewProductId: null, + startedAt: new Date(transaction.purchaseDate), + purchasedAt: new Date(transaction.purchaseDate), + revokedAt: toDate(transaction.revocationDate), + revocationReason: revocationReason(transaction), + linkedStoreKey: null, + expiredStoreKey: null, + expiredAccountToken: null, + acknowledged: true, + quantity: transaction.quantity ?? 1, + region: transaction.storefront ?? null, + lastEventAt: new Date(transaction.signedDate), + }; +} + +function pickLastTransaction( + items: ReadonlyArray, + originalTransactionId: string, +): AppStoreLastTransaction | null { + let best: AppStoreLastTransaction | null = null; + for (const item of items) { + if (item.originalTransactionId !== originalTransactionId) { + continue; + } + if (!best || (item.transaction.expiresDate ?? 0) > (best.transaction.expiresDate ?? 0)) { + best = item; + } + } + return best; +} + +export class AppStorePurchaseSync { + constructor(private readonly client: AppStoreServerApiClient) {} + + async loadPurchase(lookup: AppStorePurchaseLookup): Promise { + const slot = resolveSlot(lookup.productId); + if (STORE_PRODUCT_SLOTS[slot].kind === 'subscription') { + return await this.loadSubscription(lookup); + } + return await this.loadGift(lookup); + } + + private async loadSubscription(lookup: AppStorePurchaseLookup): Promise { + const statuses = await this.client.getAllSubscriptionStatuses({ + environment: lookup.environment, + bundleId: lookup.bundleId, + transactionId: lookup.originalTransactionId, + }); + const item = pickLastTransaction( + statuses.groups.flatMap((group) => group.lastTransactions), + lookup.originalTransactionId, + ); + if (!item) { + throw new AppStorePurchaseSyncError('not_found'); + } + if (item.transaction.bundleId !== lookup.bundleId) { + throw new AppStorePurchaseSyncError('mismatch'); + } + return buildAppStoreSubscriptionSnapshot(item); + } + + private async loadGift(lookup: AppStorePurchaseLookup): Promise { + const {transaction} = await this.client.getTransactionInfo({ + environment: lookup.environment, + bundleId: lookup.bundleId, + transactionId: lookup.transactionId, + }); + if (transaction.originalTransactionId !== lookup.originalTransactionId) { + throw new AppStorePurchaseSyncError('mismatch'); + } + return buildAppStoreGiftSnapshot(transaction); + } +} diff --git a/fluxer_api/src/api/store_billing/app_store/AppStoreServerApiClient.ts b/fluxer_api/src/api/store_billing/app_store/AppStoreServerApiClient.ts new file mode 100644 index 000000000..8e0efe37b --- /dev/null +++ b/fluxer_api/src/api/store_billing/app_store/AppStoreServerApiClient.ts @@ -0,0 +1,415 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import {createHash} from 'node:crypto'; +import {readFile} from 'node:fs/promises'; +import {Config} from '@app/api/Config'; +import { + type AppStoreRenewalInfoPayload, + type AppStoreTransactionPayload, + verifyRenewalInfo, + verifyTransaction, +} from '@app/api/store_billing/app_store/AppStoreJwsVerifier'; +import type {StoreEnvironment} from '@app/api/store_billing/StoreBillingTypes'; +import {ms, seconds} from 'itty-time'; +import {type CryptoKey, importPKCS8, SignJWT} from 'jose'; +import {z} from 'zod'; + +export const APP_STORE_PRODUCTION_BASE_URL = 'https://api.storekit.apple.com'; +export const APP_STORE_SANDBOX_BASE_URL = 'https://api.storekit-sandbox.apple.com'; +export const APP_STORE_API_AUDIENCE = 'appstoreconnect-v1'; +export const APP_STORE_TRANSACTION_ID_NOT_FOUND_ERROR = 4040010; +const APP_STORE_RETRYABLE_ERROR_CODES: ReadonlySet = new Set([4040002, 4040004, 4040006, 5000001, 4290000]); +const APP_STORE_TOKEN_TTL_SECONDS = seconds('1 hour'); +const APP_STORE_TOKEN_REUSE_SECONDS = seconds('50 minutes'); +const APP_STORE_REQUEST_TIMEOUT_MS = ms('10 seconds'); + +const AppStoreErrorBodySchema = z.object({ + errorCode: z.number(), + errorMessage: z.string().nullish(), +}); + +const TransactionInfoResponseSchema = z.object({ + signedTransactionInfo: z.string().min(1), +}); + +const StatusResponseSchema = z.object({ + environment: z.string().nullish(), + bundleId: z.string().nullish(), + appAppleId: z.number().nullish(), + data: z.array( + z.object({ + subscriptionGroupIdentifier: z.string(), + lastTransactions: z.array( + z.object({ + originalTransactionId: z.string().min(1), + status: z.number(), + signedTransactionInfo: z.string().min(1), + signedRenewalInfo: z.string().nullish(), + }), + ), + }), + ), +}); + +const NotificationHistoryResponseSchema = z.object({ + notificationHistory: z.array(z.object({signedPayload: z.string().min(1)})).nullish(), + hasMore: z.boolean().nullish(), + paginationToken: z.string().nullish(), +}); + +const TestNotificationResponseSchema = z.object({ + testNotificationToken: z.string().min(1), +}); + +export class AppStoreServerApiError extends Error { + constructor( + message: string, + readonly status: number | null, + readonly errorCode: number | null, + readonly retryable: boolean, + readonly retryAfter: Date | null = null, + ) { + super(message); + this.name = 'AppStoreServerApiError'; + } +} + +export function isAppStoreTransactionNotFound(error: unknown): boolean { + return error instanceof AppStoreServerApiError && error.errorCode === APP_STORE_TRANSACTION_ID_NOT_FOUND_ERROR; +} + +export interface AppStoreLastTransaction { + originalTransactionId: string; + status: number; + transaction: AppStoreTransactionPayload; + renewalInfo: AppStoreRenewalInfoPayload | null; +} + +export interface AppStoreSubscriptionGroup { + subscriptionGroupIdentifier: string; + lastTransactions: Array; +} + +export interface AppStoreSubscriptionStatuses { + environment: StoreEnvironment; + bundleId: string; + appAppleId: number | null; + groups: Array; +} + +export interface AppStoreTransactionInfo { + environment: StoreEnvironment; + transaction: AppStoreTransactionPayload; +} + +interface AppStoreTransactionRequest { + environment: StoreEnvironment | null; + bundleId: string; + transactionId: string; +} + +interface AppStoreNotificationHistoryRequest { + environment: StoreEnvironment; + bundleId: string; + startDate: Date; + endDate: Date; + notificationType?: string; + onlyFailures?: boolean; + paginationToken?: string | null; +} + +interface AppStoreNotificationHistoryPage { + signedPayloads: Array; + paginationToken: string | null; +} + +interface AppStoreSetAppAccountTokenRequest { + environment: StoreEnvironment; + bundleId: string; + originalTransactionId: string; + appAccountToken: string; +} + +interface AppStoreRequest { + environment: StoreEnvironment; + bundleId: string; + method: 'GET' | 'PUT' | 'POST'; + path: string; + body?: unknown; +} + +interface CachedAuthToken { + fingerprint: string; + token: string; + reuseUntilSeconds: number; +} + +interface AppStoreServerApiClientOptions { + now?: () => number; + timeoutMs?: number; +} + +function normalizePem(value: string): string { + return value.replaceAll('\\n', '\n'); +} + +function hasText(value: string | undefined): value is string { + return value !== undefined && value.trim().length > 0; +} + +function parseRetryAfter(value: string | null): Date | null { + if (!value || !/^\d+$/.test(value.trim())) { + return null; + } + const epochMs = Number.parseInt(value.trim(), 10); + return Number.isSafeInteger(epochMs) ? new Date(epochMs) : null; +} + +function parseJsonOrNull(raw: string): unknown { + if (raw.length === 0) { + return null; + } + try { + return JSON.parse(raw); + } catch { + return null; + } +} + +function invalidResponse(path: string, status: number): AppStoreServerApiError { + return new AppStoreServerApiError( + `App Store Server API returned an unexpected body for ${path}`, + status, + null, + false, + ); +} + +export class AppStoreServerApiClient { + private readonly authTokens = new Map(); + private readonly signingKeys = new Map>(); + private readonly now: () => number; + private readonly timeoutMs: number; + + constructor(options: AppStoreServerApiClientOptions = {}) { + this.now = options.now ?? Date.now; + this.timeoutMs = options.timeoutMs ?? APP_STORE_REQUEST_TIMEOUT_MS; + } + + async getAllSubscriptionStatuses(request: AppStoreTransactionRequest): Promise { + return await this.withEnvironmentFallback(request.environment, async (environment) => { + const path = `/inApps/v1/subscriptions/${encodeURIComponent(request.transactionId)}`; + const body = await this.send({environment, bundleId: request.bundleId, method: 'GET', path}); + const parsed = StatusResponseSchema.safeParse(body); + if (!parsed.success) { + throw invalidResponse(path, 200); + } + if (parsed.data.bundleId && parsed.data.bundleId !== request.bundleId) { + throw invalidResponse(path, 200); + } + const groups: Array = []; + for (const group of parsed.data.data) { + const lastTransactions: Array = []; + for (const item of group.lastTransactions) { + const transaction = await verifyTransaction(item.signedTransactionInfo, {expectedEnvironment: environment}); + const renewalInfo = item.signedRenewalInfo + ? await verifyRenewalInfo(item.signedRenewalInfo, {expectedEnvironment: environment}) + : null; + lastTransactions.push({ + originalTransactionId: item.originalTransactionId, + status: item.status, + transaction, + renewalInfo, + }); + } + groups.push({subscriptionGroupIdentifier: group.subscriptionGroupIdentifier, lastTransactions}); + } + return {environment, bundleId: request.bundleId, appAppleId: parsed.data.appAppleId ?? null, groups}; + }); + } + + async getTransactionInfo(request: AppStoreTransactionRequest): Promise { + return await this.withEnvironmentFallback(request.environment, async (environment) => { + const path = `/inApps/v1/transactions/${encodeURIComponent(request.transactionId)}`; + const body = await this.send({environment, bundleId: request.bundleId, method: 'GET', path}); + const parsed = TransactionInfoResponseSchema.safeParse(body); + if (!parsed.success) { + throw invalidResponse(path, 200); + } + const transaction = await verifyTransaction(parsed.data.signedTransactionInfo, { + expectedEnvironment: environment, + }); + if (transaction.bundleId !== request.bundleId) { + throw invalidResponse(path, 200); + } + return {environment, transaction}; + }); + } + + async setAppAccountToken(request: AppStoreSetAppAccountTokenRequest): Promise { + await this.send({ + environment: request.environment, + bundleId: request.bundleId, + method: 'PUT', + path: `/inApps/v1/transactions/${encodeURIComponent(request.originalTransactionId)}/appAccountToken`, + body: {appAccountToken: request.appAccountToken.toLowerCase()}, + }); + } + + async getNotificationHistory(request: AppStoreNotificationHistoryRequest): Promise { + const query = request.paginationToken ? `?paginationToken=${encodeURIComponent(request.paginationToken)}` : ''; + const path = `/inApps/v1/notifications/history${query}`; + const body = await this.send({ + environment: request.environment, + bundleId: request.bundleId, + method: 'POST', + path, + body: { + startDate: request.startDate.getTime(), + endDate: request.endDate.getTime(), + ...(request.notificationType ? {notificationType: request.notificationType} : {}), + ...(request.onlyFailures ? {onlyFailures: true} : {}), + }, + }); + const parsed = NotificationHistoryResponseSchema.safeParse(body); + if (!parsed.success) { + throw invalidResponse(path, 200); + } + return { + signedPayloads: (parsed.data.notificationHistory ?? []).map((item) => item.signedPayload), + paginationToken: parsed.data.hasMore ? (parsed.data.paginationToken ?? null) : null, + }; + } + + async requestTestNotification(request: {environment: StoreEnvironment; bundleId: string}): Promise { + const path = '/inApps/v1/notifications/test'; + const body = await this.send({environment: request.environment, bundleId: request.bundleId, method: 'POST', path}); + const parsed = TestNotificationResponseSchema.safeParse(body); + if (!parsed.success) { + throw invalidResponse(path, 200); + } + return parsed.data.testNotificationToken; + } + + private async withEnvironmentFallback( + environment: StoreEnvironment | null, + run: (environment: StoreEnvironment) => Promise, + ): Promise { + if (environment) { + return await run(environment); + } + try { + return await run('production'); + } catch (error) { + if (!isAppStoreTransactionNotFound(error)) { + throw error; + } + return await run('sandbox'); + } + } + + private async send(request: AppStoreRequest): Promise { + const baseUrl = request.environment === 'production' ? APP_STORE_PRODUCTION_BASE_URL : APP_STORE_SANDBOX_BASE_URL; + const token = await this.getAuthToken(request.bundleId); + const headers: Record = {Authorization: `Bearer ${token}`, Accept: 'application/json'}; + if (request.body !== undefined) { + headers['Content-Type'] = 'application/json'; + } + let response: Response; + let raw: string; + try { + response = await fetch(`${baseUrl}${request.path}`, { + method: request.method, + headers, + body: request.body === undefined ? undefined : JSON.stringify(request.body), + redirect: 'manual', + signal: AbortSignal.timeout(this.timeoutMs), + }); + raw = await response.text(); + } catch (error) { + throw new AppStoreServerApiError( + `App Store Server API request failed: ${error instanceof Error ? error.message : String(error)}`, + null, + null, + true, + ); + } + const body = parseJsonOrNull(raw); + if (response.ok) { + return body; + } + const parsedError = AppStoreErrorBodySchema.safeParse(body); + const errorCode = parsedError.success ? parsedError.data.errorCode : null; + const retryable = + (errorCode !== null && APP_STORE_RETRYABLE_ERROR_CODES.has(errorCode)) || + response.status === 429 || + (errorCode === null && response.status >= 500); + if (response.status === 401) { + this.authTokens.delete(request.bundleId); + } + throw new AppStoreServerApiError( + `App Store Server API ${request.method} ${request.path} failed with ${response.status}${errorCode === null ? '' : ` (${errorCode})`}`, + response.status, + errorCode, + retryable, + parseRetryAfter(response.headers.get('retry-after')), + ); + } + + private async getAuthToken(bundleId: string): Promise { + const cfg = Config.appStore; + if (!hasText(cfg.issuerId) || !hasText(cfg.keyId)) { + throw new AppStoreServerApiError('App Store Server API credentials are not configured', null, null, false); + } + const privateKey = await this.resolvePrivateKey(); + const fingerprint = createHash('sha256') + .update(JSON.stringify([bundleId, cfg.issuerId, cfg.keyId, privateKey])) + .digest('hex'); + const nowSeconds = Math.floor(this.now() / 1000); + const cached = this.authTokens.get(bundleId); + if (cached && cached.fingerprint === fingerprint && cached.reuseUntilSeconds > nowSeconds) { + return cached.token; + } + const key = await this.signingKey(privateKey); + const token = await new SignJWT({bid: bundleId}) + .setProtectedHeader({alg: 'ES256', kid: cfg.keyId, typ: 'JWT'}) + .setIssuer(cfg.issuerId) + .setIssuedAt(nowSeconds) + .setExpirationTime(nowSeconds + APP_STORE_TOKEN_TTL_SECONDS) + .setAudience(APP_STORE_API_AUDIENCE) + .sign(key); + this.authTokens.set(bundleId, {fingerprint, token, reuseUntilSeconds: nowSeconds + APP_STORE_TOKEN_REUSE_SECONDS}); + return token; + } + + private async resolvePrivateKey(): Promise { + const cfg = Config.appStore; + if (hasText(cfg.privateKey)) { + return normalizePem(cfg.privateKey); + } + if (hasText(cfg.privateKeyPath)) { + return normalizePem(await readFile(cfg.privateKeyPath, 'utf8')); + } + throw new AppStoreServerApiError('App Store Server API credentials are not configured', null, null, false); + } + + private async signingKey(privateKey: string): Promise { + const cached = this.signingKeys.get(privateKey); + if (cached) { + return await cached; + } + const pending = importPKCS8(privateKey, 'ES256'); + this.signingKeys.set(privateKey, pending); + try { + return await pending; + } catch (error) { + this.signingKeys.delete(privateKey); + throw new AppStoreServerApiError( + `App Store Server API signing key is invalid: ${error instanceof Error ? error.message : String(error)}`, + null, + null, + false, + ); + } + } +} diff --git a/fluxer_api/src/api/store_billing/app_store/AppleRootCertificates.ts b/fluxer_api/src/api/store_billing/app_store/AppleRootCertificates.ts new file mode 100644 index 000000000..16a573232 --- /dev/null +++ b/fluxer_api/src/api/store_billing/app_store/AppleRootCertificates.ts @@ -0,0 +1,35 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import {X509Certificate} from 'node:crypto'; + +export const APPLE_ROOT_CA_G3_PEM = `-----BEGIN CERTIFICATE----- +MIICQzCCAcmgAwIBAgIILcX8iNLFS5UwCgYIKoZIzj0EAwMwZzEbMBkGA1UEAwwS +QXBwbGUgUm9vdCBDQSAtIEczMSYwJAYDVQQLDB1BcHBsZSBDZXJ0aWZpY2F0aW9u +IEF1dGhvcml0eTETMBEGA1UECgwKQXBwbGUgSW5jLjELMAkGA1UEBhMCVVMwHhcN +MTQwNDMwMTgxOTA2WhcNMzkwNDMwMTgxOTA2WjBnMRswGQYDVQQDDBJBcHBsZSBS +b290IENBIC0gRzMxJjAkBgNVBAsMHUFwcGxlIENlcnRpZmljYXRpb24gQXV0aG9y +aXR5MRMwEQYDVQQKDApBcHBsZSBJbmMuMQswCQYDVQQGEwJVUzB2MBAGByqGSM49 +AgEGBSuBBAAiA2IABJjpLz1AcqTtkyJygRMc3RCV8cWjTnHcFBbZDuWmBSp3ZHtf +TjjTuxxEtX/1H7YyYl3J6YRbTzBPEVoA/VhYDKX1DyxNB0cTddqXl5dvMVztK517 +IDvYuVTZXpmkOlEKMaNCMEAwHQYDVR0OBBYEFLuw3qFYM4iapIqZ3r6966/ayySr +MA8GA1UdEwEB/wQFMAMBAf8wDgYDVR0PAQH/BAQDAgEGMAoGCCqGSM49BAMDA2gA +MGUCMQCD6cHEFl4aXTQY2e3v9GwOAEZLuN+yRhHFD/3meoyhpmvOwgPUnPWTxnS4 +at+qIxUCMG1mihDK1A3UT82NQz60imOlM27jbdoXt2QfyFMm+YhidDkLF1vLUagM +6BgD56KyKA== +-----END CERTIFICATE----- +`; + +let pinnedRoots: ReadonlyArray | null = null; +let injectedRoots: ReadonlyArray | undefined; + +export function getAppleRootCertificates(): ReadonlyArray { + if (injectedRoots !== undefined) { + return injectedRoots; + } + pinnedRoots ??= [new X509Certificate(APPLE_ROOT_CA_G3_PEM)]; + return pinnedRoots; +} + +export function setInjectedAppleRootCertificates(roots: ReadonlyArray | undefined): void { + injectedRoots = roots; +} diff --git a/fluxer_api/src/api/store_billing/google_play/GooglePlayAccessTokenProvider.ts b/fluxer_api/src/api/store_billing/google_play/GooglePlayAccessTokenProvider.ts new file mode 100644 index 000000000..dbd813daf --- /dev/null +++ b/fluxer_api/src/api/store_billing/google_play/GooglePlayAccessTokenProvider.ts @@ -0,0 +1,258 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import {createHash} from 'node:crypto'; +import {readFile} from 'node:fs/promises'; +import {Config} from '@app/api/Config'; +import { + classifyGooglePlayStatus, + GooglePlayApiError, + parseRetryAfterMs, +} from '@app/api/store_billing/google_play/GooglePlayApiError'; +import {ms, seconds} from 'itty-time'; +import {type CryptoKey, importPKCS8, SignJWT} from 'jose'; +import {z} from 'zod'; + +export const GOOGLE_PLAY_ANDROIDPUBLISHER_SCOPE = 'https://www.googleapis.com/auth/androidpublisher'; +export const GOOGLE_DEFAULT_TOKEN_URI = 'https://oauth2.googleapis.com/token'; +const JWT_BEARER_GRANT_TYPE = 'urn:ietf:params:oauth:grant-type:jwt-bearer'; +const ASSERTION_TTL_SECONDS = seconds('1 hour'); +const DEFAULT_EXPIRES_IN_SECONDS = seconds('1 hour'); +const REFRESH_MARGIN_MS = ms('5 minutes'); +const TOKEN_REQUEST_TIMEOUT_MS = ms('10 seconds'); + +const ServiceAccountJsonSchema = z.object({ + client_email: z.string().optional(), + private_key: z.string().optional(), + private_key_id: z.string().optional(), + token_uri: z.string().optional(), +}); + +const TokenResponseSchema = z.object({ + access_token: z.string().min(1), + expires_in: z.union([z.number(), z.string()]).optional(), + token_type: z.string().optional(), +}); + +const TokenErrorSchema = z.object({ + error: z.string().optional(), + error_description: z.string().optional(), +}); + +export interface GooglePlayServiceAccountCredentials { + clientEmail: string; + privateKey: string; + privateKeyId: string | null; + tokenUri: string; +} + +interface CachedAccessToken { + fingerprint: string; + token: string; + expiresAtMs: number; +} + +interface GooglePlayAccessTokenProviderOptions { + now?: () => number; + timeoutMs?: number; +} + +function hasText(value: string | undefined): value is string { + return value !== undefined && value.trim().length > 0; +} + +function normalizePem(value: string): string { + return value.replaceAll('\\n', '\n'); +} + +function parseJsonOrNull(raw: string): unknown { + try { + return JSON.parse(raw); + } catch { + return null; + } +} + +function configFingerprint(): string { + const cfg = Config.googlePlay; + return createHash('sha256') + .update( + JSON.stringify([ + cfg.clientEmail ?? null, + cfg.privateKey ?? null, + cfg.privateKeyPath ?? null, + cfg.serviceAccountJsonPath ?? null, + cfg.tokenUri, + ]), + ) + .digest('hex'); +} + +export async function resolveGooglePlayCredentials(): Promise { + const cfg = Config.googlePlay; + let serviceAccount: z.infer = {}; + if (hasText(cfg.serviceAccountJsonPath)) { + const raw = await readFile(cfg.serviceAccountJsonPath, 'utf8'); + const parsed = ServiceAccountJsonSchema.safeParse(parseJsonOrNull(raw)); + if (!parsed.success) { + throw new GooglePlayApiError('Google Play service account file is invalid', 'auth', null, 'invalid_credentials'); + } + serviceAccount = parsed.data; + } + const clientEmail = hasText(cfg.clientEmail) ? cfg.clientEmail : serviceAccount.client_email; + let privateKey: string | undefined; + if (hasText(cfg.privateKey)) { + privateKey = cfg.privateKey; + } else if (hasText(cfg.privateKeyPath)) { + privateKey = await readFile(cfg.privateKeyPath, 'utf8'); + } else { + privateKey = serviceAccount.private_key; + } + if (!hasText(clientEmail) || !hasText(privateKey)) { + throw new GooglePlayApiError('Google Play credentials are not configured', 'auth', null, 'missing_credentials'); + } + const tokenUri = + cfg.tokenUri !== GOOGLE_DEFAULT_TOKEN_URI || !hasText(serviceAccount.token_uri) + ? cfg.tokenUri + : serviceAccount.token_uri; + return { + clientEmail: clientEmail.trim(), + privateKey: normalizePem(privateKey), + privateKeyId: hasText(serviceAccount.private_key_id) ? serviceAccount.private_key_id : null, + tokenUri, + }; +} + +function parseExpiresInSeconds(value: number | string | undefined): number { + const parsed = typeof value === 'string' ? Number.parseInt(value.trim(), 10) : value; + if (parsed === undefined || !Number.isFinite(parsed) || parsed <= 0) { + return DEFAULT_EXPIRES_IN_SECONDS; + } + return parsed; +} + +export class GooglePlayAccessTokenProvider { + private cached: CachedAccessToken | null = null; + private inFlight: {fingerprint: string; promise: Promise} | null = null; + private readonly signingKeys = new Map>(); + private readonly now: () => number; + private readonly timeoutMs: number; + + constructor(options: GooglePlayAccessTokenProviderOptions = {}) { + this.now = options.now ?? Date.now; + this.timeoutMs = options.timeoutMs ?? TOKEN_REQUEST_TIMEOUT_MS; + } + + async getAccessToken(): Promise { + const fingerprint = configFingerprint(); + const cached = this.cached; + if (cached && cached.fingerprint === fingerprint && this.now() < cached.expiresAtMs - REFRESH_MARGIN_MS) { + return cached.token; + } + if (this.inFlight && this.inFlight.fingerprint === fingerprint) { + return await this.inFlight.promise; + } + const promise = this.fetchAccessToken(fingerprint); + const entry = {fingerprint, promise}; + this.inFlight = entry; + try { + return await promise; + } finally { + if (this.inFlight === entry) { + this.inFlight = null; + } + } + } + + invalidate(token?: string): void { + if (token === undefined || this.cached?.token === token) { + this.cached = null; + } + } + + private async fetchAccessToken(fingerprint: string): Promise { + const credentials = await resolveGooglePlayCredentials(); + const assertion = await this.signAssertion(credentials); + let response: Response; + try { + response = await fetch(credentials.tokenUri, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + Accept: 'application/json', + }, + body: new URLSearchParams({grant_type: JWT_BEARER_GRANT_TYPE, assertion}).toString(), + signal: AbortSignal.timeout(this.timeoutMs), + }); + } catch (error) { + throw new GooglePlayApiError( + `Google OAuth token request failed: ${error instanceof Error ? error.message : String(error)}`, + 'retryable', + null, + 'network_error', + ); + } + const body: unknown = await response.json().catch(() => null); + if (!response.ok) { + const kind = classifyGooglePlayStatus(response.status); + const parsedError = TokenErrorSchema.safeParse(body); + const reason = parsedError.success ? (parsedError.data.error ?? null) : null; + throw new GooglePlayApiError( + `Google OAuth token request failed with ${response.status}`, + kind === 'retryable' ? 'retryable' : 'auth', + response.status, + reason, + parseRetryAfterMs(response.headers.get('retry-after'), this.now()), + ); + } + const parsed = TokenResponseSchema.safeParse(body); + if (!parsed.success) { + throw new GooglePlayApiError( + 'Google OAuth token response is malformed', + 'retryable', + response.status, + 'malformed_response', + ); + } + const token = parsed.data.access_token; + if (configFingerprint() === fingerprint) { + this.cached = { + fingerprint, + token, + expiresAtMs: this.now() + parseExpiresInSeconds(parsed.data.expires_in) * 1000, + }; + } + return token; + } + + private async signAssertion(credentials: GooglePlayServiceAccountCredentials): Promise { + const key = await this.signingKey(credentials.privateKey); + const nowSeconds = Math.floor(this.now() / 1000); + return await new SignJWT({scope: GOOGLE_PLAY_ANDROIDPUBLISHER_SCOPE}) + .setProtectedHeader({ + alg: 'RS256', + typ: 'JWT', + ...(credentials.privateKeyId ? {kid: credentials.privateKeyId} : {}), + }) + .setIssuer(credentials.clientEmail) + .setAudience(credentials.tokenUri) + .setIssuedAt(nowSeconds) + .setExpirationTime(nowSeconds + ASSERTION_TTL_SECONDS) + .sign(key); + } + + private async signingKey(privateKey: string): Promise { + const cacheKey = createHash('sha256').update(privateKey).digest('hex'); + const cached = this.signingKeys.get(cacheKey); + if (cached) { + return await cached; + } + const pending = importPKCS8(privateKey, 'RS256'); + this.signingKeys.set(cacheKey, pending); + try { + return await pending; + } catch { + this.signingKeys.delete(cacheKey); + throw new GooglePlayApiError('Google Play private key is invalid', 'auth', null, 'invalid_credentials'); + } + } +} diff --git a/fluxer_api/src/api/store_billing/google_play/GooglePlayApiError.ts b/fluxer_api/src/api/store_billing/google_play/GooglePlayApiError.ts new file mode 100644 index 000000000..f0d551d2f --- /dev/null +++ b/fluxer_api/src/api/store_billing/google_play/GooglePlayApiError.ts @@ -0,0 +1,48 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +export type GooglePlayApiErrorKind = 'invalid_token' | 'retryable' | 'auth' | 'rejected'; + +export class GooglePlayApiError extends Error { + constructor( + message: string, + public readonly kind: GooglePlayApiErrorKind, + public readonly status: number | null, + public readonly reason: string | null, + public readonly retryAfterMs: number | null = null, + ) { + super(message); + this.name = 'GooglePlayApiError'; + } +} + +export function isGooglePlayApiError(error: unknown, kind?: GooglePlayApiErrorKind): error is GooglePlayApiError { + return error instanceof GooglePlayApiError && (kind === undefined || error.kind === kind); +} + +export function classifyGooglePlayStatus(status: number): GooglePlayApiErrorKind { + if (status === 400 || status === 404 || status === 410) { + return 'invalid_token'; + } + if (status === 401 || status === 403) { + return 'auth'; + } + if (status === 408 || status === 429 || status >= 500) { + return 'retryable'; + } + return 'rejected'; +} + +export function parseRetryAfterMs(value: string | null, nowMs: number): number | null { + if (!value) { + return null; + } + const trimmed = value.trim(); + if (/^\d+$/u.test(trimmed)) { + return Number.parseInt(trimmed, 10) * 1000; + } + const date = Date.parse(trimmed); + if (Number.isNaN(date)) { + return null; + } + return Math.max(0, date - nowMs); +} diff --git a/fluxer_api/src/api/store_billing/google_play/GooglePlayDeveloperApiClient.ts b/fluxer_api/src/api/store_billing/google_play/GooglePlayDeveloperApiClient.ts new file mode 100644 index 000000000..8ee43a41d --- /dev/null +++ b/fluxer_api/src/api/store_billing/google_play/GooglePlayDeveloperApiClient.ts @@ -0,0 +1,381 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import type {GooglePlayAccessTokenProvider} from '@app/api/store_billing/google_play/GooglePlayAccessTokenProvider'; +import { + classifyGooglePlayStatus, + GooglePlayApiError, + parseRetryAfterMs, +} from '@app/api/store_billing/google_play/GooglePlayApiError'; +import {isGooglePlayPackageConfigured} from '@app/api/store_billing/StoreBillingConfig'; +import {ms} from 'itty-time'; +import {z} from 'zod'; + +export const GOOGLE_PLAY_API_BASE_URL = 'https://androidpublisher.googleapis.com/androidpublisher/v3/applications'; +const REQUEST_TIMEOUT_MS = ms('15 seconds'); +const VOIDED_PURCHASE_TYPE_SUBSCRIPTIONS_AND_PRODUCTS = '1'; + +const ExternalAccountIdentifiersSchema = z.object({ + externalAccountId: z.string().optional(), + obfuscatedExternalAccountId: z.string().optional(), + obfuscatedExternalProfileId: z.string().optional(), + obfuscatedAccountId: z.string().optional(), + obfuscatedProfileId: z.string().optional(), +}); + +const SubscriptionLineItemSchema = z.object({ + productId: z.string(), + expiryTime: z.string().optional(), + latestSuccessfulOrderId: z.string().optional(), + autoRenewingPlan: z + .object({ + autoRenewEnabled: z.boolean().optional(), + }) + .optional(), + prepaidPlan: z + .object({ + allowExtendAfterTime: z.string().optional(), + }) + .optional(), + offerDetails: z + .object({ + basePlanId: z.string().optional(), + offerId: z.string().optional(), + offerTags: z.array(z.string()).optional(), + }) + .optional(), + deferredItemReplacement: z + .object({ + productId: z.string().optional(), + }) + .optional(), +}); + +const SubscriptionPurchaseV2Schema = z.object({ + kind: z.string().optional(), + regionCode: z.string().optional(), + lineItems: z.array(SubscriptionLineItemSchema).optional(), + startTime: z.string().optional(), + subscriptionState: z.string().optional(), + latestOrderId: z.string().optional(), + linkedPurchaseToken: z.string().optional(), + pausedStateContext: z + .object({ + autoResumeTime: z.string().optional(), + }) + .optional(), + canceledStateContext: z + .object({ + userInitiatedCancellation: z.object({cancelTime: z.string().optional()}).optional(), + systemInitiatedCancellation: z.object({}).optional(), + developerInitiatedCancellation: z.object({}).optional(), + replacementCancellation: z.object({}).optional(), + }) + .optional(), + testPurchase: z.object({}).optional(), + acknowledgementState: z.string().optional(), + externalAccountIdentifiers: ExternalAccountIdentifiersSchema.optional(), + outOfAppPurchaseContext: z + .object({ + expiredExternalAccountIdentifiers: ExternalAccountIdentifiersSchema.optional(), + expiredPurchaseToken: z.string().optional(), + }) + .optional(), + etag: z.string().optional(), +}); + +export type SubscriptionPurchaseV2 = z.infer; +export type SubscriptionPurchaseLineItem = z.infer; + +const ProductLineItemSchema = z.object({ + productId: z.string(), + productOfferDetails: z + .object({ + offerId: z.string().optional(), + purchaseOptionId: z.string().optional(), + quantity: z.number().int().optional(), + refundableQuantity: z.number().int().optional(), + consumptionState: z.string().optional(), + }) + .optional(), +}); + +const ProductPurchaseV2Schema = z.object({ + kind: z.string().optional(), + productLineItem: z.array(ProductLineItemSchema).optional(), + purchaseStateContext: z + .object({ + purchaseState: z.string().optional(), + }) + .optional(), + testPurchaseContext: z + .object({ + fopType: z.string().optional(), + }) + .optional(), + orderId: z.string().optional(), + obfuscatedExternalAccountId: z.string().optional(), + obfuscatedExternalProfileId: z.string().optional(), + regionCode: z.string().optional(), + purchaseCompletionTime: z.string().optional(), + acknowledgementState: z.string().optional(), +}); + +export type ProductPurchaseV2 = z.infer; + +const VoidedPurchaseSchema = z.object({ + kind: z.string().optional(), + purchaseToken: z.string(), + purchaseTimeMillis: z.string().optional(), + voidedTimeMillis: z.string().optional(), + orderId: z.string().optional(), + voidedSource: z.number().int().optional(), + voidedReason: z.number().int().optional(), + voidedQuantity: z.number().int().optional(), +}); + +export type GooglePlayVoidedPurchase = z.infer; + +const VoidedPurchasesResponseSchema = z.object({ + voidedPurchases: z.array(VoidedPurchaseSchema).optional(), + tokenPagination: z + .object({ + nextPageToken: z.string().optional(), + }) + .optional(), +}); + +const GoogleErrorBodySchema = z.object({ + error: z.object({ + code: z.number().optional(), + message: z.string().optional(), + status: z.string().optional(), + errors: z.array(z.object({reason: z.string().optional()})).optional(), + }), +}); + +export type GooglePlayCancellationType = 'USER_REQUESTED_STOP_RENEWALS' | 'DEVELOPER_REQUESTED_STOP_PAYMENTS'; + +export interface GooglePlayAcknowledgeOptions { + obfuscatedAccountId?: string; +} + +export interface ListGooglePlayVoidedPurchasesParams { + startTime: Date; + endTime?: Date; + pageToken?: string; + maxResults?: number; +} + +export interface GooglePlayVoidedPurchasesPage { + voidedPurchases: Array; + nextPageToken: string | null; +} + +interface GooglePlayDeveloperApiClientOptions { + accessTokenProvider: GooglePlayAccessTokenProvider; + baseUrl?: string; + timeoutMs?: number; + now?: () => number; +} + +interface RequestParams { + method: 'GET' | 'POST'; + packageName: string; + path: string; + query?: Record; + body?: unknown; +} + +function segment(value: string): string { + return encodeURIComponent(value); +} + +export class GooglePlayDeveloperApiClient { + private readonly accessTokenProvider: GooglePlayAccessTokenProvider; + private readonly baseUrl: string; + private readonly timeoutMs: number; + private readonly now: () => number; + + constructor(options: GooglePlayDeveloperApiClientOptions) { + this.accessTokenProvider = options.accessTokenProvider; + this.baseUrl = (options.baseUrl ?? GOOGLE_PLAY_API_BASE_URL).replace(/\/+$/u, ''); + this.timeoutMs = options.timeoutMs ?? REQUEST_TIMEOUT_MS; + this.now = options.now ?? Date.now; + } + + async getSubscription(packageName: string, purchaseToken: string): Promise { + const body = await this.request({ + method: 'GET', + packageName, + path: `/purchases/subscriptionsv2/tokens/${segment(purchaseToken)}`, + }); + return this.parse(SubscriptionPurchaseV2Schema, body, 'subscriptionsv2.get'); + } + + async cancelSubscription( + packageName: string, + purchaseToken: string, + cancellationType: GooglePlayCancellationType, + ): Promise { + await this.request({ + method: 'POST', + packageName, + path: `/purchases/subscriptionsv2/tokens/${segment(purchaseToken)}:cancel`, + body: {cancellationContext: {cancellationType}}, + }); + } + + async revokeSubscription(packageName: string, purchaseToken: string): Promise { + await this.request({ + method: 'POST', + packageName, + path: `/purchases/subscriptionsv2/tokens/${segment(purchaseToken)}:revoke`, + body: {revocationContext: {fullRefund: {}}}, + }); + } + + async acknowledgeSubscription( + packageName: string, + productId: string, + purchaseToken: string, + options: GooglePlayAcknowledgeOptions = {}, + ): Promise { + await this.request({ + method: 'POST', + packageName, + path: `/purchases/subscriptions/${segment(productId)}/tokens/${segment(purchaseToken)}:acknowledge`, + body: options.obfuscatedAccountId ? {externalAccountIds: {obfuscatedAccountId: options.obfuscatedAccountId}} : {}, + }); + } + + async getProduct(packageName: string, purchaseToken: string): Promise { + const body = await this.request({ + method: 'GET', + packageName, + path: `/purchases/productsv2/tokens/${segment(purchaseToken)}`, + }); + return this.parse(ProductPurchaseV2Schema, body, 'productsv2.get'); + } + + async consumeProduct(packageName: string, productId: string, purchaseToken: string): Promise { + await this.request({ + method: 'POST', + packageName, + path: `/purchases/products/${segment(productId)}/tokens/${segment(purchaseToken)}:consume`, + }); + } + + async refundOrder(packageName: string, orderId: string): Promise { + await this.request({ + method: 'POST', + packageName, + path: `/orders/${segment(orderId)}:refund`, + query: {revoke: 'true'}, + }); + } + + async listVoidedPurchases( + packageName: string, + params: ListGooglePlayVoidedPurchasesParams, + ): Promise { + const query: Record = { + type: VOIDED_PURCHASE_TYPE_SUBSCRIPTIONS_AND_PRODUCTS, + startTime: String(params.startTime.getTime()), + }; + if (params.endTime) { + query['endTime'] = String(params.endTime.getTime()); + } + if (params.pageToken) { + query['token'] = params.pageToken; + } + if (params.maxResults !== undefined) { + query['maxResults'] = String(params.maxResults); + } + const body = await this.request({method: 'GET', packageName, path: '/purchases/voidedpurchases', query}); + const parsed = this.parse(VoidedPurchasesResponseSchema, body, 'voidedpurchases.list'); + return { + voidedPurchases: parsed.voidedPurchases ?? [], + nextPageToken: parsed.tokenPagination?.nextPageToken ?? null, + }; + } + + private parse(schema: z.ZodType, body: unknown, operation: string): T { + const parsed = schema.safeParse(body); + if (!parsed.success) { + throw new GooglePlayApiError( + `Google Play ${operation} response is malformed`, + 'retryable', + 200, + 'malformed_response', + ); + } + return parsed.data; + } + + private async request(params: RequestParams): Promise { + if (!isGooglePlayPackageConfigured(params.packageName)) { + throw new GooglePlayApiError('Google Play package is not configured', 'rejected', null, 'package_not_configured'); + } + const accessToken = await this.accessTokenProvider.getAccessToken(); + const response = await this.send(params, accessToken); + if (response.status === 401) { + await response.text().catch(() => ''); + this.accessTokenProvider.invalidate(accessToken); + const retryToken = await this.accessTokenProvider.getAccessToken(); + return await this.readResponse(params, await this.send(params, retryToken)); + } + return await this.readResponse(params, response); + } + + private async send(params: RequestParams, accessToken: string): Promise { + const url = new URL(`${this.baseUrl}/${segment(params.packageName)}${params.path}`); + for (const [key, value] of Object.entries(params.query ?? {})) { + url.searchParams.set(key, value); + } + try { + return await fetch(url, { + method: params.method, + headers: { + Authorization: `Bearer ${accessToken}`, + Accept: 'application/json', + ...(params.body === undefined ? {} : {'Content-Type': 'application/json'}), + }, + body: params.body === undefined ? undefined : JSON.stringify(params.body), + signal: AbortSignal.timeout(this.timeoutMs), + }); + } catch (error) { + throw new GooglePlayApiError( + `Google Play ${params.method} request failed: ${error instanceof Error ? error.message : String(error)}`, + 'retryable', + null, + 'network_error', + ); + } + } + + private async readResponse(params: RequestParams, response: Response): Promise { + const text = await response.text().catch(() => ''); + let body: unknown = null; + if (text.length > 0) { + try { + body = JSON.parse(text); + } catch { + body = null; + } + } + if (response.ok) { + return body ?? {}; + } + const parsedError = GoogleErrorBodySchema.safeParse(body); + const reason = parsedError.success + ? (parsedError.data.error.errors?.find((entry) => entry.reason)?.reason ?? parsedError.data.error.status ?? null) + : null; + throw new GooglePlayApiError( + `Google Play ${params.method} request failed with ${response.status}`, + classifyGooglePlayStatus(response.status), + response.status, + reason, + parseRetryAfterMs(response.headers.get('retry-after'), this.now()), + ); + } +} diff --git a/fluxer_api/src/api/store_billing/google_play/GooglePlayPurchaseSync.ts b/fluxer_api/src/api/store_billing/google_play/GooglePlayPurchaseSync.ts new file mode 100644 index 000000000..c718bfb06 --- /dev/null +++ b/fluxer_api/src/api/store_billing/google_play/GooglePlayPurchaseSync.ts @@ -0,0 +1,234 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import type { + ProductPurchaseV2, + SubscriptionPurchaseLineItem, + SubscriptionPurchaseV2, +} from '@app/api/store_billing/google_play/GooglePlayDeveloperApiClient'; +import { + getGooglePlayOneTimeProductSlot, + getGooglePlaySubscriptionSlot, +} from '@app/api/store_billing/StoreBillingConfig'; +import { + buildGooglePlayStoreKey, + type StorePurchaseSnapshot, + type StorePurchaseState, + type StoreSlot, +} from '@app/api/store_billing/StoreBillingTypes'; + +const ENTITLING_SUBSCRIPTION_STATES: ReadonlySet = new Set([ + 'SUBSCRIPTION_STATE_ACTIVE', + 'SUBSCRIPTION_STATE_IN_GRACE_PERIOD', + 'SUBSCRIPTION_STATE_CANCELED', +]); + +interface SyncGooglePlaySubscriptionParams { + packageName: string; + purchaseToken: string; + purchase: SubscriptionPurchaseV2; + now: Date; +} + +interface SyncGooglePlayProductParams { + packageName: string; + purchaseToken: string; + expectedProductId?: string; + purchase: ProductPurchaseV2; + now: Date; +} + +interface ConfiguredLineItem { + lineItem: SubscriptionPurchaseLineItem; + basePlanId: string; + slot: StoreSlot; + expiresAt: Date | null; +} + +function parseTime(value: string | undefined): Date | null { + if (!value) { + return null; + } + const parsed = Date.parse(value); + return Number.isNaN(parsed) ? null : new Date(parsed); +} + +function nonEmpty(value: string | undefined): string | null { + return value && value.length > 0 ? value : null; +} + +function pickConfiguredLineItem(lineItems: Array): ConfiguredLineItem | null { + let best: ConfiguredLineItem | null = null; + for (const lineItem of lineItems) { + const basePlanId = lineItem.offerDetails?.basePlanId; + if (!basePlanId) { + continue; + } + const slot = getGooglePlaySubscriptionSlot(lineItem.productId, basePlanId); + if (!slot) { + continue; + } + const candidate: ConfiguredLineItem = {lineItem, basePlanId, slot, expiresAt: parseTime(lineItem.expiryTime)}; + if (!best || (candidate.expiresAt?.getTime() ?? 0) > (best.expiresAt?.getTime() ?? 0)) { + best = candidate; + } + } + return best; +} + +function mapSubscriptionState(subscriptionState: string, expiresAt: Date | null, now: Date): StorePurchaseState { + switch (subscriptionState) { + case 'SUBSCRIPTION_STATE_ACTIVE': + return 'active'; + case 'SUBSCRIPTION_STATE_IN_GRACE_PERIOD': + return 'grace'; + case 'SUBSCRIPTION_STATE_CANCELED': + return expiresAt && expiresAt.getTime() > now.getTime() ? 'canceled' : 'expired'; + case 'SUBSCRIPTION_STATE_ON_HOLD': + return 'on_hold'; + case 'SUBSCRIPTION_STATE_PAUSED': + return 'paused'; + case 'SUBSCRIPTION_STATE_EXPIRED': + case 'SUBSCRIPTION_STATE_PENDING_PURCHASE_CANCELED': + return 'expired'; + default: + return 'pending'; + } +} + +function isSubscriptionEntitled(subscriptionState: string, expiresAt: Date | null, now: Date): boolean { + if (!ENTITLING_SUBSCRIPTION_STATES.has(subscriptionState) || !expiresAt) { + return false; + } + if (subscriptionState === 'SUBSCRIPTION_STATE_ACTIVE') { + return true; + } + return expiresAt.getTime() > now.getTime(); +} + +function resolveAutoRenew(lineItem: SubscriptionPurchaseLineItem): boolean | null { + if (lineItem.autoRenewingPlan) { + return lineItem.autoRenewingPlan.autoRenewEnabled === true; + } + if (lineItem.prepaidPlan) { + return false; + } + return null; +} + +export function buildGooglePlaySubscriptionSnapshot( + params: SyncGooglePlaySubscriptionParams, +): StorePurchaseSnapshot | null { + const {purchase, now} = params; + const configured = pickConfiguredLineItem(purchase.lineItems ?? []); + if (!configured) { + return null; + } + const subscriptionState = purchase.subscriptionState ?? 'SUBSCRIPTION_STATE_UNSPECIFIED'; + const expiresAt = configured.expiresAt; + const state = mapSubscriptionState(subscriptionState, expiresAt, now); + const startedAt = parseTime(purchase.startTime); + const outOfApp = purchase.outOfAppPurchaseContext; + const expiredIdentifiers = outOfApp?.expiredExternalAccountIdentifiers; + return { + storeKey: buildGooglePlayStoreKey(params.purchaseToken), + provider: 'google_play', + kind: 'subscription', + slot: configured.slot, + environment: purchase.testPurchase ? 'sandbox' : 'production', + appId: params.packageName, + productId: configured.lineItem.productId, + basePlanId: configured.basePlanId, + storeReference: params.purchaseToken, + latestTransactionId: + nonEmpty(configured.lineItem.latestSuccessfulOrderId) ?? nonEmpty(purchase.latestOrderId) ?? null, + appTransactionId: null, + accountToken: nonEmpty(purchase.externalAccountIdentifiers?.obfuscatedExternalAccountId), + ownershipType: null, + state, + storeState: subscriptionState, + providerEntitled: isSubscriptionEntitled(subscriptionState, expiresAt, now), + expiresAt, + graceEndsAt: state === 'grace' ? expiresAt : null, + autoRenew: resolveAutoRenew(configured.lineItem), + autoRenewProductId: + nonEmpty(configured.lineItem.deferredItemReplacement?.productId) ?? configured.lineItem.productId, + startedAt, + purchasedAt: startedAt, + revokedAt: null, + revocationReason: null, + linkedStoreKey: purchase.linkedPurchaseToken ? buildGooglePlayStoreKey(purchase.linkedPurchaseToken) : null, + expiredStoreKey: outOfApp?.expiredPurchaseToken ? buildGooglePlayStoreKey(outOfApp.expiredPurchaseToken) : null, + expiredAccountToken: + nonEmpty(expiredIdentifiers?.obfuscatedExternalAccountId) ?? + nonEmpty(expiredIdentifiers?.obfuscatedAccountId) ?? + null, + acknowledged: purchase.acknowledgementState === 'ACKNOWLEDGEMENT_STATE_ACKNOWLEDGED', + quantity: 1, + region: nonEmpty(purchase.regionCode), + lastEventAt: now, + }; +} + +function mapProductState(purchaseState: string, consumed: boolean): StorePurchaseState { + switch (purchaseState) { + case 'PURCHASED': + return consumed ? 'fulfilled' : 'purchased'; + case 'CANCELLED': + return 'refunded'; + default: + return 'pending'; + } +} + +export function buildGooglePlayProductSnapshot(params: SyncGooglePlayProductParams): StorePurchaseSnapshot | null { + const {purchase, now} = params; + const lineItem = (purchase.productLineItem ?? []).find( + (item) => + (params.expectedProductId === undefined || item.productId === params.expectedProductId) && + getGooglePlayOneTimeProductSlot(item.productId) !== null, + ); + if (!lineItem) { + return null; + } + const slot = getGooglePlayOneTimeProductSlot(lineItem.productId); + if (!slot) { + return null; + } + const purchaseState = purchase.purchaseStateContext?.purchaseState ?? 'PURCHASE_STATE_UNSPECIFIED'; + const consumed = lineItem.productOfferDetails?.consumptionState === 'CONSUMPTION_STATE_CONSUMED'; + const state = mapProductState(purchaseState, consumed); + const purchasedAt = parseTime(purchase.purchaseCompletionTime); + return { + storeKey: buildGooglePlayStoreKey(params.purchaseToken), + provider: 'google_play', + kind: 'gift', + slot, + environment: purchase.testPurchaseContext ? 'sandbox' : 'production', + appId: params.packageName, + productId: lineItem.productId, + basePlanId: null, + storeReference: params.purchaseToken, + latestTransactionId: nonEmpty(purchase.orderId), + appTransactionId: null, + accountToken: nonEmpty(purchase.obfuscatedExternalAccountId), + ownershipType: null, + state, + storeState: purchaseState, + providerEntitled: purchaseState === 'PURCHASED', + expiresAt: null, + graceEndsAt: null, + autoRenew: null, + autoRenewProductId: null, + startedAt: purchasedAt, + purchasedAt, + revokedAt: null, + revocationReason: null, + linkedStoreKey: null, + expiredStoreKey: null, + expiredAccountToken: null, + acknowledged: consumed, + quantity: lineItem.productOfferDetails?.quantity ?? 1, + region: nonEmpty(purchase.regionCode), + lastEventAt: now, + }; +} diff --git a/fluxer_api/src/api/store_billing/google_play/GooglePlayPushVerifier.ts b/fluxer_api/src/api/store_billing/google_play/GooglePlayPushVerifier.ts new file mode 100644 index 000000000..9230f12a2 --- /dev/null +++ b/fluxer_api/src/api/store_billing/google_play/GooglePlayPushVerifier.ts @@ -0,0 +1,237 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import {Config} from '@app/api/Config'; +import {Logger} from '@app/api/Logger'; +import {StoreNotificationUnauthorizedError} from '@fluxer/errors/src/domains/payment/StoreNotificationUnauthorizedError'; +import {createRemoteJWKSet, type JWTPayload, jwtVerify} from 'jose'; +import {z} from 'zod'; + +export const GOOGLE_OIDC_JWKS_URL = 'https://www.googleapis.com/oauth2/v3/certs'; +const GOOGLE_OIDC_ISSUERS: Array = ['https://accounts.google.com', 'accounts.google.com']; +const CLOCK_TOLERANCE_SECONDS = 30; + +type RemoteJwks = ReturnType; + +export interface GooglePlayPushIdentity { + email: string; + subject: string | null; +} + +interface GooglePlayPushVerifierOptions { + jwksUrl?: string; + now?: () => Date; +} + +export class GooglePlayPushVerifier { + private readonly jwksUrl: string; + private readonly now: () => Date; + private jwks: RemoteJwks | null = null; + + constructor(options: GooglePlayPushVerifierOptions = {}) { + this.jwksUrl = options.jwksUrl ?? GOOGLE_OIDC_JWKS_URL; + this.now = options.now ?? (() => new Date()); + } + + async verifyAuthorizationHeader(header: string | null | undefined): Promise { + const match = /^Bearer\s+(\S+)$/iu.exec(header?.trim() ?? ''); + if (!match) { + throw this.reject('missing_bearer'); + } + const cfg = Config.googlePlay; + const audience = cfg.pushAudience?.trim(); + const expectedEmail = cfg.pushServiceAccountEmail?.trim().toLowerCase(); + if (!audience || !expectedEmail) { + throw this.reject('not_configured'); + } + let payload: JWTPayload; + try { + const result = await jwtVerify(match[1], this.getJwks(), { + algorithms: ['RS256'], + issuer: GOOGLE_OIDC_ISSUERS, + audience, + requiredClaims: ['exp', 'iat'], + clockTolerance: CLOCK_TOLERANCE_SECONDS, + currentDate: this.now(), + }); + payload = result.payload; + } catch (error) { + throw this.reject('token_rejected', error); + } + const email = payload['email']; + if (typeof email !== 'string' || email.toLowerCase() !== expectedEmail) { + throw this.reject('email_mismatch'); + } + if (payload['email_verified'] !== true) { + throw this.reject('email_unverified'); + } + return {email, subject: typeof payload.sub === 'string' ? payload.sub : null}; + } + + private getJwks(): RemoteJwks { + if (!this.jwks) { + this.jwks = createRemoteJWKSet(new URL(this.jwksUrl)); + } + return this.jwks; + } + + private reject(reason: string, error?: unknown): StoreNotificationUnauthorizedError { + Logger.warn( + {reason, error: error instanceof Error ? error.message : undefined}, + 'Rejected Google Play push notification', + ); + return new StoreNotificationUnauthorizedError(); + } +} + +const PushEnvelopeSchema = z.object({ + message: z.object({ + data: z.string().optional(), + messageId: z.string().optional(), + message_id: z.string().optional(), + publishTime: z.string().optional(), + }), + subscription: z.string().optional(), +}); + +export interface GooglePlayPushMessage { + messageId: string; + data: string; + subscription: string | null; + publishTime: string | null; +} + +export function parseGooglePlayPushEnvelope(body: unknown): GooglePlayPushMessage | null { + const parsed = PushEnvelopeSchema.safeParse(body); + if (!parsed.success) { + return null; + } + const {message, subscription} = parsed.data; + const messageId = message.messageId ?? message.message_id; + if (!messageId || !message.data) { + return null; + } + return { + messageId, + data: message.data, + subscription: subscription ?? null, + publishTime: message.publishTime ?? null, + }; +} + +const IntegerSchema = z.union([ + z.number().int(), + z + .string() + .regex(/^-?\d+$/u) + .transform(Number), +]); + +const DeveloperNotificationSchema = z.object({ + version: z.string().optional(), + packageName: z.string().min(1), + eventTimeMillis: IntegerSchema.optional(), + subscriptionNotification: z + .object({ + version: z.string().optional(), + notificationType: IntegerSchema, + purchaseToken: z.string().min(1), + subscriptionId: z.string().optional(), + }) + .optional(), + oneTimeProductNotification: z + .object({ + version: z.string().optional(), + notificationType: IntegerSchema, + purchaseToken: z.string().min(1), + sku: z.string().optional(), + }) + .optional(), + voidedPurchaseNotification: z + .object({ + purchaseToken: z.string().min(1), + orderId: z.string().optional(), + productType: IntegerSchema.optional(), + refundType: IntegerSchema.optional(), + }) + .optional(), + pendingRefundReviewNotification: z + .object({ + orderId: z.string().optional(), + }) + .optional(), + testNotification: z.object({version: z.string().optional()}).optional(), +}); + +export type GooglePlayNotificationEvent = + | {type: 'subscription'; notificationType: number; purchaseToken: string} + | {type: 'one_time_product'; notificationType: number; purchaseToken: string; productId: string | null} + | { + type: 'voided_purchase'; + purchaseToken: string; + orderId: string | null; + productType: 'subscription' | 'one_time' | null; + refundType: 'full' | 'quantity_based_partial' | null; + } + | {type: 'pending_refund_review'; orderId: string | null} + | {type: 'test'} + | {type: 'unknown'}; + +export interface GooglePlayDeveloperNotification { + packageName: string; + eventTime: Date | null; + event: GooglePlayNotificationEvent; +} + +function toEvent(notification: z.infer): GooglePlayNotificationEvent { + if (notification.subscriptionNotification) { + return { + type: 'subscription', + notificationType: notification.subscriptionNotification.notificationType, + purchaseToken: notification.subscriptionNotification.purchaseToken, + }; + } + if (notification.oneTimeProductNotification) { + return { + type: 'one_time_product', + notificationType: notification.oneTimeProductNotification.notificationType, + purchaseToken: notification.oneTimeProductNotification.purchaseToken, + productId: notification.oneTimeProductNotification.sku ?? null, + }; + } + if (notification.voidedPurchaseNotification) { + const voided = notification.voidedPurchaseNotification; + return { + type: 'voided_purchase', + purchaseToken: voided.purchaseToken, + orderId: voided.orderId ?? null, + productType: voided.productType === 1 ? 'subscription' : voided.productType === 2 ? 'one_time' : null, + refundType: voided.refundType === 1 ? 'full' : voided.refundType === 2 ? 'quantity_based_partial' : null, + }; + } + if (notification.pendingRefundReviewNotification) { + return {type: 'pending_refund_review', orderId: notification.pendingRefundReviewNotification.orderId ?? null}; + } + if (notification.testNotification) { + return {type: 'test'}; + } + return {type: 'unknown'}; +} + +export function decodeGooglePlayDeveloperNotification(data: string): GooglePlayDeveloperNotification | null { + let json: unknown; + try { + json = JSON.parse(Buffer.from(data, 'base64').toString('utf8')); + } catch { + return null; + } + const parsed = DeveloperNotificationSchema.safeParse(json); + if (!parsed.success) { + return null; + } + const eventTimeMillis = parsed.data.eventTimeMillis; + return { + packageName: parsed.data.packageName, + eventTime: eventTimeMillis === undefined ? null : new Date(eventTimeMillis), + event: toEvent(parsed.data), + }; +} diff --git a/fluxer_api/src/api/store_billing/tests/AppStoreJwsVerifier.test.ts b/fluxer_api/src/api/store_billing/tests/AppStoreJwsVerifier.test.ts new file mode 100644 index 000000000..c93522329 --- /dev/null +++ b/fluxer_api/src/api/store_billing/tests/AppStoreJwsVerifier.test.ts @@ -0,0 +1,654 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import {createHash, generateKeyPairSync, X509Certificate} from 'node:crypto'; +import { + APPLE_ROOT_CA_G3_PEM, + getAppleRootCertificates, + setInjectedAppleRootCertificates, +} from '@app/api/store_billing/app_store/AppleRootCertificates'; +import { + type AppStoreJwsFailureReason, + AppStoreJwsVerificationError, + certificateHasExtension, + verifyAppStoreCertificateChain, + verifyNotification, + verifyRenewalInfo, + verifyTransaction, +} from '@app/api/store_billing/app_store/AppStoreJwsVerifier'; +import { + REAL_APPLE_CHAIN_EFFECTIVE_DATE, + REAL_APPLE_INTERMEDIATE, + REAL_APPLE_ROOT, + REAL_APPLE_SIGNING_CERTIFICATE, +} from '@app/api/store_billing/tests/AppleProductionChain'; +import { + APPLE_RECEIPT_SIGNING_OID, + APPLE_WWDR_INTERMEDIATE_OID, + type AppleTestPki, + certificatePoliciesExtension, + createAppleTestPki, + encodeJwsWithSignature, +} from '@app/api/store_billing/tests/AppleTestPki'; +import {ms} from 'itty-time'; +import {CompactSign} from 'jose'; +import {afterEach, beforeAll, beforeEach, describe, expect, it} from 'vitest'; + +const APPLE_ROOT_CA_G3_SHA256 = '63343ABFB89A6A03EBB57E9B3F5FA7BE7C4F5C756F3017B3A8C488C3653E9179'; +const APPLE_ROOT_CA_G3_DER_BASE64 = APPLE_ROOT_CA_G3_PEM.replace(/-----[A-Z ]+-----/g, '').replace(/\s+/g, ''); + +async function expectRejection(promise: Promise, reason: AppStoreJwsFailureReason): Promise { + const error = await promise.then( + () => null, + (caught: unknown) => caught, + ); + expect(error).toBeInstanceOf(AppStoreJwsVerificationError); + expect((error as AppStoreJwsVerificationError).reason).toBe(reason); +} + +function expectSyncRejection(run: () => unknown, reason: AppStoreJwsFailureReason): void { + let caught: unknown = null; + try { + run(); + } catch (error) { + caught = error; + } + expect(caught).toBeInstanceOf(AppStoreJwsVerificationError); + expect((caught as AppStoreJwsVerificationError).reason).toBe(reason); +} + +function transactionPayload(overrides: Record = {}): Record { + const now = Date.now(); + return { + transactionId: '2000000123456789', + originalTransactionId: '2000000123456789', + webOrderLineItemId: '2000000011112222', + bundleId: 'com.fluxer', + productId: 'com.fluxer.plutonium.monthly', + subscriptionGroupIdentifier: '21000000', + purchaseDate: now - ms('1 hour'), + originalPurchaseDate: now - ms('1 hour'), + expiresDate: now + ms('30 days'), + quantity: 1, + type: 'Auto-Renewable Subscription', + appAccountToken: '7e3fb20b-4cdb-47cc-936d-99d65f608138', + inAppOwnershipType: 'PURCHASED', + signedDate: now, + environment: 'Production', + transactionReason: 'PURCHASE', + storefront: 'USA', + storefrontId: '143441', + price: 4990, + currency: 'USD', + appTransactionId: '704432222233344455', + ...overrides, + }; +} + +function renewalPayload(overrides: Record = {}): Record { + const now = Date.now(); + return { + originalTransactionId: '2000000123456789', + autoRenewProductId: 'com.fluxer.plutonium.monthly', + productId: 'com.fluxer.plutonium.monthly', + autoRenewStatus: 1, + signedDate: now, + environment: 'Production', + recentSubscriptionStartDate: now - ms('1 hour'), + renewalDate: now + ms('30 days'), + ...overrides, + }; +} + +async function notificationPayload( + pki: AppleTestPki, + overrides: Record = {}, + dataOverrides: Record = {}, +): Promise> { + return { + notificationType: 'DID_RENEW', + notificationUUID: '002e14d5-51f5-4503-b5a8-c3a1af68eb20', + version: '2.0', + signedDate: Date.now(), + data: { + appAppleId: 1234567890, + bundleId: 'com.fluxer', + bundleVersion: '42', + environment: 'Production', + status: 1, + signedTransactionInfo: await pki.signJws(transactionPayload()), + signedRenewalInfo: await pki.signJws(renewalPayload()), + ...dataOverrides, + }, + ...overrides, + }; +} + +describe('Apple root certificate pin', () => { + afterEach(() => { + setInjectedAppleRootCertificates(undefined); + }); + + it('embeds Apple Root CA - G3 with the published fingerprint', () => { + const der = Buffer.from(APPLE_ROOT_CA_G3_DER_BASE64, 'base64'); + expect(createHash('sha256').update(der).digest('hex').toUpperCase()).toBe(APPLE_ROOT_CA_G3_SHA256); + const [root] = getAppleRootCertificates(); + expect(root.fingerprint256.replaceAll(':', '')).toBe(APPLE_ROOT_CA_G3_SHA256); + expect(root.subject).toContain('CN=Apple Root CA - G3'); + expect(root.ca).toBe(true); + expect(getAppleRootCertificates()).toHaveLength(1); + }); + + it('matches the root of the real Apple chain', () => { + expect(REAL_APPLE_ROOT).toBe(APPLE_ROOT_CA_G3_DER_BASE64); + }); + + it('lets tests inject roots and restores the pin', () => { + const pki = createAppleTestPki(); + setInjectedAppleRootCertificates([pki.root]); + expect(getAppleRootCertificates()).toEqual([pki.root]); + setInjectedAppleRootCertificates(undefined); + expect(getAppleRootCertificates()[0].fingerprint256.replaceAll(':', '')).toBe(APPLE_ROOT_CA_G3_SHA256); + }); +}); + +describe('certificate extension lookup', () => { + it('finds the Apple extensions by walking the extension list', () => { + const pki = createAppleTestPki(); + expect(certificateHasExtension(pki.leaf, APPLE_RECEIPT_SIGNING_OID)).toBe(true); + expect(certificateHasExtension(pki.leaf, APPLE_WWDR_INTERMEDIATE_OID)).toBe(false); + expect(certificateHasExtension(pki.intermediate, APPLE_WWDR_INTERMEDIATE_OID)).toBe(true); + expect(certificateHasExtension(pki.root, APPLE_WWDR_INTERMEDIATE_OID)).toBe(false); + }); + + it('ignores the identifier when it only appears inside another extension value', () => { + const pki = createAppleTestPki({ + leaf: {appleExtension: false, extraExtensions: [certificatePoliciesExtension(APPLE_RECEIPT_SIGNING_OID)]}, + }); + expect(pki.leaf.raw.includes(Buffer.from('060a2a864886f76364060b01', 'hex'))).toBe(true); + expect(certificateHasExtension(pki.leaf, APPLE_RECEIPT_SIGNING_OID)).toBe(false); + }); + + it('finds the extensions on the real Apple chain', () => { + const leaf = new X509Certificate(Buffer.from(REAL_APPLE_SIGNING_CERTIFICATE, 'base64')); + const intermediate = new X509Certificate(Buffer.from(REAL_APPLE_INTERMEDIATE, 'base64')); + expect(certificateHasExtension(leaf, APPLE_RECEIPT_SIGNING_OID)).toBe(true); + expect(certificateHasExtension(intermediate, APPLE_WWDR_INTERMEDIATE_OID)).toBe(true); + expect(certificateHasExtension(intermediate, APPLE_RECEIPT_SIGNING_OID)).toBe(false); + }); +}); + +describe('real Apple certificate chain', () => { + const chain = [REAL_APPLE_SIGNING_CERTIFICATE, REAL_APPLE_INTERMEDIATE, REAL_APPLE_ROOT]; + + it('verifies under the pinned root at a date inside the leaf validity', () => { + const key = verifyAppStoreCertificateChain(chain, REAL_APPLE_CHAIN_EFFECTIVE_DATE); + const leaf = new X509Certificate(Buffer.from(REAL_APPLE_SIGNING_CERTIFICATE, 'base64')); + expect(key.export({type: 'spki', format: 'der'})).toEqual(leaf.publicKey.export({type: 'spki', format: 'der'})); + expect(key.asymmetricKeyDetails?.namedCurve).toBe('prime256v1'); + }); + + it('rejects the chain after the leaf expires', () => { + expectSyncRejection( + () => verifyAppStoreCertificateChain(chain, new Date('2027-10-14T00:00:00Z')), + 'certificate_expired', + ); + }); + + it('rejects the chain before the leaf was issued', () => { + expectSyncRejection( + () => verifyAppStoreCertificateChain(chain, new Date('2025-09-01T00:00:00Z')), + 'certificate_expired', + ); + }); + + it('rejects the chain under a root that is not Apple', () => { + const pki = createAppleTestPki(); + expectSyncRejection( + () => verifyAppStoreCertificateChain(chain, REAL_APPLE_CHAIN_EFFECTIVE_DATE, [pki.root]), + 'untrusted_chain', + ); + }); + + it('rejects a test chain under the pinned Apple root', () => { + const pki = createAppleTestPki(); + expectSyncRejection(() => verifyAppStoreCertificateChain(pki.x5c, new Date()), 'untrusted_chain'); + }); +}); + +describe('compact serialization', () => { + it('rejects malformed compact serializations', async () => { + await expectRejection(verifyNotification('a.b.c.d'), 'malformed'); + await expectRejection(verifyNotification('a.b.c'), 'malformed'); + await expectRejection(verifyNotification(''), 'malformed'); + await expectRejection(verifyNotification('..'), 'malformed'); + }); +}); + +describe('App Store JWS verification with a test chain', () => { + let pki: AppleTestPki; + + beforeAll(() => { + pki = createAppleTestPki(); + }); + + beforeEach(() => { + setInjectedAppleRootCertificates([pki.root]); + }); + + afterEach(() => { + setInjectedAppleRootCertificates(undefined); + }); + + it('verifies and types a transaction', async () => { + const transaction = await verifyTransaction(await pki.signJws(transactionPayload())); + expect(transaction.transactionId).toBe('2000000123456789'); + expect(transaction.environment).toBe('Production'); + expect(transaction.type).toBe('Auto-Renewable Subscription'); + expect(transaction.appAccountToken).toBe('7e3fb20b-4cdb-47cc-936d-99d65f608138'); + }); + + it('verifies renewal info', async () => { + const renewal = await verifyRenewalInfo(await pki.signJws(renewalPayload({environment: 'Sandbox'})), { + expectedEnvironment: 'sandbox', + }); + expect(renewal.autoRenewStatus).toBe(1); + expect(renewal.environment).toBe('Sandbox'); + }); + + it('tolerates enum values it does not know', async () => { + const transaction = await verifyTransaction( + await pki.signJws( + transactionPayload({ + type: 'Future Product Type', + inAppOwnershipType: 'SOMETHING_NEW', + revocationType: 'NEW_REVOCATION', + offerDiscountType: 'NEW_OFFER', + transactionReason: 'NEW_REASON', + }), + ), + ); + expect(transaction.type).toBe('Future Product Type'); + expect(transaction.inAppOwnershipType).toBe('SOMETHING_NEW'); + const notification = await verifyNotification( + await pki.signJws(await notificationPayload(pki, {notificationType: 'BRAND_NEW', subtype: 'ALSO_NEW'})), + ); + expect(notification.notificationType).toBe('BRAND_NEW'); + expect(notification.subtype).toBe('ALSO_NEW'); + }); + + it('ignores the root certificate sent in x5c', async () => { + const other = createAppleTestPki(); + const signed = await pki.signJws(transactionPayload(), {header: {x5c: [pki.x5c[0], pki.x5c[1], other.x5c[2]]}}); + await expect(verifyTransaction(signed)).resolves.toMatchObject({transactionId: '2000000123456789'}); + }); + + it.each([ + ['transactionId'], + ['originalTransactionId'], + ['bundleId'], + ['productId'], + ['type'], + ['purchaseDate'], + ['signedDate'], + ['environment'], + ])('requires %s on a transaction', async (field) => { + const payload = transactionPayload(); + delete payload[field]; + await expectRejection(verifyTransaction(await pki.signJws(payload)), 'invalid_payload'); + }); + + it.each([['originalTransactionId'], ['productId'], ['signedDate'], ['environment']])( + 'requires %s on renewal info', + async (field) => { + const payload = renewalPayload(); + delete payload[field]; + await expectRejection(verifyRenewalInfo(await pki.signJws(payload)), 'invalid_payload'); + }, + ); + + it.each([['notificationType'], ['notificationUUID'], ['signedDate']])( + 'requires %s on a notification', + async (field) => { + const payload = await notificationPayload(pki); + delete payload[field]; + await expectRejection(verifyNotification(await pki.signJws(payload)), 'invalid_payload'); + }, + ); + + it('rejects a wrongly typed field', async () => { + await expectRejection( + verifyTransaction(await pki.signJws(transactionPayload({expiresDate: '2026-10-01'}))), + 'invalid_payload', + ); + }); + + it('rejects a chain that ends in another root', async () => { + const other = createAppleTestPki(); + await expectRejection(verifyTransaction(await other.signJws(transactionPayload())), 'untrusted_chain'); + }); + + it('rejects an intermediate that copies the root name but was signed by another key', async () => { + const impostor = createAppleTestPki({root: {commonName: 'Fluxer Test Root CA'}}); + expect(impostor.root.subject).toBe(pki.root.subject); + await expectRejection(verifyTransaction(await impostor.signJws(transactionPayload())), 'untrusted_chain'); + }); + + it('rejects a leaf issued by a different intermediate', async () => { + const other = createAppleTestPki({intermediate: {issuer: pki}}); + await expect(verifyTransaction(await other.signJws(transactionPayload()))).resolves.toBeDefined(); + const mixed = await other.signJws(transactionPayload(), {header: {x5c: [other.x5c[0], pki.x5c[1], pki.x5c[2]]}}); + await expectRejection(verifyTransaction(mixed), 'untrusted_chain'); + }); + + it('rejects an intermediate that is not a CA', async () => { + const notCa = createAppleTestPki({intermediate: {ca: false}}); + setInjectedAppleRootCertificates([notCa.root]); + await expectRejection(verifyTransaction(await notCa.signJws(transactionPayload())), 'untrusted_chain'); + }); + + it('rejects a leaf that is a CA', async () => { + const caLeaf = createAppleTestPki({leaf: {ca: true}}); + setInjectedAppleRootCertificates([caLeaf.root]); + await expectRejection(verifyTransaction(await caLeaf.signJws(transactionPayload())), 'untrusted_chain'); + }); + + it('rejects a leaf without the receipt signing extension', async () => { + const missing = createAppleTestPki({leaf: {appleExtension: false}}); + setInjectedAppleRootCertificates([missing.root]); + await expectRejection( + verifyTransaction(await missing.signJws(transactionPayload())), + 'missing_certificate_extension', + ); + }); + + it('rejects an intermediate without the WWDR extension', async () => { + const missing = createAppleTestPki({intermediate: {appleExtension: false}}); + setInjectedAppleRootCertificates([missing.root]); + await expectRejection( + verifyTransaction(await missing.signJws(transactionPayload())), + 'missing_certificate_extension', + ); + }); + + it('rejects a leaf that carries the identifier only as a policy', async () => { + const policyOnly = createAppleTestPki({ + leaf: {appleExtension: false, extraExtensions: [certificatePoliciesExtension(APPLE_RECEIPT_SIGNING_OID)]}, + }); + setInjectedAppleRootCertificates([policyOnly.root]); + await expectRejection( + verifyTransaction(await policyOnly.signJws(transactionPayload())), + 'missing_certificate_extension', + ); + }); + + describe('certificate dates at signedDate', () => { + const signedDate = Date.parse('2026-06-01T12:00:00Z'); + let expiredLeaf: AppleTestPki; + + beforeAll(() => { + expiredLeaf = createAppleTestPki({ + leaf: {notBefore: new Date(signedDate - ms('30 days')), notAfter: new Date(signedDate - ms('2 minutes'))}, + }); + }); + + beforeEach(() => { + setInjectedAppleRootCertificates([expiredLeaf.root]); + }); + + it('rejects a leaf that expired before signedDate', async () => { + await expectRejection( + verifyTransaction(await expiredLeaf.signJws(transactionPayload({signedDate}))), + 'certificate_expired', + ); + }); + + it('accepts the same leaf for data signed while it was valid', async () => { + const earlier = signedDate - ms('1 day'); + await expect( + verifyTransaction(await expiredLeaf.signJws(transactionPayload({signedDate: earlier}))), + ).resolves.toMatchObject({signedDate: earlier}); + }); + + it('allows one minute of clock skew', async () => { + const withinSkew = signedDate - ms('2 minutes') + ms('50 seconds'); + await expect( + verifyTransaction(await expiredLeaf.signJws(transactionPayload({signedDate: withinSkew}))), + ).resolves.toBeDefined(); + }); + + it('rejects a leaf that is not valid yet', async () => { + await expectRejection( + verifyTransaction(await expiredLeaf.signJws(transactionPayload({signedDate: signedDate - ms('31 days')}))), + 'certificate_expired', + ); + }); + + it('rejects an intermediate that expired before signedDate', async () => { + const expiredIntermediate = createAppleTestPki({ + intermediate: {notAfter: new Date(signedDate - ms('1 day'))}, + }); + setInjectedAppleRootCertificates([expiredIntermediate.root]); + await expectRejection( + verifyTransaction(await expiredIntermediate.signJws(transactionPayload({signedDate}))), + 'certificate_expired', + ); + }); + + it('rejects a root that expired before signedDate', async () => { + const expiredRoot = createAppleTestPki({root: {notAfter: new Date(signedDate - ms('1 day'))}}); + setInjectedAppleRootCertificates([expiredRoot.root]); + await expectRejection( + verifyTransaction(await expiredRoot.signJws(transactionPayload({signedDate}))), + 'certificate_expired', + ); + }); + }); + + it('rejects a signature from a key other than the leaf', async () => { + const {privateKey} = generateKeyPairSync('ec', {namedCurve: 'prime256v1'}); + await expectRejection( + verifyTransaction(await pki.signJws(transactionPayload(), {signingKey: privateKey})), + 'invalid_signature', + ); + }); + + it('rejects a payload changed after signing', async () => { + const signed = await pki.signJws(transactionPayload()); + const [header, , signature] = signed.split('.'); + const forged = Buffer.from(JSON.stringify(transactionPayload({productId: 'com.fluxer.plutonium.yearly'}))).toString( + 'base64url', + ); + await expectRejection(verifyTransaction(`${header}.${forged}.${signature}`), 'invalid_signature'); + }); + + it('rejects alg none', async () => { + const unsigned = encodeJwsWithSignature({alg: 'none', x5c: pki.x5c}, transactionPayload(), 'AA'); + await expectRejection(verifyTransaction(unsigned), 'unsupported_algorithm'); + }); + + it('rejects HS256', async () => { + const secret = new TextEncoder().encode('0123456789abcdef0123456789abcdef'); + const hmac = await new CompactSign(new TextEncoder().encode(JSON.stringify(transactionPayload()))) + .setProtectedHeader({alg: 'HS256', x5c: pki.x5c}) + .sign(secret); + await expectRejection(verifyTransaction(hmac), 'unsupported_algorithm'); + }); + + it('rejects ES384', async () => { + const {privateKey} = generateKeyPairSync('ec', {namedCurve: 'secp384r1'}); + const signed = await new CompactSign(new TextEncoder().encode(JSON.stringify(transactionPayload()))) + .setProtectedHeader({alg: 'ES384', x5c: pki.x5c}) + .sign(privateKey); + await expectRejection(verifyTransaction(signed), 'unsupported_algorithm'); + }); + + it.each([ + ['one certificate', (chain: Array) => [chain[0]]], + ['two certificates', (chain: Array) => chain.slice(0, 2)], + ['four certificates', (chain: Array) => [...chain, chain[2]]], + ['no certificates', () => []], + ])('rejects an x5c with %s', async (_label, pick) => { + const signed = await pki.signJws(transactionPayload(), {header: {x5c: pick(pki.x5c)}}); + await expectRejection(verifyTransaction(signed), 'invalid_chain_length'); + }); + + it('rejects an x5c that is not an array', async () => { + const signed = await pki.signJws(transactionPayload(), {header: {x5c: pki.x5c[0]}}); + await expectRejection(verifyTransaction(signed), 'invalid_chain_length'); + }); + + it('rejects x5c entries that are not standard base64 DER', async () => { + const urlSafe = pki.leaf.raw.toString('base64url'); + const signed = await pki.signJws(transactionPayload(), {header: {x5c: [urlSafe, pki.x5c[1], pki.x5c[2]]}}); + const garbage = await pki.signJws(transactionPayload(), {header: {x5c: ['AAAA', pki.x5c[1], pki.x5c[2]]}}); + const notString = await pki.signJws(transactionPayload(), {header: {x5c: [42, pki.x5c[1], pki.x5c[2]]}}); + if (urlSafe !== pki.x5c[0]) { + await expectRejection(verifyTransaction(signed), 'invalid_certificate'); + } + await expectRejection(verifyTransaction(garbage), 'invalid_certificate'); + await expectRejection(verifyTransaction(notString), 'invalid_certificate'); + }); + + it.each([['Xcode'], ['LocalTesting'], ['production'], ['']])('rejects environment %j', async (environment) => { + await expectRejection( + verifyTransaction(await pki.signJws(transactionPayload({environment}))), + 'invalid_environment', + ); + await expectRejection(verifyRenewalInfo(await pki.signJws(renewalPayload({environment}))), 'invalid_environment'); + }); + + it('rejects an environment other than the expected one', async () => { + const sandbox = await pki.signJws(transactionPayload({environment: 'Sandbox'})); + await expectRejection(verifyTransaction(sandbox, {expectedEnvironment: 'production'}), 'invalid_environment'); + await expect(verifyTransaction(sandbox, {expectedEnvironment: 'sandbox'})).resolves.toBeDefined(); + await expect(verifyTransaction(sandbox, {expectedEnvironment: null})).resolves.toBeDefined(); + }); + + it('rejects a bundle that is not configured', async () => { + await expectRejection( + verifyTransaction(await pki.signJws(transactionPayload({bundleId: 'com.fluxer.other'}))), + 'invalid_app_identifier', + ); + }); + + describe('notifications', () => { + it('verifies the outer payload and the nested signed data', async () => { + const notification = await verifyNotification(await pki.signJws(await notificationPayload(pki))); + expect(notification.notificationType).toBe('DID_RENEW'); + expect(notification.environment).toBe('Production'); + expect(notification.appAppleId).toBe(1234567890); + expect(notification.status).toBe(1); + expect(notification.transaction?.originalTransactionId).toBe('2000000123456789'); + expect(notification.renewalInfo?.autoRenewStatus).toBe(1); + }); + + it('rejects a production notification for another app id', async () => { + const payload = await notificationPayload(pki, {}, {appAppleId: 999}); + await expectRejection(verifyNotification(await pki.signJws(payload)), 'invalid_app_identifier'); + }); + + it('rejects a production notification without an app id', async () => { + const payload = await notificationPayload(pki, {}, {appAppleId: undefined}); + await expectRejection(verifyNotification(await pki.signJws(payload)), 'invalid_app_identifier'); + }); + + it('accepts a sandbox notification without an app id', async () => { + const payload = await notificationPayload( + pki, + {}, + { + appAppleId: undefined, + environment: 'Sandbox', + signedTransactionInfo: await pki.signJws(transactionPayload({environment: 'Sandbox'})), + signedRenewalInfo: await pki.signJws(renewalPayload({environment: 'Sandbox'})), + }, + ); + const notification = await verifyNotification(await pki.signJws(payload)); + expect(notification.environment).toBe('Sandbox'); + expect(notification.appAppleId).toBeNull(); + }); + + it('rejects a notification for a bundle that is not configured', async () => { + const payload = await notificationPayload(pki, {}, {bundleId: 'com.fluxer.other'}); + await expectRejection(verifyNotification(await pki.signJws(payload)), 'invalid_app_identifier'); + }); + + it('rejects an Xcode notification', async () => { + const payload = await notificationPayload(pki, {}, {environment: 'Xcode'}); + await expectRejection(verifyNotification(await pki.signJws(payload)), 'invalid_environment'); + }); + + it('rejects nested signed data from another environment', async () => { + const payload = await notificationPayload( + pki, + {}, + {signedTransactionInfo: await pki.signJws(transactionPayload({environment: 'Sandbox'}))}, + ); + await expectRejection(verifyNotification(await pki.signJws(payload)), 'invalid_environment'); + }); + + it('rejects nested signed data that is not signed by Apple', async () => { + const other = createAppleTestPki(); + const payload = await notificationPayload( + pki, + {}, + {signedTransactionInfo: await other.signJws(transactionPayload())}, + ); + await expectRejection(verifyNotification(await pki.signJws(payload)), 'untrusted_chain'); + }); + + it('rejects nested renewal info for another subscription', async () => { + const payload = await notificationPayload( + pki, + {}, + {signedRenewalInfo: await pki.signJws(renewalPayload({originalTransactionId: '2000000999999999'}))}, + ); + await expectRejection(verifyNotification(await pki.signJws(payload)), 'invalid_payload'); + }); + + it('rejects a notification without app data', async () => { + const payload = await notificationPayload(pki, {data: undefined}); + await expectRejection(verifyNotification(await pki.signJws(payload)), 'invalid_payload'); + }); + + it('accepts a renewal extension summary', async () => { + const payload = await notificationPayload(pki, { + notificationType: 'RENEWAL_EXTENSION', + subtype: 'SUMMARY', + data: undefined, + summary: { + requestIdentifier: 'b3a7d8c4-4f6e-4a53-9b1e-2c4f7e8a9d10', + environment: 'Production', + appAppleId: 1234567890, + bundleId: 'com.fluxer', + productId: 'com.fluxer.plutonium.monthly', + succeededCount: 5, + failedCount: 0, + }, + }); + const notification = await verifyNotification(await pki.signJws(payload)); + expect(notification.transaction).toBeNull(); + expect(notification.payload.summary?.succeededCount).toBe(5); + }); + + it('derives the environment of an external purchase token notification', async () => { + const payload = await notificationPayload(pki, { + notificationType: 'EXTERNAL_PURCHASE_TOKEN', + subtype: 'UNREPORTED', + data: undefined, + externalPurchaseToken: { + externalPurchaseId: 'SANDBOX_b2158121-7af9-49d4-9561-1f588205523e', + tokenCreationDate: Date.now(), + bundleId: 'com.fluxer', + }, + }); + const notification = await verifyNotification(await pki.signJws(payload)); + expect(notification.environment).toBe('Sandbox'); + }); + + it('accepts a notification without a version', async () => { + const payload = await notificationPayload(pki, {version: undefined}); + await expect(verifyNotification(await pki.signJws(payload))).resolves.toBeDefined(); + }); + }); +}); diff --git a/fluxer_api/src/api/store_billing/tests/AppStorePurchaseSync.test.ts b/fluxer_api/src/api/store_billing/tests/AppStorePurchaseSync.test.ts new file mode 100644 index 000000000..8507d8655 --- /dev/null +++ b/fluxer_api/src/api/store_billing/tests/AppStorePurchaseSync.test.ts @@ -0,0 +1,383 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import {Config} from '@app/api/Config'; +import {setInjectedAppleRootCertificates} from '@app/api/store_billing/app_store/AppleRootCertificates'; +import { + type AppStorePurchaseLookup, + AppStorePurchaseSync, + AppStorePurchaseSyncError, + type AppStorePurchaseSyncFailureReason, +} from '@app/api/store_billing/app_store/AppStorePurchaseSync'; +import { + AppStoreServerApiClient, + AppStoreServerApiError, +} from '@app/api/store_billing/app_store/AppStoreServerApiClient'; +import {type AppleTestPki, createAppleTestPki} from '@app/api/store_billing/tests/AppleTestPki'; +import { + type AppStoreFakePayload, + type AppStoreServerApiFake, + createAppStoreServerApiFake, +} from '@app/api/test/msw/handlers/AppStoreServerApiHandlers'; +import {server} from '@app/api/test/msw/server'; +import {ms} from 'itty-time'; +import {afterEach, beforeAll, beforeEach, describe, expect, it} from 'vitest'; + +const MONTHLY = 'com.fluxer.plutonium.monthly'; +const YEARLY = 'com.fluxer.plutonium.yearly'; +const GIFT_MONTH = 'com.fluxer.gift.1month'; +const GIFT_YEAR = 'com.fluxer.gift.1year'; +const ORIGINAL_ID = '2000000100000001'; +const RENEWAL_ID = '2000000100000009'; +const TOKEN_UPPER = '7E3FB20B-4CDB-47CC-936D-99D65F608138'; +const TOKEN = TOKEN_UPPER.toLowerCase(); + +interface SubscriptionSeed { + environment?: 'production' | 'sandbox'; + status: number; + productId?: string; + transaction?: AppStoreFakePayload; + renewalInfo?: AppStoreFakePayload | null; +} + +async function expectSyncFailure(promise: Promise, reason: AppStorePurchaseSyncFailureReason): Promise { + const error = await promise.then( + () => null, + (caught: unknown) => caught, + ); + expect(error).toBeInstanceOf(AppStorePurchaseSyncError); + expect((error as AppStorePurchaseSyncError).reason).toBe(reason); +} + +describe('AppStorePurchaseSync', () => { + let pki: AppleTestPki; + let fake: AppStoreServerApiFake; + let sync: AppStorePurchaseSync; + let now: number; + + beforeAll(() => { + pki = createAppleTestPki(); + }); + + beforeEach(() => { + now = Date.now(); + setInjectedAppleRootCertificates([pki.root]); + fake = createAppStoreServerApiFake({sign: (payload) => pki.signJws(payload)}); + server.use(...fake.handlers); + sync = new AppStorePurchaseSync(new AppStoreServerApiClient()); + }); + + afterEach(() => { + setInjectedAppleRootCertificates(undefined); + }); + + function seedSubscription(seed: SubscriptionSeed): void { + const environment = seed.environment ?? 'production'; + fake.addTransaction(environment, { + transactionId: ORIGINAL_ID, + originalTransactionId: ORIGINAL_ID, + productId: MONTHLY, + purchaseDate: now - ms('60 days'), + expiresDate: now - ms('30 days'), + }); + fake.addTransaction(environment, { + transactionId: RENEWAL_ID, + originalTransactionId: ORIGINAL_ID, + productId: seed.productId ?? MONTHLY, + purchaseDate: now - ms('1 day'), + originalPurchaseDate: now - ms('400 days'), + expiresDate: now + ms('29 days'), + appAccountToken: TOKEN_UPPER, + appTransactionId: '704400000000000001', + storefront: 'SWE', + ...seed.transaction, + }); + fake.putSubscription(environment, { + originalTransactionId: ORIGINAL_ID, + status: seed.status, + latestTransactionId: RENEWAL_ID, + renewalInfo: + seed.renewalInfo === null + ? null + : { + recentSubscriptionStartDate: now - ms('60 days'), + appAccountToken: TOKEN_UPPER, + ...seed.renewalInfo, + }, + }); + } + + function lookup(overrides: Partial = {}): AppStorePurchaseLookup { + return { + environment: 'production', + bundleId: 'com.fluxer', + productId: MONTHLY, + transactionId: RENEWAL_ID, + originalTransactionId: ORIGINAL_ID, + ...overrides, + }; + } + + describe('subscriptions', () => { + it('maps an active subscription from Get All Subscription Statuses', async () => { + seedSubscription({status: 1}); + const snapshot = await sync.loadPurchase(lookup()); + expect(snapshot).toMatchObject({ + storeKey: `app_store:production:${ORIGINAL_ID}`, + provider: 'app_store', + kind: 'subscription', + slot: 'monthly', + environment: 'production', + appId: 'com.fluxer', + productId: MONTHLY, + basePlanId: null, + storeReference: ORIGINAL_ID, + latestTransactionId: RENEWAL_ID, + appTransactionId: '704400000000000001', + accountToken: TOKEN, + ownershipType: 'PURCHASED', + state: 'active', + storeState: '1', + providerEntitled: true, + graceEndsAt: null, + autoRenew: true, + autoRenewProductId: MONTHLY, + revokedAt: null, + revocationReason: null, + linkedStoreKey: null, + acknowledged: true, + region: 'SWE', + }); + expect(snapshot.expiresAt?.getTime()).toBe(now + ms('29 days')); + expect(snapshot.startedAt?.getTime()).toBe(now - ms('60 days')); + expect(snapshot.purchasedAt?.getTime()).toBe(now - ms('1 day')); + expect(snapshot.lastEventAt.getTime()).toBeGreaterThanOrEqual(now); + expect(fake.requests.map((request) => request.path)).toEqual([`/inApps/v1/subscriptions/${ORIGINAL_ID}`]); + }); + + it('maps auto-renew off to canceled while still entitled', async () => { + seedSubscription({status: 1, renewalInfo: {autoRenewStatus: 0, expirationIntent: 1}}); + const snapshot = await sync.loadPurchase(lookup()); + expect(snapshot).toMatchObject({state: 'canceled', providerEntitled: true, autoRenew: false}); + }); + + it('maps billing grace to grace until the grace expiry', async () => { + const graceEnd = now + ms('16 days'); + seedSubscription({ + status: 4, + transaction: {expiresDate: now - ms('1 day')}, + renewalInfo: {gracePeriodExpiresDate: graceEnd, isInBillingRetryPeriod: true}, + }); + const snapshot = await sync.loadPurchase(lookup()); + expect(snapshot).toMatchObject({state: 'grace', storeState: '4', providerEntitled: true}); + expect(snapshot.graceEndsAt?.getTime()).toBe(graceEnd); + expect(snapshot.expiresAt?.getTime()).toBe(now - ms('1 day')); + }); + + it('maps billing retry without grace to not entitled', async () => { + seedSubscription({ + status: 3, + transaction: {expiresDate: now - ms('1 day')}, + renewalInfo: {isInBillingRetryPeriod: true}, + }); + const snapshot = await sync.loadPurchase(lookup()); + expect(snapshot).toMatchObject({state: 'billing_retry', providerEntitled: false, graceEndsAt: null}); + }); + + it('maps expired to not entitled', async () => { + seedSubscription({status: 2, renewalInfo: {autoRenewStatus: 0, expirationIntent: 1}}); + const snapshot = await sync.loadPurchase(lookup()); + expect(snapshot).toMatchObject({state: 'expired', storeState: '2', providerEntitled: false}); + }); + + it('maps revoked with the revocation details', async () => { + const revokedAt = now - ms('2 hours'); + seedSubscription({ + status: 5, + transaction: {revocationDate: revokedAt, revocationReason: 0, revocationType: 'REFUND_FULL'}, + }); + const snapshot = await sync.loadPurchase(lookup()); + expect(snapshot).toMatchObject({state: 'revoked', providerEntitled: false, revocationReason: 'REFUND_FULL'}); + expect(snapshot.revokedAt?.getTime()).toBe(revokedAt); + }); + + it('treats a revocation date as revoked whatever the status', async () => { + seedSubscription({status: 1, transaction: {revocationDate: now - ms('1 hour'), revocationReason: 1}}); + const snapshot = await sync.loadPurchase(lookup()); + expect(snapshot).toMatchObject({state: 'revoked', providerEntitled: false, revocationReason: '1'}); + }); + + it('keeps an unknown status out of entitlement', async () => { + seedSubscription({status: 9}); + const snapshot = await sync.loadPurchase(lookup()); + expect(snapshot).toMatchObject({state: 'pending', storeState: '9', providerEntitled: false}); + }); + + it('takes the slot from the latest transaction after a crossgrade', async () => { + seedSubscription({status: 1, productId: YEARLY, transaction: {expiresDate: now + ms('364 days')}}); + const snapshot = await sync.loadPurchase(lookup()); + expect(snapshot).toMatchObject({slot: 'yearly', productId: YEARLY, autoRenewProductId: YEARLY}); + }); + + it('picks the item for the original transaction among other subscriptions', async () => { + seedSubscription({status: 1}); + fake.addTransaction('production', { + transactionId: '2000000200000001', + originalTransactionId: '2000000200000001', + productId: YEARLY, + expiresDate: now + ms('300 days'), + }); + fake.putSubscription('production', { + originalTransactionId: '2000000200000001', + status: 2, + latestTransactionId: '2000000200000001', + customerId: ORIGINAL_ID, + subscriptionGroupIdentifier: '21000009', + }); + const snapshot = await sync.loadPurchase(lookup()); + expect(snapshot).toMatchObject({storeReference: ORIGINAL_ID, state: 'active', slot: 'monthly'}); + const statuses = await new AppStoreServerApiClient().getAllSubscriptionStatuses({ + environment: 'production', + bundleId: 'com.fluxer', + transactionId: ORIGINAL_ID, + }); + expect(statuses.groups.flatMap((group) => group.lastTransactions)).toHaveLength(2); + }); + + it('falls back to the transaction token and purchase date without renewal info', async () => { + seedSubscription({status: 1, renewalInfo: null}); + const snapshot = await sync.loadPurchase(lookup()); + expect(snapshot).toMatchObject({accountToken: TOKEN, autoRenew: null, autoRenewProductId: null, state: 'active'}); + expect(snapshot.startedAt?.getTime()).toBe(now - ms('1 day')); + }); + + it('prefers the renewal token after an account token change', async () => { + seedSubscription({status: 1, renewalInfo: {appAccountToken: '11111111-2222-4333-8444-555555555555'}}); + const snapshot = await sync.loadPurchase(lookup()); + expect(snapshot.accountToken).toBe('11111111-2222-4333-8444-555555555555'); + }); + + it('keys sandbox purchases apart and routes an unknown environment', async () => { + seedSubscription({environment: 'sandbox', status: 1}); + const snapshot = await sync.loadPurchase(lookup({environment: null})); + expect(snapshot).toMatchObject({storeKey: `app_store:sandbox:${ORIGINAL_ID}`, environment: 'sandbox'}); + expect(fake.requests.map((request) => request.environment)).toEqual(['production', 'sandbox']); + }); + + it('reports family shared ownership', async () => { + seedSubscription({status: 1, transaction: {inAppOwnershipType: 'FAMILY_SHARED'}}); + const snapshot = await sync.loadPurchase(lookup()); + expect(snapshot.ownershipType).toBe('FAMILY_SHARED'); + }); + + it('fails when the statuses do not include the subscription', async () => { + fake.addTransaction('production', { + transactionId: ORIGINAL_ID, + originalTransactionId: ORIGINAL_ID, + productId: MONTHLY, + expiresDate: now + ms('10 days'), + }); + await expectSyncFailure(sync.loadPurchase(lookup()), 'not_found'); + }); + + it('fails when the latest transaction has no expiry', async () => { + seedSubscription({status: 1, transaction: {expiresDate: null}}); + await expectSyncFailure(sync.loadPurchase(lookup()), 'missing_expiry'); + }); + + it('fails when the product moved to one that is not configured', async () => { + seedSubscription({status: 1, productId: 'com.fluxer.unknown'}); + await expectSyncFailure(sync.loadPurchase(lookup()), 'unknown_product'); + }); + + it('fails when a subscription slot points at a one-time product', async () => { + seedSubscription({status: 1, productId: GIFT_MONTH}); + await expectSyncFailure(sync.loadPurchase(lookup()), 'mismatch'); + }); + + it('passes retryable API errors through', async () => { + seedSubscription({status: 1}); + fake.failNext({}, {status: 500, errorCode: 5000001}); + const error = await sync.loadPurchase(lookup()).then( + () => null, + (caught: unknown) => caught, + ); + expect(error).toBeInstanceOf(AppStoreServerApiError); + expect((error as AppStoreServerApiError).retryable).toBe(true); + }); + }); + + describe('gifts', () => { + const GIFT_ID = '2000000300000001'; + + function seedGift(overrides: AppStoreFakePayload = {}): void { + fake.addTransaction('production', { + transactionId: GIFT_ID, + originalTransactionId: GIFT_ID, + productId: GIFT_YEAR, + type: 'Consumable', + purchaseDate: now - ms('5 minutes'), + appAccountToken: TOKEN_UPPER, + storefront: 'BRA', + ...overrides, + }); + } + + function giftLookup(): AppStorePurchaseLookup { + return lookup({productId: GIFT_YEAR, transactionId: GIFT_ID, originalTransactionId: GIFT_ID}); + } + + it('maps a purchased gift from Get Transaction Info', async () => { + seedGift(); + const snapshot = await sync.loadPurchase(giftLookup()); + expect(snapshot).toMatchObject({ + storeKey: `app_store:production:${GIFT_ID}`, + kind: 'gift', + slot: 'gift_1_year', + state: 'purchased', + providerEntitled: true, + accountToken: TOKEN, + expiresAt: null, + graceEndsAt: null, + autoRenew: null, + storeState: null, + region: 'BRA', + latestTransactionId: GIFT_ID, + }); + expect(snapshot.purchasedAt?.getTime()).toBe(now - ms('5 minutes')); + expect(fake.requests.map((request) => request.path)).toEqual([`/inApps/v1/transactions/${GIFT_ID}`]); + }); + + it('maps a refunded gift', async () => { + const revokedAt = now - ms('1 minute'); + seedGift({revocationDate: revokedAt, revocationReason: 0, revocationType: 'REFUND_FULL'}); + const snapshot = await sync.loadPurchase(giftLookup()); + expect(snapshot).toMatchObject({ + state: 'refunded', + providerEntitled: false, + storeState: 'REFUND_FULL', + revocationReason: 'REFUND_FULL', + }); + expect(snapshot.revokedAt?.getTime()).toBe(revokedAt); + }); + + it('fails when a gift product is sold as a subscription', async () => { + seedGift({type: 'Auto-Renewable Subscription'}); + await expectSyncFailure(sync.loadPurchase(giftLookup()), 'mismatch'); + }); + + it('fails when the transaction belongs to another original transaction', async () => { + seedGift({originalTransactionId: '2000000399999999'}); + await expectSyncFailure(sync.loadPurchase(giftLookup()), 'mismatch'); + }); + }); + + it('refuses a product that is not configured without calling Apple', async () => { + await expectSyncFailure(sync.loadPurchase(lookup({productId: 'com.fluxer.unknown'})), 'unknown_product'); + expect(fake.requests).toHaveLength(0); + }); + + it('uses the configured product table', () => { + expect(Config.appStore.products[MONTHLY]).toBe('monthly'); + expect(Config.appStore.products[GIFT_YEAR]).toBe('gift_1_year'); + }); +}); diff --git a/fluxer_api/src/api/store_billing/tests/AppStoreServerApiClient.test.ts b/fluxer_api/src/api/store_billing/tests/AppStoreServerApiClient.test.ts new file mode 100644 index 000000000..65cdf00b9 --- /dev/null +++ b/fluxer_api/src/api/store_billing/tests/AppStoreServerApiClient.test.ts @@ -0,0 +1,493 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import {createPublicKey} from 'node:crypto'; +import {mkdtemp, rm, writeFile} from 'node:fs/promises'; +import {tmpdir} from 'node:os'; +import {join} from 'node:path'; +import {Config} from '@app/api/Config'; +import {setInjectedAppleRootCertificates} from '@app/api/store_billing/app_store/AppleRootCertificates'; +import {AppStoreJwsVerificationError} from '@app/api/store_billing/app_store/AppStoreJwsVerifier'; +import { + APP_STORE_PRODUCTION_BASE_URL, + APP_STORE_SANDBOX_BASE_URL, + AppStoreServerApiClient, + AppStoreServerApiError, + isAppStoreTransactionNotFound, +} from '@app/api/store_billing/app_store/AppStoreServerApiClient'; +import {type AppleTestPki, createAppleTestPki} from '@app/api/store_billing/tests/AppleTestPki'; +import { + type AppStoreServerApiFake, + createAppStoreServerApiFake, +} from '@app/api/test/msw/handlers/AppStoreServerApiHandlers'; +import {server} from '@app/api/test/msw/server'; +import {ms} from 'itty-time'; +import {decodeJwt, decodeProtectedHeader, jwtVerify} from 'jose'; +import {delay, HttpResponse, http} from 'msw'; +import {afterEach, beforeAll, beforeEach, describe, expect, it} from 'vitest'; + +const MONTHLY = 'com.fluxer.plutonium.monthly'; +const GIFT = 'com.fluxer.gift.1month'; + +async function captureError(promise: Promise): Promise { + return await promise.then( + () => null, + (error: unknown) => error, + ); +} + +function seedSubscription(fake: AppStoreServerApiFake, environment: 'production' | 'sandbox', id: string): void { + fake.addTransaction(environment, { + transactionId: id, + originalTransactionId: id, + productId: MONTHLY, + expiresDate: Date.now() + ms('30 days'), + }); + fake.putSubscription(environment, {originalTransactionId: id, status: 1, latestTransactionId: id}); +} + +describe('AppStoreServerApiClient', () => { + let pki: AppleTestPki; + let fake: AppStoreServerApiFake; + let savedAppStore: typeof Config.appStore; + + beforeAll(() => { + pki = createAppleTestPki(); + }); + + beforeEach(() => { + savedAppStore = {...Config.appStore}; + setInjectedAppleRootCertificates([pki.root]); + fake = createAppStoreServerApiFake({sign: (payload) => pki.signJws(payload)}); + server.use(...fake.handlers); + }); + + afterEach(() => { + Object.assign(Config.appStore, savedAppStore); + setInjectedAppleRootCertificates(undefined); + }); + + describe('authentication', () => { + it('signs an ES256 bearer token with the documented claims', async () => { + const authorizations: Array = []; + server.use( + http.get(`${APP_STORE_PRODUCTION_BASE_URL}/inApps/v1/transactions/:id`, ({request}) => { + authorizations.push(request.headers.get('authorization') ?? ''); + return HttpResponse.json({errorCode: 4040010, errorMessage: 'not found'}, {status: 404}); + }), + ); + const client = new AppStoreServerApiClient(); + await captureError( + client.getTransactionInfo({environment: 'production', bundleId: 'com.fluxer', transactionId: '1'}), + ); + expect(authorizations).toHaveLength(1); + const token = authorizations[0].replace(/^Bearer /, ''); + expect(decodeProtectedHeader(token)).toEqual({alg: 'ES256', kid: 'FLUXERTEST', typ: 'JWT'}); + const claims = decodeJwt(token); + expect(claims).toMatchObject({ + iss: Config.appStore.issuerId, + aud: 'appstoreconnect-v1', + bid: 'com.fluxer', + }); + expect((claims.exp ?? 0) - (claims.iat ?? 0)).toBe(3600); + const publicKey = createPublicKey(Config.appStore.privateKey ?? ''); + await expect(jwtVerify(token, publicKey, {algorithms: ['ES256']})).resolves.toBeDefined(); + }); + + it('reuses a token for 50 minutes per bundle', async () => { + let now = Date.parse('2026-09-29T10:00:00Z'); + const tokens: Array = []; + server.use( + http.get(`${APP_STORE_PRODUCTION_BASE_URL}/inApps/v1/transactions/:id`, ({request}) => { + tokens.push(request.headers.get('authorization') ?? ''); + return HttpResponse.json({errorCode: 4040010}, {status: 404}); + }), + ); + const client = new AppStoreServerApiClient({now: () => now}); + const call = (bundleId: string) => + captureError(client.getTransactionInfo({environment: 'production', bundleId, transactionId: '1'})); + await call('com.fluxer'); + now += ms('49 minutes'); + await call('com.fluxer'); + await call('com.fluxer.beta'); + now += ms('2 minutes'); + await call('com.fluxer'); + expect(tokens[1]).toBe(tokens[0]); + expect(tokens[2]).not.toBe(tokens[0]); + expect(decodeJwt(tokens[2].replace(/^Bearer /, '')).bid).toBe('com.fluxer.beta'); + expect(tokens[3]).not.toBe(tokens[0]); + }); + + it('signs a new token when the key id changes', async () => { + seedSubscription(fake, 'production', '100'); + const client = new AppStoreServerApiClient(); + await client.getTransactionInfo({environment: 'production', bundleId: 'com.fluxer', transactionId: '100'}); + Config.appStore.keyId = 'ROTATED'; + await expect( + client.getTransactionInfo({environment: 'production', bundleId: 'com.fluxer', transactionId: '100'}), + ).resolves.toMatchObject({environment: 'production'}); + expect(fake.requests.map((request) => request.bundleId)).toEqual(['com.fluxer', 'com.fluxer']); + }); + + it('surfaces a rejected token as final', async () => { + seedSubscription(fake, 'production', '103'); + fake.failNext({}, {status: 401}); + const client = new AppStoreServerApiClient(); + const error = await captureError( + client.getTransactionInfo({environment: 'production', bundleId: 'com.fluxer', transactionId: '103'}), + ); + expect(error).toMatchObject({status: 401, errorCode: null, retryable: false}); + }); + + it('accepts a private key with escaped newlines', async () => { + seedSubscription(fake, 'production', '101'); + Config.appStore.privateKey = (savedAppStore.privateKey ?? '').replaceAll('\n', '\\n'); + const client = new AppStoreServerApiClient(); + await expect( + client.getTransactionInfo({environment: 'production', bundleId: 'com.fluxer', transactionId: '101'}), + ).resolves.toMatchObject({environment: 'production'}); + }); + + it('reads the private key from a file', async () => { + seedSubscription(fake, 'production', '102'); + const directory = await mkdtemp(join(tmpdir(), 'app-store-key-')); + try { + const path = join(directory, 'AuthKey.p8'); + await writeFile(path, savedAppStore.privateKey ?? ''); + Config.appStore.privateKey = undefined; + Config.appStore.privateKeyPath = path; + const client = new AppStoreServerApiClient(); + await expect( + client.getTransactionInfo({environment: 'production', bundleId: 'com.fluxer', transactionId: '102'}), + ).resolves.toMatchObject({environment: 'production'}); + } finally { + await rm(directory, {recursive: true, force: true}); + } + }); + + it('fails without credentials and makes no request', async () => { + Config.appStore.privateKey = undefined; + Config.appStore.privateKeyPath = undefined; + const client = new AppStoreServerApiClient(); + const error = await captureError( + client.getTransactionInfo({environment: 'production', bundleId: 'com.fluxer', transactionId: '1'}), + ); + expect(error).toBeInstanceOf(AppStoreServerApiError); + expect((error as AppStoreServerApiError).retryable).toBe(false); + expect(fake.requests).toHaveLength(0); + }); + }); + + describe('environment routing', () => { + it('calls only the sandbox host for a sandbox transaction', async () => { + seedSubscription(fake, 'sandbox', '200'); + const client = new AppStoreServerApiClient(); + const result = await client.getAllSubscriptionStatuses({ + environment: 'sandbox', + bundleId: 'com.fluxer', + transactionId: '200', + }); + expect(result.environment).toBe('sandbox'); + expect(result.appAppleId).toBeNull(); + expect(result.groups[0].lastTransactions[0].transaction.environment).toBe('Sandbox'); + expect(fake.requests.map((request) => request.environment)).toEqual(['sandbox']); + }); + + it('tries production first and falls back to sandbox on a missing transaction', async () => { + seedSubscription(fake, 'sandbox', '201'); + const client = new AppStoreServerApiClient(); + const result = await client.getTransactionInfo({environment: null, bundleId: 'com.fluxer', transactionId: '201'}); + expect(result.environment).toBe('sandbox'); + expect(fake.requests.map((request) => request.environment)).toEqual(['production', 'sandbox']); + }); + + it('stays on production when the transaction exists there', async () => { + seedSubscription(fake, 'production', '202'); + const client = new AppStoreServerApiClient(); + const result = await client.getAllSubscriptionStatuses({ + environment: null, + bundleId: 'com.fluxer', + transactionId: '202', + }); + expect(result.environment).toBe('production'); + expect(result.appAppleId).toBe(1234567890); + expect(fake.requests.map((request) => request.environment)).toEqual(['production']); + }); + + it('does not fall back on other errors', async () => { + seedSubscription(fake, 'sandbox', '203'); + fake.failNext({environment: 'production'}, {status: 500, errorCode: 5000001}); + const client = new AppStoreServerApiClient(); + const error = await captureError( + client.getTransactionInfo({environment: null, bundleId: 'com.fluxer', transactionId: '203'}), + ); + expect((error as AppStoreServerApiError).errorCode).toBe(5000001); + expect(fake.requests.map((request) => request.environment)).toEqual(['production']); + }); + + it('reports a transaction missing from both environments', async () => { + const client = new AppStoreServerApiClient(); + const error = await captureError( + client.getTransactionInfo({environment: null, bundleId: 'com.fluxer', transactionId: '204'}), + ); + expect(isAppStoreTransactionNotFound(error)).toBe(true); + expect((error as AppStoreServerApiError).retryable).toBe(false); + expect(fake.requests.map((request) => request.environment)).toEqual(['production', 'sandbox']); + }); + + it('never falls back when the environment is known', async () => { + seedSubscription(fake, 'sandbox', '205'); + const client = new AppStoreServerApiClient(); + const error = await captureError( + client.getTransactionInfo({environment: 'production', bundleId: 'com.fluxer', transactionId: '205'}), + ); + expect(isAppStoreTransactionNotFound(error)).toBe(true); + expect(fake.requests.map((request) => request.environment)).toEqual(['production']); + }); + }); + + describe('errors', () => { + it.each([ + [4040002, 404], + [4040004, 404], + [4040006, 404], + [5000001, 500], + ])('marks error code %i as retryable', async (errorCode, status) => { + seedSubscription(fake, 'production', '300'); + fake.failNext({}, {status, errorCode}); + const client = new AppStoreServerApiClient(); + const error = await captureError( + client.getAllSubscriptionStatuses({environment: 'production', bundleId: 'com.fluxer', transactionId: '300'}), + ); + expect(error).toBeInstanceOf(AppStoreServerApiError); + expect(error).toMatchObject({status, errorCode, retryable: true}); + }); + + it.each([ + [4000006, 400], + [4040001, 404], + [4040005, 404], + [5000000, 500], + ])('marks error code %i as final', async (errorCode, status) => { + seedSubscription(fake, 'production', '301'); + fake.failNext({}, {status, errorCode}); + const client = new AppStoreServerApiClient(); + const error = await captureError( + client.getAllSubscriptionStatuses({environment: 'production', bundleId: 'com.fluxer', transactionId: '301'}), + ); + expect(error).toMatchObject({status, errorCode, retryable: false}); + }); + + it('reads Retry-After as epoch milliseconds on a rate limit', async () => { + seedSubscription(fake, 'production', '302'); + const retryAt = Date.parse('2026-09-29T12:00:00Z'); + fake.failNext({}, {status: 429, errorCode: 4290000, retryAfter: String(retryAt)}); + const client = new AppStoreServerApiClient(); + const error = await captureError( + client.getTransactionInfo({environment: 'production', bundleId: 'com.fluxer', transactionId: '302'}), + ); + expect(error).toMatchObject({status: 429, errorCode: 4290000, retryable: true}); + expect((error as AppStoreServerApiError).retryAfter?.getTime()).toBe(retryAt); + }); + + it('treats a server error without a body as retryable', async () => { + seedSubscription(fake, 'production', '303'); + fake.failNext({}, {status: 503}); + const client = new AppStoreServerApiClient(); + const error = await captureError( + client.getTransactionInfo({environment: 'production', bundleId: 'com.fluxer', transactionId: '303'}), + ); + expect(error).toMatchObject({status: 503, errorCode: null, retryable: true}); + }); + + it('treats a network failure as retryable', async () => { + server.use(http.get(`${APP_STORE_SANDBOX_BASE_URL}/inApps/v1/transactions/:id`, () => HttpResponse.error())); + const client = new AppStoreServerApiClient(); + const error = await captureError( + client.getTransactionInfo({environment: 'sandbox', bundleId: 'com.fluxer', transactionId: '304'}), + ); + expect(error).toMatchObject({status: null, retryable: true}); + }); + + it('times out a slow request', async () => { + server.use( + http.get(`${APP_STORE_SANDBOX_BASE_URL}/inApps/v1/transactions/:id`, async () => { + await delay(500); + return HttpResponse.json({}); + }), + ); + const client = new AppStoreServerApiClient({timeoutMs: 20}); + const error = await captureError( + client.getTransactionInfo({environment: 'sandbox', bundleId: 'com.fluxer', transactionId: '305'}), + ); + expect(error).toMatchObject({status: null, retryable: true}); + }); + + it('rejects a response body of the wrong shape', async () => { + server.use( + http.get(`${APP_STORE_PRODUCTION_BASE_URL}/inApps/v1/subscriptions/:id`, () => + HttpResponse.json({data: 'nope'}), + ), + ); + const client = new AppStoreServerApiClient(); + const error = await captureError( + client.getAllSubscriptionStatuses({environment: 'production', bundleId: 'com.fluxer', transactionId: '306'}), + ); + expect(error).toMatchObject({status: 200, retryable: false}); + }); + }); + + describe('signed response data', () => { + it('verifies every signed field in the statuses response', async () => { + seedSubscription(fake, 'production', '400'); + fake.addTransaction('production', { + transactionId: '401', + originalTransactionId: '401', + productId: 'com.fluxer.plutonium.yearly', + expiresDate: Date.now() + ms('365 days'), + }); + fake.putSubscription('production', { + originalTransactionId: '401', + status: 2, + latestTransactionId: '401', + customerId: '400', + subscriptionGroupIdentifier: '21000001', + renewalInfo: null, + }); + fake.putSubscription('production', { + originalTransactionId: '400', + status: 1, + latestTransactionId: '400', + customerId: '400', + }); + const client = new AppStoreServerApiClient(); + const result = await client.getAllSubscriptionStatuses({ + environment: 'production', + bundleId: 'com.fluxer', + transactionId: '400', + }); + expect(result.groups).toHaveLength(2); + const items = result.groups.flatMap((group) => group.lastTransactions); + expect(items.map((item) => [item.originalTransactionId, item.status])).toEqual([ + ['400', 1], + ['401', 2], + ]); + expect(items[0].renewalInfo?.autoRenewStatus).toBe(1); + expect(items[0].transaction.productId).toBe(MONTHLY); + expect(items[1].renewalInfo).toBeNull(); + }); + + it('rejects signed data from a chain that is not trusted', async () => { + const other = createAppleTestPki(); + const untrusted = createAppStoreServerApiFake({sign: (payload) => other.signJws(payload)}); + server.use(...untrusted.handlers); + seedSubscription(untrusted, 'production', '402'); + const client = new AppStoreServerApiClient(); + const error = await captureError( + client.getAllSubscriptionStatuses({environment: 'production', bundleId: 'com.fluxer', transactionId: '402'}), + ); + expect(error).toBeInstanceOf(AppStoreJwsVerificationError); + expect((error as AppStoreJwsVerificationError).reason).toBe('untrusted_chain'); + }); + + it('rejects signed data from the other environment', async () => { + fake.addTransaction('production', { + transactionId: '403', + originalTransactionId: '403', + productId: GIFT, + type: 'Consumable', + environment: 'Sandbox', + }); + const client = new AppStoreServerApiClient(); + const error = await captureError( + client.getTransactionInfo({environment: 'production', bundleId: 'com.fluxer', transactionId: '403'}), + ); + expect((error as AppStoreJwsVerificationError).reason).toBe('invalid_environment'); + }); + + it('rejects a transaction for another bundle', async () => { + Config.appStore.apps = [...savedAppStore.apps, {bundleId: 'com.fluxer.beta', appAppleId: 1234567891}]; + fake.addTransaction('production', { + transactionId: '404', + originalTransactionId: '404', + productId: GIFT, + type: 'Consumable', + bundleId: 'com.fluxer.beta', + }); + const client = new AppStoreServerApiClient(); + const error = await captureError( + client.getTransactionInfo({environment: 'production', bundleId: 'com.fluxer', transactionId: '404'}), + ); + expect(error).toMatchObject({status: 200, retryable: false}); + }); + }); + + describe('app account token', () => { + it('sets a lowercase token on the original transaction', async () => { + seedSubscription(fake, 'production', '500'); + const client = new AppStoreServerApiClient(); + await client.setAppAccountToken({ + environment: 'production', + bundleId: 'com.fluxer', + originalTransactionId: '500', + appAccountToken: '7E3FB20B-4CDB-47CC-936D-99D65F608138', + }); + const put = fake.requests.find((request) => request.method === 'PUT'); + expect(put).toMatchObject({ + environment: 'production', + path: '/inApps/v1/transactions/500/appAccountToken', + body: {appAccountToken: '7e3fb20b-4cdb-47cc-936d-99d65f608138'}, + bundleId: 'com.fluxer', + }); + const result = await client.getAllSubscriptionStatuses({ + environment: 'production', + bundleId: 'com.fluxer', + transactionId: '500', + }); + const [item] = result.groups[0].lastTransactions; + expect(item.transaction.appAccountToken).toBe('7e3fb20b-4cdb-47cc-936d-99d65f608138'); + expect(item.renewalInfo?.appAccountToken).toBe('7e3fb20b-4cdb-47cc-936d-99d65f608138'); + }); + + it('surfaces the family sharing refusal as final', async () => { + fake.addTransaction('production', { + transactionId: '501', + originalTransactionId: '501', + productId: MONTHLY, + inAppOwnershipType: 'FAMILY_SHARED', + }); + const client = new AppStoreServerApiClient(); + const error = await captureError( + client.setAppAccountToken({ + environment: 'production', + bundleId: 'com.fluxer', + originalTransactionId: '501', + appAccountToken: '7e3fb20b-4cdb-47cc-936d-99d65f608138', + }), + ); + expect(error).toMatchObject({status: 400, errorCode: 4000185, retryable: false}); + }); + + it('surfaces a renewal transaction id as final', async () => { + fake.addTransaction('production', {transactionId: '503', originalTransactionId: '502', productId: MONTHLY}); + const client = new AppStoreServerApiClient(); + const error = await captureError( + client.setAppAccountToken({ + environment: 'production', + bundleId: 'com.fluxer', + originalTransactionId: '503', + appAccountToken: '7e3fb20b-4cdb-47cc-936d-99d65f608138', + }), + ); + expect(error).toMatchObject({status: 400, errorCode: 4000187, retryable: false}); + }); + }); + + it('requests a test notification', async () => { + const client = new AppStoreServerApiClient(); + const token = await client.requestTestNotification({environment: 'sandbox', bundleId: 'com.fluxer'}); + expect(token.length).toBeGreaterThan(0); + expect(fake.requests).toMatchObject([ + {environment: 'sandbox', method: 'POST', path: '/inApps/v1/notifications/test'}, + ]); + }); +}); diff --git a/fluxer_api/src/api/store_billing/tests/AppleProductionChain.ts b/fluxer_api/src/api/store_billing/tests/AppleProductionChain.ts new file mode 100644 index 000000000..f27415dbb --- /dev/null +++ b/fluxer_api/src/api/store_billing/tests/AppleProductionChain.ts @@ -0,0 +1,12 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +export const REAL_APPLE_ROOT = + 'MIICQzCCAcmgAwIBAgIILcX8iNLFS5UwCgYIKoZIzj0EAwMwZzEbMBkGA1UEAwwSQXBwbGUgUm9vdCBDQSAtIEczMSYwJAYDVQQLDB1BcHBsZSBDZXJ0aWZpY2F0aW9uIEF1dGhvcml0eTETMBEGA1UECgwKQXBwbGUgSW5jLjELMAkGA1UEBhMCVVMwHhcNMTQwNDMwMTgxOTA2WhcNMzkwNDMwMTgxOTA2WjBnMRswGQYDVQQDDBJBcHBsZSBSb290IENBIC0gRzMxJjAkBgNVBAsMHUFwcGxlIENlcnRpZmljYXRpb24gQXV0aG9yaXR5MRMwEQYDVQQKDApBcHBsZSBJbmMuMQswCQYDVQQGEwJVUzB2MBAGByqGSM49AgEGBSuBBAAiA2IABJjpLz1AcqTtkyJygRMc3RCV8cWjTnHcFBbZDuWmBSp3ZHtfTjjTuxxEtX/1H7YyYl3J6YRbTzBPEVoA/VhYDKX1DyxNB0cTddqXl5dvMVztK517IDvYuVTZXpmkOlEKMaNCMEAwHQYDVR0OBBYEFLuw3qFYM4iapIqZ3r6966/ayySrMA8GA1UdEwEB/wQFMAMBAf8wDgYDVR0PAQH/BAQDAgEGMAoGCCqGSM49BAMDA2gAMGUCMQCD6cHEFl4aXTQY2e3v9GwOAEZLuN+yRhHFD/3meoyhpmvOwgPUnPWTxnS4at+qIxUCMG1mihDK1A3UT82NQz60imOlM27jbdoXt2QfyFMm+YhidDkLF1vLUagM6BgD56KyKA=='; + +export const REAL_APPLE_INTERMEDIATE = + 'MIIDFjCCApygAwIBAgIUIsGhRwp0c2nvU4YSycafPTjzbNcwCgYIKoZIzj0EAwMwZzEbMBkGA1UEAwwSQXBwbGUgUm9vdCBDQSAtIEczMSYwJAYDVQQLDB1BcHBsZSBDZXJ0aWZpY2F0aW9uIEF1dGhvcml0eTETMBEGA1UECgwKQXBwbGUgSW5jLjELMAkGA1UEBhMCVVMwHhcNMjEwMzE3MjAzNzEwWhcNMzYwMzE5MDAwMDAwWjB1MUQwQgYDVQQDDDtBcHBsZSBXb3JsZHdpZGUgRGV2ZWxvcGVyIFJlbGF0aW9ucyBDZXJ0aWZpY2F0aW9uIEF1dGhvcml0eTELMAkGA1UECwwCRzYxEzARBgNVBAoMCkFwcGxlIEluYy4xCzAJBgNVBAYTAlVTMHYwEAYHKoZIzj0CAQYFK4EEACIDYgAEbsQKC94PrlWmZXnXgtxzdVJL8T0SGYngDRGpngn3N6PT8JMEb7FDi4bBmPhCnZ3/sq6PF/cGcKXWsL5vOteRhyJ45x3ASP7cOB+aao90fcpxSv/EZFbniAbNgZGhIhpIo4H6MIH3MBIGA1UdEwEB/wQIMAYBAf8CAQAwHwYDVR0jBBgwFoAUu7DeoVgziJqkipnevr3rr9rLJKswRgYIKwYBBQUHAQEEOjA4MDYGCCsGAQUFBzABhipodHRwOi8vb2NzcC5hcHBsZS5jb20vb2NzcDAzLWFwcGxlcm9vdGNhZzMwNwYDVR0fBDAwLjAsoCqgKIYmaHR0cDovL2NybC5hcHBsZS5jb20vYXBwbGVyb290Y2FnMy5jcmwwHQYDVR0OBBYEFD8vlCNR01DJmig97bB85c+lkGKZMA4GA1UdDwEB/wQEAwIBBjAQBgoqhkiG92NkBgIBBAIFADAKBggqhkjOPQQDAwNoADBlAjBAXhSq5IyKogMCPtw490BaB677CaEGJXufQB/EqZGd6CSjiCtOnuMTbXVXmxxcxfkCMQDTSPxarZXvNrkxU3TkUMI33yzvFVVRT4wxWJC994OsdcZ4+RGNsYDyR5gmdr0nDGg='; + +export const REAL_APPLE_SIGNING_CERTIFICATE = + 'MIIEMTCCA7agAwIBAgIQR8KHzdn554Z/UoradNx9tzAKBggqhkjOPQQDAzB1MUQwQgYDVQQDDDtBcHBsZSBXb3JsZHdpZGUgRGV2ZWxvcGVyIFJlbGF0aW9ucyBDZXJ0aWZpY2F0aW9uIEF1dGhvcml0eTELMAkGA1UECwwCRzYxEzARBgNVBAoMCkFwcGxlIEluYy4xCzAJBgNVBAYTAlVTMB4XDTI1MDkxOTE5NDQ1MVoXDTI3MTAxMzE3NDcyM1owgZIxQDA+BgNVBAMMN1Byb2QgRUNDIE1hYyBBcHAgU3RvcmUgYW5kIGlUdW5lcyBTdG9yZSBSZWNlaXB0IFNpZ25pbmcxLDAqBgNVBAsMI0FwcGxlIFdvcmxkd2lkZSBEZXZlbG9wZXIgUmVsYXRpb25zMRMwEQYDVQQKDApBcHBsZSBJbmMuMQswCQYDVQQGEwJVUzBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABNnVvhcv7iT+7Ex5tBMBgrQspHzIsXRi0Yxfek7lv8wEmj/bHiWtNwJqc2BoHzsQiEjP7KFIIKg4Y8y0/nynuAmjggIIMIICBDAMBgNVHRMBAf8EAjAAMB8GA1UdIwQYMBaAFD8vlCNR01DJmig97bB85c+lkGKZMHAGCCsGAQUFBwEBBGQwYjAtBggrBgEFBQcwAoYhaHR0cDovL2NlcnRzLmFwcGxlLmNvbS93d2RyZzYuZGVyMDEGCCsGAQUFBzABhiVodHRwOi8vb2NzcC5hcHBsZS5jb20vb2NzcDAzLXd3ZHJnNjAyMIIBHgYDVR0gBIIBFTCCAREwggENBgoqhkiG92NkBQYBMIH+MIHDBggrBgEFBQcCAjCBtgyBs1JlbGlhbmNlIG9uIHRoaXMgY2VydGlmaWNhdGUgYnkgYW55IHBhcnR5IGFzc3VtZXMgYWNjZXB0YW5jZSBvZiB0aGUgdGhlbiBhcHBsaWNhYmxlIHN0YW5kYXJkIHRlcm1zIGFuZCBjb25kaXRpb25zIG9mIHVzZSwgY2VydGlmaWNhdGUgcG9saWN5IGFuZCBjZXJ0aWZpY2F0aW9uIHByYWN0aWNlIHN0YXRlbWVudHMuMDYGCCsGAQUFBwIBFipodHRwOi8vd3d3LmFwcGxlLmNvbS9jZXJ0aWZpY2F0ZWF1dGhvcml0eS8wHQYDVR0OBBYEFIFioG4wMMVA1ku9zJmGNPAVn3eqMA4GA1UdDwEB/wQEAwIHgDAQBgoqhkiG92NkBgsBBAIFADAKBggqhkjOPQQDAwNpADBmAjEA+qXnREC7hXIWVLsLxznjRpIzPf7VHz9V/CTm8+LJlrQepnmcPvGLNcX6XPnlcgLAAjEA5IjNZKgg5pQ79knF4IbTXdKv8vutIDMXDmjPVT3dGvFtsGRwXOywR2kZCdSrfeot'; + +export const REAL_APPLE_CHAIN_EFFECTIVE_DATE = new Date(1761962975000); diff --git a/fluxer_api/src/api/store_billing/tests/AppleTestPki.ts b/fluxer_api/src/api/store_billing/tests/AppleTestPki.ts new file mode 100644 index 000000000..e27468161 --- /dev/null +++ b/fluxer_api/src/api/store_billing/tests/AppleTestPki.ts @@ -0,0 +1,244 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import {generateKeyPairSync, type KeyObject, randomBytes, sign, X509Certificate} from 'node:crypto'; +import {ms} from 'itty-time'; +import {CompactSign} from 'jose'; + +export const APPLE_RECEIPT_SIGNING_OID = '1.2.840.113635.100.6.11.1'; +export const APPLE_WWDR_INTERMEDIATE_OID = '1.2.840.113635.100.6.2.1'; +const OID_ECDSA_WITH_SHA256 = '1.2.840.10045.4.3.2'; +const OID_ECDSA_WITH_SHA384 = '1.2.840.10045.4.3.3'; +const OID_COMMON_NAME = '2.5.4.3'; +const OID_ORGANIZATION = '2.5.4.10'; +const OID_BASIC_CONSTRAINTS = '2.5.29.19'; +const OID_KEY_USAGE = '2.5.29.15'; +const OID_CERTIFICATE_POLICIES = '2.5.29.32'; + +export interface TestCertificateExtension { + oid: string; + critical?: boolean; + value: Buffer; +} + +export interface TestCertificateOptions { + commonName?: string; + notBefore?: Date; + notAfter?: Date; + appleExtension?: boolean; + ca?: boolean; + extraExtensions?: Array; +} + +export interface AppleTestPkiOptions { + root?: TestCertificateOptions; + intermediate?: TestCertificateOptions & {issuer?: AppleTestPki}; + leaf?: TestCertificateOptions; +} + +export interface SignJwsOptions { + header?: Record; + signingKey?: KeyObject; +} + +export interface AppleTestPki { + root: X509Certificate; + intermediate: X509Certificate; + leaf: X509Certificate; + rootKey: KeyObject; + intermediateKey: KeyObject; + leafKey: KeyObject; + x5c: Array; + signJws(payload: Record, options?: SignJwsOptions): Promise; +} + +function derLength(length: number): Buffer { + if (length < 0x80) { + return Buffer.from([length]); + } + const bytes: Array = []; + let remaining = length; + while (remaining > 0) { + bytes.unshift(remaining & 0xff); + remaining = Math.floor(remaining / 256); + } + return Buffer.from([0x80 | bytes.length, ...bytes]); +} + +function der(tag: number, ...contents: Array): Buffer { + const body = Buffer.concat(contents); + return Buffer.concat([Buffer.from([tag]), derLength(body.length), body]); +} + +function derSequence(...contents: Array): Buffer { + return der(0x30, ...contents); +} + +function derInteger(value: Buffer): Buffer { + const trimmed = value[0] & 0x80 ? Buffer.concat([Buffer.from([0]), value]) : value; + return der(0x02, trimmed); +} + +export function derObjectIdentifier(oid: string): Buffer { + const arcs = oid.split('.').map(Number); + const bytes = [arcs[0] * 40 + arcs[1]]; + for (const arc of arcs.slice(2)) { + const chunk = [arc % 128]; + for (let rest = Math.floor(arc / 128); rest > 0; rest = Math.floor(rest / 128)) { + chunk.unshift((rest % 128) | 0x80); + } + bytes.push(...chunk); + } + return der(0x06, Buffer.from(bytes)); +} + +function derTime(date: Date): Buffer { + const iso = date.toISOString(); + const compact = `${iso.slice(0, 4)}${iso.slice(5, 7)}${iso.slice(8, 10)}${iso.slice(11, 13)}${iso.slice(14, 16)}${iso.slice(17, 19)}Z`; + const year = date.getUTCFullYear(); + if (year >= 1950 && year < 2050) { + return der(0x17, Buffer.from(compact.slice(2), 'ascii')); + } + return der(0x18, Buffer.from(compact, 'ascii')); +} + +function derName(commonName: string): Buffer { + const attribute = (oid: string, value: string) => + der(0x31, derSequence(derObjectIdentifier(oid), der(0x0c, Buffer.from(value, 'utf8')))); + return derSequence(attribute(OID_ORGANIZATION, 'Fluxer Test PKI'), attribute(OID_COMMON_NAME, commonName)); +} + +function derExtension(extension: TestCertificateExtension): Buffer { + return derSequence( + derObjectIdentifier(extension.oid), + ...(extension.critical ? [der(0x01, Buffer.from([0xff]))] : []), + der(0x04, extension.value), + ); +} + +export function certificatePoliciesExtension(policyOid: string): TestCertificateExtension { + return {oid: OID_CERTIFICATE_POLICIES, value: derSequence(derSequence(derObjectIdentifier(policyOid)))}; +} + +interface IssueCertificateParams { + subjectKey: KeyObject; + subjectName: string; + issuerKey: KeyObject; + issuerName: string; + issuerHash: 'sha256' | 'sha384'; + options: TestCertificateOptions; + defaultCa: boolean; + appleOid: string | null; +} + +function issueCertificate(params: IssueCertificateParams): X509Certificate { + const now = Date.now(); + const ca = params.options.ca ?? params.defaultCa; + const extensions: Array = [ + { + oid: OID_BASIC_CONSTRAINTS, + critical: true, + value: ca ? derSequence(der(0x01, Buffer.from([0xff]))) : derSequence(), + }, + { + oid: OID_KEY_USAGE, + critical: true, + value: ca ? der(0x03, Buffer.from([0x01, 0x06])) : der(0x03, Buffer.from([0x07, 0x80])), + }, + ]; + if (params.appleOid && params.options.appleExtension !== false) { + extensions.push({oid: params.appleOid, value: der(0x05)}); + } + extensions.push(...(params.options.extraExtensions ?? [])); + const signatureAlgorithm = derSequence( + derObjectIdentifier(params.issuerHash === 'sha256' ? OID_ECDSA_WITH_SHA256 : OID_ECDSA_WITH_SHA384), + ); + const serial = randomBytes(8); + serial[0] = (serial[0] & 0x3f) | 0x40; + const tbs = derSequence( + der(0xa0, derInteger(Buffer.from([2]))), + derInteger(serial), + signatureAlgorithm, + derName(params.issuerName), + derSequence( + derTime(params.options.notBefore ?? new Date(now - ms('365 days'))), + derTime(params.options.notAfter ?? new Date(now + ms('1825 days'))), + ), + derName(params.subjectName), + params.subjectKey.export({type: 'spki', format: 'der'}), + der(0xa3, derSequence(...extensions.map(derExtension))), + ); + const signature = sign(params.issuerHash, tbs, params.issuerKey); + const certificate = derSequence(tbs, signatureAlgorithm, der(0x03, Buffer.concat([Buffer.from([0]), signature]))); + return new X509Certificate(certificate); +} + +export function createAppleTestPki(options: AppleTestPkiOptions = {}): AppleTestPki { + const rootPair = generateKeyPairSync('ec', {namedCurve: 'secp384r1'}); + const intermediatePair = generateKeyPairSync('ec', {namedCurve: 'secp384r1'}); + const leafPair = generateKeyPairSync('ec', {namedCurve: 'prime256v1'}); + const rootName = options.root?.commonName ?? 'Fluxer Test Root CA'; + const intermediateName = options.intermediate?.commonName ?? 'Fluxer Test WWDR CA'; + const root = issueCertificate({ + subjectKey: rootPair.publicKey, + subjectName: rootName, + issuerKey: rootPair.privateKey, + issuerName: rootName, + issuerHash: 'sha384', + options: options.root ?? {}, + defaultCa: true, + appleOid: null, + }); + const intermediateIssuer = options.intermediate?.issuer; + const intermediate = issueCertificate({ + subjectKey: intermediatePair.publicKey, + subjectName: intermediateName, + issuerKey: intermediateIssuer?.rootKey ?? rootPair.privateKey, + issuerName: intermediateIssuer ? commonNameOf(intermediateIssuer.root) : rootName, + issuerHash: 'sha384', + options: options.intermediate ?? {}, + defaultCa: true, + appleOid: APPLE_WWDR_INTERMEDIATE_OID, + }); + const leaf = issueCertificate({ + subjectKey: leafPair.publicKey, + subjectName: options.leaf?.commonName ?? 'Fluxer Test Receipt Signing', + issuerKey: intermediatePair.privateKey, + issuerName: intermediateName, + issuerHash: 'sha384', + options: options.leaf ?? {}, + defaultCa: false, + appleOid: APPLE_RECEIPT_SIGNING_OID, + }); + const x5c = [leaf, intermediate, root].map((certificate) => certificate.raw.toString('base64')); + return { + root, + intermediate, + leaf, + rootKey: rootPair.privateKey, + intermediateKey: intermediatePair.privateKey, + leafKey: leafPair.privateKey, + x5c, + async signJws(payload, signOptions = {}) { + return await new CompactSign(new TextEncoder().encode(JSON.stringify(payload))) + .setProtectedHeader({alg: 'ES256', x5c, ...signOptions.header}) + .sign(signOptions.signingKey ?? leafPair.privateKey); + }, + }; +} + +function commonNameOf(certificate: X509Certificate): string { + const match = /CN=(.+)/.exec(certificate.subject); + if (!match) { + throw new Error('Test certificate has no common name'); + } + return match[1]; +} + +export function encodeJwsWithSignature( + header: Record, + payload: Record, + signature: string, +): string { + const encode = (value: Record) => Buffer.from(JSON.stringify(value)).toString('base64url'); + return `${encode(header)}.${encode(payload)}.${signature}`; +} diff --git a/fluxer_api/src/api/store_billing/tests/GooglePlayAccessTokenProvider.test.ts b/fluxer_api/src/api/store_billing/tests/GooglePlayAccessTokenProvider.test.ts new file mode 100644 index 000000000..1d064c32f --- /dev/null +++ b/fluxer_api/src/api/store_billing/tests/GooglePlayAccessTokenProvider.test.ts @@ -0,0 +1,204 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import {generateKeyPairSync} from 'node:crypto'; +import {mkdtemp, rm, writeFile} from 'node:fs/promises'; +import {tmpdir} from 'node:os'; +import path from 'node:path'; +import {Config} from '@app/api/Config'; +import { + GOOGLE_PLAY_ANDROIDPUBLISHER_SCOPE, + GooglePlayAccessTokenProvider, + resolveGooglePlayCredentials, +} from '@app/api/store_billing/google_play/GooglePlayAccessTokenProvider'; +import {GooglePlayApiError} from '@app/api/store_billing/google_play/GooglePlayApiError'; +import { + createGooglePlayDeveloperApiHandlers, + type FakeGooglePlayDeveloperApi, +} from '@app/api/test/msw/handlers/GooglePlayDeveloperApiHandlers'; +import {server} from '@app/api/test/msw/server'; +import {HttpResponse, http} from 'msw'; +import {afterEach, beforeEach, describe, expect, it} from 'vitest'; + +function generateRsaPem(): string { + const {privateKey} = generateKeyPairSync('rsa', {modulusLength: 2048}); + return privateKey.export({format: 'pem', type: 'pkcs8'}).toString(); +} + +describe('GooglePlayAccessTokenProvider', () => { + let fake: FakeGooglePlayDeveloperApi; + let savedGooglePlay: typeof Config.googlePlay; + let tempDir: string | null; + + beforeEach(() => { + savedGooglePlay = {...Config.googlePlay}; + tempDir = null; + fake = createGooglePlayDeveloperApiHandlers(); + server.use(...fake.handlers); + }); + + afterEach(async () => { + Object.assign(Config.googlePlay, savedGooglePlay); + if (tempDir) { + await rm(tempDir, {recursive: true, force: true}); + } + }); + + async function writeServiceAccountFile(contents: unknown): Promise { + tempDir = await mkdtemp(path.join(tmpdir(), 'fluxer-play-sa-')); + const filePath = path.join(tempDir, 'service-account.json'); + await writeFile(filePath, typeof contents === 'string' ? contents : JSON.stringify(contents)); + return filePath; + } + + it('exchanges a signed RS256 assertion for an access token', async () => { + const provider = new GooglePlayAccessTokenProvider(); + const token = await provider.getAccessToken(); + expect(token).toBe('ya29.fake-1'); + expect(fake.spies.tokenAssertions).toHaveLength(1); + const [{header, claims}] = fake.spies.tokenAssertions; + expect(header).toEqual({alg: 'RS256', typ: 'JWT'}); + expect(claims.iss).toBe(Config.googlePlay.clientEmail); + expect(claims.aud).toBe('https://oauth2.googleapis.com/token'); + expect(claims['scope']).toBe(GOOGLE_PLAY_ANDROIDPUBLISHER_SCOPE); + expect((claims.exp ?? 0) - (claims.iat ?? 0)).toBe(3600); + }); + + it('reuses the cached token until five minutes before expiry', async () => { + let now = Date.now(); + const provider = new GooglePlayAccessTokenProvider({now: () => now}); + expect(await provider.getAccessToken()).toBe('ya29.fake-1'); + now += 54 * 60 * 1000; + expect(await provider.getAccessToken()).toBe('ya29.fake-1'); + now += 60 * 1000 + 1; + expect(await provider.getAccessToken()).toBe('ya29.fake-2'); + expect(fake.spies.tokenAssertions).toHaveLength(2); + }); + + it('accepts expires_in sent as a string', async () => { + fake.setAccessTokenExpiresIn('600'); + let now = Date.now(); + const provider = new GooglePlayAccessTokenProvider({now: () => now}); + await provider.getAccessToken(); + now += 4 * 60 * 1000; + expect(await provider.getAccessToken()).toBe('ya29.fake-1'); + now += 60 * 1000 + 1; + expect(await provider.getAccessToken()).toBe('ya29.fake-2'); + }); + + it('shares one token request between concurrent callers', async () => { + const provider = new GooglePlayAccessTokenProvider(); + const tokens = await Promise.all([provider.getAccessToken(), provider.getAccessToken(), provider.getAccessToken()]); + expect(new Set(tokens)).toEqual(new Set(['ya29.fake-1'])); + expect(fake.spies.tokenAssertions).toHaveLength(1); + }); + + it('fetches a new token after invalidation of the current one', async () => { + const provider = new GooglePlayAccessTokenProvider(); + const first = await provider.getAccessToken(); + provider.invalidate('ya29.other'); + expect(await provider.getAccessToken()).toBe(first); + provider.invalidate(first); + expect(await provider.getAccessToken()).toBe('ya29.fake-2'); + }); + + it('fetches a new token when the credentials change', async () => { + const provider = new GooglePlayAccessTokenProvider(); + await provider.getAccessToken(); + const pem = generateRsaPem(); + Config.googlePlay.privateKey = pem; + expect(await provider.getAccessToken()).toBe('ya29.fake-2'); + }); + + it('reads credentials from a service account JSON file', async () => { + const pem = generateRsaPem(); + Config.googlePlay.clientEmail = undefined; + Config.googlePlay.privateKey = undefined; + Config.googlePlay.serviceAccountJsonPath = await writeServiceAccountFile({ + type: 'service_account', + client_email: 'json-account@fluxer-test.iam.gserviceaccount.com', + private_key: pem, + private_key_id: 'abc123', + token_uri: 'https://oauth2.googleapis.com/token', + }); + fake.setServiceAccountPrivateKey(pem); + const provider = new GooglePlayAccessTokenProvider(); + expect(await provider.getAccessToken()).toBe('ya29.fake-1'); + const [{header, claims}] = fake.spies.tokenAssertions; + expect(header['kid']).toBe('abc123'); + expect(claims.iss).toBe('json-account@fluxer-test.iam.gserviceaccount.com'); + }); + + it('prefers explicit config values over the service account file', async () => { + const filePem = generateRsaPem(); + Config.googlePlay.serviceAccountJsonPath = await writeServiceAccountFile({ + client_email: 'json-account@fluxer-test.iam.gserviceaccount.com', + private_key: filePem, + }); + const credentials = await resolveGooglePlayCredentials(); + expect(credentials.clientEmail).toBe(savedGooglePlay.clientEmail); + expect(credentials.privateKey).toBe(savedGooglePlay.privateKey); + }); + + it('uses the token_uri from the service account file when the config keeps the default', async () => { + Config.googlePlay.serviceAccountJsonPath = await writeServiceAccountFile({ + token_uri: 'https://oauth2.fluxer.test/token', + }); + expect((await resolveGooglePlayCredentials()).tokenUri).toBe('https://oauth2.fluxer.test/token'); + Config.googlePlay.tokenUri = 'https://oauth2.override.test/token'; + expect((await resolveGooglePlayCredentials()).tokenUri).toBe('https://oauth2.override.test/token'); + }); + + it('accepts escaped newlines in an inline private key', async () => { + Config.googlePlay.privateKey = savedGooglePlay.privateKey?.replaceAll('\n', '\\n'); + const provider = new GooglePlayAccessTokenProvider(); + expect(await provider.getAccessToken()).toBe('ya29.fake-1'); + }); + + it('rejects an unreadable service account file', async () => { + Config.googlePlay.clientEmail = undefined; + Config.googlePlay.privateKey = undefined; + Config.googlePlay.serviceAccountJsonPath = await writeServiceAccountFile('not json'); + const provider = new GooglePlayAccessTokenProvider(); + await expect(provider.getAccessToken()).rejects.toMatchObject({kind: 'auth', reason: 'invalid_credentials'}); + }); + + it('rejects when no credentials are configured', async () => { + Config.googlePlay.clientEmail = undefined; + Config.googlePlay.privateKey = undefined; + Config.googlePlay.serviceAccountJsonPath = undefined; + const provider = new GooglePlayAccessTokenProvider(); + await expect(provider.getAccessToken()).rejects.toMatchObject({kind: 'auth', reason: 'missing_credentials'}); + expect(fake.spies.tokenAssertions).toHaveLength(0); + }); + + it('reports a rejected grant as an auth error', async () => { + fake.setServiceAccountPrivateKey(generateRsaPem()); + const provider = new GooglePlayAccessTokenProvider(); + const error = await provider.getAccessToken().catch((caught: unknown) => caught); + expect(error).toBeInstanceOf(GooglePlayApiError); + expect(error).toMatchObject({kind: 'auth', status: 400, reason: 'invalid_grant'}); + }); + + it('reports token endpoint outages as retryable and does not cache the failure', async () => { + fake.failNext('token', {status: 503, retryAfter: '7'}); + const provider = new GooglePlayAccessTokenProvider(); + await expect(provider.getAccessToken()).rejects.toMatchObject({ + kind: 'retryable', + status: 503, + retryAfterMs: 7000, + }); + expect(await provider.getAccessToken()).toBe('ya29.fake-1'); + }); + + it('reports a malformed token response as retryable', async () => { + server.use(http.post('https://oauth2.googleapis.com/token', () => HttpResponse.json({token_type: 'Bearer'}))); + const provider = new GooglePlayAccessTokenProvider(); + await expect(provider.getAccessToken()).rejects.toMatchObject({kind: 'retryable', reason: 'malformed_response'}); + }); + + it('reports network failures as retryable', async () => { + server.use(http.post('https://oauth2.googleapis.com/token', () => HttpResponse.error())); + const provider = new GooglePlayAccessTokenProvider(); + await expect(provider.getAccessToken()).rejects.toMatchObject({kind: 'retryable', reason: 'network_error'}); + }); +}); diff --git a/fluxer_api/src/api/store_billing/tests/GooglePlayDeveloperApiClient.test.ts b/fluxer_api/src/api/store_billing/tests/GooglePlayDeveloperApiClient.test.ts new file mode 100644 index 000000000..0c173f1b2 --- /dev/null +++ b/fluxer_api/src/api/store_billing/tests/GooglePlayDeveloperApiClient.test.ts @@ -0,0 +1,288 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import {GooglePlayAccessTokenProvider} from '@app/api/store_billing/google_play/GooglePlayAccessTokenProvider'; +import {GooglePlayApiError, isGooglePlayApiError} from '@app/api/store_billing/google_play/GooglePlayApiError'; +import { + GOOGLE_PLAY_API_BASE_URL, + GooglePlayDeveloperApiClient, +} from '@app/api/store_billing/google_play/GooglePlayDeveloperApiClient'; +import { + buildGooglePlayProductSnapshot, + buildGooglePlaySubscriptionSnapshot, +} from '@app/api/store_billing/google_play/GooglePlayPurchaseSync'; +import { + buildFakeProductPurchase, + buildFakeSubscriptionPurchase, + createGooglePlayDeveloperApiHandlers, + type FakeGooglePlayDeveloperApi, +} from '@app/api/test/msw/handlers/GooglePlayDeveloperApiHandlers'; +import {server} from '@app/api/test/msw/server'; +import {HttpResponse, http} from 'msw'; +import {beforeEach, describe, expect, it} from 'vitest'; + +const PACKAGE = 'com.fluxer'; +const TOKEN = 'opaque.purchase-token/with+chars=='; + +describe('GooglePlayDeveloperApiClient', () => { + let fake: FakeGooglePlayDeveloperApi; + let client: GooglePlayDeveloperApiClient; + + beforeEach(() => { + fake = createGooglePlayDeveloperApiHandlers(); + server.use(...fake.handlers); + client = new GooglePlayDeveloperApiClient({accessTokenProvider: new GooglePlayAccessTokenProvider()}); + }); + + async function captureError(promise: Promise): Promise { + const error = await promise.catch((caught: unknown) => caught); + expect(error).toBeInstanceOf(GooglePlayApiError); + return error as GooglePlayApiError; + } + + it('reads a subscription with a bearer token and an encoded purchase token', async () => { + fake.setSubscription(PACKAGE, TOKEN, buildFakeSubscriptionPurchase({linkedPurchaseToken: 'older-token'})); + const purchase = await client.getSubscription(PACKAGE, TOKEN); + expect(purchase.subscriptionState).toBe('SUBSCRIPTION_STATE_ACTIVE'); + expect(purchase.linkedPurchaseToken).toBe('older-token'); + expect(purchase.lineItems?.[0]?.offerDetails?.basePlanId).toBe('monthly'); + expect(fake.spies.apiRequests).toEqual([ + {operation: 'subscriptions.get', method: 'GET', packageName: PACKAGE, authorization: 'Bearer ya29.fake-1'}, + ]); + }); + + it('keeps unknown enum values and drops unknown fields', async () => { + server.use( + http.get(`${GOOGLE_PLAY_API_BASE_URL}/${PACKAGE}/purchases/subscriptionsv2/tokens/:token`, () => + HttpResponse.json({ + subscriptionState: 'SUBSCRIPTION_STATE_SOMETHING_NEW', + acknowledgementState: 'ACKNOWLEDGEMENT_STATE_FUTURE', + futureField: {nested: true}, + lineItems: [{productId: 'plutonium', offerPhase: {basePrice: {}}, offerDetails: {basePlanId: 'monthly'}}], + }), + ), + ); + const purchase = await client.getSubscription(PACKAGE, TOKEN); + expect(purchase.subscriptionState).toBe('SUBSCRIPTION_STATE_SOMETHING_NEW'); + expect(purchase.acknowledgementState).toBe('ACKNOWLEDGEMENT_STATE_FUTURE'); + expect(purchase).not.toHaveProperty('futureField'); + }); + + it('treats a malformed response as retryable', async () => { + server.use( + http.get(`${GOOGLE_PLAY_API_BASE_URL}/${PACKAGE}/purchases/subscriptionsv2/tokens/:token`, () => + HttpResponse.json({lineItems: 'nope'}), + ), + ); + const error = await captureError(client.getSubscription(PACKAGE, TOKEN)); + expect(error.kind).toBe('retryable'); + expect(error.reason).toBe('malformed_response'); + }); + + it('classifies an unknown token as invalid', async () => { + const error = await captureError(client.getSubscription(PACKAGE, 'missing')); + expect(error).toMatchObject({kind: 'invalid_token', status: 404, reason: 'purchaseTokenNotFound'}); + }); + + it('classifies a long expired token as invalid', async () => { + fake.setSubscription(PACKAGE, TOKEN, buildFakeSubscriptionPurchase()); + fake.markTokenGone(TOKEN); + const error = await captureError(client.getSubscription(PACKAGE, TOKEN)); + expect(error).toMatchObject({kind: 'invalid_token', status: 410, reason: 'purchaseTokenNoLongerValid'}); + }); + + it('classifies a malformed token as invalid', async () => { + fake.failNext('subscriptions.get', {status: 400, reason: 'invalid'}); + const error = await captureError(client.getSubscription(PACKAGE, TOKEN)); + expect(error).toMatchObject({kind: 'invalid_token', status: 400}); + expect(isGooglePlayApiError(error, 'invalid_token')).toBe(true); + expect(isGooglePlayApiError(error, 'retryable')).toBe(false); + }); + + it('classifies a token owned by another package as invalid', async () => { + fake.setSubscription('com.other', TOKEN, buildFakeSubscriptionPurchase()); + const error = await captureError(client.getSubscription(PACKAGE, TOKEN)); + expect(error).toMatchObject({kind: 'invalid_token', status: 404}); + }); + + it('classifies rate limits with Retry-After as retryable', async () => { + fake.failNext('products.get', {status: 429, reason: 'rateLimitExceeded', retryAfter: '30'}); + const error = await captureError(client.getProduct(PACKAGE, TOKEN)); + expect(error).toMatchObject({kind: 'retryable', status: 429, reason: 'rateLimitExceeded', retryAfterMs: 30_000}); + }); + + it('classifies server errors as retryable', async () => { + fake.failNext('subscriptions.get', {status: 503}); + const error = await captureError(client.getSubscription(PACKAGE, TOKEN)); + expect(error).toMatchObject({kind: 'retryable', status: 503, retryAfterMs: null}); + }); + + it('classifies a permission failure as auth', async () => { + fake.failNext('subscriptions.get', {status: 403, reason: 'permissionDenied'}); + const error = await captureError(client.getSubscription(PACKAGE, TOKEN)); + expect(error).toMatchObject({kind: 'auth', status: 403, reason: 'permissionDenied'}); + }); + + it('classifies other client errors as rejected', async () => { + fake.failNext('orders.refund', {status: 409, reason: 'conflict'}); + const error = await captureError(client.refundOrder(PACKAGE, 'GPA.1')); + expect(error).toMatchObject({kind: 'rejected', status: 409}); + }); + + it('classifies network failures as retryable', async () => { + server.use( + http.get(`${GOOGLE_PLAY_API_BASE_URL}/${PACKAGE}/purchases/productsv2/tokens/:token`, () => HttpResponse.error()), + ); + const error = await captureError(client.getProduct(PACKAGE, TOKEN)); + expect(error).toMatchObject({kind: 'retryable', status: null, reason: 'network_error'}); + }); + + it('refreshes the access token once after a 401', async () => { + fake.setSubscription(PACKAGE, TOKEN, buildFakeSubscriptionPurchase()); + await client.getSubscription(PACKAGE, TOKEN); + fake.revokeAccessTokens(); + await client.getSubscription(PACKAGE, TOKEN); + expect(fake.spies.apiRequests.map((request) => request.authorization)).toEqual([ + 'Bearer ya29.fake-1', + 'Bearer ya29.fake-1', + 'Bearer ya29.fake-2', + ]); + }); + + it('gives up with an auth error when the refreshed token is also rejected', async () => { + fake.failNext('subscriptions.get', {status: 401}); + fake.failNext('subscriptions.get', {status: 401}); + const error = await captureError(client.getSubscription(PACKAGE, TOKEN)); + expect(error).toMatchObject({kind: 'auth', status: 401}); + expect(fake.spies.apiRequests).toHaveLength(2); + }); + + it('never calls the API for a package that is not configured', async () => { + const error = await captureError(client.getSubscription('com.attacker', TOKEN)); + expect(error).toMatchObject({kind: 'rejected', reason: 'package_not_configured'}); + expect(fake.spies.apiRequests).toHaveLength(0); + expect(fake.spies.tokenAssertions).toHaveLength(0); + }); + + it('acknowledges a subscription with the account token and reports a repeat as invalid', async () => { + fake.setSubscription(PACKAGE, TOKEN, buildFakeSubscriptionPurchase()); + await client.acknowledgeSubscription(PACKAGE, 'plutonium', TOKEN, { + obfuscatedAccountId: '0f1e2d3c-4b5a-4968-8776-655443322110', + }); + expect(fake.spies.acknowledged).toEqual([ + { + packageName: PACKAGE, + productId: 'plutonium', + purchaseToken: TOKEN, + body: {externalAccountIds: {obfuscatedAccountId: '0f1e2d3c-4b5a-4968-8776-655443322110'}}, + }, + ]); + expect(fake.getSubscription(TOKEN)?.acknowledgementState).toBe('ACKNOWLEDGEMENT_STATE_ACKNOWLEDGED'); + const repeat = await captureError(client.acknowledgeSubscription(PACKAGE, 'plutonium', TOKEN)); + expect(repeat).toMatchObject({kind: 'invalid_token', status: 400}); + const reread = await client.getSubscription(PACKAGE, TOKEN); + expect(reread.acknowledgementState).toBe('ACKNOWLEDGEMENT_STATE_ACKNOWLEDGED'); + }); + + it('acknowledges without a body when no account token is given', async () => { + fake.setSubscription(PACKAGE, TOKEN, buildFakeSubscriptionPurchase()); + await client.acknowledgeSubscription(PACKAGE, 'plutonium', TOKEN); + expect(fake.spies.acknowledged[0]?.body).toEqual({}); + }); + + it('cancels a subscription to stop payments', async () => { + fake.setSubscription(PACKAGE, TOKEN, buildFakeSubscriptionPurchase()); + await client.cancelSubscription(PACKAGE, TOKEN, 'DEVELOPER_REQUESTED_STOP_PAYMENTS'); + expect(fake.spies.canceled[0]?.body).toEqual({ + cancellationContext: {cancellationType: 'DEVELOPER_REQUESTED_STOP_PAYMENTS'}, + }); + const purchase = await client.getSubscription(PACKAGE, TOKEN); + expect(purchase.subscriptionState).toBe('SUBSCRIPTION_STATE_CANCELED'); + expect(purchase.lineItems?.[0]?.autoRenewingPlan?.autoRenewEnabled).toBe(false); + }); + + it('revokes a subscription with a full refund', async () => { + fake.setSubscription(PACKAGE, TOKEN, buildFakeSubscriptionPurchase()); + await client.revokeSubscription(PACKAGE, TOKEN); + expect(fake.spies.revoked[0]?.body).toEqual({revocationContext: {fullRefund: {}}}); + expect((await client.getSubscription(PACKAGE, TOKEN)).subscriptionState).toBe('SUBSCRIPTION_STATE_EXPIRED'); + }); + + it('reads and consumes a one-time product', async () => { + fake.setProduct(PACKAGE, TOKEN, buildFakeProductPurchase()); + const before = await client.getProduct(PACKAGE, TOKEN); + expect(before.purchaseStateContext?.purchaseState).toBe('PURCHASED'); + await client.consumeProduct(PACKAGE, 'gift_1_month', TOKEN); + expect(fake.spies.consumed).toEqual([{packageName: PACKAGE, productId: 'gift_1_month', purchaseToken: TOKEN}]); + const after = await client.getProduct(PACKAGE, TOKEN); + expect(after.productLineItem?.[0]?.productOfferDetails?.consumptionState).toBe('CONSUMPTION_STATE_CONSUMED'); + expect(after.acknowledgementState).toBe('ACKNOWLEDGEMENT_STATE_ACKNOWLEDGED'); + const repeat = await captureError(client.consumeProduct(PACKAGE, 'gift_1_month', TOKEN)); + expect(repeat).toMatchObject({kind: 'invalid_token', status: 400}); + }); + + it('refunds an order with revoke and lists it as voided', async () => { + const start = new Date(Date.now() - 1000); + fake.setSubscription(PACKAGE, TOKEN, buildFakeSubscriptionPurchase()); + await client.refundOrder(PACKAGE, 'GPA.1111-2222-3333-44444'); + expect(fake.spies.refunded).toEqual([{packageName: PACKAGE, orderId: 'GPA.1111-2222-3333-44444', revoke: true}]); + expect((await client.getSubscription(PACKAGE, TOKEN)).subscriptionState).toBe('SUBSCRIPTION_STATE_EXPIRED'); + const page = await client.listVoidedPurchases(PACKAGE, {startTime: start}); + expect(page.nextPageToken).toBeNull(); + expect(page.voidedPurchases).toEqual([ + expect.objectContaining({purchaseToken: TOKEN, orderId: 'GPA.1111-2222-3333-44444', voidedSource: 1}), + ]); + expect(fake.spies.voidedQueries[0]).toEqual({type: '1', startTime: String(start.getTime())}); + }); + + it('pages through voided purchases', async () => { + const now = Date.now(); + for (let index = 0; index < 3; index++) { + fake.addVoidedPurchase(PACKAGE, { + purchaseToken: `voided-${index}`, + orderId: `GPA.${index}`, + voidedTimeMillis: String(now - index * 1000), + voidedReason: 1, + }); + } + fake.addVoidedPurchase('com.other', {purchaseToken: 'other', voidedTimeMillis: String(now)}); + const startTime = new Date(now - 60_000); + const endTime = new Date(now + 60_000); + const first = await client.listVoidedPurchases(PACKAGE, {startTime, endTime, maxResults: 2}); + expect(first.voidedPurchases.map((entry) => entry.purchaseToken)).toEqual(['voided-0', 'voided-1']); + expect(first.nextPageToken).not.toBeNull(); + const second = await client.listVoidedPurchases(PACKAGE, { + startTime, + endTime, + maxResults: 2, + pageToken: first.nextPageToken ?? undefined, + }); + expect(second.voidedPurchases.map((entry) => entry.purchaseToken)).toEqual(['voided-2']); + expect(second.nextPageToken).toBeNull(); + expect(fake.spies.voidedQueries[1]).toMatchObject({ + type: '1', + endTime: String(endTime.getTime()), + maxResults: '2', + token: first.nextPageToken, + }); + }); + + it('feeds the purchase sync end to end', async () => { + fake.setSubscription(PACKAGE, TOKEN, buildFakeSubscriptionPurchase()); + fake.setProduct(PACKAGE, 'gift-token', buildFakeProductPurchase()); + const now = new Date(); + const subscription = buildGooglePlaySubscriptionSnapshot({ + packageName: PACKAGE, + purchaseToken: TOKEN, + purchase: await client.getSubscription(PACKAGE, TOKEN), + now, + }); + expect(subscription).toMatchObject({state: 'active', providerEntitled: true, slot: 'monthly', acknowledged: false}); + const gift = buildGooglePlayProductSnapshot({ + packageName: PACKAGE, + purchaseToken: 'gift-token', + purchase: await client.getProduct(PACKAGE, 'gift-token'), + now, + }); + expect(gift).toMatchObject({state: 'purchased', providerEntitled: true, slot: 'gift_1_month'}); + }); +}); diff --git a/fluxer_api/src/api/store_billing/tests/GooglePlayPurchaseSync.test.ts b/fluxer_api/src/api/store_billing/tests/GooglePlayPurchaseSync.test.ts new file mode 100644 index 000000000..e193ff220 --- /dev/null +++ b/fluxer_api/src/api/store_billing/tests/GooglePlayPurchaseSync.test.ts @@ -0,0 +1,448 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import { + buildGooglePlayProductSnapshot, + buildGooglePlaySubscriptionSnapshot, +} from '@app/api/store_billing/google_play/GooglePlayPurchaseSync'; +import type {StorePurchaseState} from '@app/api/store_billing/StoreBillingTypes'; +import { + buildFakeProductPurchase, + buildFakeSubscriptionPurchase, +} from '@app/api/test/msw/handlers/GooglePlayDeveloperApiHandlers'; +import {describe, expect, it} from 'vitest'; + +const NOW = new Date('2026-09-29T12:00:00.000Z'); +const FUTURE = '2026-10-29T12:00:00.000Z'; +const PAST = '2026-09-28T12:00:00.000Z'; +const PACKAGE = 'com.fluxer'; +const TOKEN = 'purchase-token-1'; + +function subscriptionSnapshot(overrides: Parameters[0] = {}) { + return buildGooglePlaySubscriptionSnapshot({ + packageName: PACKAGE, + purchaseToken: TOKEN, + purchase: buildFakeSubscriptionPurchase({startTime: '2026-09-01T00:00:00.000Z', ...overrides}), + now: NOW, + }); +} + +function lineItem(productId: string, basePlanId: string | undefined, expiryTime: string, autoRenewEnabled = true) { + return { + productId, + expiryTime, + latestSuccessfulOrderId: `GPA.${productId}.${basePlanId ?? 'none'}`, + autoRenewingPlan: {autoRenewEnabled}, + ...(basePlanId ? {offerDetails: {basePlanId}} : {}), + }; +} + +describe('buildGooglePlaySubscriptionSnapshot', () => { + it('builds a full snapshot for an active purchase', () => { + expect( + subscriptionSnapshot({ + regionCode: 'BR', + externalAccountIdentifiers: {obfuscatedExternalAccountId: '0f1e2d3c-4b5a-4968-8776-655443322110'}, + lineItems: [lineItem('plutonium', 'yearly', FUTURE)], + }), + ).toEqual({ + storeKey: `google_play:${TOKEN}`, + provider: 'google_play', + kind: 'subscription', + slot: 'yearly', + environment: 'production', + appId: PACKAGE, + productId: 'plutonium', + basePlanId: 'yearly', + storeReference: TOKEN, + latestTransactionId: 'GPA.plutonium.yearly', + appTransactionId: null, + accountToken: '0f1e2d3c-4b5a-4968-8776-655443322110', + ownershipType: null, + state: 'active', + storeState: 'SUBSCRIPTION_STATE_ACTIVE', + providerEntitled: true, + expiresAt: new Date(FUTURE), + graceEndsAt: null, + autoRenew: true, + autoRenewProductId: 'plutonium', + startedAt: new Date('2026-09-01T00:00:00.000Z'), + purchasedAt: new Date('2026-09-01T00:00:00.000Z'), + revokedAt: null, + revocationReason: null, + linkedStoreKey: null, + expiredStoreKey: null, + expiredAccountToken: null, + acknowledged: false, + quantity: 1, + region: 'BR', + lastEventAt: NOW, + }); + }); + + const stateCases: Array<{ + subscriptionState: string; + expiryTime: string; + state: StorePurchaseState; + entitled: boolean; + graceEndsAt: string | null; + }> = [ + { + subscriptionState: 'SUBSCRIPTION_STATE_ACTIVE', + expiryTime: FUTURE, + state: 'active', + entitled: true, + graceEndsAt: null, + }, + { + subscriptionState: 'SUBSCRIPTION_STATE_ACTIVE', + expiryTime: PAST, + state: 'active', + entitled: true, + graceEndsAt: null, + }, + { + subscriptionState: 'SUBSCRIPTION_STATE_IN_GRACE_PERIOD', + expiryTime: FUTURE, + state: 'grace', + entitled: true, + graceEndsAt: FUTURE, + }, + { + subscriptionState: 'SUBSCRIPTION_STATE_IN_GRACE_PERIOD', + expiryTime: PAST, + state: 'grace', + entitled: false, + graceEndsAt: PAST, + }, + { + subscriptionState: 'SUBSCRIPTION_STATE_CANCELED', + expiryTime: FUTURE, + state: 'canceled', + entitled: true, + graceEndsAt: null, + }, + { + subscriptionState: 'SUBSCRIPTION_STATE_CANCELED', + expiryTime: PAST, + state: 'expired', + entitled: false, + graceEndsAt: null, + }, + { + subscriptionState: 'SUBSCRIPTION_STATE_ON_HOLD', + expiryTime: PAST, + state: 'on_hold', + entitled: false, + graceEndsAt: null, + }, + { + subscriptionState: 'SUBSCRIPTION_STATE_ON_HOLD', + expiryTime: FUTURE, + state: 'on_hold', + entitled: false, + graceEndsAt: null, + }, + { + subscriptionState: 'SUBSCRIPTION_STATE_PAUSED', + expiryTime: FUTURE, + state: 'paused', + entitled: false, + graceEndsAt: null, + }, + { + subscriptionState: 'SUBSCRIPTION_STATE_EXPIRED', + expiryTime: PAST, + state: 'expired', + entitled: false, + graceEndsAt: null, + }, + { + subscriptionState: 'SUBSCRIPTION_STATE_EXPIRED', + expiryTime: FUTURE, + state: 'expired', + entitled: false, + graceEndsAt: null, + }, + { + subscriptionState: 'SUBSCRIPTION_STATE_PENDING', + expiryTime: FUTURE, + state: 'pending', + entitled: false, + graceEndsAt: null, + }, + { + subscriptionState: 'SUBSCRIPTION_STATE_PENDING_PURCHASE_CANCELED', + expiryTime: FUTURE, + state: 'expired', + entitled: false, + graceEndsAt: null, + }, + { + subscriptionState: 'SUBSCRIPTION_STATE_UNSPECIFIED', + expiryTime: FUTURE, + state: 'pending', + entitled: false, + graceEndsAt: null, + }, + { + subscriptionState: 'SUBSCRIPTION_STATE_INTRODUCED_LATER', + expiryTime: FUTURE, + state: 'pending', + entitled: false, + graceEndsAt: null, + }, + ]; + + it.each(stateCases)( + 'maps $subscriptionState expiring $expiryTime to $state', + ({subscriptionState, expiryTime, state, entitled, graceEndsAt}) => { + const snapshot = subscriptionSnapshot({ + subscriptionState, + lineItems: [lineItem('plutonium', 'monthly', expiryTime)], + }); + expect(snapshot?.state).toBe(state); + expect(snapshot?.storeState).toBe(subscriptionState); + expect(snapshot?.providerEntitled).toBe(entitled); + expect(snapshot?.expiresAt).toEqual(new Date(expiryTime)); + expect(snapshot?.graceEndsAt).toEqual(graceEndsAt === null ? null : new Date(graceEndsAt)); + }, + ); + + it('treats an active purchase past its expiry as silent grace with no explicit grace end', () => { + const snapshot = subscriptionSnapshot({lineItems: [lineItem('plutonium', 'monthly', PAST)]}); + expect(snapshot).toMatchObject({state: 'active', providerEntitled: true, graceEndsAt: null}); + expect(snapshot?.expiresAt).toEqual(new Date(PAST)); + }); + + it('treats a missing subscription state as pending', () => { + const snapshot = subscriptionSnapshot({subscriptionState: undefined}); + expect(snapshot).toMatchObject({ + state: 'pending', + storeState: 'SUBSCRIPTION_STATE_UNSPECIFIED', + providerEntitled: false, + }); + }); + + it('never entitles an active purchase without an expiry', () => { + const snapshot = subscriptionSnapshot({ + lineItems: [{productId: 'plutonium', offerDetails: {basePlanId: 'monthly'}}], + }); + expect(snapshot).toMatchObject({state: 'active', providerEntitled: false, expiresAt: null}); + }); + + it('ignores line items for products that are not configured', () => { + const snapshot = subscriptionSnapshot({ + lineItems: [ + lineItem('addon', 'monthly', '2027-09-29T12:00:00.000Z'), + lineItem('plutonium', 'weekly', '2027-09-29T12:00:00.000Z'), + lineItem('plutonium', 'monthly', FUTURE), + ], + }); + expect(snapshot).toMatchObject({productId: 'plutonium', basePlanId: 'monthly', slot: 'monthly'}); + expect(snapshot?.expiresAt).toEqual(new Date(FUTURE)); + }); + + it('picks the configured line item with the latest expiry', () => { + const snapshot = subscriptionSnapshot({ + lineItems: [ + lineItem('plutonium', 'monthly', FUTURE), + lineItem('plutonium', 'yearly', '2027-09-29T12:00:00.000Z'), + ], + }); + expect(snapshot).toMatchObject({slot: 'yearly', latestTransactionId: 'GPA.plutonium.yearly'}); + }); + + it('returns null when no line item is a configured subscription', () => { + expect(subscriptionSnapshot({lineItems: [lineItem('addon', 'monthly', FUTURE)]})).toBeNull(); + expect(subscriptionSnapshot({lineItems: [lineItem('plutonium', undefined, FUTURE)]})).toBeNull(); + expect(subscriptionSnapshot({lineItems: [lineItem('gift_1_month', 'monthly', FUTURE)]})).toBeNull(); + expect(subscriptionSnapshot({lineItems: []})).toBeNull(); + expect(subscriptionSnapshot({lineItems: undefined})).toBeNull(); + }); + + it('records the linked token of an upgrade or resubscribe', () => { + expect(subscriptionSnapshot({linkedPurchaseToken: 'old-token'})?.linkedStoreKey).toBe('google_play:old-token'); + }); + + it('records the out-of-app resubscribe context', () => { + expect( + subscriptionSnapshot({ + outOfAppPurchaseContext: { + expiredPurchaseToken: 'expired-token', + expiredExternalAccountIdentifiers: {obfuscatedExternalAccountId: 'aaaa-account'}, + }, + }), + ).toMatchObject({ + expiredStoreKey: 'google_play:expired-token', + expiredAccountToken: 'aaaa-account', + accountToken: null, + }); + }); + + it('reads the out-of-app account id under its documented example name too', () => { + expect( + subscriptionSnapshot({ + outOfAppPurchaseContext: {expiredExternalAccountIdentifiers: {obfuscatedAccountId: 'bbbb-account'}}, + }), + ).toMatchObject({expiredStoreKey: null, expiredAccountToken: 'bbbb-account'}); + }); + + it('marks license test purchases as sandbox', () => { + expect(subscriptionSnapshot({testPurchase: {}})?.environment).toBe('sandbox'); + expect(subscriptionSnapshot()?.environment).toBe('production'); + }); + + it('reads the acknowledgement state', () => { + expect(subscriptionSnapshot({acknowledgementState: 'ACKNOWLEDGEMENT_STATE_ACKNOWLEDGED'})?.acknowledged).toBe(true); + expect(subscriptionSnapshot({acknowledgementState: 'ACKNOWLEDGEMENT_STATE_PENDING'})?.acknowledged).toBe(false); + expect(subscriptionSnapshot({acknowledgementState: undefined})?.acknowledged).toBe(false); + }); + + it('reads auto renewal from the plan type', () => { + const base = {productId: 'plutonium', expiryTime: FUTURE, offerDetails: {basePlanId: 'monthly'}}; + expect(subscriptionSnapshot({lineItems: [{...base, autoRenewingPlan: {}}]})?.autoRenew).toBe(false); + expect(subscriptionSnapshot({lineItems: [{...base, autoRenewingPlan: {autoRenewEnabled: false}}]})?.autoRenew).toBe( + false, + ); + expect(subscriptionSnapshot({lineItems: [{...base, prepaidPlan: {}}]})?.autoRenew).toBe(false); + expect(subscriptionSnapshot({lineItems: [base]})?.autoRenew).toBeNull(); + }); + + it('reports the product a deferred replacement renews into', () => { + const snapshot = subscriptionSnapshot({ + lineItems: [ + {...lineItem('plutonium', 'monthly', FUTURE), deferredItemReplacement: {productId: 'plutonium_plus'}}, + ], + }); + expect(snapshot?.autoRenewProductId).toBe('plutonium_plus'); + }); + + it('falls back to the deprecated latest order id', () => { + const snapshot = subscriptionSnapshot({ + latestOrderId: 'GPA.legacy', + lineItems: [{productId: 'plutonium', expiryTime: FUTURE, offerDetails: {basePlanId: 'monthly'}}], + }); + expect(snapshot?.latestTransactionId).toBe('GPA.legacy'); + }); + + it('leaves start dates empty when Google omits or garbles them', () => { + expect(subscriptionSnapshot({startTime: 'yesterday'})).toMatchObject({startedAt: null, purchasedAt: null}); + }); +}); + +describe('buildGooglePlayProductSnapshot', () => { + function productSnapshot(overrides: Parameters[0] = {}, expectedProductId?: string) { + return buildGooglePlayProductSnapshot({ + packageName: PACKAGE, + purchaseToken: TOKEN, + expectedProductId, + purchase: buildFakeProductPurchase({purchaseCompletionTime: '2026-09-29T11:00:00.000Z', ...overrides}), + now: NOW, + }); + } + + it('builds a full snapshot for a purchased gift', () => { + expect( + productSnapshot({ + obfuscatedExternalAccountId: '0f1e2d3c-4b5a-4968-8776-655443322110', + productLineItem: [{productId: 'gift_1_year', productOfferDetails: {quantity: 1}}], + }), + ).toEqual({ + storeKey: `google_play:${TOKEN}`, + provider: 'google_play', + kind: 'gift', + slot: 'gift_1_year', + environment: 'production', + appId: PACKAGE, + productId: 'gift_1_year', + basePlanId: null, + storeReference: TOKEN, + latestTransactionId: 'GPA.5555-6666-7777-88888', + appTransactionId: null, + accountToken: '0f1e2d3c-4b5a-4968-8776-655443322110', + ownershipType: null, + state: 'purchased', + storeState: 'PURCHASED', + providerEntitled: true, + expiresAt: null, + graceEndsAt: null, + autoRenew: null, + autoRenewProductId: null, + startedAt: new Date('2026-09-29T11:00:00.000Z'), + purchasedAt: new Date('2026-09-29T11:00:00.000Z'), + revokedAt: null, + revocationReason: null, + linkedStoreKey: null, + expiredStoreKey: null, + expiredAccountToken: null, + acknowledged: false, + quantity: 1, + region: 'US', + lastEventAt: NOW, + }); + }); + + it('maps a consumed purchase to fulfilled and acknowledged', () => { + expect( + productSnapshot({ + productLineItem: [ + {productId: 'gift_1_month', productOfferDetails: {consumptionState: 'CONSUMPTION_STATE_CONSUMED'}}, + ], + }), + ).toMatchObject({state: 'fulfilled', providerEntitled: true, acknowledged: true}); + }); + + it('maps a cancelled purchase to refunded', () => { + expect(productSnapshot({purchaseStateContext: {purchaseState: 'CANCELLED'}})).toMatchObject({ + state: 'refunded', + storeState: 'CANCELLED', + providerEntitled: false, + }); + }); + + it('never entitles a pending or unknown purchase state', () => { + expect(productSnapshot({purchaseStateContext: {purchaseState: 'PENDING'}})).toMatchObject({ + state: 'pending', + providerEntitled: false, + }); + expect(productSnapshot({purchaseStateContext: {purchaseState: 'PURCHASE_STATE_NEW'}})).toMatchObject({ + state: 'pending', + providerEntitled: false, + }); + expect(productSnapshot({purchaseStateContext: undefined})).toMatchObject({ + state: 'pending', + storeState: 'PURCHASE_STATE_UNSPECIFIED', + providerEntitled: false, + }); + }); + + it('marks test instrument purchases as sandbox', () => { + expect(productSnapshot({testPurchaseContext: {fopType: 'TEST'}})?.environment).toBe('sandbox'); + expect(productSnapshot({testPurchaseContext: {}})?.environment).toBe('sandbox'); + }); + + it('treats an acknowledged but unconsumed gift as not settled', () => { + expect(productSnapshot({acknowledgementState: 'ACKNOWLEDGEMENT_STATE_ACKNOWLEDGED'})?.acknowledged).toBe(false); + }); + + it('reads the purchased quantity', () => { + expect(productSnapshot({})?.quantity).toBe(1); + expect( + productSnapshot({productLineItem: [{productId: 'gift_1_month', productOfferDetails: {quantity: 3}}]})?.quantity, + ).toBe(3); + }); + + it('only accepts configured gift products', () => { + expect(productSnapshot({productLineItem: [{productId: 'gems_100'}]})).toBeNull(); + expect(productSnapshot({productLineItem: [{productId: 'plutonium'}]})).toBeNull(); + expect(productSnapshot({productLineItem: []})).toBeNull(); + expect(productSnapshot({productLineItem: [{productId: 'gems_100'}, {productId: 'gift_1_year'}]})?.slot).toBe( + 'gift_1_year', + ); + }); + + it('requires the product the client claimed', () => { + expect(productSnapshot({}, 'gift_1_year')).toBeNull(); + expect(productSnapshot({}, 'gift_1_month')?.slot).toBe('gift_1_month'); + }); +}); diff --git a/fluxer_api/src/api/store_billing/tests/GooglePlayPushVerifier.test.ts b/fluxer_api/src/api/store_billing/tests/GooglePlayPushVerifier.test.ts new file mode 100644 index 000000000..de39e4f93 --- /dev/null +++ b/fluxer_api/src/api/store_billing/tests/GooglePlayPushVerifier.test.ts @@ -0,0 +1,284 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import {Config} from '@app/api/Config'; +import { + decodeGooglePlayDeveloperNotification, + GooglePlayPushVerifier, + parseGooglePlayPushEnvelope, +} from '@app/api/store_billing/google_play/GooglePlayPushVerifier'; +import { + buildGooglePlayPushEnvelope, + createGooglePlayDeveloperApiHandlers, + encodeGooglePlayDeveloperNotification, + type FakeGooglePlayDeveloperApi, +} from '@app/api/test/msw/handlers/GooglePlayDeveloperApiHandlers'; +import {server} from '@app/api/test/msw/server'; +import {StoreNotificationUnauthorizedError} from '@fluxer/errors/src/domains/payment/StoreNotificationUnauthorizedError'; +import {SignJWT} from 'jose'; +import {afterEach, beforeEach, describe, expect, it} from 'vitest'; + +describe('GooglePlayPushVerifier', () => { + let fake: FakeGooglePlayDeveloperApi; + let verifier: GooglePlayPushVerifier; + let savedGooglePlay: typeof Config.googlePlay; + + beforeEach(() => { + savedGooglePlay = {...Config.googlePlay}; + fake = createGooglePlayDeveloperApiHandlers(); + server.use(...fake.handlers); + verifier = new GooglePlayPushVerifier(); + }); + + afterEach(() => { + Object.assign(Config.googlePlay, savedGooglePlay); + }); + + async function expectRejected(header: string | null | undefined): Promise { + await expect(verifier.verifyAuthorizationHeader(header)).rejects.toBeInstanceOf(StoreNotificationUnauthorizedError); + } + + it('accepts a token from the configured push service account', async () => { + const token = await fake.signPushToken(); + await expect(verifier.verifyAuthorizationHeader(`Bearer ${token}`)).resolves.toEqual({ + email: 'rtdn@fluxer-test.iam.gserviceaccount.com', + subject: '112233445566778899000', + }); + expect(fake.spies.jwksFetches).toBe(1); + }); + + it('accepts the issuer without a scheme', async () => { + const token = await fake.signPushToken({claims: {iss: 'accounts.google.com'}}); + await expect(verifier.verifyAuthorizationHeader(`Bearer ${token}`)).resolves.toMatchObject({ + email: 'rtdn@fluxer-test.iam.gserviceaccount.com', + }); + }); + + it('caches the key set across verifications', async () => { + await verifier.verifyAuthorizationHeader(`Bearer ${await fake.signPushToken()}`); + await verifier.verifyAuthorizationHeader(`Bearer ${await fake.signPushToken()}`); + expect(fake.spies.jwksFetches).toBe(1); + }); + + it('uses an injected key set URL', async () => { + const customFake = createGooglePlayDeveloperApiHandlers({jwksUrl: 'https://oidc.fluxer.test/certs'}); + server.use(...customFake.handlers); + const customVerifier = new GooglePlayPushVerifier({jwksUrl: 'https://oidc.fluxer.test/certs'}); + const token = await customFake.signPushToken(); + await expect(customVerifier.verifyAuthorizationHeader(`Bearer ${token}`)).resolves.toMatchObject({ + email: 'rtdn@fluxer-test.iam.gserviceaccount.com', + }); + expect(customFake.spies.jwksFetches).toBe(1); + expect(fake.spies.jwksFetches).toBe(0); + }); + + it('rejects a missing or malformed authorization header', async () => { + await expectRejected(undefined); + await expectRejected(null); + await expectRejected(''); + await expectRejected('Basic abc'); + await expectRejected('Bearer'); + await expectRejected(await fake.signPushToken()); + }); + + it('rejects a token for another audience', async () => { + await expectRejected(`Bearer ${await fake.signPushToken({claims: {aud: 'https://evil.test/webhooks'}})}`); + }); + + it('rejects a token from another service account', async () => { + await expectRejected( + `Bearer ${await fake.signPushToken({claims: {email: 'other@fluxer-test.iam.gserviceaccount.com'}})}`, + ); + }); + + it('rejects a token without an email', async () => { + await expectRejected(`Bearer ${await fake.signPushToken({omitClaims: ['email']})}`); + }); + + it('rejects an unverified email', async () => { + await expectRejected(`Bearer ${await fake.signPushToken({claims: {email_verified: false}})}`); + await expectRejected(`Bearer ${await fake.signPushToken({claims: {email_verified: 'true'}})}`); + await expectRejected(`Bearer ${await fake.signPushToken({omitClaims: ['email_verified']})}`); + }); + + it('rejects a token from another issuer', async () => { + await expectRejected(`Bearer ${await fake.signPushToken({claims: {iss: 'https://accounts.evil.test'}})}`); + }); + + it('rejects an expired token', async () => { + const nowSeconds = Math.floor(Date.now() / 1000); + await expectRejected( + `Bearer ${await fake.signPushToken({claims: {iat: nowSeconds - 7200, exp: nowSeconds - 3600}})}`, + ); + }); + + it('rejects a token without an expiry', async () => { + await expectRejected(`Bearer ${await fake.signPushToken({omitClaims: ['exp']})}`); + }); + + it('rejects a token signed by an untrusted key', async () => { + await expectRejected(`Bearer ${await fake.signPushToken({signer: 'untrusted'})}`); + }); + + it('rejects a token signed with an unknown key id', async () => { + await expectRejected(`Bearer ${await fake.signPushToken({signer: 'untrusted', kid: 'unknown-key'})}`); + }); + + it('rejects a token signed with a shared secret', async () => { + const nowSeconds = Math.floor(Date.now() / 1000); + const token = await new SignJWT({ + email: Config.googlePlay.pushServiceAccountEmail, + email_verified: true, + }) + .setProtectedHeader({alg: 'HS256', kid: 'fake-google-oidc-key'}) + .setIssuer('https://accounts.google.com') + .setAudience(Config.googlePlay.pushAudience ?? '') + .setIssuedAt(nowSeconds) + .setExpirationTime(nowSeconds + 600) + .sign(new TextEncoder().encode('0123456789abcdef0123456789abcdef')); + await expectRejected(`Bearer ${token}`); + }); + + it('rejects everything when the push audience is not configured', async () => { + const token = await fake.signPushToken(); + Config.googlePlay.pushAudience = undefined; + await expectRejected(`Bearer ${token}`); + }); + + it('rejects everything when the push service account is not configured', async () => { + const token = await fake.signPushToken(); + Config.googlePlay.pushServiceAccountEmail = ''; + await expectRejected(`Bearer ${token}`); + }); + + it('rejects when the key set cannot be fetched', async () => { + fake.failNext('jwks', {status: 500}); + await expectRejected(`Bearer ${await fake.signPushToken()}`); + }); +}); + +describe('parseGooglePlayPushEnvelope', () => { + it('extracts the message id and data', () => { + const envelope = buildGooglePlayPushEnvelope({version: '1.0', packageName: 'com.fluxer'}, '42'); + expect(parseGooglePlayPushEnvelope(envelope)).toEqual({ + messageId: '42', + data: encodeGooglePlayDeveloperNotification({version: '1.0', packageName: 'com.fluxer'}), + subscription: 'projects/fluxer-test/subscriptions/play-rtdn', + publishTime: expect.any(String), + }); + }); + + it('falls back to the snake case message id', () => { + expect(parseGooglePlayPushEnvelope({message: {data: 'e30=', message_id: '7'}})).toEqual({ + messageId: '7', + data: 'e30=', + subscription: null, + publishTime: null, + }); + }); + + it('returns null for an envelope without data or id', () => { + expect(parseGooglePlayPushEnvelope({message: {messageId: '1'}})).toBeNull(); + expect(parseGooglePlayPushEnvelope({message: {data: 'e30='}})).toBeNull(); + expect(parseGooglePlayPushEnvelope({message: {data: '', messageId: '1'}})).toBeNull(); + expect(parseGooglePlayPushEnvelope({})).toBeNull(); + expect(parseGooglePlayPushEnvelope(null)).toBeNull(); + expect(parseGooglePlayPushEnvelope('message')).toBeNull(); + }); +}); + +describe('decodeGooglePlayDeveloperNotification', () => { + function decode(notification: Record) { + return decodeGooglePlayDeveloperNotification(encodeGooglePlayDeveloperNotification(notification)); + } + + it('decodes a subscription notification', () => { + expect( + decode({ + version: '1.0', + packageName: 'com.fluxer', + eventTimeMillis: '1759147200000', + subscriptionNotification: {version: '1.0', notificationType: 4, purchaseToken: 'sub-token'}, + }), + ).toEqual({ + packageName: 'com.fluxer', + eventTime: new Date(1759147200000), + event: {type: 'subscription', notificationType: 4, purchaseToken: 'sub-token'}, + }); + }); + + it('keeps unknown subscription notification types', () => { + expect( + decode({ + packageName: 'com.fluxer', + subscriptionNotification: {notificationType: 99, purchaseToken: 'sub-token', subscriptionId: 'plutonium'}, + })?.event, + ).toEqual({type: 'subscription', notificationType: 99, purchaseToken: 'sub-token'}); + }); + + it('decodes a one-time product notification', () => { + expect( + decode({ + packageName: 'com.fluxer', + eventTimeMillis: 1759147200000, + oneTimeProductNotification: {version: '1.0', notificationType: 1, purchaseToken: 'otp', sku: 'gift_1_year'}, + }), + ).toEqual({ + packageName: 'com.fluxer', + eventTime: new Date(1759147200000), + event: {type: 'one_time_product', notificationType: 1, purchaseToken: 'otp', productId: 'gift_1_year'}, + }); + }); + + it('decodes voided purchase notifications for both product types', () => { + expect( + decode({ + packageName: 'com.fluxer', + voidedPurchaseNotification: {purchaseToken: 'v1', orderId: 'GPA.1', productType: 1, refundType: 1}, + })?.event, + ).toEqual({ + type: 'voided_purchase', + purchaseToken: 'v1', + orderId: 'GPA.1', + productType: 'subscription', + refundType: 'full', + }); + expect( + decode({ + packageName: 'com.fluxer', + voidedPurchaseNotification: {purchaseToken: 'v2', productType: 2, refundType: 2}, + })?.event, + ).toEqual({ + type: 'voided_purchase', + purchaseToken: 'v2', + orderId: null, + productType: 'one_time', + refundType: 'quantity_based_partial', + }); + expect( + decode({packageName: 'com.fluxer', voidedPurchaseNotification: {purchaseToken: 'v3', productType: 9}})?.event, + ).toMatchObject({productType: null, refundType: null}); + }); + + it('decodes pending refund review, test and unknown notifications', () => { + expect( + decode({ + packageName: 'com.fluxer', + pendingRefundReviewNotification: {orderId: 'GPA.9', refundReason: 7, pendingRefundToken: 'x'}, + })?.event, + ).toEqual({type: 'pending_refund_review', orderId: 'GPA.9'}); + expect(decode({version: '1.0', packageName: 'com.fluxer', testNotification: {version: '1.0'}})).toEqual({ + packageName: 'com.fluxer', + eventTime: null, + event: {type: 'test'}, + }); + expect(decode({packageName: 'com.fluxer', somethingNewNotification: {}})?.event).toEqual({type: 'unknown'}); + }); + + it('returns null for undecodable data', () => { + expect(decodeGooglePlayDeveloperNotification('!!!')).toBeNull(); + expect(decodeGooglePlayDeveloperNotification(Buffer.from('not json').toString('base64'))).toBeNull(); + expect(decode({subscriptionNotification: {notificationType: 4, purchaseToken: 't'}})).toBeNull(); + expect(decode({packageName: 'com.fluxer', subscriptionNotification: {notificationType: 4}})).toBeNull(); + expect(decode({packageName: 'com.fluxer', eventTimeMillis: 'soon'})).toBeNull(); + }); +}); diff --git a/fluxer_api/src/api/store_billing/tests/PremiumSubscriptionProvider.test.ts b/fluxer_api/src/api/store_billing/tests/PremiumSubscriptionProvider.test.ts new file mode 100644 index 000000000..cffd9fe2f --- /dev/null +++ b/fluxer_api/src/api/store_billing/tests/PremiumSubscriptionProvider.test.ts @@ -0,0 +1,156 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import {randomUUID} from 'node:crypto'; +import {createTestAccount, type TestAccount} from '@app/api/auth/tests/AuthTestUtils'; +import {createUserID} from '@app/api/BrandedTypes'; +import {Config} from '@app/api/Config'; +import {buildGooglePlayStoreKey} from '@app/api/store_billing/StoreBillingTypes'; +import type {StoreEntitlementService} from '@app/api/store_billing/StoreEntitlementService'; +import { + createTestStoreEntitlementService, + seedStorePurchase, + setTestPremium, +} from '@app/api/store_billing/tests/StoreBillingTestUtils'; +import {createTestUserWithPremium} from '@app/api/stripe/tests/StripeWebhookTestUtils'; +import {type ApiTestHarness, createApiTestHarness} from '@app/api/test/ApiTestHarness'; +import {createStripeApiHandlers} from '@app/api/test/msw/handlers/StripeApiHandlers'; +import {server} from '@app/api/test/msw/server'; +import {HTTP_STATUS} from '@app/api/test/TestConstants'; +import {createBuilder} from '@app/api/test/TestRequestBuilder'; +import {UserPremiumTypes} from '@fluxer/constants/src/UserConstants'; +import type {PremiumStateResponse} from '@fluxer/schema/src/domains/premium/PremiumSchemas'; +import {ms} from 'itty-time'; +import {afterAll, afterEach, beforeAll, beforeEach, describe, expect, it} from 'vitest'; + +describe('Premium state subscription provider', () => { + let harness: ApiTestHarness; + let service: StoreEntitlementService; + + beforeAll(async () => { + harness = await createApiTestHarness(); + }); + + afterAll(async () => { + await harness.shutdown(); + }); + + beforeEach(async () => { + await harness.resetData(); + server.use(...createStripeApiHandlers().handlers); + service = createTestStoreEntitlementService(); + }); + + afterEach(() => { + server.resetHandlers(); + }); + + async function getState(account: TestAccount): Promise { + return createBuilder(harness, account.token) + .get('/premium/state') + .expect(HTTP_STATUS.OK) + .execute(); + } + + async function makeStripeSubscriber(account: TestAccount, premiumUntil: Date): Promise { + await setTestPremium(harness, account.token, account.userId, { + premium_type: UserPremiumTypes.SUBSCRIPTION, + premium_until: premiumUntil.toISOString(), + premium_billing_cycle: 'monthly', + stripe_customer_id: `cus_provider_${account.userId}`, + stripe_subscription_id: `sub_provider_${account.userId}`, + }); + } + + it('reports stripe for a Stripe subscriber', async () => { + const account = await createTestAccount(harness); + await makeStripeSubscriber(account, new Date(Date.now() + ms('30 days'))); + const state = await getState(account); + expect(state.store).toBeNull(); + expect(state.subscription_provider).toBe('stripe'); + }); + + it('reports app_store for an App Store subscriber', async () => { + const account = await createTestAccount(harness); + const userId = createUserID(BigInt(account.userId)); + await seedStorePurchase(userId); + await service.applyStoreEntitlementToUser(userId); + const state = await getState(account); + expect(state.store?.provider).toBe('app_store'); + expect(state.subscription_provider).toBe('app_store'); + }); + + it('reports google_play for a Google Play subscriber', async () => { + const account = await createTestAccount(harness); + const userId = createUserID(BigInt(account.userId)); + await seedStorePurchase(userId, { + provider: 'google_play', + store_key: buildGooglePlayStoreKey(`token-${randomUUID()}`), + product_id: 'plutonium', + base_plan_id: 'monthly', + }); + await service.applyStoreEntitlementToUser(userId); + const state = await getState(account); + expect(state.store?.provider).toBe('google_play'); + expect(state.subscription_provider).toBe('google_play'); + }); + + it('reports null for gift-only premium', async () => { + const account = await createTestUserWithPremium(harness, UserPremiumTypes.SUBSCRIPTION, { + premiumUntil: new Date(Date.now() + ms('30 days')), + }); + const state = await getState(account); + expect(state.effective.is_premium).toBe(true); + expect(state.subscription_provider).toBeNull(); + }); + + it('reports null for lifetime premium', async () => { + const account = await createTestUserWithPremium(harness, UserPremiumTypes.LIFETIME); + const state = await getState(account); + expect(state.actual.is_visionary).toBe(true); + expect(state.subscription_provider).toBeNull(); + }); + + it('reports null for a stale Stripe subscription id without a billing cycle', async () => { + const account = await createTestUserWithPremium(harness, UserPremiumTypes.SUBSCRIPTION, { + premiumUntil: new Date(Date.now() + ms('30 days')), + stripeSubscriptionId: 'sub_provider_stale', + }); + const state = await getState(account); + expect(state.actual.premium_billing_cycle).toBeNull(); + expect(state.subscription_provider).toBeNull(); + }); + + it('reports the provider paid through later when Stripe and a store both bill', async () => { + const stripeLater = await createTestAccount(harness); + const stripeLaterId = createUserID(BigInt(stripeLater.userId)); + await makeStripeSubscriber(stripeLater, new Date(Date.now() + ms('60 days'))); + await seedStorePurchase(stripeLaterId, {expires_at: new Date(Date.now() + ms('20 days'))}); + await service.applyStoreEntitlementToUser(stripeLaterId); + const stripeLaterState = await getState(stripeLater); + expect(stripeLaterState.store?.provider).toBe('app_store'); + expect(stripeLaterState.subscription_provider).toBe('stripe'); + + const storeLater = await createTestAccount(harness); + const storeLaterId = createUserID(BigInt(storeLater.userId)); + await makeStripeSubscriber(storeLater, new Date(Date.now() + ms('10 days'))); + await seedStorePurchase(storeLaterId, {expires_at: new Date(Date.now() + ms('90 days'))}); + await service.applyStoreEntitlementToUser(storeLaterId); + const storeLaterState = await getState(storeLater); + expect(storeLaterState.subscription_provider).toBe('app_store'); + }); + + it('reports stripe on a self-hosted instance and ignores store rows', async () => { + const account = await createTestAccount(harness); + await makeStripeSubscriber(account, new Date(Date.now() + ms('30 days'))); + await seedStorePurchase(createUserID(BigInt(account.userId)), {expires_at: new Date(Date.now() + ms('90 days'))}); + const originalSelfHosted = Config.instance.selfHosted; + Config.instance.selfHosted = true; + try { + const state = await getState(account); + expect(state.store).toBeNull(); + expect(state.subscription_provider).toBe('stripe'); + } finally { + Config.instance.selfHosted = originalSelfHosted; + } + }); +}); diff --git a/fluxer_api/src/api/store_billing/tests/StoreBillingAppStore.test.ts b/fluxer_api/src/api/store_billing/tests/StoreBillingAppStore.test.ts new file mode 100644 index 000000000..e84a59565 --- /dev/null +++ b/fluxer_api/src/api/store_billing/tests/StoreBillingAppStore.test.ts @@ -0,0 +1,1113 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import {randomUUID} from 'node:crypto'; +import {createTestAccount, type TestAccount} from '@app/api/auth/tests/AuthTestUtils'; +import {createUserID} from '@app/api/BrandedTypes'; +import {Config} from '@app/api/Config'; +import {getKVClient} from '@app/api/middleware/ServiceRegistry'; +import {getStoreBillingRepository, getUserRepository} from '@app/api/middleware/ServiceSingletons'; +import {addGiftCodeDuration} from '@app/api/models/GiftCode'; +import type {User} from '@app/api/models/User'; +import {setInjectedAppleRootCertificates} from '@app/api/store_billing/app_store/AppleRootCertificates'; +import type {StoreEntitlementService} from '@app/api/store_billing/StoreEntitlementService'; +import {STORE_PURCHASE_REFRESH_QUEUE_KEY} from '@app/api/store_billing/StorePurchaseRefresh'; +import {type AppleTestPki, createAppleTestPki} from '@app/api/store_billing/tests/AppleTestPki'; +import { + expectCloseTo, + findUser, + getStoreContext, + installStoreBillingWorker, + setTestPremium, + uninstallStoreBillingWorker, +} from '@app/api/store_billing/tests/StoreBillingTestUtils'; +import {type ApiTestHarness, createApiTestHarness} from '@app/api/test/ApiTestHarness'; +import {NoopLogger} from '@app/api/test/mocks/NoopLogger'; +import { + type AppStoreFakePayload, + type AppStoreServerApiFake, + createAppStoreServerApiFake, +} from '@app/api/test/msw/handlers/AppStoreServerApiHandlers'; +import {server} from '@app/api/test/msw/server'; +import {createBuilder, createBuilderWithoutAuth} from '@app/api/test/TestRequestBuilder'; +import pollAppStoreNotificationHistory from '@app/api/worker/tasks/PollAppStoreNotificationHistory'; +import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes'; +import {UserFlags, UserPremiumTypes} from '@fluxer/constants/src/UserConstants'; +import type {PremiumStateResponse} from '@fluxer/schema/src/domains/premium/PremiumSchemas'; +import type { + StorePurchaseClaimResponse, + StorePurchaseResponse, +} from '@fluxer/schema/src/domains/premium/StoreBillingSchemas'; +import type {WorkerTaskHelpers} from '@pkgs/worker/src/contracts/WorkerTask'; +import {ms} from 'itty-time'; +import {afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi} from 'vitest'; + +const BUNDLE_ID = 'com.fluxer'; +const APP_APPLE_ID = 1234567890; +const MONTHLY = 'com.fluxer.plutonium.monthly'; +const GIFT_MONTH = 'com.fluxer.gift.1month'; + +type FakeEnvironment = 'production' | 'sandbox'; + +interface SubscriptionSeed { + environment?: FakeEnvironment; + status?: number; + expiresDate: number; + appAccountToken?: string; + autoRenewStatus?: number; + gracePeriodExpiresDate?: number; + recentSubscriptionStartDate?: number; + productId?: string; +} + +let transactionCounter = 0; + +function createWorkerHelpers(): WorkerTaskHelpers { + return { + logger: new NoopLogger(), + jobId: 1n, + addJob: async () => 0n, + reportProgress: async () => {}, + shouldCancel: async () => false, + setContextLink: async () => {}, + }; +} + +function expectAccessCut(user: User): void { + expect(user.premiumUntil!.getTime()).toBeLessThanOrEqual(Date.now()); + expect(user.premiumGraceEndsAt?.getTime()).toBe(user.premiumUntil!.getTime()); +} + +function nextTransactionId(): string { + transactionCounter += 1; + return `2000000${Date.now().toString().slice(-6)}${transactionCounter.toString().padStart(4, '0')}`; +} + +describe('App Store purchases', () => { + let harness: ApiTestHarness; + let pki: AppleTestPki; + let fake: AppStoreServerApiFake; + let service: StoreEntitlementService; + let savedStoreBilling: typeof Config.storeBilling; + + beforeAll(async () => { + harness = await createApiTestHarness(); + pki = createAppleTestPki(); + }); + + afterAll(async () => { + await harness.shutdown(); + }); + + beforeEach(async () => { + await harness.resetData(); + savedStoreBilling = {...Config.storeBilling}; + setInjectedAppleRootCertificates([pki.root]); + fake = createAppStoreServerApiFake({sign: (payload) => pki.signJws(payload)}); + server.use(...fake.handlers); + service = installStoreBillingWorker(); + }); + + afterEach(() => { + Object.assign(Config.storeBilling, savedStoreBilling); + setInjectedAppleRootCertificates(undefined); + uninstallStoreBillingWorker(); + }); + + function environmentName(environment: FakeEnvironment): string { + return environment === 'production' ? 'Production' : 'Sandbox'; + } + + function seedSubscription(seed: SubscriptionSeed): string { + const environment = seed.environment ?? 'production'; + const originalTransactionId = nextTransactionId(); + const now = Date.now(); + fake.addTransaction(environment, { + transactionId: originalTransactionId, + originalTransactionId, + productId: seed.productId ?? MONTHLY, + purchaseDate: now - ms('1 day'), + expiresDate: seed.expiresDate, + ...(seed.appAccountToken ? {appAccountToken: seed.appAccountToken} : {}), + }); + const renewalInfo: AppStoreFakePayload = {autoRenewStatus: seed.autoRenewStatus ?? 1}; + if (seed.gracePeriodExpiresDate !== undefined) { + renewalInfo.gracePeriodExpiresDate = seed.gracePeriodExpiresDate; + } + if (seed.recentSubscriptionStartDate !== undefined) { + renewalInfo.recentSubscriptionStartDate = seed.recentSubscriptionStartDate; + } + if (seed.appAccountToken) { + renewalInfo.appAccountToken = seed.appAccountToken; + } + fake.putSubscription(environment, { + originalTransactionId, + status: seed.status ?? 1, + latestTransactionId: originalTransactionId, + renewalInfo, + }); + return originalTransactionId; + } + + function seedGift(environment: FakeEnvironment, appAccountToken?: string): string { + const transactionId = nextTransactionId(); + fake.addTransaction(environment, { + transactionId, + originalTransactionId: transactionId, + productId: GIFT_MONTH, + type: 'Consumable', + purchaseDate: Date.now() - ms('1 minute'), + ...(appAccountToken ? {appAccountToken} : {}), + }); + return transactionId; + } + + async function signStoredTransaction(environment: FakeEnvironment, transactionId: string): Promise { + const transaction = fake.getTransaction(environment, transactionId); + if (!transaction) { + throw new Error(`Unknown fake transaction ${transactionId}`); + } + return pki.signJws({ + bundleId: BUNDLE_ID, + environment: environmentName(environment), + type: 'Auto-Renewable Subscription', + inAppOwnershipType: 'PURCHASED', + purchaseDate: Date.now(), + ...transaction, + signedDate: Date.now(), + }); + } + + async function signNotification( + notificationType: string, + environment: FakeEnvironment, + transactionId: string, + signer: AppleTestPki = pki, + ): Promise { + const signedTransactionInfo = await signStoredTransaction(environment, transactionId); + return signer.signJws({ + notificationType, + notificationUUID: randomUUID(), + signedDate: Date.now(), + version: '2.0', + data: { + bundleId: BUNDLE_ID, + environment: environmentName(environment), + ...(environment === 'production' ? {appAppleId: APP_APPLE_ID} : {}), + signedTransactionInfo, + }, + }); + } + + async function claim( + account: TestAccount, + signedTransaction: string, + status = 200, + code?: string, + ): Promise { + return createBuilder(harness, account.token) + .post('/premium/store/app-store/transactions') + .body({signed_transaction: signedTransaction}) + .expect(status, code) + .execute(); + } + + async function notify(signedPayload: string, status = 200): Promise { + await createBuilderWithoutAuth(harness).post('/webhooks/app-store').body({signedPayload}).expect(status).execute(); + } + + async function accountToken(account: TestAccount): Promise { + return (await getStoreContext(harness, account.token)).app_account_token; + } + + async function release(account: TestAccount, purchaseId: string): Promise { + await createBuilder(harness, account.token) + .delete(`/premium/store/purchases/${purchaseId}`) + .body({password: account.password}) + .expect(204) + .execute(); + } + + function addRenewal(originalTransactionId: string, expiresDate: number, appAccountToken?: string): string { + const renewalId = nextTransactionId(); + fake.addTransaction('production', { + transactionId: renewalId, + originalTransactionId, + productId: MONTHLY, + purchaseDate: Date.now(), + expiresDate, + ...(appAccountToken ? {appAccountToken} : {}), + }); + const subscription = fake.getSubscription('production', originalTransactionId); + fake.putSubscription('production', { + ...subscription!, + status: 1, + latestTransactionId: renewalId, + renewalInfo: {autoRenewStatus: 1, ...(appAccountToken ? {appAccountToken} : {})}, + }); + return renewalId; + } + + async function redeem(account: TestAccount, code: string): Promise { + await createBuilder(harness, account.token).post(`/gifts/${code}/redeem`).expect(204).execute(); + } + + function giftSyncParams(transactionId: string) { + return { + environment: 'production' as const, + bundleId: BUNDLE_ID, + productId: GIFT_MONTH, + transactionId, + originalTransactionId: transactionId, + hintAccountToken: null, + }; + } + + it('grants premium for a claim that carries the account token and answers repeats the same way', async () => { + const account = await createTestAccount(harness); + const token = await accountToken(account); + const expiresDate = Date.now() + ms('30 days'); + const startedAt = Date.now() - ms('40 days'); + const originalTransactionId = seedSubscription({ + expiresDate, + appAccountToken: token, + recentSubscriptionStartDate: startedAt, + }); + const jws = await signStoredTransaction('production', originalTransactionId); + const first = await claim(account, jws); + expect(first.purchase).toMatchObject({ + provider: 'app_store', + kind: 'subscription', + slot: 'monthly', + state: 'active', + entitled: true, + will_renew: true, + gift_code: null, + environment: 'production', + }); + expect(first.purchase.expires_at).toBe(new Date(expiresDate).toISOString()); + const second = await claim(account, jws); + expect(second.purchase.id).toBe(first.purchase.id); + const user = await findUser(account.userId); + expect(user.premiumType).toBe(UserPremiumTypes.SUBSCRIPTION); + expect(user.premiumUntil?.getTime()).toBe(expiresDate); + expect(user.premiumBillingCycle).toBeNull(); + expect(user.premiumWillCancel).toBe(false); + expect(user.premiumGraceEndsAt).toBeNull(); + expect(user.hasEverPurchased).toBe(true); + expect(user.premiumSince?.getTime()).toBe(startedAt); + expect(user.isPremium()).toBe(true); + expect(fake.requests.some((request) => request.method === 'PUT')).toBe(false); + const state = await createBuilder(harness, account.token).get('/premium/state').execute(); + expect(state.store).toMatchObject({ + provider: 'app_store', + purchase_id: first.purchase.id, + slot: 'monthly', + billing_cycle: 'monthly', + state: 'active', + grace_ends_at: null, + will_renew: true, + manage_url: 'https://apps.apple.com/account/subscriptions', + environment: 'production', + }); + const purchases = await createBuilder>(harness, account.token) + .get('/premium/store/purchases') + .execute(); + expect(purchases.map((purchase) => purchase.id)).toEqual([first.purchase.id]); + const entitlement = await service.getActiveStoreEntitlement(user.id); + expect(entitlement).toMatchObject({provider: 'app_store', slot: 'monthly', autoRenew: true, graceEndsAt: null}); + }); + + it('rejects a claim whose account token belongs to another account', async () => { + const owner = await createTestAccount(harness); + const other = await createTestAccount(harness); + const token = await accountToken(owner); + const originalTransactionId = seedSubscription({expiresDate: Date.now() + ms('30 days'), appAccountToken: token}); + const jws = await signStoredTransaction('production', originalTransactionId); + await claim(other, jws, 403, APIErrorCodes.STORE_PURCHASE_OWNED_BY_OTHER_ACCOUNT); + const user = await findUser(other.userId); + expect(user.isPremium()).toBe(false); + await claim(owner, jws); + }); + + it('binds a purchase without a token to the first claimer and sets the account token', async () => { + const account = await createTestAccount(harness); + const originalTransactionId = seedSubscription({expiresDate: Date.now() + ms('30 days')}); + const jws = await signStoredTransaction('production', originalTransactionId); + await claim(account, jws); + const token = await accountToken(account); + const put = fake.requests.find((request) => request.method === 'PUT'); + expect(put?.path).toBe(`/inApps/v1/transactions/${originalTransactionId}/appAccountToken`); + expect(put?.body).toEqual({appAccountToken: token}); + expect(fake.getTransaction('production', originalTransactionId)?.appAccountToken).toBe(token); + expect((await findUser(account.userId)).isPremium()).toBe(true); + }); + + it('grants premium when a claim binds an unbound purchase that a newer sync already stored', async () => { + const account = await createTestAccount(harness); + const expiresDate = Date.now() + ms('30 days'); + const originalTransactionId = seedSubscription({expiresDate}); + const stored = await service.syncAppStorePurchase({ + environment: 'production', + bundleId: BUNDLE_ID, + productId: MONTHLY, + transactionId: originalTransactionId, + originalTransactionId, + hintAccountToken: null, + }); + expect(stored).toMatchObject({user_id: null, entitled: false, state: 'active'}); + await getStoreBillingRepository().updatePurchase(stored!, {last_event_at: new Date(Date.now() + ms('1 hour'))}); + const result = await claim(account, await signStoredTransaction('production', originalTransactionId)); + expect(result.purchase).toMatchObject({state: 'active', entitled: true}); + expect((await findUser(account.userId)).premiumUntil?.getTime()).toBe(expiresDate); + }); + + it('extends premium_until on DID_RENEW', async () => { + const account = await createTestAccount(harness); + const token = await accountToken(account); + const originalTransactionId = seedSubscription({expiresDate: Date.now() + ms('1 day'), appAccountToken: token}); + await claim(account, await signStoredTransaction('production', originalTransactionId)); + const renewedExpiry = Date.now() + ms('31 days'); + const renewalId = nextTransactionId(); + fake.addTransaction('production', { + transactionId: renewalId, + originalTransactionId, + productId: MONTHLY, + purchaseDate: Date.now(), + expiresDate: renewedExpiry, + appAccountToken: token, + }); + const subscription = fake.getSubscription('production', originalTransactionId); + fake.putSubscription('production', {...subscription!, latestTransactionId: renewalId}); + await notify(await signNotification('DID_RENEW', 'production', renewalId)); + const user = await findUser(account.userId); + expect(user.premiumUntil?.getTime()).toBe(renewedExpiry); + expect(user.premiumGraceEndsAt).toBeNull(); + }); + + it('sets premium_grace_ends_at while Apple reports a billing grace period', async () => { + const account = await createTestAccount(harness); + const token = await accountToken(account); + const expiresDate = Date.now() - ms('1 day'); + const graceEnd = Date.now() + ms('5 days'); + const originalTransactionId = seedSubscription({ + status: 4, + expiresDate, + gracePeriodExpiresDate: graceEnd, + appAccountToken: token, + }); + const result = await claim(account, await signStoredTransaction('production', originalTransactionId)); + expect(result.purchase.state).toBe('grace'); + expect(result.purchase.entitled_until).toBe(new Date(graceEnd).toISOString()); + const user = await findUser(account.userId); + expect(user.premiumUntil?.getTime()).toBe(expiresDate); + expect(user.premiumGraceEndsAt?.getTime()).toBe(graceEnd); + expect(user.isPremium()).toBe(true); + }); + + it('cuts access when Apple reports the subscription expired', async () => { + const account = await createTestAccount(harness); + const token = await accountToken(account); + const expiresDate = Date.now() - ms('1 hour'); + const originalTransactionId = seedSubscription({expiresDate, appAccountToken: token}); + await claim(account, await signStoredTransaction('production', originalTransactionId)); + expect((await findUser(account.userId)).isPremium()).toBe(true); + const subscription = fake.getSubscription('production', originalTransactionId); + fake.putSubscription('production', {...subscription!, status: 2, renewalInfo: {autoRenewStatus: 0}}); + await notify(await signNotification('EXPIRED', 'production', originalTransactionId)); + const user = await findUser(account.userId); + expect(user.premiumUntil?.getTime()).toBe(expiresDate); + expect(user.premiumGraceEndsAt?.getTime()).toBe(expiresDate); + expect(user.isPremium()).toBe(false); + }); + + it('cuts access at the revocation date on REFUND', async () => { + const account = await createTestAccount(harness); + const token = await accountToken(account); + const originalTransactionId = seedSubscription({expiresDate: Date.now() + ms('20 days'), appAccountToken: token}); + await claim(account, await signStoredTransaction('production', originalTransactionId)); + const revocationDate = Date.now() - ms('1 minute'); + fake.updateTransaction('production', originalTransactionId, {revocationDate, revocationReason: 0}); + const subscription = fake.getSubscription('production', originalTransactionId); + fake.putSubscription('production', {...subscription!, status: 5}); + await notify(await signNotification('REFUND', 'production', originalTransactionId)); + const user = await findUser(account.userId); + expect(user.premiumUntil?.getTime()).toBe(revocationDate); + expect(user.premiumGraceEndsAt?.getTime()).toBe(revocationDate); + expect(user.isPremium()).toBe(false); + const purchases = await service.listStorePurchases(user.id); + expect(purchases[0]).toMatchObject({state: 'revoked', entitled: false}); + }); + + it('revokes an unredeemed gift code when the gift purchase is refunded', async () => { + const account = await createTestAccount(harness); + const transactionId = seedGift('production'); + const result = await claim(account, await signStoredTransaction('production', transactionId)); + expect(result.purchase).toMatchObject({kind: 'gift', slot: 'gift_1_month', state: 'fulfilled'}); + const giftCode = result.gift_code; + expect(giftCode).toBeTruthy(); + await createBuilder(harness, account.token).get(`/gifts/${giftCode}`).execute(); + fake.updateTransaction('production', transactionId, {revocationDate: Date.now() - 1000, revocationReason: 0}); + await notify(await signNotification('REFUND', 'production', transactionId)); + const gift = await getUserRepository().findGiftCode(giftCode!); + expect(gift?.revokedAt).not.toBeNull(); + await createBuilder(harness, account.token) + .get(`/gifts/${giftCode}`) + .expect(404, APIErrorCodes.UNKNOWN_GIFT_CODE) + .execute(); + }); + + it('mints exactly one gift code when a claim and a notification race', async () => { + const account = await createTestAccount(harness); + const token = await accountToken(account); + const transactionId = seedGift('production', token); + const jws = await signStoredTransaction('production', transactionId); + const notification = await signNotification('ONE_TIME_CHARGE', 'production', transactionId); + const [claimed] = await Promise.all([claim(account, jws), notify(notification)]); + const gifts = await getUserRepository().findGiftCodesByCreator((await findUser(account.userId)).id); + expect(gifts).toHaveLength(1); + expect(claimed.gift_code).toBe(gifts[0]?.code); + expect(gifts[0]).toMatchObject({durationType: 'months', durationQuantity: 1}); + const purchaser = await findUser(account.userId); + expect(purchaser.giftInventoryServerSeq).toBe(1); + expect(purchaser.hasEverPurchased).toBe(true); + expect(purchaser.isPremium()).toBe(false); + }); + + it('records a sandbox purchase without entitlement unless the account is allowed', async () => { + const tester = await createTestAccount(harness); + const originalTransactionId = seedSubscription({environment: 'sandbox', expiresDate: Date.now() + ms('1 day')}); + await claim( + tester, + await signStoredTransaction('sandbox', originalTransactionId), + 403, + APIErrorCodes.STORE_PURCHASE_SANDBOX_NOT_ENTITLED, + ); + const purchases = await createBuilder>(harness, tester.token) + .get('/premium/store/purchases') + .execute(); + expect(purchases).toHaveLength(1); + expect(purchases[0]).toMatchObject({environment: 'sandbox', entitled: false, state: 'active'}); + expect((await findUser(tester.userId)).isPremium()).toBe(false); + const giftTransactionId = seedGift('sandbox'); + await claim( + tester, + await signStoredTransaction('sandbox', giftTransactionId), + 403, + APIErrorCodes.STORE_PURCHASE_SANDBOX_NOT_ENTITLED, + ); + expect(await getUserRepository().findGiftCodesByCreator((await findUser(tester.userId)).id)).toHaveLength(0); + + const allowed = await createTestAccount(harness); + Config.storeBilling.sandboxUserIds = [allowed.userId]; + const allowedTransactionId = seedSubscription({environment: 'sandbox', expiresDate: Date.now() + ms('1 day')}); + const result = await claim(allowed, await signStoredTransaction('sandbox', allowedTransactionId)); + expect(result.purchase).toMatchObject({environment: 'sandbox', entitled: true}); + expect((await findUser(allowed.userId)).isPremium()).toBe(true); + }); + + it('moves a sandbox purchase to an allowed account that restores it after another account bought it', async () => { + const tester = await createTestAccount(harness); + const allowed = await createTestAccount(harness); + const originalTransactionId = seedSubscription({ + environment: 'sandbox', + expiresDate: Date.now() + ms('1 day'), + appAccountToken: await accountToken(tester), + }); + const jws = await signStoredTransaction('sandbox', originalTransactionId); + await claim(tester, jws, 403, APIErrorCodes.STORE_PURCHASE_SANDBOX_NOT_ENTITLED); + Config.storeBilling.sandboxUserIds = [allowed.userId]; + const result = await claim(allowed, jws); + expect(result.purchase).toMatchObject({environment: 'sandbox', entitled: true}); + expect((await findUser(allowed.userId)).isPremium()).toBe(true); + expect(fake.getTransaction('sandbox', originalTransactionId)?.appAccountToken).toBe(await accountToken(allowed)); + expect(await service.listStorePurchases((await findUser(tester.userId)).id)).toHaveLength(0); + expect((await findUser(tester.userId)).isPremium()).toBe(false); + }); + + it('blocks a lifetime account from a subscription without granting anything', async () => { + const account = await createTestAccount(harness); + await setTestPremium(harness, account.token, account.userId, {premium_type: UserPremiumTypes.LIFETIME}); + const originalTransactionId = seedSubscription({expiresDate: Date.now() + ms('30 days')}); + const {json} = await createBuilder<{code: string; reason: string; provider: string}>(harness, account.token) + .post('/premium/store/app-store/transactions') + .body({signed_transaction: await signStoredTransaction('production', originalTransactionId)}) + .expect(403, APIErrorCodes.PREMIUM_PURCHASE_BLOCKED) + .executeWithResponse(); + expect(json).toMatchObject({reason: 'lifetime', provider: 'app_store'}); + const user = await findUser(account.userId); + expect(user.premiumType).toBe(UserPremiumTypes.LIFETIME); + expect(user.premiumUntil).toBeNull(); + }); + + it('moves a released subscription to the next account that claims it', async () => { + const first = await createTestAccount(harness); + const second = await createTestAccount(harness); + const firstToken = await accountToken(first); + const originalTransactionId = seedSubscription({ + expiresDate: Date.now() + ms('30 days'), + appAccountToken: firstToken, + }); + const jws = await signStoredTransaction('production', originalTransactionId); + const claimed = await claim(first, jws); + await claim(second, jws, 403, APIErrorCodes.STORE_PURCHASE_OWNED_BY_OTHER_ACCOUNT); + await createBuilder(harness, first.token) + .delete(`/premium/store/purchases/${claimed.purchase.id}`) + .body({}) + .expect(403, APIErrorCodes.SUDO_MODE_REQUIRED) + .execute(); + await createBuilder(harness, first.token) + .delete(`/premium/store/purchases/${claimed.purchase.id}`) + .body({password: first.password}) + .expect(204) + .execute(); + const released = await findUser(first.userId); + expect(released.premiumGraceEndsAt?.getTime()).toBe(released.premiumUntil?.getTime()); + expect(released.premiumUntil!.getTime()).toBeLessThanOrEqual(Date.now()); + expect(released.premiumUntil!.getTime()).toBeLessThan(new Date(claimed.purchase.expires_at!).getTime()); + expect(await service.listStorePurchases(released.id)).toHaveLength(0); + const moved = await claim(second, jws); + expect(moved.purchase.id).toBe(claimed.purchase.id); + const secondToken = await accountToken(second); + expect(fake.getTransaction('production', originalTransactionId)?.appAccountToken).toBe(secondToken); + expect((await findUser(second.userId)).isPremium()).toBe(true); + }); + + it('rejects notifications that fail signature checks', async () => { + const account = await createTestAccount(harness); + const originalTransactionId = seedSubscription({expiresDate: Date.now() + ms('30 days')}); + const foreign = createAppleTestPki(); + await notify(await signNotification('DID_RENEW', 'production', originalTransactionId, foreign), 401); + await createBuilderWithoutAuth(harness) + .post('/webhooks/app-store') + .body({signedPayload: 'not-a-jws'}) + .expect(401, APIErrorCodes.STORE_NOTIFICATION_UNAUTHORIZED) + .execute(); + expect(await service.listStorePurchases((await findUser(account.userId)).id)).toHaveLength(0); + }); + + it('stores a notification for an unknown account without binding it', async () => { + const originalTransactionId = seedSubscription({expiresDate: Date.now() + ms('30 days')}); + await notify(await signNotification('SUBSCRIBED', 'production', originalTransactionId)); + const row = await getStoreBillingRepository().findPurchase(`app_store:production:${originalTransactionId}`); + expect(row).toMatchObject({user_id: null, entitled: false, state: 'active'}); + }); + + it('rejects a transaction for a product that is not on sale', async () => { + const account = await createTestAccount(harness); + const originalTransactionId = seedSubscription({ + expiresDate: Date.now() + ms('30 days'), + productId: 'com.fluxer.unknown', + }); + await claim( + account, + await signStoredTransaction('production', originalTransactionId), + 400, + APIErrorCodes.STORE_PURCHASE_INVALID, + ); + }); + + it('writes the store portion of the users row as specified', async () => { + const account = await createTestAccount(harness); + const token = await accountToken(account); + const giftEnd = Date.now() + ms('20 days'); + const premiumSince = Date.now() - ms('100 days'); + await setTestPremium(harness, account.token, account.userId, { + premium_type: UserPremiumTypes.SUBSCRIPTION, + premium_until: null, + premium_gift_extension_ends_at: new Date(giftEnd).toISOString(), + premium_since: new Date(premiumSince).toISOString(), + premium_billing_cycle: 'monthly', + }); + const expiresDate = Date.now() + ms('30 days'); + const originalTransactionId = seedSubscription({ + expiresDate, + appAccountToken: token, + autoRenewStatus: 0, + recentSubscriptionStartDate: Date.now() - ms('3 days'), + }); + const grantedAt = Date.now(); + const result = await claim(account, await signStoredTransaction('production', originalTransactionId)); + expect(result.purchase).toMatchObject({state: 'canceled', will_renew: false, entitled: true}); + const user = await findUser(account.userId); + expect(user.premiumType).toBe(UserPremiumTypes.SUBSCRIPTION); + expect(user.premiumUntil?.getTime()).toBe(expiresDate); + expect(user.premiumBillingCycle).toBeNull(); + expect(user.premiumWillCancel).toBe(true); + expect(user.premiumGraceEndsAt).toBeNull(); + expect(user.hasEverPurchased).toBe(true); + expect(user.premiumSince?.getTime()).toBe(premiumSince); + expectCloseTo(user.premiumGiftExtensionEndsAt, giftEnd + (expiresDate - grantedAt)); + await claim(account, await signStoredTransaction('production', originalTransactionId)); + const unchanged = await findUser(account.userId); + expect(unchanged.premiumGiftExtensionEndsAt?.getTime()).toBe(user.premiumGiftExtensionEndsAt?.getTime()); + }); + + it('uses the subscription start as premium_since for a first premium grant', async () => { + const account = await createTestAccount(harness); + const token = await accountToken(account); + const startedAt = Date.now() - ms('2 days'); + const originalTransactionId = seedSubscription({ + expiresDate: Date.now() + ms('30 days'), + appAccountToken: token, + recentSubscriptionStartDate: startedAt, + }); + await notify(await signNotification('SUBSCRIBED', 'production', originalTransactionId)); + const user = await findUser(account.userId); + expect(user.premiumSince?.getTime()).toBe(startedAt); + expect(user.isPremium()).toBe(true); + }); + + it('reinstates an unredeemed gift code when a gift refund is reversed', async () => { + const account = await createTestAccount(harness); + const transactionId = seedGift('production'); + const result = await claim(account, await signStoredTransaction('production', transactionId)); + const giftCode = result.gift_code!; + fake.updateTransaction('production', transactionId, {revocationDate: Date.now() - 1000, revocationReason: 0}); + await notify(await signNotification('REFUND', 'production', transactionId)); + expect((await getUserRepository().findGiftCode(giftCode))?.revokedAt).not.toBeNull(); + fake.updateTransaction('production', transactionId, {revocationDate: undefined, revocationReason: undefined}); + await notify(await signNotification('REFUND_REVERSED', 'production', transactionId)); + expect((await getUserRepository().findGiftCode(giftCode))?.revokedAt).toBeNull(); + const row = await getStoreBillingRepository().findPurchase(`app_store:production:${transactionId}`); + expect(row).toMatchObject({state: 'fulfilled', entitled: true, revoked_at: null, revocation_reason: null}); + await createBuilder(harness, account.token).get(`/gifts/${giftCode}`).execute(); + }); + + it('restores the gift time of a redeemed gift when its refund is reversed', async () => { + const purchaser = await createTestAccount(harness); + const redeemer = await createTestAccount(harness); + const transactionId = seedGift('production'); + const result = await claim(purchaser, await signStoredTransaction('production', transactionId)); + const giftCode = result.gift_code!; + await redeem(redeemer, giftCode); + const granted = await findUser(redeemer.userId); + expect(granted.isPremium()).toBe(true); + const grantedGiftEnd = granted.premiumGiftExtensionEndsAt!.getTime(); + fake.updateTransaction('production', transactionId, {revocationDate: Date.now() - 1000, revocationReason: 0}); + await notify(await signNotification('REFUND', 'production', transactionId)); + expect((await findUser(redeemer.userId)).isPremium()).toBe(false); + fake.updateTransaction('production', transactionId, {revocationDate: undefined, revocationReason: undefined}); + await notify(await signNotification('REFUND_REVERSED', 'production', transactionId)); + expect((await getUserRepository().findGiftCode(giftCode))?.revokedAt).toBeNull(); + const restored = await findUser(redeemer.userId); + expect(restored.isPremium()).toBe(true); + expectCloseTo(restored.premiumGiftExtensionEndsAt, grantedGiftEnd, ms('1 minute')); + }); + + it('retries the gift reversal when it failed after the refund was stored', async () => { + const account = await createTestAccount(harness); + const transactionId = seedGift('production'); + const result = await claim(account, await signStoredTransaction('production', transactionId)); + fake.updateTransaction('production', transactionId, {revocationDate: Date.now() - 1000, revocationReason: 0}); + const revoke = vi + .spyOn(getUserRepository(), 'revokeGiftCode') + .mockRejectedValueOnce(new Error('Cassandra write timed out')); + try { + await expect(service.syncAppStorePurchase(giftSyncParams(transactionId))).rejects.toThrow(); + const stored = await getStoreBillingRepository().findPurchase(`app_store:production:${transactionId}`); + expect(stored?.state).toBe('refunded'); + await service.syncAppStorePurchase(giftSyncParams(transactionId)); + } finally { + revoke.mockRestore(); + } + expect((await getUserRepository().findGiftCode(result.gift_code!))?.revokedAt).not.toBeNull(); + }); + + it('clears a reversed subscription refund from the stored purchase', async () => { + const account = await createTestAccount(harness); + const token = await accountToken(account); + const expiresDate = Date.now() + ms('20 days'); + const originalTransactionId = seedSubscription({expiresDate, appAccountToken: token}); + await claim(account, await signStoredTransaction('production', originalTransactionId)); + fake.updateTransaction('production', originalTransactionId, { + revocationDate: Date.now() - ms('1 minute'), + revocationReason: 0, + }); + const subscription = fake.getSubscription('production', originalTransactionId); + fake.putSubscription('production', {...subscription!, status: 5}); + await notify(await signNotification('REFUND', 'production', originalTransactionId)); + fake.updateTransaction('production', originalTransactionId, { + revocationDate: undefined, + revocationReason: undefined, + }); + fake.putSubscription('production', {...subscription!, status: 1}); + await notify(await signNotification('REFUND_REVERSED', 'production', originalTransactionId)); + const row = await getStoreBillingRepository().findPurchase(`app_store:production:${originalTransactionId}`); + expect(row).toMatchObject({state: 'active', entitled: true, revoked_at: null, revocation_reason: null}); + const user = await findUser(account.userId); + expect(user.premiumUntil?.getTime()).toBe(expiresDate); + expect(user.isPremium()).toBe(true); + }); + + it('keeps the stacked gift time fixed across release and reclaim', async () => { + const account = await createTestAccount(harness); + const token = await accountToken(account); + const giftDuration = ms('20 days'); + await setTestPremium(harness, account.token, account.userId, { + premium_type: UserPremiumTypes.SUBSCRIPTION, + premium_until: null, + premium_gift_extension_ends_at: new Date(Date.now() + giftDuration).toISOString(), + }); + const expiresDate = Date.now() + ms('30 days'); + const originalTransactionId = seedSubscription({expiresDate, appAccountToken: token}); + const jws = await signStoredTransaction('production', originalTransactionId); + const claimed = await claim(account, jws); + for (let cycle = 0; cycle < 2; cycle++) { + await release(account, claimed.purchase.id); + const released = await findUser(account.userId); + expect(released.premiumGiftExtensionEndsAt!.getTime()).toBeLessThanOrEqual(Date.now() + giftDuration + 5000); + await claim(account, jws); + const reclaimed = await findUser(account.userId); + expect(reclaimed.premiumUntil?.getTime()).toBe(expiresDate); + expectCloseTo(reclaimed.premiumGiftExtensionEndsAt, expiresDate + giftDuration); + } + }); + + it('pulls stacked gift time back to the revocation date on a refund', async () => { + const account = await createTestAccount(harness); + const token = await accountToken(account); + const giftDuration = ms('20 days'); + await setTestPremium(harness, account.token, account.userId, { + premium_type: UserPremiumTypes.SUBSCRIPTION, + premium_until: null, + premium_gift_extension_ends_at: new Date(Date.now() + giftDuration).toISOString(), + }); + const originalTransactionId = seedSubscription({expiresDate: Date.now() + ms('30 days'), appAccountToken: token}); + await claim(account, await signStoredTransaction('production', originalTransactionId)); + const revocationDate = Date.now() - ms('1 minute'); + fake.updateTransaction('production', originalTransactionId, {revocationDate, revocationReason: 0}); + const subscription = fake.getSubscription('production', originalTransactionId); + fake.putSubscription('production', {...subscription!, status: 5}); + await notify(await signNotification('REFUND', 'production', originalTransactionId)); + const user = await findUser(account.userId); + expect(user.premiumUntil?.getTime()).toBe(revocationDate); + expectCloseTo(user.premiumGiftExtensionEndsAt, revocationDate + giftDuration); + }); + + it('moves a lapsed subscription to the account that resubscribes with the same Apple Account', async () => { + const first = await createTestAccount(harness); + const second = await createTestAccount(harness); + const firstToken = await accountToken(first); + const secondToken = await accountToken(second); + const originalTransactionId = seedSubscription({ + expiresDate: Date.now() + ms('30 days'), + appAccountToken: firstToken, + }); + await claim(first, await signStoredTransaction('production', originalTransactionId)); + const lapsedAt = Date.now() - ms('1 hour'); + fake.updateTransaction('production', originalTransactionId, {expiresDate: lapsedAt}); + const subscription = fake.getSubscription('production', originalTransactionId); + fake.putSubscription('production', {...subscription!, status: 2, renewalInfo: {autoRenewStatus: 0}}); + await notify(await signNotification('EXPIRED', 'production', originalTransactionId)); + expectAccessCut(await findUser(first.userId)); + const renewedExpiry = Date.now() + ms('30 days'); + const renewalId = addRenewal(originalTransactionId, renewedExpiry, secondToken); + await notify(await signNotification('SUBSCRIBED', 'production', renewalId)); + const row = await getStoreBillingRepository().findPurchase(`app_store:production:${originalTransactionId}`); + expect(row?.user_id?.toString()).toBe(second.userId); + expect(row?.account_token).toBe(secondToken); + expectAccessCut(await findUser(first.userId)); + const resubscribed = await findUser(second.userId); + expect(resubscribed.premiumUntil?.getTime()).toBe(renewedExpiry); + expect(resubscribed.isPremium()).toBe(true); + const restored = await claim(second, await signStoredTransaction('production', renewalId)); + expect(restored.purchase.id).toBe(row?.id.toString()); + expect(await service.listStorePurchases((await findUser(first.userId)).id)).toHaveLength(0); + }); + + it('takes only the refunded gift off the stacked gift time of a store subscriber', async () => { + const purchaser = await createTestAccount(harness); + const redeemer = await createTestAccount(harness); + const redeemerToken = await accountToken(redeemer); + const storeExpiry = Date.now() + ms('20 days'); + const originalTransactionId = seedSubscription({expiresDate: storeExpiry, appAccountToken: redeemerToken}); + await claim(redeemer, await signStoredTransaction('production', originalTransactionId)); + const otherCode = `storegift${randomUUID().replaceAll('-', '').slice(0, 20)}`; + await getUserRepository().createGiftCode({ + code: otherCode, + duration_months: null, + duration_type: 'months', + duration_quantity: 1, + created_at: new Date(), + created_by_user_id: createUserID(BigInt(purchaser.userId)), + redeemed_at: null, + redeemed_by_user_id: null, + stripe_payment_intent_id: null, + visionary_sequence_number: null, + checkout_session_id: null, + version: 1, + }); + await redeem(redeemer, otherCode); + const transactionId = seedGift('production'); + const result = await claim(purchaser, await signStoredTransaction('production', transactionId)); + await redeem(redeemer, result.gift_code!); + const stackedEnd = (await findUser(redeemer.userId)).premiumGiftExtensionEndsAt!; + expect(stackedEnd.getTime()).toBeGreaterThan(storeExpiry + ms('55 days')); + fake.updateTransaction('production', transactionId, {revocationDate: Date.now() - 1000, revocationReason: 0}); + await notify(await signNotification('REFUND', 'production', transactionId)); + const after = await findUser(redeemer.userId); + const oneMonth = addGiftCodeDuration(stackedEnd, 'months', 1)!.getTime() - stackedEnd.getTime(); + expect(after.premiumGiftExtensionEndsAt?.getTime()).toBe(stackedEnd.getTime() - oneMonth); + expect(after.premiumUntil?.getTime()).toBe(storeExpiry); + }); + + it('takes a refunded gift off a store subscriber once when the refund is retried', async () => { + const purchaser = await createTestAccount(harness); + const redeemer = await createTestAccount(harness); + const redeemerToken = await accountToken(redeemer); + const storeExpiry = Date.now() + ms('20 days'); + const originalTransactionId = seedSubscription({expiresDate: storeExpiry, appAccountToken: redeemerToken}); + await claim(redeemer, await signStoredTransaction('production', originalTransactionId)); + const firstGift = seedGift('production'); + await redeem(redeemer, (await claim(purchaser, await signStoredTransaction('production', firstGift))).gift_code!); + const transactionId = seedGift('production'); + const result = await claim(purchaser, await signStoredTransaction('production', transactionId)); + await redeem(redeemer, result.gift_code!); + const stackedEnd = (await findUser(redeemer.userId)).premiumGiftExtensionEndsAt!; + fake.updateTransaction('production', transactionId, {revocationDate: Date.now() - 1000, revocationReason: 0}); + const revoke = vi + .spyOn(getUserRepository(), 'revokeGiftCode') + .mockRejectedValueOnce(new Error('Cassandra write timed out')); + try { + await expect(service.syncAppStorePurchase(giftSyncParams(transactionId))).rejects.toThrow(); + await service.syncAppStorePurchase(giftSyncParams(transactionId)); + } finally { + revoke.mockRestore(); + } + const oneMonth = addGiftCodeDuration(stackedEnd, 'months', 1)!.getTime() - stackedEnd.getTime(); + expect((await findUser(redeemer.userId)).premiumGiftExtensionEndsAt?.getTime()).toBe( + stackedEnd.getTime() - oneMonth, + ); + expect((await getUserRepository().findGiftCode(result.gift_code!))?.revokedAt).not.toBeNull(); + }); + + it('credits a reversed gift refund once when the reinstatement is retried', async () => { + const purchaser = await createTestAccount(harness); + const redeemer = await createTestAccount(harness); + const transactionId = seedGift('production'); + const result = await claim(purchaser, await signStoredTransaction('production', transactionId)); + const giftCode = result.gift_code!; + await redeem(redeemer, giftCode); + const grantedGiftEnd = (await findUser(redeemer.userId)).premiumGiftExtensionEndsAt!.getTime(); + fake.updateTransaction('production', transactionId, {revocationDate: Date.now() - 1000, revocationReason: 0}); + await notify(await signNotification('REFUND', 'production', transactionId)); + fake.updateTransaction('production', transactionId, {revocationDate: undefined, revocationReason: undefined}); + const unrevoke = vi + .spyOn(getUserRepository(), 'unrevokeGiftCode') + .mockRejectedValueOnce(new Error('Cassandra write timed out')); + try { + await expect(service.syncAppStorePurchase(giftSyncParams(transactionId))).rejects.toThrow(); + await service.syncAppStorePurchase(giftSyncParams(transactionId)); + } finally { + unrevoke.mockRestore(); + } + expect((await getUserRepository().findGiftCode(giftCode))?.revokedAt).toBeNull(); + expectCloseTo((await findUser(redeemer.userId)).premiumGiftExtensionEndsAt, grantedGiftEnd, ms('1 minute')); + }); + + it('restores nothing when a refund of a used gift is reversed', async () => { + const purchaser = await createTestAccount(harness); + const redeemer = await createTestAccount(harness); + const transactionId = seedGift('production'); + const result = await claim(purchaser, await signStoredTransaction('production', transactionId)); + const giftCode = result.gift_code!; + await redeem(redeemer, giftCode); + await getUserRepository().updateGiftCode(giftCode, {redeemed_at: new Date(Date.now() - ms('40 days'))}); + const redeemed = await findUser(redeemer.userId); + await getUserRepository().patchUpsert( + redeemed.id, + {premium_gift_extension_ends_at: new Date(Date.now() - ms('10 days'))}, + redeemed.toRow(), + ); + fake.updateTransaction('production', transactionId, {revocationDate: Date.now() - 1000, revocationReason: 0}); + await notify(await signNotification('REFUND', 'production', transactionId)); + fake.updateTransaction('production', transactionId, {revocationDate: undefined, revocationReason: undefined}); + await notify(await signNotification('REFUND_REVERSED', 'production', transactionId)); + expect((await getUserRepository().findGiftCode(giftCode))?.revokedAt).toBeNull(); + expect((await findUser(redeemer.userId)).isPremium()).toBe(false); + }); + + it('keeps a subscription whose product was removed from the config while refreshing it', async () => { + const account = await createTestAccount(harness); + const token = await accountToken(account); + const expiresDate = Date.now() + ms('30 days'); + const originalTransactionId = seedSubscription({expiresDate, appAccountToken: token}); + await claim(account, await signStoredTransaction('production', originalTransactionId)); + const storeKey = `app_store:production:${originalTransactionId}`; + const savedProducts = Config.appStore.products; + Config.appStore.products = Object.fromEntries( + Object.entries(savedProducts).filter(([productId]) => productId !== MONTHLY), + ); + try { + const refreshed = await service.refreshStorePurchase(storeKey); + expect(refreshed).toMatchObject({state: 'active', entitled: true}); + } finally { + Config.appStore.products = savedProducts; + } + expect(await getStoreBillingRepository().findPurchase(storeKey)).toMatchObject({state: 'active', entitled: true}); + expect(await getKVClient().zrangebyscore(STORE_PURCHASE_REFRESH_QUEUE_KEY, '-inf', '+inf')).toContain(storeKey); + const user = await findUser(account.userId); + expect(user.premiumUntil?.getTime()).toBe(expiresDate); + expect(user.isPremium()).toBe(true); + }); + + it('recovers a gift refund from the notification history when its delivery was lost', async () => { + const account = await createTestAccount(harness); + const transactionId = seedGift('production'); + const result = await claim(account, await signStoredTransaction('production', transactionId)); + fake.updateTransaction('production', transactionId, {revocationDate: Date.now() - 1000, revocationReason: 0}); + fake.addNotificationHistory('production', { + signedPayload: await signNotification('REFUND', 'production', transactionId), + notificationType: 'REFUND', + sentAt: Date.now() - ms('1 hour'), + failed: false, + }); + fake.addNotificationHistory('production', { + signedPayload: await signNotification('DID_RENEW', 'production', transactionId), + notificationType: 'DID_RENEW', + sentAt: Date.now() - ms('3 days'), + failed: true, + }); + await pollAppStoreNotificationHistory({}, createWorkerHelpers()); + expect((await getUserRepository().findGiftCode(result.gift_code!))?.revokedAt).not.toBeNull(); + expect((await getStoreBillingRepository().findPurchase(`app_store:production:${transactionId}`))?.state).toBe( + 'refunded', + ); + const historyRequests = fake.requests.filter((request) => request.path === '/inApps/v1/notifications/history'); + expect(historyRequests.map((request) => request.body)).toContainEqual( + expect.objectContaining({onlyFailures: true}), + ); + await pollAppStoreNotificationHistory({}, createWorkerHelpers()); + }); + + describe('restore', () => { + it('answers a second device that sends a later renewal of the same subscription', async () => { + const account = await createTestAccount(harness); + const token = await accountToken(account); + const originalTransactionId = seedSubscription({expiresDate: Date.now() + ms('1 day'), appAccountToken: token}); + const first = await claim(account, await signStoredTransaction('production', originalTransactionId)); + const renewedExpiry = Date.now() + ms('31 days'); + const renewalId = addRenewal(originalTransactionId, renewedExpiry, token); + const restored = await claim(account, await signStoredTransaction('production', renewalId)); + expect(restored.purchase.id).toBe(first.purchase.id); + expect(restored.purchase.expires_at).toBe(new Date(renewedExpiry).toISOString()); + const again = await claim(account, await signStoredTransaction('production', renewalId)); + expect(again.purchase.id).toBe(first.purchase.id); + expect(await service.listStorePurchases((await findUser(account.userId)).id)).toHaveLength(1); + expect((await findUser(account.userId)).premiumUntil?.getTime()).toBe(renewedExpiry); + }); + + it('moves the purchase to the restoring account after the owner was deleted', async () => { + const owner = await createTestAccount(harness); + const restorer = await createTestAccount(harness); + const ownerToken = await accountToken(owner); + const expiresDate = Date.now() + ms('30 days'); + const originalTransactionId = seedSubscription({expiresDate, appAccountToken: ownerToken}); + const jws = await signStoredTransaction('production', originalTransactionId); + const claimed = await claim(owner, jws); + const ownerUser = await findUser(owner.userId); + await getUserRepository().patchUpsert( + ownerUser.id, + {flags: ownerUser.flags | UserFlags.DELETED}, + ownerUser.toRow(), + ); + const restored = await claim(restorer, jws); + expect(restored.purchase.id).toBe(claimed.purchase.id); + const row = await getStoreBillingRepository().findPurchase(`app_store:production:${originalTransactionId}`); + expect(row?.user_id?.toString()).toBe(restorer.userId); + expect(fake.getTransaction('production', originalTransactionId)?.appAccountToken).toBe( + await accountToken(restorer), + ); + expect((await findUser(restorer.userId)).premiumUntil?.getTime()).toBe(expiresDate); + }); + + it('refuses a restore while another live account owns the purchase without naming it', async () => { + const owner = await createTestAccount(harness); + const other = await createTestAccount(harness); + const originalTransactionId = seedSubscription({expiresDate: Date.now() + ms('30 days')}); + const jws = await signStoredTransaction('production', originalTransactionId); + await claim(owner, jws); + const {json} = await createBuilder>(harness, other.token) + .post('/premium/store/app-store/transactions') + .body({signed_transaction: jws}) + .expect(403, APIErrorCodes.STORE_PURCHASE_OWNED_BY_OTHER_ACCOUNT) + .executeWithResponse(); + const body = JSON.stringify(json); + expect(body).not.toContain(owner.userId); + expect(body).not.toContain(await accountToken(owner)); + expect((await findUser(other.userId)).isPremium()).toBe(false); + }); + + it('records an expired subscription on restore without granting premium', async () => { + const account = await createTestAccount(harness); + const token = await accountToken(account); + const originalTransactionId = seedSubscription({ + status: 2, + expiresDate: Date.now() - ms('10 days'), + appAccountToken: token, + autoRenewStatus: 0, + }); + const result = await claim(account, await signStoredTransaction('production', originalTransactionId)); + expect(result.purchase).toMatchObject({state: 'expired', entitled: false, entitled_until: null}); + const user = await findUser(account.userId); + expect(user.premiumUntil).toBeNull(); + expect(user.isPremium()).toBe(false); + }); + + it('accepts an old transaction signed while its leaf certificate was valid', async () => { + const account = await createTestAccount(harness); + const token = await accountToken(account); + const now = Date.now(); + const oldPki = createAppleTestPki({ + leaf: {notBefore: new Date(now - ms('400 days')), notAfter: new Date(now - ms('10 days'))}, + }); + setInjectedAppleRootCertificates([pki.root, oldPki.root]); + const expiresDate = now + ms('30 days'); + const originalTransactionId = seedSubscription({expiresDate, appAccountToken: token}); + const transaction = fake.getTransaction('production', originalTransactionId); + const oldJws = await oldPki.signJws({ + bundleId: BUNDLE_ID, + environment: 'Production', + type: 'Auto-Renewable Subscription', + inAppOwnershipType: 'PURCHASED', + ...transaction, + purchaseDate: now - ms('20 days'), + signedDate: now - ms('20 days'), + }); + const result = await claim(account, oldJws); + expect(result.purchase).toMatchObject({state: 'active', entitled: true}); + expect((await findUser(account.userId)).premiumUntil?.getTime()).toBe(expiresDate); + }); + }); + + it('marks a subscription Apple no longer knows as expired and stops refreshing it', async () => { + const account = await createTestAccount(harness); + const token = await accountToken(account); + const originalTransactionId = seedSubscription({expiresDate: Date.now() + ms('30 days'), appAccountToken: token}); + await claim(account, await signStoredTransaction('production', originalTransactionId)); + const storeKey = `app_store:production:${originalTransactionId}`; + fake.failNext( + {path: `/inApps/v1/subscriptions/${originalTransactionId}`}, + {status: 404, errorCode: 4040010, errorMessage: 'Transaction id not found.'}, + ); + const refreshed = await service.refreshStorePurchase(storeKey); + expect(refreshed).toMatchObject({state: 'expired', entitled: false}); + expect(await getKVClient().zrangebyscore(STORE_PURCHASE_REFRESH_QUEUE_KEY, '-inf', '+inf')).not.toContain(storeKey); + expectAccessCut(await findUser(account.userId)); + }); + + it('refuses to fulfil a gift bought in a quantity above one', async () => { + const account = await createTestAccount(harness); + const transactionId = seedGift('production'); + fake.updateTransaction('production', transactionId, {quantity: 2}); + await claim( + account, + await signStoredTransaction('production', transactionId), + 400, + APIErrorCodes.STORE_PURCHASE_INVALID, + ); + const row = await getStoreBillingRepository().findPurchase(`app_store:production:${transactionId}`); + expect(row).toMatchObject({entitled: false, gift_code: null, revocation_reason: 'unsupported_quantity'}); + expect(await getUserRepository().findGiftCodesByCreator((await findUser(account.userId)).id)).toHaveLength(0); + }); +}); diff --git a/fluxer_api/src/api/store_billing/tests/StoreBillingConfig.test.ts b/fluxer_api/src/api/store_billing/tests/StoreBillingConfig.test.ts new file mode 100644 index 000000000..a0d7f7266 --- /dev/null +++ b/fluxer_api/src/api/store_billing/tests/StoreBillingConfig.test.ts @@ -0,0 +1,164 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import {createUserID} from '@app/api/BrandedTypes'; +import {Config} from '@app/api/Config'; +import { + getAppStoreApp, + getAppStoreConfig, + getAppStoreProductSlot, + getGooglePlayConfig, + getGooglePlayOneTimeProductSlot, + getGooglePlaySubscriptionSlot, + isAppStoreConfigured, + isGooglePlayConfigured, + isGooglePlayPackageConfigured, + isGooglePlaySubscriptionProduct, + isSandboxEntitlementAllowed, + listAppStoreProducts, + listGooglePlayProducts, + STORE_PRODUCT_SLOTS, +} from '@app/api/store_billing/StoreBillingConfig'; +import { + buildAppStoreStoreKey, + buildGooglePlayStoreKey, + isTerminalStorePurchaseState, +} from '@app/api/store_billing/StoreBillingTypes'; +import {afterEach, beforeEach, describe, expect, it} from 'vitest'; + +describe('store product slots', () => { + it('mirrors the Stripe product shapes', () => { + expect(STORE_PRODUCT_SLOTS).toEqual({ + monthly: {kind: 'subscription', billingCycle: 'monthly', durationMonths: 1}, + yearly: {kind: 'subscription', billingCycle: 'yearly', durationMonths: 12}, + gift_1_month: {kind: 'gift', billingCycle: null, durationMonths: 1}, + gift_1_year: {kind: 'gift', billingCycle: null, durationMonths: 12}, + }); + }); +}); + +describe('store billing config', () => { + let savedAppStore: typeof Config.appStore; + let savedGooglePlay: typeof Config.googlePlay; + let savedStoreBilling: typeof Config.storeBilling; + let savedSelfHosted: boolean; + + beforeEach(() => { + savedAppStore = {...Config.appStore}; + savedGooglePlay = {...Config.googlePlay}; + savedStoreBilling = {...Config.storeBilling}; + savedSelfHosted = Config.instance.selfHosted; + }); + + afterEach(() => { + Object.assign(Config.appStore, savedAppStore); + Object.assign(Config.googlePlay, savedGooglePlay); + Object.assign(Config.storeBilling, savedStoreBilling); + Config.instance.selfHosted = savedSelfHosted; + }); + + it('treats both stores as configured with the test credentials', () => { + expect(getAppStoreConfig().enabled).toBe(true); + expect(getGooglePlayConfig().packages).toEqual(['com.fluxer']); + expect(isAppStoreConfigured()).toBe(true); + expect(isGooglePlayConfigured()).toBe(true); + }); + + it('treats a store as not configured when disabled', () => { + Config.appStore.enabled = false; + Config.googlePlay.enabled = false; + + expect(isAppStoreConfigured()).toBe(false); + expect(isGooglePlayConfigured()).toBe(false); + }); + + it('treats both stores as not configured on a self-hosted instance', () => { + Config.instance.selfHosted = true; + + expect(isAppStoreConfigured()).toBe(false); + expect(isGooglePlayConfigured()).toBe(false); + }); + + it('requires App Store signing credentials, an app and a product', () => { + Config.appStore.privateKey = undefined; + expect(isAppStoreConfigured()).toBe(false); + Config.appStore.privateKeyPath = '/etc/fluxer/keys/app-store.p8'; + expect(isAppStoreConfigured()).toBe(true); + Config.appStore.keyId = ' '; + expect(isAppStoreConfigured()).toBe(false); + Config.appStore.keyId = savedAppStore.keyId; + Config.appStore.apps = []; + expect(isAppStoreConfigured()).toBe(false); + Config.appStore.apps = savedAppStore.apps; + Config.appStore.products = {}; + expect(isAppStoreConfigured()).toBe(false); + }); + + it('accepts Google Play credentials inline or from a service account file', () => { + Config.googlePlay.privateKey = undefined; + expect(isGooglePlayConfigured()).toBe(false); + Config.googlePlay.clientEmail = undefined; + Config.googlePlay.serviceAccountJsonPath = '/etc/fluxer/keys/play.json'; + expect(isGooglePlayConfigured()).toBe(true); + Config.googlePlay.packages = []; + expect(isGooglePlayConfigured()).toBe(false); + }); + + it('resolves App Store apps and product slots', () => { + expect(getAppStoreApp('com.fluxer')).toEqual({bundleId: 'com.fluxer', appAppleId: 1234567890}); + expect(getAppStoreApp('com.other')).toBeNull(); + expect(getAppStoreProductSlot('com.fluxer.plutonium.yearly')).toBe('yearly'); + expect(getAppStoreProductSlot('com.fluxer.gift.1month')).toBe('gift_1_month'); + expect(getAppStoreProductSlot('com.fluxer.unknown')).toBeNull(); + expect(getAppStoreProductSlot('toString')).toBeNull(); + expect(listAppStoreProducts()).toContainEqual({productId: 'com.fluxer.plutonium.monthly', slot: 'monthly'}); + }); + + it('resolves Google Play subscriptions by base plan and one-time products by id', () => { + expect(getGooglePlaySubscriptionSlot('plutonium', 'monthly')).toBe('monthly'); + expect(getGooglePlaySubscriptionSlot('plutonium', 'weekly')).toBeNull(); + expect(getGooglePlaySubscriptionSlot('gift_1_month', '')).toBeNull(); + expect(getGooglePlayOneTimeProductSlot('gift_1_year')).toBe('gift_1_year'); + expect(getGooglePlayOneTimeProductSlot('plutonium')).toBeNull(); + expect(getGooglePlayOneTimeProductSlot('constructor')).toBeNull(); + expect(isGooglePlaySubscriptionProduct('plutonium')).toBe(true); + expect(isGooglePlaySubscriptionProduct('gift_1_month')).toBe(false); + expect(isGooglePlayPackageConfigured('com.fluxer')).toBe(true); + expect(isGooglePlayPackageConfigured('com.fluxer.canary')).toBe(false); + expect(listGooglePlayProducts()).toEqual( + expect.arrayContaining([ + {productId: 'plutonium', basePlanId: 'yearly', slot: 'yearly'}, + {productId: 'gift_1_month', basePlanId: null, slot: 'gift_1_month'}, + ]), + ); + }); + + it('entitles sandbox purchases only for allowed users unless every user is allowed', () => { + const allowed = createUserID(4242n); + const other = createUserID(4343n); + expect(isSandboxEntitlementAllowed(allowed)).toBe(false); + + Config.storeBilling.sandboxUserIds = ['4242']; + expect(isSandboxEntitlementAllowed(allowed)).toBe(true); + expect(isSandboxEntitlementAllowed(other)).toBe(false); + + Config.storeBilling.sandboxEntitlesAll = true; + expect(isSandboxEntitlementAllowed(other)).toBe(true); + }); +}); + +describe('store purchase keys and states', () => { + it('builds store keys per provider and environment', () => { + expect(buildAppStoreStoreKey('sandbox', '2000000000000001')).toBe('app_store:sandbox:2000000000000001'); + expect(buildGooglePlayStoreKey('token.abc')).toBe('google_play:token.abc'); + }); + + it('treats only final states as terminal', () => { + for (const state of ['expired', 'revoked', 'superseded', 'fulfilled', 'refunded'] as const) { + expect(isTerminalStorePurchaseState(state)).toBe(true); + } + for (const state of ['pending', 'active', 'grace', 'billing_retry', 'on_hold', 'paused', 'canceled'] as const) { + expect(isTerminalStorePurchaseState(state)).toBe(false); + } + expect(isTerminalStorePurchaseState('purchased')).toBe(false); + }); +}); diff --git a/fluxer_api/src/api/store_billing/tests/StoreBillingGooglePlay.test.ts b/fluxer_api/src/api/store_billing/tests/StoreBillingGooglePlay.test.ts new file mode 100644 index 000000000..5c9bef259 --- /dev/null +++ b/fluxer_api/src/api/store_billing/tests/StoreBillingGooglePlay.test.ts @@ -0,0 +1,778 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import {randomUUID} from 'node:crypto'; +import {createTestAccount, type TestAccount} from '@app/api/auth/tests/AuthTestUtils'; +import {Config} from '@app/api/Config'; +import {getKVClient} from '@app/api/middleware/ServiceRegistry'; +import {getStoreBillingRepository, getUserRepository} from '@app/api/middleware/ServiceSingletons'; +import type {SubscriptionPurchaseV2} from '@app/api/store_billing/google_play/GooglePlayDeveloperApiClient'; +import type {StoreEntitlementService} from '@app/api/store_billing/StoreEntitlementService'; +import {googlePlayMessageClaimKey, StoreNotificationClaims} from '@app/api/store_billing/StoreNotificationClaims'; +import {STORE_PURCHASE_REFRESH_QUEUE_KEY} from '@app/api/store_billing/StorePurchaseRefresh'; +import { + findUser, + getStoreContext, + installStoreBillingWorker, + setTestPremium, + uninstallStoreBillingWorker, +} from '@app/api/store_billing/tests/StoreBillingTestUtils'; +import {type ApiTestHarness, createApiTestHarness} from '@app/api/test/ApiTestHarness'; +import {NoopLogger} from '@app/api/test/mocks/NoopLogger'; +import { + buildFakeProductPurchase, + buildFakeSubscriptionPurchase, + buildGooglePlayPushEnvelope, + createGooglePlayDeveloperApiHandlers, + encodeGooglePlayDeveloperNotification, + type FakeGooglePlayDeveloperApi, +} from '@app/api/test/msw/handlers/GooglePlayDeveloperApiHandlers'; +import {server} from '@app/api/test/msw/server'; +import {createBuilder, createBuilderWithoutAuth} from '@app/api/test/TestRequestBuilder'; +import pollGooglePlayVoidedPurchases from '@app/api/worker/tasks/PollGooglePlayVoidedPurchases'; +import processGooglePlayNotification from '@app/api/worker/tasks/ProcessGooglePlayNotification'; +import processStorePurchaseRefreshQueue from '@app/api/worker/tasks/ProcessStorePurchaseRefreshQueue'; +import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes'; +import {UserPremiumTypes} from '@fluxer/constants/src/UserConstants'; +import type {PremiumStateResponse} from '@fluxer/schema/src/domains/premium/PremiumSchemas'; +import type { + StorePurchaseClaimResponse, + StorePurchaseResponse, +} from '@fluxer/schema/src/domains/premium/StoreBillingSchemas'; +import type {WorkerTaskHelpers} from '@pkgs/worker/src/contracts/WorkerTask'; +import {ms} from 'itty-time'; +import {afterAll, afterEach, beforeAll, beforeEach, describe, expect, it} from 'vitest'; + +const PACKAGE_NAME = 'com.fluxer'; +const SUBSCRIPTION_PRODUCT = 'plutonium'; + +interface SubscriptionSeed { + state?: string; + expiresAt: number; + basePlanId?: string; + accountToken?: string; + linkedPurchaseToken?: string; + orderId?: string; + testPurchase?: boolean; +} + +interface ScheduledJob { + taskType: string; + payload: unknown; + runAt: Date | undefined; +} + +function createWorkerHelpers(isLastAttempt = false, scheduled: Array = []): WorkerTaskHelpers { + return { + logger: new NoopLogger(), + jobId: 1n, + attempt: {isLastAttempt}, + addJob: async (taskType, payload, options) => { + scheduled.push({taskType, payload, runAt: options?.runAt}); + return 0n; + }, + reportProgress: async () => {}, + shouldCancel: async () => false, + setContextLink: async () => {}, + }; +} + +function buildSubscription(seed: SubscriptionSeed): SubscriptionPurchaseV2 { + return buildFakeSubscriptionPurchase({ + subscriptionState: seed.state ?? 'SUBSCRIPTION_STATE_ACTIVE', + startTime: new Date(Date.now() - ms('1 day')).toISOString(), + lineItems: [ + { + productId: SUBSCRIPTION_PRODUCT, + expiryTime: new Date(seed.expiresAt).toISOString(), + latestSuccessfulOrderId: seed.orderId ?? `GPA.${randomUUID()}`, + autoRenewingPlan: {autoRenewEnabled: true}, + offerDetails: {basePlanId: seed.basePlanId ?? 'monthly'}, + }, + ], + ...(seed.accountToken ? {externalAccountIdentifiers: {obfuscatedExternalAccountId: seed.accountToken}} : {}), + ...(seed.linkedPurchaseToken ? {linkedPurchaseToken: seed.linkedPurchaseToken} : {}), + ...(seed.testPurchase ? {testPurchase: {}} : {}), + }); +} + +describe('Google Play purchases', () => { + let harness: ApiTestHarness; + let fake: FakeGooglePlayDeveloperApi; + let service: StoreEntitlementService; + + beforeAll(async () => { + harness = await createApiTestHarness(); + fake = createGooglePlayDeveloperApiHandlers(); + }); + + afterAll(async () => { + await harness.shutdown(); + }); + + beforeEach(async () => { + await harness.resetData(); + server.use(...fake.handlers); + service = installStoreBillingWorker(); + }); + + afterEach(() => { + uninstallStoreBillingWorker(); + }); + + function newToken(): string { + return `token-${randomUUID()}`; + } + + async function claim( + account: TestAccount, + purchaseToken: string, + productId = SUBSCRIPTION_PRODUCT, + status = 200, + code?: string, + ): Promise { + return createBuilder(harness, account.token) + .post('/premium/store/google-play/purchases') + .body({purchase_token: purchaseToken, product_id: productId, package_name: PACKAGE_NAME}) + .expect(status, code) + .execute(); + } + + async function sendRtdn(notification: Record, authorization?: string, status = 204): Promise { + const header = authorization ?? `Bearer ${await fake.signPushToken()}`; + await createBuilderWithoutAuth(harness) + .post('/webhooks/google-play') + .header('Authorization', header) + .body(buildGooglePlayPushEnvelope({packageName: PACKAGE_NAME, ...notification}, randomUUID())) + .expect(status) + .execute(); + } + + async function sendSubscriptionRtdn(purchaseToken: string, notificationType: number): Promise { + await sendRtdn({ + version: '1.0', + eventTimeMillis: String(Date.now()), + subscriptionNotification: {version: '1.0', notificationType, purchaseToken, subscriptionId: SUBSCRIPTION_PRODUCT}, + }); + } + + async function accountToken(account: TestAccount): Promise { + return (await getStoreContext(harness, account.token)).app_account_token; + } + + async function refreshDueAt(storeKey: string): Promise { + const kv = getKVClient(); + const keys = await kv.zrangebyscore(STORE_PURCHASE_REFRESH_QUEUE_KEY, '-inf', '+inf'); + if (!keys.includes(storeKey)) { + return null; + } + for (const window of [ms('1 hour'), ms('2 hours'), ms('400 days')]) { + const due = await kv.zrangebyscore(STORE_PURCHASE_REFRESH_QUEUE_KEY, '-inf', Date.now() + window); + if (due.includes(storeKey)) { + return window; + } + } + return null; + } + + async function runRefreshQueueFor(storeKey: string): Promise { + await getKVClient().zadd(STORE_PURCHASE_REFRESH_QUEUE_KEY, Date.now() - 1000, storeKey); + await processStorePurchaseRefreshQueue({}, createWorkerHelpers()); + } + + it('acknowledges a claimed subscription and grants premium', async () => { + const account = await createTestAccount(harness); + const token = await accountToken(account); + const purchaseToken = newToken(); + const expiresAt = Date.now() + ms('30 days'); + fake.setSubscription(PACKAGE_NAME, purchaseToken, buildSubscription({expiresAt, accountToken: token})); + const first = await claim(account, purchaseToken); + expect(first.purchase).toMatchObject({provider: 'google_play', slot: 'monthly', state: 'active', entitled: true}); + expect(fake.spies.acknowledged.filter((entry) => entry.purchaseToken === purchaseToken)).toHaveLength(1); + const second = await claim(account, purchaseToken); + expect(second.purchase.id).toBe(first.purchase.id); + expect(fake.spies.acknowledged.filter((entry) => entry.purchaseToken === purchaseToken)).toHaveLength(1); + const user = await findUser(account.userId); + expect(user.premiumUntil?.getTime()).toBe(expiresAt); + expect(user.premiumBillingCycle).toBeNull(); + expect(user.isPremium()).toBe(true); + const state = await createBuilder(harness, account.token).get('/premium/state').execute(); + expect(state.store).toMatchObject({ + provider: 'google_play', + billing_cycle: 'monthly', + manage_url: `https://play.google.com/store/account/subscriptions?sku=${SUBSCRIPTION_PRODUCT}&package=${PACKAGE_NAME}`, + }); + const context = await getStoreContext(harness, account.token); + expect(context).toMatchObject({purchase_blocked_reason: 'existing_subscription', blocking_provider: 'google_play'}); + expect(context.google_play.products).toContainEqual({ + product_id: SUBSCRIPTION_PRODUCT, + base_plan_id: 'monthly', + slot: 'monthly', + }); + }); + + it('moves the entitlement to the new token on an upgrade and supersedes the old one', async () => { + const account = await createTestAccount(harness); + const token = await accountToken(account); + const oldToken = newToken(); + fake.setSubscription( + PACKAGE_NAME, + oldToken, + buildSubscription({expiresAt: Date.now() + ms('10 days'), accountToken: token}), + ); + await claim(account, oldToken); + fake.setSubscription( + PACKAGE_NAME, + oldToken, + buildSubscription({state: 'SUBSCRIPTION_STATE_EXPIRED', expiresAt: Date.now() - 1000, accountToken: token}), + ); + const newPurchaseToken = newToken(); + const yearlyExpiry = Date.now() + ms('365 days'); + fake.setSubscription( + PACKAGE_NAME, + newPurchaseToken, + buildSubscription({expiresAt: yearlyExpiry, basePlanId: 'yearly', linkedPurchaseToken: oldToken}), + ); + await sendSubscriptionRtdn(newPurchaseToken, 4); + const purchases = await createBuilder>(harness, account.token) + .get('/premium/store/purchases') + .execute(); + expect(purchases).toHaveLength(2); + const oldRow = await getStoreBillingRepository().findPurchase(`google_play:${oldToken}`); + expect(oldRow).toMatchObject({ + state: 'superseded', + entitled: false, + superseded_by_store_key: `google_play:${newPurchaseToken}`, + }); + const newRow = await getStoreBillingRepository().findPurchase(`google_play:${newPurchaseToken}`); + expect(newRow).toMatchObject({user_id: oldRow?.user_id, slot: 'yearly', entitled: true, acknowledged: true}); + expect(purchases.filter((purchase) => purchase.entitled)).toHaveLength(1); + const user = await findUser(account.userId); + expect(user.premiumUntil?.getTime()).toBe(yearlyExpiry); + expect(await service.getActiveStoreEntitlement(user.id)).toMatchObject({provider: 'google_play', slot: 'yearly'}); + }); + + it('keeps the paid subscription while a linked replacement is pending or its payment is cancelled', async () => { + const account = await createTestAccount(harness); + const token = await accountToken(account); + const oldToken = newToken(); + const oldExpiry = Date.now() + ms('10 days'); + fake.setSubscription(PACKAGE_NAME, oldToken, buildSubscription({expiresAt: oldExpiry, accountToken: token})); + await claim(account, oldToken); + const newPurchaseToken = newToken(); + const yearlyExpiry = Date.now() + ms('365 days'); + for (const state of ['SUBSCRIPTION_STATE_PENDING', 'SUBSCRIPTION_STATE_PENDING_PURCHASE_CANCELED']) { + fake.setSubscription( + PACKAGE_NAME, + newPurchaseToken, + buildSubscription({state, expiresAt: yearlyExpiry, basePlanId: 'yearly', linkedPurchaseToken: oldToken}), + ); + await sendSubscriptionRtdn(newPurchaseToken, 4); + const oldRow = await getStoreBillingRepository().findPurchase(`google_play:${oldToken}`); + expect(oldRow).toMatchObject({state: 'active', entitled: true, superseded_by_store_key: null}); + const user = await findUser(account.userId); + expect(user.premiumUntil?.getTime()).toBe(oldExpiry); + expect(user.isPremium()).toBe(true); + } + fake.setSubscription( + PACKAGE_NAME, + newPurchaseToken, + buildSubscription({expiresAt: yearlyExpiry, basePlanId: 'yearly', linkedPurchaseToken: oldToken}), + ); + await sendSubscriptionRtdn(newPurchaseToken, 4); + expect(await getStoreBillingRepository().findPurchase(`google_play:${oldToken}`)).toMatchObject({ + state: 'superseded', + superseded_by_store_key: `google_play:${newPurchaseToken}`, + }); + expect((await findUser(account.userId)).premiumUntil?.getTime()).toBe(yearlyExpiry); + }); + + it('lets another account claim an upgraded subscription after its owner releases it', async () => { + const owner = await createTestAccount(harness); + const other = await createTestAccount(harness); + const token = await accountToken(owner); + const oldToken = newToken(); + fake.setSubscription( + PACKAGE_NAME, + oldToken, + buildSubscription({expiresAt: Date.now() + ms('10 days'), accountToken: token}), + ); + await claim(owner, oldToken); + const upgradedToken = newToken(); + fake.setSubscription( + PACKAGE_NAME, + upgradedToken, + buildSubscription({ + expiresAt: Date.now() + ms('365 days'), + basePlanId: 'yearly', + accountToken: token, + linkedPurchaseToken: oldToken, + }), + ); + const upgraded = await claim(owner, upgradedToken); + await createBuilder(harness, owner.token) + .delete(`/premium/store/purchases/${upgraded.purchase.id}`) + .body({password: owner.password}) + .expect(204) + .execute(); + await sendSubscriptionRtdn(upgradedToken, 2); + const storeKey = `google_play:${upgradedToken}`; + expect((await getStoreBillingRepository().findPurchase(storeKey))?.user_id).toBeNull(); + expect((await findUser(owner.userId)).isPremium()).toBe(false); + const moved = await claim(other, upgradedToken); + expect(moved.purchase.id).toBe(upgraded.purchase.id); + await sendSubscriptionRtdn(upgradedToken, 2); + const row = await getStoreBillingRepository().findPurchase(storeKey); + expect(row?.user_id?.toString()).toBe(other.userId); + expect(row?.released_at).toBeNull(); + expect((await findUser(other.userId)).isPremium()).toBe(true); + expect((await findUser(owner.userId)).isPremium()).toBe(false); + }); + + it('stores an unbound purchase unacknowledged and binds it on a later claim', async () => { + const purchaseToken = newToken(); + fake.setSubscription(PACKAGE_NAME, purchaseToken, buildSubscription({expiresAt: Date.now() + ms('30 days')})); + await sendSubscriptionRtdn(purchaseToken, 4); + const stored = await getStoreBillingRepository().findPurchase(`google_play:${purchaseToken}`); + expect(stored).toMatchObject({user_id: null, acknowledged: false, entitled: false, state: 'active'}); + expect(fake.spies.acknowledged.some((entry) => entry.purchaseToken === purchaseToken)).toBe(false); + const account = await createTestAccount(harness); + const result = await claim(account, purchaseToken); + expect(result.purchase.id).toBe(stored?.id.toString()); + expect(result.purchase.entitled).toBe(true); + expect(fake.spies.acknowledged.some((entry) => entry.purchaseToken === purchaseToken)).toBe(true); + expect((await findUser(account.userId)).isPremium()).toBe(true); + }); + + it('cuts access while on hold and restores it on recovery', async () => { + const account = await createTestAccount(harness); + const token = await accountToken(account); + const purchaseToken = newToken(); + const expiresAt = Date.now() - ms('1 hour'); + fake.setSubscription(PACKAGE_NAME, purchaseToken, buildSubscription({expiresAt, accountToken: token})); + await claim(account, purchaseToken); + expect((await findUser(account.userId)).isPremium()).toBe(true); + fake.setSubscription( + PACKAGE_NAME, + purchaseToken, + buildSubscription({state: 'SUBSCRIPTION_STATE_ON_HOLD', expiresAt, accountToken: token}), + ); + await sendSubscriptionRtdn(purchaseToken, 5); + const held = await findUser(account.userId); + expect(held.premiumGraceEndsAt?.getTime()).toBe(expiresAt); + expect(held.isPremium()).toBe(false); + const recoveredExpiry = Date.now() + ms('30 days'); + fake.setSubscription( + PACKAGE_NAME, + purchaseToken, + buildSubscription({expiresAt: recoveredExpiry, accountToken: token}), + ); + await sendSubscriptionRtdn(purchaseToken, 1); + const recovered = await findUser(account.userId); + expect(recovered.premiumUntil?.getTime()).toBe(recoveredExpiry); + expect(recovered.premiumGraceEndsAt).toBeNull(); + expect(recovered.isPremium()).toBe(true); + }); + + it('records a test purchase without entitlement for an account that is not allowed', async () => { + const account = await createTestAccount(harness); + const purchaseToken = newToken(); + fake.setSubscription( + PACKAGE_NAME, + purchaseToken, + buildSubscription({expiresAt: Date.now() + ms('1 day'), testPurchase: true}), + ); + await claim(account, purchaseToken, SUBSCRIPTION_PRODUCT, 403, APIErrorCodes.STORE_PURCHASE_SANDBOX_NOT_ENTITLED); + const stored = await getStoreBillingRepository().findPurchase(`google_play:${purchaseToken}`); + expect(stored).toMatchObject({environment: 'sandbox', entitled: false}); + expect(stored?.user_id?.toString()).toBe(account.userId); + expect((await findUser(account.userId)).isPremium()).toBe(false); + }); + + it('refunds a subscription bought by a lifetime account', async () => { + const account = await createTestAccount(harness); + await setTestPremium(harness, account.token, account.userId, {premium_type: UserPremiumTypes.LIFETIME}); + const purchaseToken = newToken(); + const orderId = `GPA.${randomUUID()}`; + fake.setSubscription( + PACKAGE_NAME, + purchaseToken, + buildSubscription({expiresAt: Date.now() + ms('30 days'), orderId}), + ); + const {json} = await createBuilder<{reason: string; provider: string}>(harness, account.token) + .post('/premium/store/google-play/purchases') + .body({purchase_token: purchaseToken, product_id: SUBSCRIPTION_PRODUCT}) + .expect(403, APIErrorCodes.PREMIUM_PURCHASE_BLOCKED) + .executeWithResponse(); + expect(json).toMatchObject({reason: 'lifetime', provider: 'google_play'}); + expect(fake.spies.refunded).toContainEqual({packageName: PACKAGE_NAME, orderId, revoke: true}); + expect(fake.spies.acknowledged.some((entry) => entry.purchaseToken === purchaseToken)).toBe(false); + const user = await findUser(account.userId); + expect(user.premiumType).toBe(UserPremiumTypes.LIFETIME); + expect(user.premiumUntil).toBeNull(); + await createBuilder(harness, account.token) + .post('/premium/store/google-play/purchases') + .body({purchase_token: purchaseToken, product_id: SUBSCRIPTION_PRODUCT}) + .expect(403, APIErrorCodes.PREMIUM_PURCHASE_BLOCKED) + .execute(); + expect(fake.spies.refunded.filter((entry) => entry.orderId === orderId)).toHaveLength(1); + }); + + it('mints and consumes a gift purchase', async () => { + const account = await createTestAccount(harness); + const purchaseToken = newToken(); + fake.setProduct(PACKAGE_NAME, purchaseToken, buildFakeProductPurchase({orderId: `GPA.${randomUUID()}`})); + const result = await claim(account, purchaseToken, 'gift_1_month'); + expect(result.purchase).toMatchObject({kind: 'gift', state: 'fulfilled'}); + expect(result.gift_code).toBeTruthy(); + expect(fake.spies.consumed.some((entry) => entry.purchaseToken === purchaseToken)).toBe(true); + const again = await claim(account, purchaseToken, 'gift_1_month'); + expect(again.gift_code).toBe(result.gift_code); + const gifts = await getUserRepository().findGiftCodesByCreator((await findUser(account.userId)).id); + expect(gifts.map((gift) => gift.code)).toEqual([result.gift_code]); + }); + + it('rejects push requests without a valid Google token', async () => { + const purchaseToken = newToken(); + fake.setSubscription(PACKAGE_NAME, purchaseToken, buildSubscription({expiresAt: Date.now() + ms('30 days')})); + const notification = {subscriptionNotification: {notificationType: 4, purchaseToken}}; + await sendRtdn(notification, `Bearer ${await fake.signPushToken({signer: 'untrusted'})}`, 401); + await sendRtdn(notification, 'Bearer not-a-token', 401); + await sendRtdn(notification, `Bearer ${await fake.signPushToken({claims: {email_verified: false}})}`, 401); + expect(await getStoreBillingRepository().findPurchase(`google_play:${purchaseToken}`)).toBeNull(); + }); + + it('rejects a product that is not on sale', async () => { + const account = await createTestAccount(harness); + await claim(account, newToken(), 'not_a_fluxer_product', 400, APIErrorCodes.STORE_PURCHASE_INVALID); + await createBuilder(harness, account.token) + .post('/premium/store/google-play/purchases') + .body({purchase_token: newToken(), product_id: SUBSCRIPTION_PRODUCT, package_name: 'com.example.other'}) + .expect(400, APIErrorCodes.STORE_PURCHASE_INVALID) + .execute(); + }); + + it('schedules refreshes and re-reads due purchases from the refresh queue', async () => { + const account = await createTestAccount(harness); + const token = await accountToken(account); + const purchaseToken = newToken(); + const storeKey = `google_play:${purchaseToken}`; + const expiresAt = Date.now() + ms('2 days'); + fake.setSubscription(PACKAGE_NAME, purchaseToken, buildSubscription({expiresAt, accountToken: token})); + await claim(account, purchaseToken); + const kv = getKVClient(); + const scheduled = await kv.zrangebyscore(STORE_PURCHASE_REFRESH_QUEUE_KEY, expiresAt, expiresAt + ms('10 minutes')); + expect(scheduled).toEqual([storeKey]); + await kv.zadd(STORE_PURCHASE_REFRESH_QUEUE_KEY, Date.now() - 1000, storeKey); + const renewedExpiry = Date.now() + ms('32 days'); + fake.setSubscription( + PACKAGE_NAME, + purchaseToken, + buildSubscription({expiresAt: renewedExpiry, accountToken: token}), + ); + await processStorePurchaseRefreshQueue({}, createWorkerHelpers()); + expect((await findUser(account.userId)).premiumUntil?.getTime()).toBe(renewedExpiry); + const rescheduled = await kv.zrangebyscore(STORE_PURCHASE_REFRESH_QUEUE_KEY, '-inf', '+inf'); + expect(rescheduled).toEqual([storeKey]); + }); + + it('revokes the gift code of a voided gift purchase found by the daily poll', async () => { + const account = await createTestAccount(harness); + const purchaseToken = newToken(); + const orderId = `GPA.${randomUUID()}`; + fake.setProduct(PACKAGE_NAME, purchaseToken, buildFakeProductPurchase({orderId})); + const result = await claim(account, purchaseToken, 'gift_1_month'); + fake.setProduct( + PACKAGE_NAME, + purchaseToken, + buildFakeProductPurchase({orderId, purchaseStateContext: {purchaseState: 'CANCELLED'}}), + ); + fake.addVoidedPurchase(PACKAGE_NAME, { + purchaseToken, + orderId, + voidedTimeMillis: String(Date.now()), + voidedSource: 1, + voidedReason: 1, + }); + await pollGooglePlayVoidedPurchases({}, createWorkerHelpers()); + const gift = await getUserRepository().findGiftCode(result.gift_code!); + expect(gift?.revokedAt).not.toBeNull(); + const row = await getStoreBillingRepository().findPurchase(`google_play:${purchaseToken}`); + expect(row?.state).toBe('refunded'); + }); + + it('rejects a purchase token Google does not know', async () => { + const account = await createTestAccount(harness); + await claim(account, newToken(), SUBSCRIPTION_PRODUCT, 400, APIErrorCodes.STORE_PURCHASE_INVALID); + }); + + it('keeps retrying a subscription acknowledgement that failed', async () => { + const account = await createTestAccount(harness); + const token = await accountToken(account); + const purchaseToken = newToken(); + const storeKey = `google_play:${purchaseToken}`; + fake.setSubscription( + PACKAGE_NAME, + purchaseToken, + buildSubscription({expiresAt: Date.now() + ms('30 days'), accountToken: token}), + ); + fake.failNext('subscriptions.acknowledge', {status: 503, reason: 'backendError'}); + const result = await claim(account, purchaseToken); + expect(result.purchase.entitled).toBe(true); + expect((await getStoreBillingRepository().findPurchase(storeKey))?.acknowledged).toBe(false); + expect(await refreshDueAt(storeKey)).toBe(ms('1 hour')); + await runRefreshQueueFor(storeKey); + expect((await getStoreBillingRepository().findPurchase(storeKey))?.acknowledged).toBe(true); + expect(fake.spies.acknowledged.filter((entry) => entry.purchaseToken === purchaseToken)).toHaveLength(1); + expect(await refreshDueAt(storeKey)).toBe(ms('400 days')); + }); + + it('retries a failed test purchase acknowledgement within minutes', async () => { + const account = await createTestAccount(harness); + const token = await accountToken(account); + const purchaseToken = newToken(); + const storeKey = `google_play:${purchaseToken}`; + fake.setSubscription( + PACKAGE_NAME, + purchaseToken, + buildSubscription({expiresAt: Date.now() + ms('1 day'), accountToken: token, testPurchase: true}), + ); + fake.failNext('subscriptions.acknowledge', {status: 503, reason: 'backendError'}); + const savedSandboxUserIds = Config.storeBilling.sandboxUserIds; + Config.storeBilling.sandboxUserIds = [account.userId]; + try { + const result = await claim(account, purchaseToken); + expect(result.purchase).toMatchObject({environment: 'sandbox', entitled: true}); + } finally { + Config.storeBilling.sandboxUserIds = savedSandboxUserIds; + } + expect((await getStoreBillingRepository().findPurchase(storeKey))?.acknowledged).toBe(false); + const due = await getKVClient().zrangebyscore( + STORE_PURCHASE_REFRESH_QUEUE_KEY, + '-inf', + Date.now() + ms('2 minutes'), + ); + expect(due).toContain(storeKey); + }); + + it('keeps retrying a gift consumption that failed', async () => { + const account = await createTestAccount(harness); + const purchaseToken = newToken(); + const storeKey = `google_play:${purchaseToken}`; + fake.setProduct(PACKAGE_NAME, purchaseToken, buildFakeProductPurchase({orderId: `GPA.${randomUUID()}`})); + fake.failNext('products.consume', {status: 503, reason: 'backendError'}); + const result = await claim(account, purchaseToken, 'gift_1_month'); + expect(result.purchase.state).toBe('fulfilled'); + expect(await refreshDueAt(storeKey)).toBe(ms('1 hour')); + await runRefreshQueueFor(storeKey); + expect(fake.spies.consumed.some((entry) => entry.purchaseToken === purchaseToken)).toBe(true); + expect((await getStoreBillingRepository().findPurchase(storeKey))?.acknowledged).toBe(true); + expect(await refreshDueAt(storeKey)).toBeNull(); + }); + + it('consumes a gift that was acknowledged but never consumed', async () => { + const account = await createTestAccount(harness); + const purchaseToken = newToken(); + fake.setProduct( + PACKAGE_NAME, + purchaseToken, + buildFakeProductPurchase({ + orderId: `GPA.${randomUUID()}`, + acknowledgementState: 'ACKNOWLEDGEMENT_STATE_ACKNOWLEDGED', + }), + ); + await claim(account, purchaseToken, 'gift_1_month'); + expect(fake.spies.consumed.some((entry) => entry.purchaseToken === purchaseToken)).toBe(true); + }); + + it('re-reads a subscription past its expiry about hourly while Google still reports it active', async () => { + const account = await createTestAccount(harness); + const token = await accountToken(account); + const purchaseToken = newToken(); + fake.setSubscription( + PACKAGE_NAME, + purchaseToken, + buildSubscription({expiresAt: Date.now() - ms('1 hour'), accountToken: token}), + ); + await claim(account, purchaseToken); + const due = await getKVClient().zrangebyscore( + STORE_PURCHASE_REFRESH_QUEUE_KEY, + Date.now() + ms('50 minutes'), + Date.now() + ms('70 minutes'), + ); + expect(due).toContain(`google_play:${purchaseToken}`); + }); + + it('cuts access when Google revokes a subscription and moves its expiry', async () => { + const account = await createTestAccount(harness); + const token = await accountToken(account); + const purchaseToken = newToken(); + fake.setSubscription( + PACKAGE_NAME, + purchaseToken, + buildSubscription({expiresAt: Date.now() + ms('365 days'), basePlanId: 'yearly', accountToken: token}), + ); + await claim(account, purchaseToken); + expect((await findUser(account.userId)).isPremium()).toBe(true); + fake.setSubscription( + PACKAGE_NAME, + purchaseToken, + buildSubscription({ + state: 'SUBSCRIPTION_STATE_EXPIRED', + expiresAt: Date.now(), + basePlanId: 'yearly', + accountToken: token, + }), + ); + await sendSubscriptionRtdn(purchaseToken, 12); + const user = await findUser(account.userId); + expect(user.premiumUntil!.getTime()).toBeLessThanOrEqual(Date.now()); + expect(user.premiumGraceEndsAt?.getTime()).toBe(user.premiumUntil?.getTime()); + }); + + it('refunds a gift whose voided purchase Google still reports as purchased', async () => { + const account = await createTestAccount(harness); + const purchaseToken = newToken(); + const orderId = `GPA.${randomUUID()}`; + fake.setProduct(PACKAGE_NAME, purchaseToken, buildFakeProductPurchase({orderId})); + const result = await claim(account, purchaseToken, 'gift_1_month'); + fake.addVoidedPurchase(PACKAGE_NAME, { + purchaseToken, + orderId, + voidedTimeMillis: String(Date.now() - ms('1 minute')), + voidedSource: 1, + voidedReason: 1, + }); + await pollGooglePlayVoidedPurchases({}, createWorkerHelpers()); + const storeKey = `google_play:${purchaseToken}`; + expect(await getStoreBillingRepository().findPurchase(storeKey)).toMatchObject({ + state: 'refunded', + entitled: false, + }); + const gift = await getUserRepository().findGiftCode(result.gift_code!); + expect(gift?.revokedAt).not.toBeNull(); + await pollGooglePlayVoidedPurchases({}, createWorkerHelpers()); + await sendRtdn({ + version: '1.0', + oneTimeProductNotification: {version: '1.0', notificationType: 1, purchaseToken, sku: 'gift_1_month'}, + }); + expect((await getStoreBillingRepository().findPurchase(storeKey))?.state).toBe('refunded'); + expect((await getUserRepository().findGiftCode(result.gift_code!))?.revokedAt?.getTime()).toBe( + gift?.revokedAt?.getTime(), + ); + }); + + it('revokes a subscription whose latest order Google voided while the token stays active', async () => { + const account = await createTestAccount(harness); + const token = await accountToken(account); + const purchaseToken = newToken(); + const orderId = `GPA.${randomUUID()}`; + fake.setSubscription( + PACKAGE_NAME, + purchaseToken, + buildSubscription({expiresAt: Date.now() + ms('30 days'), accountToken: token, orderId}), + ); + await claim(account, purchaseToken); + const voidedAt = Date.now() - ms('1 minute'); + await sendRtdn({ + version: '1.0', + eventTimeMillis: String(voidedAt), + voidedPurchaseNotification: {purchaseToken, orderId, productType: 1, refundType: 1}, + }); + const storeKey = `google_play:${purchaseToken}`; + expect(await getStoreBillingRepository().findPurchase(storeKey)).toMatchObject({ + state: 'revoked', + entitled: false, + }); + let user = await findUser(account.userId); + expect(user.premiumUntil?.getTime()).toBe(voidedAt); + expect(user.premiumGraceEndsAt?.getTime()).toBe(voidedAt); + await sendSubscriptionRtdn(purchaseToken, 2); + expect((await getStoreBillingRepository().findPurchase(storeKey))?.state).toBe('revoked'); + user = await findUser(account.userId); + expect(user.premiumUntil?.getTime()).toBe(voidedAt); + }); + + it('stops billing a voided subscription that Google still renews when its owner is deleted or banned', async () => { + const account = await createTestAccount(harness); + const token = await accountToken(account); + const purchaseToken = newToken(); + const orderId = `GPA.${randomUUID()}`; + fake.setSubscription( + PACKAGE_NAME, + purchaseToken, + buildSubscription({expiresAt: Date.now() + ms('30 days'), accountToken: token, orderId}), + ); + await claim(account, purchaseToken); + await sendRtdn({ + version: '1.0', + eventTimeMillis: String(Date.now()), + voidedPurchaseNotification: {purchaseToken, orderId, productType: 1, refundType: 1}, + }); + expect(await getStoreBillingRepository().findPurchase(`google_play:${purchaseToken}`)).toMatchObject({ + state: 'revoked', + auto_renew: true, + }); + expect(await getKVClient().zrangebyscore(STORE_PURCHASE_REFRESH_QUEUE_KEY, '-inf', '+inf')).toContain( + `google_play:${purchaseToken}`, + ); + const userId = (await findUser(account.userId)).id; + await service.revokeForBannedUser(userId); + expect(fake.spies.revoked.map((entry) => entry.purchaseToken)).toEqual([purchaseToken]); + await service.stopBillingForDeletedUser(userId); + expect(fake.spies.canceled.map((entry) => entry.purchaseToken)).toEqual([purchaseToken]); + }); + + it('refunds a gift bought in a quantity above one without minting', async () => { + const account = await createTestAccount(harness); + const purchaseToken = newToken(); + const orderId = `GPA.${randomUUID()}`; + fake.setProduct( + PACKAGE_NAME, + purchaseToken, + buildFakeProductPurchase({ + orderId, + productLineItem: [ + { + productId: 'gift_1_month', + productOfferDetails: {quantity: 3, consumptionState: 'CONSUMPTION_STATE_YET_TO_BE_CONSUMED'}, + }, + ], + }), + ); + await claim(account, purchaseToken, 'gift_1_month', 400, APIErrorCodes.STORE_PURCHASE_INVALID); + await claim(account, purchaseToken, 'gift_1_month', 400, APIErrorCodes.STORE_PURCHASE_INVALID); + expect(fake.spies.refunded.filter((entry) => entry.orderId === orderId)).toEqual([ + {packageName: PACKAGE_NAME, orderId, revoke: true}, + ]); + expect(fake.spies.consumed.some((entry) => entry.purchaseToken === purchaseToken)).toBe(false); + expect(await getUserRepository().findGiftCodesByCreator((await findUser(account.userId)).id)).toHaveLength(0); + }); + + it('reschedules a notification that still fails on its last delivery', async () => { + const purchaseToken = newToken(); + fake.setSubscription(PACKAGE_NAME, purchaseToken, buildSubscription({expiresAt: Date.now() + ms('30 days')})); + const messageId = randomUUID(); + const data = encodeGooglePlayDeveloperNotification({ + version: '1.0', + packageName: PACKAGE_NAME, + subscriptionNotification: {version: '1.0', notificationType: 4, purchaseToken}, + }); + fake.failNext('subscriptions.get', {status: 503, reason: 'backendError'}); + await expect(processGooglePlayNotification({messageId, data}, createWorkerHelpers(false))).rejects.toThrow(); + const scheduled: Array = []; + fake.failNext('subscriptions.get', {status: 503, reason: 'backendError'}); + const before = Date.now(); + await processGooglePlayNotification({messageId, data}, createWorkerHelpers(true, scheduled)); + expect(scheduled).toHaveLength(1); + expect(scheduled[0]).toMatchObject({ + taskType: 'processGooglePlayNotification', + payload: {messageId, data, retry: 1}, + }); + expect(scheduled[0].runAt!.getTime()).toBeGreaterThanOrEqual(before + ms('5 minutes')); + const claims = new StoreNotificationClaims(getKVClient()); + expect(await claims.tryClaim(googlePlayMessageClaimKey(messageId))).toBe('claimed'); + await claims.releaseClaim(googlePlayMessageClaimKey(messageId)); + await processGooglePlayNotification({messageId, data, retry: 1}, createWorkerHelpers(true)); + expect(await getStoreBillingRepository().findPurchase(`google_play:${purchaseToken}`)).not.toBeNull(); + }); +}); diff --git a/fluxer_api/src/api/store_billing/tests/StoreBillingRepository.test.ts b/fluxer_api/src/api/store_billing/tests/StoreBillingRepository.test.ts new file mode 100644 index 000000000..4f6f3aa3e --- /dev/null +++ b/fluxer_api/src/api/store_billing/tests/StoreBillingRepository.test.ts @@ -0,0 +1,211 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import {createUserID} from '@app/api/BrandedTypes'; +import type {StorePurchaseRow} from '@app/api/database/types/StoreBillingTypes'; +import {StoreBillingRepository} from '@app/api/store_billing/StoreBillingRepository'; +import {buildAppStoreStoreKey, buildGooglePlayStoreKey} from '@app/api/store_billing/StoreBillingTypes'; +import {describe, expect, it} from 'vitest'; + +const USER_A = createUserID(1001n); +const USER_B = createUserID(1002n); + +function purchaseRow(overrides: Partial = {}): StorePurchaseRow { + const createdAt = new Date('2026-09-01T00:00:00.000Z'); + return { + store_key: buildAppStoreStoreKey('production', '2000000000000001'), + id: 5000n, + provider: 'app_store', + kind: 'subscription', + slot: 'monthly', + environment: 'production', + app_id: 'com.fluxer', + product_id: 'com.fluxer.plutonium.monthly', + base_plan_id: null, + store_reference: '2000000000000001', + latest_transaction_id: '2000000000000009', + app_transaction_id: null, + user_id: null, + bound_at: null, + released_at: null, + account_token: null, + ownership_type: 'PURCHASED', + state: 'active', + store_state: '1', + entitled: true, + expires_at: new Date('2026-10-01T00:00:00.000Z'), + grace_ends_at: null, + auto_renew: true, + auto_renew_product_id: 'com.fluxer.plutonium.monthly', + started_at: createdAt, + purchased_at: createdAt, + revoked_at: null, + revocation_reason: null, + linked_store_key: null, + superseded_by_store_key: null, + acknowledged: true, + gift_code: null, + region: 'SWE', + last_event_at: createdAt, + synced_at: createdAt, + created_at: createdAt, + updated_at: createdAt, + version: 1, + ...overrides, + }; +} + +describe('StoreBillingRepository purchases', () => { + it('creates a purchase once and reads it back by store key', async () => { + const repository = new StoreBillingRepository(); + const row = purchaseRow(); + + expect(await repository.insertPurchase(row)).toBe(true); + expect(await repository.insertPurchase({...row, id: 5001n})).toBe(false); + + const stored = await repository.findPurchase(row.store_key); + expect(stored?.id).toBe(5000n); + expect(stored?.expires_at?.toISOString()).toBe('2026-10-01T00:00:00.000Z'); + expect(await repository.findPurchase(buildGooglePlayStoreKey('missing'))).toBeNull(); + }); + + it('lists a purchase created with an owner under that owner', async () => { + const repository = new StoreBillingRepository(); + await repository.insertPurchase(purchaseRow({user_id: USER_A, bound_at: new Date()})); + + const listed = await repository.listPurchasesForUser(USER_A); + expect(listed.map((row) => row.id)).toEqual([5000n]); + expect(await repository.listPurchasesForUser(USER_B)).toEqual([]); + }); + + it('applies an update against the current version and bumps it', async () => { + const repository = new StoreBillingRepository(); + await repository.insertPurchase(purchaseRow()); + const current = await repository.findPurchase(purchaseRow().store_key); + if (!current) throw new Error('missing purchase'); + + const updated = await repository.updatePurchase(current, {state: 'canceled', auto_renew: false}); + + expect(updated?.version).toBe(2); + const stored = await repository.findPurchase(current.store_key); + expect(stored).toMatchObject({state: 'canceled', auto_renew: false, version: 2}); + }); + + it('refuses an update built from a stale version', async () => { + const repository = new StoreBillingRepository(); + await repository.insertPurchase(purchaseRow()); + const stale = await repository.findPurchase(purchaseRow().store_key); + if (!stale) throw new Error('missing purchase'); + await repository.updatePurchase(stale, {state: 'grace'}); + + expect(await repository.updatePurchase(stale, {state: 'expired', entitled: false})).toBeNull(); + expect(await repository.findPurchase(stale.store_key)).toMatchObject({state: 'grace', entitled: true, version: 2}); + }); + + it('writes nothing when an update changes no column', async () => { + const repository = new StoreBillingRepository(); + await repository.insertPurchase(purchaseRow()); + const current = await repository.findPurchase(purchaseRow().store_key); + if (!current) throw new Error('missing purchase'); + + const result = await repository.updatePurchase(current, {state: 'active', entitled: true}); + + expect(result).toBe(current); + expect((await repository.findPurchase(current.store_key))?.version).toBe(1); + }); + + it('keeps the by-user index in step through bind, rebind and unbind', async () => { + const repository = new StoreBillingRepository(); + await repository.insertPurchase(purchaseRow()); + const unbound = await repository.findPurchase(purchaseRow().store_key); + if (!unbound) throw new Error('missing purchase'); + + const boundToA = await repository.bindPurchase(unbound, USER_A, new Date('2026-09-02T00:00:00.000Z')); + if (!boundToA) throw new Error('bind failed'); + expect((await repository.listPurchasesForUser(USER_A)).map((row) => row.store_key)).toEqual([unbound.store_key]); + + const boundToB = await repository.bindPurchase(boundToA, USER_B, new Date('2026-09-03T00:00:00.000Z')); + if (!boundToB) throw new Error('rebind failed'); + expect(await repository.listPurchasesForUser(USER_A)).toEqual([]); + expect((await repository.listPurchasesForUser(USER_B)).map((row) => row.user_id)).toEqual([USER_B]); + + const released = await repository.unbindPurchase(boundToB); + expect(released).toMatchObject({user_id: null, bound_at: null, version: 4}); + expect(await repository.listPurchasesForUser(USER_B)).toEqual([]); + expect(await repository.findPurchase(unbound.store_key)).toMatchObject({user_id: null, bound_at: null}); + }); + + it('does not list a purchase for a user whose bind lost the version race', async () => { + const repository = new StoreBillingRepository(); + await repository.insertPurchase(purchaseRow()); + const current = await repository.findPurchase(purchaseRow().store_key); + if (!current) throw new Error('missing purchase'); + await repository.bindPurchase(current, USER_A, new Date()); + + expect(await repository.bindPurchase(current, USER_B, new Date())).toBeNull(); + expect(await repository.listPurchasesForUser(USER_B)).toEqual([]); + expect((await repository.listPurchasesForUser(USER_A)).map((row) => row.user_id)).toEqual([USER_A]); + }); + + it('records a gift code only while none is set', async () => { + const repository = new StoreBillingRepository(); + const row = purchaseRow({ + store_key: buildGooglePlayStoreKey('gift-token'), + kind: 'gift', + slot: 'gift_1_month', + state: 'purchased', + }); + await repository.insertPurchase(row); + + expect(await repository.setGiftCode(row.store_key, 'FIRSTCODE')).toBe(true); + expect(await repository.setGiftCode(row.store_key, 'SECONDCODE')).toBe(false); + expect(await repository.findPurchase(row.store_key)).toMatchObject({gift_code: 'FIRSTCODE', version: 1}); + }); + + it('does not lose a recorded gift code to a later version-guarded update', async () => { + const repository = new StoreBillingRepository(); + const row = purchaseRow({store_key: buildGooglePlayStoreKey('gift-token-2'), kind: 'gift', slot: 'gift_1_year'}); + await repository.insertPurchase(row); + const beforeMint = await repository.findPurchase(row.store_key); + if (!beforeMint) throw new Error('missing purchase'); + await repository.setGiftCode(row.store_key, 'MINTED'); + + await repository.updatePurchase(beforeMint, {state: 'fulfilled'}); + + expect(await repository.findPurchase(row.store_key)).toMatchObject({gift_code: 'MINTED', state: 'fulfilled'}); + }); +}); + +describe('StoreBillingRepository account tokens', () => { + it('creates one lowercase UUID token per user and maps it back', async () => { + const repository = new StoreBillingRepository(); + + const token = await repository.getOrCreateAccountToken(USER_A); + + expect(token).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/); + expect(await repository.getOrCreateAccountToken(USER_A)).toBe(token); + expect(await repository.findAccountTokenForUser(USER_A)).toBe(token); + expect(await repository.findUserIdByAccountToken(token)).toBe(USER_A); + expect(await repository.findUserIdByAccountToken(token.toUpperCase())).toBe(USER_A); + expect(await repository.getOrCreateAccountToken(USER_B)).not.toBe(token); + }); + + it('settles concurrent creation on a single token', async () => { + const repository = new StoreBillingRepository(); + + const tokens = await Promise.all([ + repository.getOrCreateAccountToken(USER_A), + repository.getOrCreateAccountToken(USER_A), + repository.getOrCreateAccountToken(USER_A), + ]); + + expect(new Set(tokens).size).toBe(1); + expect(await repository.findAccountTokenForUser(USER_A)).toBe(tokens[0]); + expect(await repository.findUserIdByAccountToken(tokens[0])).toBe(USER_A); + }); + + it('returns no user for an unknown token', async () => { + expect( + await new StoreBillingRepository().findUserIdByAccountToken('00000000-0000-4000-8000-000000000000'), + ).toBeNull(); + }); +}); diff --git a/fluxer_api/src/api/store_billing/tests/StoreBillingStripeCoexistence.test.ts b/fluxer_api/src/api/store_billing/tests/StoreBillingStripeCoexistence.test.ts new file mode 100644 index 000000000..c3efacf51 --- /dev/null +++ b/fluxer_api/src/api/store_billing/tests/StoreBillingStripeCoexistence.test.ts @@ -0,0 +1,344 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import crypto, {randomUUID} from 'node:crypto'; +import {createTestAccount, type TestAccount} from '@app/api/auth/tests/AuthTestUtils'; +import {createUserID, type UserID} from '@app/api/BrandedTypes'; +import {Config} from '@app/api/Config'; +import {getGatewayService} from '@app/api/middleware/ServiceRegistry'; +import { + getPremiumStateReconciliationQueueService, + getStoreBillingRepository, + getUserRepository, +} from '@app/api/middleware/ServiceSingletons'; +import type {StoreEntitlementService} from '@app/api/store_billing/StoreEntitlementService'; +import { + createTestStoreEntitlementService, + findUser, + seedStorePurchase, +} from '@app/api/store_billing/tests/StoreBillingTestUtils'; +import {StripeGiftReversalHandler} from '@app/api/stripe/services/StripeGiftReversalHandler'; +import {setupSyncStripeWebhookWorker} from '@app/api/stripe/tests/StripeWebhookTestUtils'; +import {type ApiTestHarness, createApiTestHarness} from '@app/api/test/ApiTestHarness'; +import { + buildFakeSubscriptionPurchase, + createGooglePlayDeveloperApiHandlers, + type FakeGooglePlayDeveloperApi, +} from '@app/api/test/msw/handlers/GooglePlayDeveloperApiHandlers'; +import { + createMockWebhookPayload, + createStripeApiHandlers, + createSubscriptionDeletedEvent, + type StripeApiHandlers, + type StripeWebhookEventData, +} from '@app/api/test/msw/handlers/StripeApiHandlers'; +import {server} from '@app/api/test/msw/server'; +import {HTTP_STATUS} from '@app/api/test/TestConstants'; +import {createBuilder} from '@app/api/test/TestRequestBuilder'; +import { + deleteAccount, + setPendingDeletionAt, + triggerDeletionWorker, + waitForDeletionCompletion, +} from '@app/api/user/tests/UserTestUtils'; +import {checkHasActivePaidPremium} from '@app/api/user/UserHelpers'; +import {getWorkerDependencies, setWorkerDependenciesForTest} from '@app/api/worker/WorkerContext'; +import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes'; +import {UserPremiumTypes} from '@fluxer/constants/src/UserConstants'; +import {ms} from 'itty-time'; +import {afterAll, afterEach, beforeAll, beforeEach, describe, expect, it} from 'vitest'; + +const MOCK_PRICES = { + monthlyUsd: 'price_store_coexist_monthly_usd', + yearlyUsd: 'price_store_coexist_yearly_usd', + gift1MonthUsd: 'price_store_coexist_gift_1_month_usd', + gift1YearUsd: 'price_store_coexist_gift_1_year_usd', +}; + +const MOCK_PRICE_SEEDS = { + [MOCK_PRICES.monthlyUsd]: {unit_amount: 499, currency: 'usd', interval: 'month' as const}, + [MOCK_PRICES.yearlyUsd]: {unit_amount: 4999, currency: 'usd', interval: 'year' as const}, +}; + +describe('Store purchases alongside Stripe billing', () => { + let harness: ApiTestHarness; + let stripeHandlers: StripeApiHandlers; + let google: FakeGooglePlayDeveloperApi; + let service: StoreEntitlementService; + let originalWebhookSecret: string | undefined; + let originalPrices: typeof Config.stripe.prices; + + beforeAll(async () => { + harness = await createApiTestHarness(); + originalWebhookSecret = Config.stripe.webhookSecret; + originalPrices = Config.stripe.prices; + google = createGooglePlayDeveloperApiHandlers(); + }); + + afterAll(async () => { + await harness.shutdown(); + Config.stripe.webhookSecret = originalWebhookSecret; + Config.stripe.prices = originalPrices; + }); + + beforeEach(async () => { + await harness.resetData(); + Config.stripe.webhookSecret = 'whsec_store_coexist'; + Config.stripe.prices = MOCK_PRICES; + stripeHandlers = createStripeApiHandlers({prices: MOCK_PRICE_SEEDS, subscriptionsListEmpty: true}); + google.reset(); + server.use(...stripeHandlers.handlers, ...google.handlers); + service = createTestStoreEntitlementService(); + setupSyncStripeWebhookWorker(); + setWorkerDependenciesForTest({...getWorkerDependencies(), storeEntitlementService: service}); + }); + + afterEach(() => { + server.resetHandlers(); + }); + + async function sendStripeWebhook(eventData: StripeWebhookEventData): Promise { + const {payload, timestamp} = createMockWebhookPayload(eventData); + const signature = crypto + .createHmac('sha256', Config.stripe.webhookSecret!) + .update(`${timestamp}.${payload}`) + .digest('hex'); + await createBuilder(harness, '') + .post('/stripe/webhook') + .header('stripe-signature', `t=${timestamp},v1=${signature}`) + .header('content-type', 'application/json') + .body(payload) + .execute(); + } + + async function createStoreSubscriber(): Promise<{account: TestAccount; userId: UserID; expiresAt: Date}> { + const account = await createTestAccount(harness); + const userId = createUserID(BigInt(account.userId)); + const expiresAt = new Date(Date.now() + ms('20 days')); + await seedStorePurchase(userId, {expires_at: expiresAt}); + await service.applyStoreEntitlementToUser(userId); + return {account, userId, expiresAt}; + } + + it('keeps an App Store subscription after an old Stripe subscription is deleted', async () => { + const {account, userId, expiresAt} = await createStoreSubscriber(); + const user = await findUser(account.userId); + await getUserRepository().patchUpsert( + userId, + {stripe_subscription_id: 'sub_store_coexist_old', stripe_customer_id: 'cus_store_coexist'}, + user.toRow(), + ); + + await sendStripeWebhook( + createSubscriptionDeletedEvent({ + subscriptionId: 'sub_store_coexist_old', + customerId: 'cus_store_coexist', + endedAt: Math.floor(Date.now() / 1000) - 60, + }), + ); + + const after = await findUser(account.userId); + expect(after.stripeSubscriptionId).toBeNull(); + expect(after.premiumType).toBe(UserPremiumTypes.SUBSCRIPTION); + expect(after.premiumUntil?.getTime()).toBe(expiresAt.getTime()); + expect(after.premiumGraceEndsAt).toBeNull(); + expect(checkHasActivePaidPremium(after)).toBe(true); + }); + + it('keeps a store subscriber premium when a Stripe gift they redeemed is reversed', async () => { + const {account, userId, expiresAt} = await createStoreSubscriber(); + const users = getUserRepository(); + const gifter = await createTestAccount(harness); + const code = `storecoexist${randomUUID().replaceAll('-', '').slice(0, 20)}`; + await users.createGiftCode({ + code, + duration_months: null, + duration_type: 'days', + duration_quantity: 30, + created_at: new Date(), + created_by_user_id: createUserID(BigInt(gifter.userId)), + redeemed_at: null, + redeemed_by_user_id: null, + stripe_payment_intent_id: 'pi_store_coexist', + visionary_sequence_number: null, + checkout_session_id: null, + version: 1, + }); + await users.redeemGiftCode(code, userId); + const redeemer = await findUser(account.userId); + await users.patchUpsert( + userId, + {premium_gift_extension_ends_at: new Date(expiresAt.getTime() + ms('30 days'))}, + redeemer.toRow(), + ); + const handler = new StripeGiftReversalHandler( + users, + getGatewayService(), + getPremiumStateReconciliationQueueService(), + service, + ); + + const gift = await users.findGiftCode(code); + await handler.handleGiftPremiumReversal(gift!, {reason: 'charge_dispute'}); + + const after = await findUser(account.userId); + expect(after.premiumType).toBe(UserPremiumTypes.SUBSCRIPTION); + expect(after.premiumUntil?.getTime()).toBe(expiresAt.getTime()); + expect(after.premiumGiftExtensionEndsAt).toBeNull(); + expect(after.premiumWillCancel).toBe(false); + expect(checkHasActivePaidPremium(after)).toBe(true); + }); + + it('takes a reversed Stripe gift off a store subscriber once across repeated reversals', async () => { + const {account, userId, expiresAt} = await createStoreSubscriber(); + const users = getUserRepository(); + const gifter = await createTestAccount(harness); + const code = `storecoexist${randomUUID().replaceAll('-', '').slice(0, 20)}`; + await users.createGiftCode({ + code, + duration_months: null, + duration_type: 'days', + duration_quantity: 30, + created_at: new Date(), + created_by_user_id: createUserID(BigInt(gifter.userId)), + redeemed_at: null, + redeemed_by_user_id: null, + stripe_payment_intent_id: 'pi_store_coexist_repeat', + visionary_sequence_number: null, + checkout_session_id: null, + version: 1, + }); + await users.redeemGiftCode(code, userId); + const stackedEnd = new Date(expiresAt.getTime() + ms('60 days')); + const redeemer = await findUser(account.userId); + await users.patchUpsert( + userId, + {stripe_customer_id: 'cus_store_coexist_repeat', premium_gift_extension_ends_at: stackedEnd}, + redeemer.toRow(), + ); + const handler = new StripeGiftReversalHandler( + users, + getGatewayService(), + getPremiumStateReconciliationQueueService(), + service, + ); + + const gift = await users.findGiftCode(code); + await handler.handleGiftPremiumReversal(gift!, {reason: 'gift_refund'}); + await handler.handleGiftPremiumReversal(gift!, {reason: 'gift_chargeback'}); + + const after = await findUser(account.userId); + expect(after.premiumGiftExtensionEndsAt?.getTime()).toBe(stackedEnd.getTime() - ms('30 days')); + expect(after.premiumUntil?.getTime()).toBe(expiresAt.getTime()); + }); + + it('blocks a recurring Stripe checkout while a store subscription is active', async () => { + const {account} = await createStoreSubscriber(); + await createBuilder(harness, account.token) + .post(`/test/users/${account.userId}/security-flags`) + .body({email_verified: true}) + .execute(); + + const {json} = await createBuilder<{code: string; reason: string; provider: string}>(harness, account.token) + .post('/stripe/checkout/subscription') + .body({price_id: MOCK_PRICES.monthlyUsd}) + .expect(HTTP_STATUS.FORBIDDEN, APIErrorCodes.PREMIUM_PURCHASE_BLOCKED) + .executeWithResponse(); + + expect(json).toMatchObject({reason: 'existing_subscription', provider: 'app_store'}); + expect(stripeHandlers.spies.createdCheckoutSessions).toHaveLength(0); + const response = await createBuilder<{url: string}>(harness, account.token) + .post('/stripe/checkout/gift') + .body({price_id: MOCK_PRICES.gift1MonthUsd}) + .execute(); + expect(response.url).toContain('checkout.stripe.com'); + }); + + it('asks Google to stop billing on self deletion and still deletes when Google fails', async () => { + const account = await createTestAccount(harness); + const userId = createUserID(BigInt(account.userId)); + const purchaseToken = `token-${randomUUID()}`; + google.setSubscription( + 'com.fluxer', + purchaseToken, + buildFakeSubscriptionPurchase({subscriptionState: 'SUBSCRIPTION_STATE_ACTIVE'}), + ); + await seedStorePurchase(userId, { + store_key: `google_play:${purchaseToken}`, + provider: 'google_play', + app_id: 'com.fluxer', + product_id: 'plutonium', + base_plan_id: 'monthly', + store_reference: purchaseToken, + store_state: 'SUBSCRIPTION_STATE_ACTIVE', + }); + google.failNext('subscriptions.cancel', {status: 503, reason: 'backendError'}); + + await deleteAccount(harness, account.token, account.password); + await setPendingDeletionAt(harness, account.userId, new Date(Date.now() - 60_000)); + const result = await triggerDeletionWorker(harness); + await waitForDeletionCompletion(harness, account.userId); + + expect(result.scheduled).toBe(1); + expect(google.spies.apiRequests.filter((request) => request.operation === 'subscriptions.cancel')).toHaveLength(1); + expect(google.spies.canceled).toEqual([]); + }); + + it('does not credit gift time again when Stripe lowers premium_until and the store raises it', async () => { + const account = await createTestAccount(harness); + const userId = createUserID(BigInt(account.userId)); + const stripeEnd = new Date(Date.now() + ms('10 days')); + const giftEnd = new Date(Date.now() + ms('40 days')); + const storeExpiry = new Date(Date.now() + ms('20 days')); + const users = getUserRepository(); + await users.patchUpsert( + userId, + { + premium_type: UserPremiumTypes.SUBSCRIPTION, + premium_until: stripeEnd, + premium_billing_cycle: 'monthly', + premium_gift_extension_ends_at: giftEnd, + stripe_subscription_id: 'sub_store_coexist_live', + stripe_customer_id: 'cus_store_coexist_live', + }, + (await findUser(account.userId)).toRow(), + ); + await seedStorePurchase(userId, {expires_at: storeExpiry}); + for (let cycle = 0; cycle < 3; cycle++) { + await service.applyStoreEntitlementToUser(userId); + const raised = await findUser(account.userId); + expect(raised.premiumUntil?.getTime()).toBe(storeExpiry.getTime()); + expect(raised.premiumGiftExtensionEndsAt?.getTime()).toBe(giftEnd.getTime()); + await users.patchUpsert(userId, {premium_until: stripeEnd}, raised.toRow()); + } + }); + + it('lets the store own the premium fields when the Stripe subscription id is stale', async () => { + const account = await createTestAccount(harness); + const userId = createUserID(BigInt(account.userId)); + await getUserRepository().patchUpsert( + userId, + { + premium_type: UserPremiumTypes.SUBSCRIPTION, + premium_until: new Date(Date.now() - ms('5 days')), + premium_billing_cycle: null, + stripe_subscription_id: 'sub_store_coexist_stale', + stripe_customer_id: 'cus_store_coexist_stale', + }, + (await findUser(account.userId)).toRow(), + ); + const expiresAt = new Date(Date.now() + ms('20 days')); + const row = await seedStorePurchase(userId, {expires_at: expiresAt}); + await service.applyStoreEntitlementToUser(userId); + expect(await findUser(account.userId)).toMatchObject({premiumUntil: expiresAt, premiumWillCancel: false}); + const repository = getStoreBillingRepository(); + const canceled = await repository.updatePurchase(row, {state: 'canceled', auto_renew: false}); + await service.applyStoreEntitlementToUser(userId); + expect((await findUser(account.userId)).premiumWillCancel).toBe(true); + const revokedAt = new Date(Date.now() - ms('1 minute')); + await repository.updatePurchase(canceled!, {state: 'revoked', entitled: false, revoked_at: revokedAt}); + await service.applyStoreEntitlementToUser(userId); + const cut = await findUser(account.userId); + expect(cut.premiumUntil?.getTime()).toBe(revokedAt.getTime()); + expect(cut.premiumGraceEndsAt?.getTime()).toBe(revokedAt.getTime()); + }); +}); diff --git a/fluxer_api/src/api/store_billing/tests/StoreBillingTestUtils.ts b/fluxer_api/src/api/store_billing/tests/StoreBillingTestUtils.ts new file mode 100644 index 000000000..1301d5eae --- /dev/null +++ b/fluxer_api/src/api/store_billing/tests/StoreBillingTestUtils.ts @@ -0,0 +1,149 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import {createUserID, type UserID} from '@app/api/BrandedTypes'; +import type {StorePurchaseRow} from '@app/api/database/types/StoreBillingTypes'; +import { + getGatewayService, + getKVClient, + getSnowflakeService, + setInjectedWorkerService, +} from '@app/api/middleware/ServiceRegistry'; +import { + getPremiumStateReconciliationQueueService, + getStoreBillingRepository, + getUserCacheService, + getUserRepository, +} from '@app/api/middleware/ServiceSingletons'; +import type {User} from '@app/api/models/User'; +import {buildAppStoreStoreKey} from '@app/api/store_billing/StoreBillingTypes'; +import type {StoreEntitlementService} from '@app/api/store_billing/StoreEntitlementService'; +import {createStoreEntitlementService} from '@app/api/store_billing/StoreEntitlementServiceFactory'; +import type {ApiTestHarness} from '@app/api/test/ApiTestHarness'; +import {NoopWorkerService} from '@app/api/test/NoopWorkerService'; +import {SyncTaskWorkerService} from '@app/api/test/SyncTaskWorkerService'; +import {createBuilder} from '@app/api/test/TestRequestBuilder'; +import processAppStoreNotification from '@app/api/worker/tasks/ProcessAppStoreNotification'; +import processGooglePlayNotification from '@app/api/worker/tasks/ProcessGooglePlayNotification'; +import refreshStorePurchase from '@app/api/worker/tasks/RefreshStorePurchase'; +import {clearWorkerDependencies, setWorkerDependenciesForTest} from '@app/api/worker/WorkerContext'; +import type {StoreBillingContextResponse} from '@fluxer/schema/src/domains/premium/StoreBillingSchemas'; +import {ms} from 'itty-time'; + +export function createTestStoreEntitlementService(): StoreEntitlementService { + return createStoreEntitlementService({ + userRepository: getUserRepository(), + userCacheService: getUserCacheService(), + gatewayService: getGatewayService(), + kvClient: getKVClient(), + snowflakeService: getSnowflakeService(), + premiumStateReconciliationQueueService: getPremiumStateReconciliationQueueService(), + }); +} + +export function installStoreBillingWorker(): StoreEntitlementService { + const storeEntitlementService = createTestStoreEntitlementService(); + const workerService = new SyncTaskWorkerService({ + processAppStoreNotification, + processGooglePlayNotification, + refreshStorePurchase, + }); + setWorkerDependenciesForTest({kvClient: getKVClient(), storeEntitlementService, workerService}); + setInjectedWorkerService(workerService); + return storeEntitlementService; +} + +export function uninstallStoreBillingWorker(): void { + clearWorkerDependencies(); + setInjectedWorkerService(new NoopWorkerService()); +} + +let seededPurchaseCounter = 0; + +export function buildStorePurchaseRow(overrides: Partial = {}): StorePurchaseRow { + seededPurchaseCounter += 1; + const now = new Date(); + const reference = `3000000${Date.now().toString().slice(-6)}${seededPurchaseCounter.toString().padStart(4, '0')}`; + return { + store_key: buildAppStoreStoreKey('production', reference), + id: BigInt(`1${reference}`), + provider: 'app_store', + kind: 'subscription', + slot: 'monthly', + environment: 'production', + app_id: 'com.fluxer', + product_id: 'com.fluxer.plutonium.monthly', + base_plan_id: null, + store_reference: reference, + latest_transaction_id: reference, + app_transaction_id: null, + user_id: null, + bound_at: null, + released_at: null, + account_token: null, + ownership_type: 'PURCHASED', + state: 'active', + store_state: '1', + entitled: true, + expires_at: new Date(now.getTime() + ms('20 days')), + grace_ends_at: null, + auto_renew: true, + auto_renew_product_id: 'com.fluxer.plutonium.monthly', + started_at: new Date(now.getTime() - ms('10 days')), + purchased_at: new Date(now.getTime() - ms('10 days')), + revoked_at: null, + revocation_reason: null, + linked_store_key: null, + superseded_by_store_key: null, + acknowledged: true, + gift_code: null, + region: null, + last_event_at: now, + synced_at: now, + created_at: now, + updated_at: now, + version: 1, + ...overrides, + }; +} + +export async function seedStorePurchase( + userId: UserID, + overrides: Partial = {}, +): Promise { + const row = buildStorePurchaseRow({user_id: userId, bound_at: new Date(), ...overrides}); + if (!(await getStoreBillingRepository().insertPurchase(row))) { + throw new Error(`Store purchase ${row.store_key} already exists`); + } + return row; +} + +export async function findUser(userId: string): Promise { + const user = await getUserRepository().findUnique(createUserID(BigInt(userId))); + if (!user) { + throw new Error(`Test user ${userId} not found`); + } + return user; +} + +export async function getStoreContext(harness: ApiTestHarness, token: string): Promise { + return createBuilder(harness, token).get('/premium/store').execute(); +} + +export async function setTestPremium( + harness: ApiTestHarness, + token: string, + userId: string, + body: Record, +): Promise { + await createBuilder(harness, token).post(`/test/users/${userId}/premium`).body(body).execute(); +} + +export function expectCloseTo(actual: Date | null | undefined, expected: number, toleranceMs = 5000): void { + if (!actual) { + throw new Error('Expected a date, got null'); + } + const delta = Math.abs(actual.getTime() - expected); + if (delta > toleranceMs) { + throw new Error(`Expected ${new Date(expected).toISOString()}, got ${actual.toISOString()}`); + } +} diff --git a/fluxer_api/src/api/store_billing/tests/StoreBillingTypes.test.ts b/fluxer_api/src/api/store_billing/tests/StoreBillingTypes.test.ts new file mode 100644 index 000000000..dcfc9cea4 --- /dev/null +++ b/fluxer_api/src/api/store_billing/tests/StoreBillingTypes.test.ts @@ -0,0 +1,20 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import {buildAppStoreStoreKey, buildGooglePlayStoreKey, redactStoreKey} from '@app/api/store_billing/StoreBillingTypes'; +import {describe, expect, it} from 'vitest'; + +describe('redactStoreKey', () => { + it('replaces a Google Play purchase token with a stable digest', () => { + const token = 'opaque-token.AO-J1OxExampleExampleExample'; + const redacted = redactStoreKey(buildGooglePlayStoreKey(token)); + expect(redacted).toMatch(/^google_play:[0-9a-f]{16}$/u); + expect(redacted).not.toContain(token); + expect(redactStoreKey(buildGooglePlayStoreKey(token))).toBe(redacted); + expect(redactStoreKey(buildGooglePlayStoreKey(`${token}x`))).not.toBe(redacted); + }); + + it('keeps App Store keys as they are', () => { + const key = buildAppStoreStoreKey('production', '2000000123456789'); + expect(redactStoreKey(key)).toBe(key); + }); +}); diff --git a/fluxer_api/src/api/store_billing/tests/StoreBillingUnconfigured.test.ts b/fluxer_api/src/api/store_billing/tests/StoreBillingUnconfigured.test.ts new file mode 100644 index 000000000..44b6c9a80 --- /dev/null +++ b/fluxer_api/src/api/store_billing/tests/StoreBillingUnconfigured.test.ts @@ -0,0 +1,210 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import {createTestAccount} from '@app/api/auth/tests/AuthTestUtils'; +import {buildAPIConfigFromMaster, getConfig} from '@app/api/Config'; +import type {APIConfig} from '@app/api/config/APIConfig'; +import {getKVClient} from '@app/api/middleware/ServiceRegistry'; +import {isAppStoreConfigured, isGooglePlayConfigured} from '@app/api/store_billing/StoreBillingConfig'; +import {STORE_PURCHASE_REFRESH_QUEUE_KEY} from '@app/api/store_billing/StorePurchaseRefresh'; +import { + getStoreContext, + installStoreBillingWorker, + setTestPremium, + uninstallStoreBillingWorker, +} from '@app/api/store_billing/tests/StoreBillingTestUtils'; +import {type ApiTestHarness, createApiTestHarness} from '@app/api/test/ApiTestHarness'; +import {NoopLogger} from '@app/api/test/mocks/NoopLogger'; +import { + createGooglePlayDeveloperApiHandlers, + type FakeGooglePlayDeveloperApi, +} from '@app/api/test/msw/handlers/GooglePlayDeveloperApiHandlers'; +import {createStripeApiHandlers} from '@app/api/test/msw/handlers/StripeApiHandlers'; +import {server} from '@app/api/test/msw/server'; +import {HTTP_STATUS} from '@app/api/test/TestConstants'; +import {createBuilder, createBuilderWithoutAuth} from '@app/api/test/TestRequestBuilder'; +import pollAppStoreNotificationHistory from '@app/api/worker/tasks/PollAppStoreNotificationHistory'; +import pollGooglePlayVoidedPurchases from '@app/api/worker/tasks/PollGooglePlayVoidedPurchases'; +import processStorePurchaseRefreshQueue from '@app/api/worker/tasks/ProcessStorePurchaseRefreshQueue'; +import {loadConfig} from '@fluxer/config/src/ConfigLoader'; +import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes'; +import {UserPremiumTypes} from '@fluxer/constants/src/UserConstants'; +import type {PremiumStateResponse} from '@fluxer/schema/src/domains/premium/PremiumSchemas'; +import type {WorkerTaskHelpers} from '@pkgs/worker/src/contracts/WorkerTask'; +import {ms} from 'itty-time'; +import {afterAll, afterEach, beforeAll, beforeEach, describe, expect, it} from 'vitest'; + +const STORE_ENV_PREFIXES = ['FLUXER_APP_STORE_', 'FLUXER_GOOGLE_PLAY_', 'FLUXER_STORE_BILLING_']; + +type StoreConfigState = Pick; + +function captureStoreConfig(): StoreConfigState { + const config = getConfig(); + return {appStore: config.appStore, googlePlay: config.googlePlay, storeBilling: config.storeBilling}; +} + +function applyStoreConfig(state: StoreConfigState): void { + const config = getConfig(); + config.appStore = state.appStore; + config.googlePlay = state.googlePlay; + config.storeBilling = state.storeBilling; +} + +function createWorkerHelpers(scheduled: Array): WorkerTaskHelpers { + return { + logger: new NoopLogger(), + jobId: 1n, + attempt: {isLastAttempt: false}, + addJob: async (taskType) => { + scheduled.push(taskType); + return 0n; + }, + reportProgress: async () => {}, + shouldCancel: async () => false, + setContextLink: async () => {}, + }; +} + +describe('Store billing with no store configuration', () => { + let harness: ApiTestHarness; + let google: FakeGooglePlayDeveloperApi; + let original: StoreConfigState; + let unset: StoreConfigState; + + beforeAll(async () => { + harness = await createApiTestHarness(); + google = createGooglePlayDeveloperApiHandlers(); + original = captureStoreConfig(); + const master = await loadConfig(); + unset = buildAPIConfigFromMaster(master); + }); + + afterAll(async () => { + applyStoreConfig(original); + await harness.shutdown(); + }); + + beforeEach(async () => { + await harness.resetData(); + google.reset(); + server.use(...createStripeApiHandlers().handlers, ...google.handlers); + installStoreBillingWorker(); + applyStoreConfig(unset); + }); + + afterEach(() => { + applyStoreConfig(original); + uninstallStoreBillingWorker(); + server.resetHandlers(); + }); + + it('loads the empty store defaults when no store variable is set', async () => { + expect( + Object.keys(process.env).filter((key) => STORE_ENV_PREFIXES.some((prefix) => key.startsWith(prefix))), + ).toEqual([]); + const master = await loadConfig(); + expect(master.integrations.app_store).toEqual({enabled: false, apps: [], products: {}}); + expect(master.integrations.google_play).toEqual({ + enabled: false, + packages: [], + token_uri: 'https://oauth2.googleapis.com/token', + products: {}, + }); + expect(master.integrations.store_billing).toEqual({sandbox_user_ids: [], sandbox_entitles_all: false}); + expect(isAppStoreConfigured()).toBe(false); + expect(isGooglePlayConfigured()).toBe(false); + }); + + it('reports both stores disabled with no products', async () => { + const account = await createTestAccount(harness); + const context = await getStoreContext(harness, account.token); + expect(context.app_store).toEqual({enabled: false, bundle_ids: [], products: []}); + expect(context.google_play).toEqual({enabled: false, package_names: [], products: []}); + expect(context.purchase_blocked_reason).toBeNull(); + expect(context.blocking_provider).toBeNull(); + expect(context.app_account_token).toEqual(expect.any(String)); + }); + + it('answers both claim routes with STORE_BILLING_UNAVAILABLE', async () => { + const account = await createTestAccount(harness); + await createBuilder(harness, account.token) + .post('/premium/store/app-store/transactions') + .body({signed_transaction: 'not-a-transaction'}) + .expect(HTTP_STATUS.SERVICE_UNAVAILABLE, APIErrorCodes.STORE_BILLING_UNAVAILABLE) + .execute(); + await createBuilder(harness, account.token) + .post('/premium/store/google-play/purchases') + .body({purchase_token: 'not-a-token', product_id: 'plutonium', package_name: 'com.fluxer'}) + .expect(HTTP_STATUS.SERVICE_UNAVAILABLE, APIErrorCodes.STORE_BILLING_UNAVAILABLE) + .execute(); + }); + + it('answers both store webhooks with 503', async () => { + await createBuilderWithoutAuth(harness) + .post('/webhooks/app-store') + .body({signedPayload: 'not-a-payload'}) + .expect(HTTP_STATUS.SERVICE_UNAVAILABLE, APIErrorCodes.STORE_BILLING_UNAVAILABLE) + .execute(); + await createBuilderWithoutAuth(harness) + .post('/webhooks/google-play') + .header('authorization', 'Bearer not-a-token') + .body({message: {messageId: '1', data: ''}}) + .expect(HTTP_STATUS.SERVICE_UNAVAILABLE, APIErrorCodes.STORE_BILLING_UNAVAILABLE) + .execute(); + expect(google.spies.jwksFetches).toBe(0); + }); + + it('serves premium state with no store block', async () => { + const account = await createTestAccount(harness); + const state = await createBuilder(harness, account.token) + .get('/premium/state') + .expect(HTTP_STATUS.OK) + .execute(); + expect(state.store).toBeNull(); + expect(state.subscription_provider).toBeNull(); + }); + + it('leaves a Stripe subscriber unaffected', async () => { + const account = await createTestAccount(harness); + const premiumUntil = new Date(Date.now() + ms('30 days')); + await setTestPremium(harness, account.token, account.userId, { + premium_type: UserPremiumTypes.SUBSCRIPTION, + premium_until: premiumUntil.toISOString(), + premium_billing_cycle: 'monthly', + stripe_customer_id: 'cus_unconfigured_store', + stripe_subscription_id: 'sub_unconfigured_store', + }); + const state = await createBuilder(harness, account.token) + .get('/premium/state') + .expect(HTTP_STATUS.OK) + .execute(); + expect(state.store).toBeNull(); + expect(state.subscription_provider).toBe('stripe'); + expect(state.actual).toMatchObject({ + premium_type: UserPremiumTypes.SUBSCRIPTION, + premium_until: premiumUntil.toISOString(), + premium_billing_cycle: 'monthly', + has_active_paid_premium: true, + }); + expect(state.effective.is_premium).toBe(true); + const context = await getStoreContext(harness, account.token); + expect(context).toMatchObject({purchase_blocked_reason: 'existing_subscription', blocking_provider: 'stripe'}); + }); + + it('does nothing in the store cron tasks', async () => { + const storeKey = 'gp:unconfigured-store-refresh'; + const dueAt = Date.now() - 1000; + await getKVClient().zadd(STORE_PURCHASE_REFRESH_QUEUE_KEY, dueAt, storeKey); + google.addVoidedPurchase('com.fluxer', { + purchaseToken: 'unconfigured-voided-token', + orderId: 'GPA.unconfigured', + voidedTimeMillis: String(Date.now()), + }); + const scheduled: Array = []; + await processStorePurchaseRefreshQueue({}, createWorkerHelpers(scheduled)); + await pollGooglePlayVoidedPurchases({}, createWorkerHelpers(scheduled)); + await pollAppStoreNotificationHistory({}, createWorkerHelpers(scheduled)); + expect(scheduled).toEqual([]); + expect(await getKVClient().zrangebyscore(STORE_PURCHASE_REFRESH_QUEUE_KEY, dueAt, dueAt)).toEqual([storeKey]); + expect(google.spies.voidedQueries).toEqual([]); + }); +}); diff --git a/fluxer_api/src/api/store_billing/tests/StoreEntitlementUserWrite.test.ts b/fluxer_api/src/api/store_billing/tests/StoreEntitlementUserWrite.test.ts new file mode 100644 index 000000000..3e4238615 --- /dev/null +++ b/fluxer_api/src/api/store_billing/tests/StoreEntitlementUserWrite.test.ts @@ -0,0 +1,96 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import {createTestAccount} from '@app/api/auth/tests/AuthTestUtils'; +import {createUserID} from '@app/api/BrandedTypes'; +import {getStoreBillingRepository, getUserRepository} from '@app/api/middleware/ServiceSingletons'; +import type {StoreEntitlementService} from '@app/api/store_billing/StoreEntitlementService'; +import { + createTestStoreEntitlementService, + expectCloseTo, + findUser, + seedStorePurchase, +} from '@app/api/store_billing/tests/StoreBillingTestUtils'; +import {type ApiTestHarness, createApiTestHarness} from '@app/api/test/ApiTestHarness'; +import {UserPremiumTypes} from '@fluxer/constants/src/UserConstants'; +import {ms} from 'itty-time'; +import {afterAll, beforeAll, beforeEach, describe, expect, it} from 'vitest'; + +describe('Store entitlement user write', () => { + let harness: ApiTestHarness; + let service: StoreEntitlementService; + + beforeAll(async () => { + harness = await createApiTestHarness(); + }); + + afterAll(async () => { + await harness.shutdown(); + }); + + beforeEach(async () => { + await harness.resetData(); + service = createTestStoreEntitlementService(); + }); + + it('shifts stacked gift time from the old paid-through date when a subscription recovers in grace', async () => { + const account = await createTestAccount(harness); + const userId = createUserID(BigInt(account.userId)); + const lapsedAt = new Date(Date.now() - ms('2 days')); + const graceEnd = new Date(Date.now() + ms('14 days')); + const giftEnd = new Date(lapsedAt.getTime() + ms('30 days')); + const row = await seedStorePurchase(userId, { + state: 'grace', + expires_at: lapsedAt, + grace_ends_at: graceEnd, + }); + await getUserRepository().patchUpsert( + userId, + { + premium_type: UserPremiumTypes.SUBSCRIPTION, + premium_until: lapsedAt, + premium_grace_ends_at: graceEnd, + premium_gift_extension_ends_at: giftEnd, + }, + (await findUser(account.userId)).toRow(), + ); + await service.applyStoreEntitlementToUser(userId); + expect((await findUser(account.userId)).premiumGiftExtensionEndsAt?.getTime()).toBe(giftEnd.getTime()); + const renewedExpiry = new Date(lapsedAt.getTime() + ms('30 days')); + await getStoreBillingRepository().updatePurchase(row, { + state: 'active', + expires_at: renewedExpiry, + grace_ends_at: null, + }); + await service.applyStoreEntitlementToUser(userId); + const recovered = await findUser(account.userId); + expect(recovered.premiumUntil?.getTime()).toBe(renewedExpiry.getTime()); + expect(recovered.premiumGraceEndsAt).toBeNull(); + expect(recovered.premiumGiftExtensionEndsAt?.getTime()).toBe( + giftEnd.getTime() + (renewedExpiry.getTime() - lapsedAt.getTime()), + ); + }); + + it('shifts stacked gift time from now when an ended Stripe subscription left its grace behind', async () => { + const account = await createTestAccount(harness); + const userId = createUserID(BigInt(account.userId)); + const stripeEnd = new Date(Date.now() - ms('150 days')); + const giftEnd = new Date(stripeEnd.getTime() + ms('365 days')); + await getUserRepository().patchUpsert( + userId, + { + premium_type: UserPremiumTypes.SUBSCRIPTION, + premium_until: stripeEnd, + premium_grace_ends_at: new Date(stripeEnd.getTime() + ms('3 days')), + premium_gift_extension_ends_at: giftEnd, + }, + (await findUser(account.userId)).toRow(), + ); + const expiresAt = new Date(Date.now() + ms('30 days')); + await seedStorePurchase(userId, {expires_at: expiresAt}); + const grantedAt = Date.now(); + await service.applyStoreEntitlementToUser(userId); + const user = await findUser(account.userId); + expect(user.premiumUntil?.getTime()).toBe(expiresAt.getTime()); + expectCloseTo(user.premiumGiftExtensionEndsAt, giftEnd.getTime() + (expiresAt.getTime() - grantedAt)); + }); +}); diff --git a/fluxer_api/src/api/store_billing/tests/StoreNotificationClaims.test.ts b/fluxer_api/src/api/store_billing/tests/StoreNotificationClaims.test.ts new file mode 100644 index 000000000..3355937fb --- /dev/null +++ b/fluxer_api/src/api/store_billing/tests/StoreNotificationClaims.test.ts @@ -0,0 +1,30 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import {appStoreNotificationClaimKey, StoreNotificationClaims} from '@app/api/store_billing/StoreNotificationClaims'; +import {WORKER_LANES} from '@app/api/worker/WorkerLaneConfig'; +import type {IKVProvider} from '@pkgs/kv_client/src/IKVProvider'; +import {ms} from 'itty-time'; +import {describe, expect, it} from 'vitest'; + +const LIFECYCLE_REDELIVERY_DELAY_MS = ms('5 seconds'); + +describe('StoreNotificationClaims', () => { + it('lets an abandoned claim expire before the lifecycle lane stops redelivering', async () => { + const ttls: Array = []; + const kv = { + async setnx(_key: string, _value: string, ttlSeconds?: number): Promise { + ttls.push(ttlSeconds ?? 0); + return true; + }, + } as unknown as IKVProvider; + const claims = new StoreNotificationClaims(kv); + expect(await claims.tryClaim(appStoreNotificationClaimKey('7b4d9c1e-0000-4000-8000-000000000001'))).toBe('claimed'); + const lane = WORKER_LANES.find((entry) => entry.name === 'lifecycle'); + expect(lane?.taskTypes).toContain('processAppStoreNotification'); + expect(lane?.taskTypes).toContain('processGooglePlayNotification'); + const retryWindowMs = lane!.ackWaitMs + (lane!.maxDeliver - 1) * LIFECYCLE_REDELIVERY_DELAY_MS; + expect(ttls).toHaveLength(1); + expect(ttls[0] * 1000).toBeGreaterThanOrEqual(lane!.ackWaitMs); + expect(ttls[0] * 1000).toBeLessThanOrEqual(retryWindowMs / 2); + }); +}); diff --git a/fluxer_api/src/api/stripe/StripeService.ts b/fluxer_api/src/api/stripe/StripeService.ts index 29586f4bd..4a82bcd7c 100644 --- a/fluxer_api/src/api/stripe/StripeService.ts +++ b/fluxer_api/src/api/stripe/StripeService.ts @@ -9,6 +9,8 @@ import type {GuildService} from '@app/api/guild/services/GuildService'; import type {IGatewayService} from '@app/api/infrastructure/IGatewayService'; import type {GiftCode} from '@app/api/models/GiftCode'; import type {User} from '@app/api/models/User'; +import type {StoreBillingRepository} from '@app/api/store_billing/StoreBillingRepository'; +import type {StoreEntitlementService} from '@app/api/store_billing/StoreEntitlementService'; import {getProductRegistry, type ProductRegistry} from '@app/api/stripe/ProductRegistry'; import {getStripeClient} from '@app/api/stripe/StripeClient'; import {PremiumStateService} from '@app/api/stripe/services/PremiumStateService'; @@ -51,6 +53,8 @@ export class StripeService { private guildService: GuildService, private cacheService: ICacheService, private billingRepository: BillingRepository, + private storeBillingRepository: StoreBillingRepository | null = null, + private storeEntitlementService: StoreEntitlementService | null = null, ) { this.productRegistry = getProductRegistry(); this.stripe = getStripeClient(); @@ -66,12 +70,14 @@ export class StripeService { this.billingRepository, this.stripe, this.cacheService, + this.storeBillingRepository, ); this.checkoutService = new StripeCheckoutService( this.stripe, this.userRepository, this.productRegistry, this.cacheService, + this.storeEntitlementService, ); this.subscriptionService = new StripeSubscriptionService( this.stripe, @@ -79,6 +85,7 @@ export class StripeService { this.productRegistry, this.cacheService, this.gatewayService, + this.storeEntitlementService, ); this.giftService = new StripeGiftService( this.stripe, @@ -88,6 +95,7 @@ export class StripeService { this.checkoutService, this.premiumService, this.subscriptionService, + this.storeEntitlementService, ); this.refundService = new StripeRefundService(this.stripe, this.userRepository, this.subscriptionService); } diff --git a/fluxer_api/src/api/stripe/services/PremiumStateService.ts b/fluxer_api/src/api/stripe/services/PremiumStateService.ts index 9d76fee91..31d504db7 100644 --- a/fluxer_api/src/api/stripe/services/PremiumStateService.ts +++ b/fluxer_api/src/api/stripe/services/PremiumStateService.ts @@ -9,9 +9,17 @@ import type { BillingRefundRow, BillingSubscriptionRow, } from '@app/api/database/types/BillingTypes'; +import type {StorePurchaseRow} from '@app/api/database/types/StoreBillingTypes'; import type {IGatewayService} from '@app/api/infrastructure/IGatewayService'; import {Logger} from '@app/api/Logger'; import type {User} from '@app/api/models/User'; +import { + mapPremiumStoreSubscriptionState, + resolveStoreAccessEnd, + selectActiveStoreSubscription, +} from '@app/api/store_billing/StoreBillingMappers'; +import type {StoreBillingRepository} from '@app/api/store_billing/StoreBillingRepository'; +import {isStripeSubscriptionActive} from '@app/api/store_billing/StoreEntitlementWriter'; import {isBillingActive} from '@app/api/stripe/BillingConfigCache'; import {getProductRegistry, type RecurringBillingCycle} from '@app/api/stripe/ProductRegistry'; import {getStripeClient} from '@app/api/stripe/StripeClient'; @@ -42,6 +50,7 @@ import type { PremiumBillingSubscriptionResponse, PremiumPricingState, PremiumStateResponse, + PremiumSubscriptionProvider, PriceIdsResponse, SelfServeRefundEligibilityResponse, SelfServeRefundIneligibilityReason, @@ -176,6 +185,23 @@ function refundEligibility({ }; } +function resolveSubscriptionProvider( + user: User, + stripeSubscription: BillingSubscriptionRow | null, + storeRow: StorePurchaseRow | null, + now: Date, +): PremiumSubscriptionProvider | null { + if (user.premiumType === UserPremiumTypes.LIFETIME) return null; + const stripePeriodEnd = + stripeSubscription?.provider_id === user.stripeSubscriptionId ? stripeSubscription?.current_period_end : null; + const stripeEnd = isStripeSubscriptionActive(user, now) ? (stripePeriodEnd ?? user.premiumUntil) : null; + const storeEnd = storeRow ? resolveStoreAccessEnd(storeRow) : null; + if (storeRow && storeEnd && (!stripeEnd || storeEnd.getTime() >= stripeEnd.getTime())) { + return storeRow.provider; + } + return stripeEnd ? 'stripe' : null; +} + function mapInvoice(row: BillingInvoiceRow): PremiumBillingInvoiceResponse { return { id: row.provider_id, @@ -214,6 +240,7 @@ export class PremiumStateService { private readonly billingRepository: BillingRepository, private readonly stripe: Stripe | null = null, private readonly cacheService: ICacheService | null = null, + private readonly storeBillingRepository: StoreBillingRepository | null = null, ) {} async getState(userId: UserID, countryCode?: string): Promise { @@ -259,6 +286,8 @@ export class PremiumStateService { this.resolvePendingSubscriptionChange(user), this.resolvePricing(countryCode), ]); + const storeRow = await this.resolveActiveStoreSubscription(user); + const store = storeRow ? mapPremiumStoreSubscriptionState(storeRow) : null; const refundEligibilityState = await this.resolveRefundEligibility(user, invoices.allRows); const listPriceSwitch = this.resolveListPriceSwitch(subscription, subscriptionPrice, pendingSubscriptionChange); const pendingBillingCycleChange = @@ -306,9 +335,19 @@ export class PremiumStateService { refund_eligibility: refundEligibilityState, }, pricing, + store, + subscription_provider: resolveSubscriptionProvider(user, subscription, store ? storeRow : null, new Date()), }; } + private async resolveActiveStoreSubscription(user: User): Promise { + if (!this.storeBillingRepository || Config.instance.selfHosted) { + return null; + } + const rows = await this.storeBillingRepository.listPurchasesForUser(user.id); + return selectActiveStoreSubscription(rows, new Date()); + } + private async resolveCustomerIds(user: User): Promise> { const ids = new Set(); if (user.stripeCustomerId) { diff --git a/fluxer_api/src/api/stripe/services/StripeCheckoutService.ts b/fluxer_api/src/api/stripe/services/StripeCheckoutService.ts index dde5de901..b2dca2af6 100644 --- a/fluxer_api/src/api/stripe/services/StripeCheckoutService.ts +++ b/fluxer_api/src/api/stripe/services/StripeCheckoutService.ts @@ -8,6 +8,7 @@ import type {UserRow} from '@app/api/database/types/UserTypes'; import {Logger} from '@app/api/Logger'; import {getBillingRepository} from '@app/api/middleware/ServiceRegistry'; import type {User} from '@app/api/models/User'; +import type {StoreEntitlementService} from '@app/api/store_billing/StoreEntitlementService'; import {getBillingBranding} from '@app/api/stripe/BillingBranding'; import {getEffectiveBillingConfig, isCurrentCatalogPriceId} from '@app/api/stripe/BillingConfigCache'; import type {ProductInfo, ProductRegistry} from '@app/api/stripe/ProductRegistry'; @@ -168,6 +169,7 @@ export class StripeCheckoutService { private userRepository: IUserRepository, private productRegistry: ProductRegistry, private cacheService: ICacheService, + private storeEntitlementService: StoreEntitlementService | null = null, ) {} async createCheckoutSession({ @@ -592,6 +594,12 @@ export class StripeCheckoutService { throw new PremiumPurchaseBlockedError('lifetime'); } this.validateUserCanPurchase(user); + if (isRecurringSubscription) { + const storeEntitlement = await this.storeEntitlementService?.getActiveStoreEntitlement(user.id); + if (storeEntitlement) { + throw new PremiumPurchaseBlockedError('existing_subscription', {provider: storeEntitlement.provider}); + } + } const customerUser = await this.ensureStripeCustomer(user); const customerId = customerUser.stripeCustomerId; if (!customerId) { diff --git a/fluxer_api/src/api/stripe/services/StripeGiftReversalHandler.ts b/fluxer_api/src/api/stripe/services/StripeGiftReversalHandler.ts index bbc278b76..618a58b0b 100644 --- a/fluxer_api/src/api/stripe/services/StripeGiftReversalHandler.ts +++ b/fluxer_api/src/api/stripe/services/StripeGiftReversalHandler.ts @@ -7,8 +7,9 @@ import type {PremiumStateReconciliationQueueService} from '@app/api/infrastructu import {Logger} from '@app/api/Logger'; import {addGiftCodeDuration, type GiftCode} from '@app/api/models/GiftCode'; import type {User} from '@app/api/models/User'; -import type {StripePremiumService} from '@app/api/stripe/services/StripePremiumService'; +import type {StoreEntitlementService} from '@app/api/store_billing/StoreEntitlementService'; import type {IUserRepository} from '@app/api/user/IUserRepository'; +import {clearPerksSanitizedFlag} from '@app/api/user/UserHelpers'; import {mapUserToPrivateResponse} from '@app/api/user/UserMappers'; import {UserPremiumTypes} from '@fluxer/constants/src/UserConstants'; @@ -21,8 +22,8 @@ export class StripeGiftReversalHandler { constructor( private userRepository: IUserRepository, private gatewayService: IGatewayService, - private premiumService: StripePremiumService, private premiumStateReconciliationQueueService: PremiumStateReconciliationQueueService, + private storeEntitlementService: StoreEntitlementService | null = null, ) {} async handleGiftPremiumReversal( @@ -43,16 +44,25 @@ export class StripeGiftReversalHandler { } if (redeemer.stripeSubscriptionId || redeemer.stripeCustomerId) { const redeemedGifts = await this.userRepository.findGiftCodesByRedeemer(redeemer.id); - const remainingEntitlement = this.computeRemainingGiftEntitlement(redeemedGifts, giftCode.code); const currentGiftEnd = redeemer.premiumGiftExtensionEndsAt; - const newGiftEnd = remainingEntitlement.giftExtensionEndsAt; - const needsAdjustment = - (newGiftEnd?.getTime() ?? 0) !== (currentGiftEnd?.getTime() ?? 0) && - (currentGiftEnd == null || newGiftEnd == null || currentGiftEnd.getTime() > newGiftEnd.getTime()); - if (needsAdjustment) { - const patch: Partial = {premium_gift_extension_ends_at: newGiftEnd}; - const updatedUser = await this.userRepository.patchUpsert(redeemer.id, patch, redeemer.toRow()); - await this.dispatchUser(updatedUser); + let newGiftEnd: Date | null; + let needsAdjustment: boolean; + let reapplyIfMarked = true; + if (await this.storeEntitlementService?.getActiveStoreEntitlement(redeemer.id)) { + const reduced = this.reduceStackedGiftExtension(redeemer, giftCode, redeemedGifts, new Date()); + newGiftEnd = reduced.giftExtensionEndsAt; + needsAdjustment = reduced.changed; + reapplyIfMarked = false; + } else { + newGiftEnd = this.computeRemainingGiftEntitlement(redeemedGifts, giftCode.code).giftExtensionEndsAt; + needsAdjustment = + (newGiftEnd?.getTime() ?? 0) !== (currentGiftEnd?.getTime() ?? 0) && + (currentGiftEnd == null || newGiftEnd == null || currentGiftEnd.getTime() > newGiftEnd.getTime()); + } + if ( + needsAdjustment && + (await this.commitReversal(redeemer, giftCode, {premium_gift_extension_ends_at: newGiftEnd}, {reapplyIfMarked})) + ) { Logger.info( { giftCode: giftCode.code, @@ -80,6 +90,10 @@ export class StripeGiftReversalHandler { ); return; } + if (await this.storeEntitlementService?.getActiveStoreEntitlement(redeemer.id)) { + await this.reverseGiftForStoreSubscriber(redeemer, giftCode, context); + return; + } const redeemedGifts = await this.userRepository.findGiftCodesByRedeemer(redeemer.id); const hasGiftInRedeemerIndex = redeemedGifts.some((entry) => entry.code === giftCode.code); if (!hasGiftInRedeemerIndex) { @@ -122,8 +136,7 @@ export class StripeGiftReversalHandler { patch.premium_will_cancel = false; } if (Object.keys(patch).length > 0) { - const updatedUser = await this.userRepository.patchUpsert(redeemer.id, patch, redeemer.toRow()); - await this.dispatchUser(updatedUser); + await this.commitReversal(redeemer, giftCode, patch, {reapplyIfMarked: true}); } Logger.info( { @@ -148,7 +161,16 @@ export class StripeGiftReversalHandler { ); return; } - await this.premiumService.revokePremium(redeemer.id); + await this.commitReversal( + redeemer, + giftCode, + { + premium_type: UserPremiumTypes.NONE, + premium_until: null, + premium_gift_extension_ends_at: null, + }, + {reapplyIfMarked: true}, + ); Logger.debug( { giftCode: giftCode.code, @@ -160,9 +182,170 @@ export class StripeGiftReversalHandler { ); } + private async reverseGiftForStoreSubscriber( + redeemer: User, + giftCode: GiftCode, + context: { + reason: string; + chargeId?: string; + }, + ): Promise { + const currentGiftEnd = redeemer.premiumGiftExtensionEndsAt; + const newGiftEnd = await this.reverseStackedGift(redeemer, giftCode); + await this.storeEntitlementService?.applyStoreEntitlementToUser(redeemer.id); + Logger.info( + { + giftCode: giftCode.code, + redeemerId: redeemer.id, + chargeId: context.chargeId, + reason: context.reason, + adjustedGiftEnd: newGiftEnd?.toISOString() ?? null, + previousGiftEnd: currentGiftEnd?.toISOString() ?? null, + }, + 'Reduced gift extension after gift reversal for user with a store subscription', + ); + } + + async reverseStackedGift(redeemer: User, giftCode: GiftCode): Promise { + const redeemedGifts = await this.userRepository.findGiftCodesByRedeemer(redeemer.id); + const reduced = this.reduceStackedGiftExtension(redeemer, giftCode, redeemedGifts, new Date()); + if ( + !reduced.changed || + !(await this.commitReversal( + redeemer, + giftCode, + {premium_gift_extension_ends_at: reduced.giftExtensionEndsAt}, + {reapplyIfMarked: false}, + )) + ) { + return redeemer.premiumGiftExtensionEndsAt; + } + return reduced.giftExtensionEndsAt; + } + + private reduceStackedGiftExtension( + redeemer: User, + giftCode: GiftCode, + redeemedGifts: Array, + now: Date, + ): {changed: boolean; giftExtensionEndsAt: Date | null} { + const currentGiftEnd = redeemer.premiumGiftExtensionEndsAt; + const remaining = this.computeRemainingGiftEntitlement(redeemedGifts, giftCode.code); + if (!currentGiftEnd || remaining.hasLifetimeGift) { + return {changed: false, giftExtensionEndsAt: currentGiftEnd}; + } + const extendedEnd = addGiftCodeDuration(currentGiftEnd, giftCode.durationType, giftCode.durationQuantity); + if (!extendedEnd) { + const recomputed = remaining.giftExtensionEndsAt; + const changed = !recomputed || recomputed.getTime() < currentGiftEnd.getTime(); + return {changed, giftExtensionEndsAt: changed ? recomputed : currentGiftEnd}; + } + const reducedMs = currentGiftEnd.getTime() - (extendedEnd.getTime() - currentGiftEnd.getTime()); + const floorMs = Math.max(now.getTime(), redeemer.premiumUntil?.getTime() ?? 0); + return {changed: true, giftExtensionEndsAt: reducedMs <= floorMs ? null : new Date(reducedMs)}; + } + + async restoreReversedGift(giftCode: GiftCode): Promise { + const redeemerId = giftCode.redeemedByUserId; + if (!redeemerId) { + return; + } + const seconds = (await this.userRepository.findGiftCode(giftCode.code))?.premiumReversedSeconds ?? null; + if (seconds === null || !(await this.userRepository.clearGiftPremiumReversed(giftCode.code, seconds))) { + return; + } + const user = seconds > 0 ? await this.userRepository.findUnique(redeemerId) : null; + if (!user) { + return; + } + const now = new Date(); + const anchorMs = Math.max( + now.getTime(), + user.premiumUntil?.getTime() ?? 0, + user.premiumGiftExtensionEndsAt?.getTime() ?? 0, + ); + const patch: Partial = { + premium_gift_extension_ends_at: new Date(anchorMs + seconds * 1000), + premium_grace_ends_at: null, + }; + if ((user.premiumType ?? 0) <= 0) { + patch.premium_type = UserPremiumTypes.SUBSCRIPTION; + patch.premium_flags = clearPerksSanitizedFlag(user.premiumFlags); + patch.premium_since = user.premiumSince ?? now; + } + let updatedUser: User; + try { + updatedUser = await this.userRepository.patchUpsert(redeemerId, patch, user.toRow()); + } catch (error) { + try { + await this.userRepository.markGiftPremiumReversed(giftCode, seconds); + } catch (markError) { + Logger.error({giftCode: giftCode.code, redeemerId, markError}, 'Failed to restore gift reversal marker'); + } + throw error; + } + await this.dispatchUser(updatedUser); + } + + private async commitReversal( + redeemer: User, + giftCode: GiftCode, + patch: Partial, + {reapplyIfMarked}: {reapplyIfMarked: boolean}, + ): Promise { + const seconds = this.computeRemovedSeconds(redeemer, patch, Date.now()); + if (!(await this.userRepository.markGiftPremiumReversed(giftCode, seconds))) { + if (!reapplyIfMarked) { + Logger.info( + {giftCode: giftCode.code, redeemerId: redeemer.id}, + 'Skipped a gift premium reversal that was already applied', + ); + return false; + } + await this.dispatchUser(await this.userRepository.patchUpsert(redeemer.id, patch, redeemer.toRow())); + return true; + } + let updatedUser: User; + try { + updatedUser = await this.userRepository.patchUpsert(redeemer.id, patch, redeemer.toRow()); + } catch (error) { + try { + await this.userRepository.clearGiftPremiumReversed(giftCode.code, seconds); + } catch (clearError) { + Logger.error( + {giftCode: giftCode.code, redeemerId: redeemer.id, clearError}, + 'Failed to release gift reversal marker', + ); + } + throw error; + } + await this.dispatchUser(updatedUser); + return true; + } + + private computeRemovedSeconds(redeemer: User, patch: Partial, nowMs: number): number { + const nextUntil = 'premium_until' in patch ? patch.premium_until : redeemer.premiumUntil; + const nextGiftEnd = + 'premium_gift_extension_ends_at' in patch + ? patch.premium_gift_extension_ends_at + : redeemer.premiumGiftExtensionEndsAt; + const beforeMs = Math.max( + redeemer.premiumUntil?.getTime() ?? 0, + redeemer.premiumGiftExtensionEndsAt?.getTime() ?? 0, + ); + const afterMs = Math.max(nowMs, nextUntil?.getTime() ?? 0, nextGiftEnd?.getTime() ?? 0); + return Math.max(0, Math.ceil((beforeMs - afterMs) / 1000)); + } + computeRemainingGiftEntitlement(redeemedGifts: Array, excludedCode: string): RemainingGiftEntitlement { const sortedGifts = redeemedGifts - .filter((giftCode) => giftCode.code !== excludedCode && giftCode.redeemedAt != null) + .filter( + (giftCode) => + giftCode.code !== excludedCode && + giftCode.redeemedAt != null && + giftCode.premiumReversedSeconds === null && + giftCode.revokedAt === null, + ) .sort((left, right) => { const leftRedeemedAt = left.redeemedAt?.getTime() ?? 0; const rightRedeemedAt = right.redeemedAt?.getTime() ?? 0; diff --git a/fluxer_api/src/api/stripe/services/StripeGiftService.ts b/fluxer_api/src/api/stripe/services/StripeGiftService.ts index b922446f1..d49bdfb47 100644 --- a/fluxer_api/src/api/stripe/services/StripeGiftService.ts +++ b/fluxer_api/src/api/stripe/services/StripeGiftService.ts @@ -8,6 +8,7 @@ import {Logger} from '@app/api/Logger'; import {getBillingRepository} from '@app/api/middleware/ServiceRegistry'; import {type GiftCode, mapGiftDurationMonthsToFields} from '@app/api/models/GiftCode'; import type {User} from '@app/api/models/User'; +import type {StoreEntitlementService} from '@app/api/store_billing/StoreEntitlementService'; import {getBillingBranding} from '@app/api/stripe/BillingBranding'; import type {ProductInfo} from '@app/api/stripe/ProductRegistry'; import type {StripeCheckoutService} from '@app/api/stripe/services/StripeCheckoutService'; @@ -37,6 +38,7 @@ export class StripeGiftService { private checkoutService: StripeCheckoutService, private premiumService: StripePremiumService, private subscriptionService: StripeSubscriptionService, + private storeEntitlementService: StoreEntitlementService | null = null, ) {} async getGiftCode(code: string): Promise { @@ -481,6 +483,7 @@ export class StripeGiftService { Logger.debug({userId: user.id, patch}, 'Clearing stale Stripe identity before premium field fallback'); const updatedUser = await this.userRepository.patchUpsert(user.id, patch, user.toRow()); await this.dispatchUser(updatedUser); + await this.storeEntitlementService?.reapplyAfterStripeChange(user.id); } private async cancelStripeSubscriptionImmediately(user: User): Promise { diff --git a/fluxer_api/src/api/stripe/services/StripeSubscriptionService.ts b/fluxer_api/src/api/stripe/services/StripeSubscriptionService.ts index e01c45b0e..7fc6ed6b1 100644 --- a/fluxer_api/src/api/stripe/services/StripeSubscriptionService.ts +++ b/fluxer_api/src/api/stripe/services/StripeSubscriptionService.ts @@ -8,6 +8,7 @@ import {Logger} from '@app/api/Logger'; import {getBillingRepository} from '@app/api/middleware/ServiceRegistry'; import {addGiftCodeDuration} from '@app/api/models/GiftCode'; import type {User} from '@app/api/models/User'; +import type {StoreEntitlementService} from '@app/api/store_billing/StoreEntitlementService'; import type {ProductInfo, RecurringBillingCycle} from '@app/api/stripe/ProductRegistry'; import { getPrimarySubscriptionItem, @@ -116,6 +117,7 @@ export class StripeSubscriptionService { }, private cacheService: ICacheService, private gatewayService: IGatewayService, + private storeEntitlementService: StoreEntitlementService | null = null, ) {} async cancelSubscriptionAtPeriodEnd(userId: UserID): Promise { @@ -250,6 +252,7 @@ export class StripeSubscriptionService { const message = error instanceof Error ? error.message : 'Failed to cancel subscription immediately'; throw new StripeError(message); } + await this.storeEntitlementService?.reapplyAfterStripeChange(userId); } async reactivateSubscription(userId: UserID): Promise { diff --git a/fluxer_api/src/api/stripe/services/StripeSubscriptionWebhookHandler.ts b/fluxer_api/src/api/stripe/services/StripeSubscriptionWebhookHandler.ts index 7b46f6906..222c4186b 100644 --- a/fluxer_api/src/api/stripe/services/StripeSubscriptionWebhookHandler.ts +++ b/fluxer_api/src/api/stripe/services/StripeSubscriptionWebhookHandler.ts @@ -9,6 +9,7 @@ import type {IGatewayService} from '@app/api/infrastructure/IGatewayService'; import type {PremiumStateReconciliationQueueService} from '@app/api/infrastructure/PremiumStateReconciliationQueueService'; import {Logger} from '@app/api/Logger'; import type {User} from '@app/api/models/User'; +import type {StoreEntitlementService} from '@app/api/store_billing/StoreEntitlementService'; import type {ProductInfo} from '@app/api/stripe/ProductRegistry'; import { canProvisionPremiumFromSubscriptionStatus, @@ -59,6 +60,7 @@ export class StripeSubscriptionWebhookHandler { private premiumStateReconciliationQueueService: PremiumStateReconciliationQueueService, private reconciler: StripeSubscriptionReconciler, private billingRepository: BillingRepository, + private storeEntitlementService: StoreEntitlementService | null = null, ) {} async handleInvoicePaymentSucceeded(eventId: string, invoice: Stripe.Invoice): Promise { @@ -568,6 +570,7 @@ export class StripeSubscriptionWebhookHandler { throw new StripeError('Updated user not found for subscription update'); } await this.dispatchUser(updatedUser); + await this.restoreStoreEntitlement(updatedUser.id); await this.enqueuePremiumStateReconciliation(updatedUser.id, { reason: 'subscription_updated', subscriptionId: canonicalSubscription.id, @@ -670,6 +673,7 @@ export class StripeSubscriptionWebhookHandler { } const updatedUser = await this.userRepository.patchUpsert(targetUser.id, updates, targetUser.toRow()); await this.dispatchUser(updatedUser); + await this.restoreStoreEntitlement(updatedUser.id); await this.enqueuePremiumStateReconciliation(updatedUser.id, { reason: 'subscription_deleted', subscriptionId: subscription.id, @@ -771,7 +775,7 @@ export class StripeSubscriptionWebhookHandler { } const updatedUser = await this.userRepository.patchUpsert(user.id, patch, user.toRow()); await this.dispatchUser(updatedUser); - return updatedUser; + return (await this.restoreStoreEntitlement(updatedUser.id)) ?? updatedUser; } private getInvoiceServicePeriod(invoice: Stripe.Invoice): { @@ -822,6 +826,13 @@ export class StripeSubscriptionWebhookHandler { } } + private async restoreStoreEntitlement(userId: UserID): Promise { + if (!(await this.storeEntitlementService?.reapplyAfterStripeChange(userId))) { + return null; + } + return this.userRepository.findUnique(userId); + } + private async dispatchUser(user: User): Promise { await this.gatewayService.dispatchPresence({ userId: user.id, diff --git a/fluxer_api/src/api/stripe/services/StripeWebhookService.ts b/fluxer_api/src/api/stripe/services/StripeWebhookService.ts index a3d6e57f0..c3f7d9501 100644 --- a/fluxer_api/src/api/stripe/services/StripeWebhookService.ts +++ b/fluxer_api/src/api/stripe/services/StripeWebhookService.ts @@ -12,6 +12,7 @@ import type {PremiumStateReconciliationQueueService} from '@app/api/infrastructu import type {UserCacheService} from '@app/api/infrastructure/UserCacheService'; import {Logger} from '@app/api/Logger'; import {OAuth2TokenRepository} from '@app/api/oauth/repositories/OAuth2TokenRepository'; +import type {StoreEntitlementService} from '@app/api/store_billing/StoreEntitlementService'; import {getAcceptedWebhookSecrets} from '@app/api/stripe/BillingConfigCache'; import type {ProductRegistry} from '@app/api/stripe/ProductRegistry'; import type {AgeVerificationService} from '@app/api/stripe/services/AgeVerificationService'; @@ -64,6 +65,7 @@ export class StripeWebhookService { snowflakeService: ISnowflakeService, private billingRepository: BillingRepository, private refundService: StripeRefundService, + storeEntitlementService: StoreEntitlementService | null = null, ) { this.checkoutHandler = new StripeCheckoutWebhookHandler( stripe, @@ -80,8 +82,8 @@ export class StripeWebhookService { const giftReversalHandler = new StripeGiftReversalHandler( userRepository, gatewayService, - premiumService, premiumStateReconciliationQueueService, + storeEntitlementService, ); const auditService = new AdminAuditService(adminRepository, snowflakeService); this.paymentFraudService = new StripePaymentFraudService({ @@ -105,6 +107,7 @@ export class StripeWebhookService { premiumStateReconciliationQueueService, reconciler, billingRepository, + storeEntitlementService, ); this.disputeHandler = new StripeDisputeWebhookHandler( userRepository, diff --git a/fluxer_api/src/api/stripe/tests/SelfHostedPremiumRoutes.test.ts b/fluxer_api/src/api/stripe/tests/SelfHostedPremiumRoutes.test.ts index a8821c449..4b0d9ae1e 100644 --- a/fluxer_api/src/api/stripe/tests/SelfHostedPremiumRoutes.test.ts +++ b/fluxer_api/src/api/stripe/tests/SelfHostedPremiumRoutes.test.ts @@ -94,7 +94,7 @@ async function generateGiftCode(harness: ApiTestHarness): Promise { async function expectRoutesNotFound( harness: ApiTestHarness, token: string, - routes: ReadonlyArray, + routes: ReadonlyArray, ): Promise { for (const [method, path] of routes) { expect({path, ...(await readRouteStatus(harness, token, method, path))}).toEqual({ @@ -108,7 +108,7 @@ async function expectRoutesNotFound( async function expectRoutesRegistered( harness: ApiTestHarness, token: string, - routes: ReadonlyArray, + routes: ReadonlyArray, ): Promise { for (const [method, path] of routes) { const result = await readRouteStatus(harness, token, method, path); @@ -130,16 +130,19 @@ async function postSignedWebhook(harness: ApiTestHarness, secret: string): Promi async function readRouteStatus( harness: ApiTestHarness, token: string | null, - method: 'GET' | 'POST', + method: RouteMethod, path: string, ): Promise<{status: number; code: string | undefined}> { const builder = token === null ? createBuilderWithoutAuth(harness) : createBuilder(harness, token); - const request = method === 'GET' ? builder.get(path) : builder.post(path).body({}); + const request = + method === 'GET' ? builder.get(path) : method === 'DELETE' ? builder.delete(path) : builder.post(path).body({}); const {response, json} = await request.executeRaw(); return {status: response.status, code: (json as {code?: string} | undefined)?.code}; } -const PURCHASE_ROUTES: ReadonlyArray<['GET' | 'POST', string]> = [ +type RouteMethod = 'GET' | 'POST' | 'DELETE'; + +const PURCHASE_ROUTES: ReadonlyArray<[RouteMethod, string]> = [ ['POST', '/stripe/checkout/subscription'], ['POST', '/stripe/checkout/subscription/preapproval'], ['POST', '/stripe/checkout/subscription/preapproval/continue'], @@ -147,7 +150,7 @@ const PURCHASE_ROUTES: ReadonlyArray<['GET' | 'POST', string]> = [ ['GET', '/premium/price-ids'], ]; -const SERVICING_ROUTES: ReadonlyArray<['GET' | 'POST', string]> = [ +const SERVICING_ROUTES: ReadonlyArray<[RouteMethod, string]> = [ ['GET', '/premium/current-subscription-price'], ['POST', '/premium/customer-portal'], ['POST', '/premium/grace/end'], @@ -157,15 +160,15 @@ const SERVICING_ROUTES: ReadonlyArray<['GET' | 'POST', string]> = [ ['POST', '/premium/cancel-pending-subscription-change'], ]; -const BILLING_ROUTES: ReadonlyArray<['GET' | 'POST', string]> = [...PURCHASE_ROUTES, ...SERVICING_ROUTES]; +const BILLING_ROUTES: ReadonlyArray<[RouteMethod, string]> = [...PURCHASE_ROUTES, ...SERVICING_ROUTES]; -const GIFT_ROUTES: ReadonlyArray<['GET' | 'POST', string]> = [ +const GIFT_ROUTES: ReadonlyArray<[RouteMethod, string]> = [ ['GET', '/gifts/somegiftcode'], ['POST', '/gifts/somegiftcode/redeem'], ['GET', '/users/@me/gifts'], ]; -const HOSTED_ONLY_ROUTES: ReadonlyArray<['GET' | 'POST', string]> = [ +const HOSTED_ONLY_ROUTES: ReadonlyArray<[RouteMethod, string]> = [ ['POST', '/users/@me/age-verification'], ['GET', '/premium/refund-eligibility'], ['POST', '/premium/refund-latest'], @@ -173,6 +176,18 @@ const HOSTED_ONLY_ROUTES: ReadonlyArray<['GET' | 'POST', string]> = [ ['POST', '/premium/visionary/rejoin'], ]; +const STORE_BILLING_ROUTES: ReadonlyArray<[RouteMethod, string]> = [ + ['GET', '/premium/store'], + ['POST', '/premium/store/app-store/transactions'], + ['POST', '/premium/store/google-play/purchases'], + ['GET', '/premium/store/purchases'], + ['DELETE', '/premium/store/purchases/1234567890123456789'], + ['POST', '/webhooks/app-store'], + ['POST', '/webhooks/google-play'], + ['GET', '/admin/users/1234567890123456789/store-purchases'], + ['POST', '/admin/users/1234567890123456789/store-purchases/refresh'], +]; + describe('self-hosted premium routes', () => { let harness: ApiTestHarness; let original: GlobalState; @@ -208,6 +223,7 @@ describe('self-hosted premium routes', () => { ...BILLING_ROUTES, ...GIFT_ROUTES, ...HOSTED_ONLY_ROUTES, + ...STORE_BILLING_ROUTES, ['POST', '/stripe/webhook'] as const, ]) { expect({path, ...(await readRouteStatus(harness, account.token, method, path))}).toEqual({ @@ -270,7 +286,11 @@ describe('self-hosted premium routes', () => { test('keeps purchase and hosted-only routes unavailable', async () => { const account = await createTestAccount(harness); - await expectRoutesNotFound(harness, account.token, [...PURCHASE_ROUTES, ...HOSTED_ONLY_ROUTES]); + await expectRoutesNotFound(harness, account.token, [ + ...PURCHASE_ROUTES, + ...HOSTED_ONLY_ROUTES, + ...STORE_BILLING_ROUTES, + ]); }); test('keeps servicing routes and the webhook available while a Stripe key is configured', async () => { @@ -373,7 +393,7 @@ describe('self-hosted premium routes', () => { test('keeps hosted-only routes unavailable', async () => { getConfig().stripe.prices = {...USD_PRICES}; const account = await createTestAccount(harness); - for (const [method, path] of HOSTED_ONLY_ROUTES) { + for (const [method, path] of [...HOSTED_ONLY_ROUTES, ...STORE_BILLING_ROUTES]) { expect({path, ...(await readRouteStatus(harness, account.token, method, path))}).toEqual({ path, status: HTTP_STATUS.NOT_FOUND, @@ -407,7 +427,7 @@ describe('hosted premium routes', () => { test('keeps every Stripe and gift route registered regardless of premium mode', async () => { setCachedInstancePremiumMode('everyone'); const account = await createTestAccount(harness); - const routes = [...BILLING_ROUTES, ...GIFT_ROUTES, ...HOSTED_ONLY_ROUTES].filter( + const routes = [...BILLING_ROUTES, ...GIFT_ROUTES, ...HOSTED_ONLY_ROUTES, ...STORE_BILLING_ROUTES].filter( ([, path]) => path !== '/users/@me/age-verification', ); for (const [method, path] of routes) { diff --git a/fluxer_api/src/api/stripe/tests/StripeGiftReversalHandler.test.ts b/fluxer_api/src/api/stripe/tests/StripeGiftReversalHandler.test.ts new file mode 100644 index 000000000..4e67ef56b --- /dev/null +++ b/fluxer_api/src/api/stripe/tests/StripeGiftReversalHandler.test.ts @@ -0,0 +1,128 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import {randomUUID} from 'node:crypto'; +import {createTestAccount} from '@app/api/auth/tests/AuthTestUtils'; +import {createUserID, type UserID} from '@app/api/BrandedTypes'; +import {getGatewayService} from '@app/api/middleware/ServiceRegistry'; +import {getPremiumStateReconciliationQueueService, getUserRepository} from '@app/api/middleware/ServiceSingletons'; +import {findUser} from '@app/api/store_billing/tests/StoreBillingTestUtils'; +import {StripeGiftReversalHandler} from '@app/api/stripe/services/StripeGiftReversalHandler'; +import {type ApiTestHarness, createApiTestHarness} from '@app/api/test/ApiTestHarness'; +import type {IUserRepository} from '@app/api/user/IUserRepository'; +import {UserPremiumTypes} from '@fluxer/constants/src/UserConstants'; +import {ms} from 'itty-time'; +import {afterAll, beforeAll, beforeEach, describe, expect, it} from 'vitest'; + +describe('StripeGiftReversalHandler', () => { + let harness: ApiTestHarness; + + beforeAll(async () => { + harness = await createApiTestHarness(); + }); + + afterAll(async () => { + await harness.shutdown(); + }); + + beforeEach(async () => { + await harness.resetData(); + }); + + function createHandler(users: IUserRepository = getUserRepository()): StripeGiftReversalHandler { + return new StripeGiftReversalHandler(users, getGatewayService(), getPremiumStateReconciliationQueueService()); + } + + async function createRedeemedGift(creatorId: UserID, redeemerId: UserID, redeemedAt: Date): Promise { + const code = `giftreversal${randomUUID().replaceAll('-', '').slice(0, 20)}`; + await getUserRepository().createGiftCode({ + code, + duration_months: null, + duration_type: 'months', + duration_quantity: 1, + created_at: redeemedAt, + created_by_user_id: creatorId, + redeemed_at: redeemedAt, + redeemed_by_user_id: redeemerId, + stripe_payment_intent_id: `pi_${code}`, + visionary_sequence_number: null, + checkout_session_id: null, + version: 1, + }); + return code; + } + + async function createGiftedUser(giftCount: number): Promise<{accountId: string; codes: Array}> { + const gifter = await createTestAccount(harness); + const account = await createTestAccount(harness); + const userId = createUserID(BigInt(account.userId)); + const creatorId = createUserID(BigInt(gifter.userId)); + const codes: Array = []; + for (let index = 0; index < giftCount; index++) { + codes.push(await createRedeemedGift(creatorId, userId, new Date(Date.now() - ms('1 hour') + index * 1000))); + } + const user = await findUser(account.userId); + await getUserRepository().patchUpsert( + userId, + { + premium_type: UserPremiumTypes.SUBSCRIPTION, + premium_until: null, + premium_gift_extension_ends_at: new Date(Date.now() + giftCount * ms('30 days')), + }, + user.toRow(), + ); + return {accountId: account.userId, codes}; + } + + async function reverse(code: string, handler = createHandler()): Promise { + const gift = await getUserRepository().findGiftCode(code); + await handler.handleGiftPremiumReversal(gift!, {reason: 'charge_dispute'}); + } + + it('revokes premium on a retry after an earlier reversal claimed the gift but never wrote the user', async () => { + const {accountId, codes} = await createGiftedUser(1); + const gift = await getUserRepository().findGiftCode(codes[0]!); + expect(await getUserRepository().markGiftPremiumReversed(gift!, 2_592_000)).toBe(true); + + await reverse(codes[0]!); + + const after = await findUser(accountId); + expect(after.premiumType).toBe(UserPremiumTypes.NONE); + expect(after.premiumGiftExtensionEndsAt).toBeNull(); + }); + + it('surfaces the write failure when releasing the reversal claim also fails', async () => { + const {codes} = await createGiftedUser(1); + const users = getUserRepository(); + const failing = new Proxy(users, { + get(target, property, receiver) { + if (property === 'patchUpsert') { + return async () => { + throw new Error('user write failed'); + }; + } + if (property === 'clearGiftPremiumReversed') { + return async () => { + throw new Error('release failed'); + }; + } + const value = Reflect.get(target, property, receiver); + return typeof value === 'function' ? value.bind(target) : value; + }, + }); + + await expect(reverse(codes[0]!, createHandler(failing))).rejects.toThrow('user write failed'); + }); + + it('removes premium once every stacked gift has been reversed', async () => { + const {accountId, codes} = await createGiftedUser(2); + + await reverse(codes[1]!); + const between = await findUser(accountId); + expect(between.premiumType).toBe(UserPremiumTypes.SUBSCRIPTION); + await reverse(codes[0]!); + + const after = await findUser(accountId); + expect(after.premiumType).toBe(UserPremiumTypes.NONE); + expect(after.premiumGiftExtensionEndsAt).toBeNull(); + }); +}); diff --git a/fluxer_api/src/api/test/Setup.ts b/fluxer_api/src/api/test/Setup.ts index b2ed4d28d..4d1d5e2e1 100644 --- a/fluxer_api/src/api/test/Setup.ts +++ b/fluxer_api/src/api/test/Setup.ts @@ -1,9 +1,11 @@ // SPDX-License-Identifier: AGPL-3.0-or-later +import {generateKeyPairSync} from 'node:crypto'; import {existsSync} from 'node:fs'; import type {UserID} from '@app/api/BrandedTypes'; import {buildAPIConfigFromMaster, initializeConfig} from '@app/api/Config'; import {setInjectedMessageResponseDataService} from '@app/api/channel/services/message/MessageResponseDataService'; +import type {APIConfig} from '@app/api/config/APIConfig'; import { resetCassandraQueryExecutorForTesting, setCassandraQueryExecutorForTesting, @@ -114,9 +116,17 @@ class RepositoryBackedUsersServiceClient implements IUsersServiceClient { setDefaultTestEnv(); process.env.FLUXER_MEDIA_PROXY_UPLOAD_RELAY_SECRET_BASE64 ??= 'AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8='; +function generateTestPrivateKeyPem(type: 'ec' | 'rsa'): string { + const {privateKey} = + type === 'ec' + ? generateKeyPairSync('ec', {namedCurve: 'prime256v1'}) + : generateKeyPairSync('rsa', {modulusLength: 2048}); + return privateKey.export({format: 'pem', type: 'pkcs8'}).toString(); +} + const master = await loadConfig(); const apiConfig = buildAPIConfigFromMaster(master); -const testApiConfig = { +const testApiConfig: APIConfig = { ...apiConfig, auth: { ...apiConfig.auth, @@ -136,6 +146,43 @@ const testApiConfig = { secretKey: 'sk_test_fluxer', webhookSecret: 'whsec_test_fluxer', }, + appStore: { + ...apiConfig.appStore, + enabled: true, + issuerId: '57246542-96fe-1a63-e053-0824d011072a', + keyId: 'FLUXERTEST', + privateKey: generateTestPrivateKeyPem('ec'), + privateKeyPath: undefined, + apps: [{bundleId: 'com.fluxer', appAppleId: 1234567890}], + products: { + 'com.fluxer.plutonium.monthly': 'monthly', + 'com.fluxer.plutonium.yearly': 'yearly', + 'com.fluxer.gift.1month': 'gift_1_month', + 'com.fluxer.gift.1year': 'gift_1_year', + }, + }, + googlePlay: { + ...apiConfig.googlePlay, + enabled: true, + packages: ['com.fluxer'], + clientEmail: 'play-billing@fluxer-test.iam.gserviceaccount.com', + privateKey: generateTestPrivateKeyPem('rsa'), + privateKeyPath: undefined, + serviceAccountJsonPath: undefined, + tokenUri: 'https://oauth2.googleapis.com/token', + products: { + 'plutonium:monthly': 'monthly', + 'plutonium:yearly': 'yearly', + gift_1_month: 'gift_1_month', + gift_1_year: 'gift_1_year', + }, + pushAudience: 'https://api.fluxer.test/webhooks/google-play', + pushServiceAccountEmail: 'rtdn@fluxer-test.iam.gserviceaccount.com', + }, + storeBilling: { + sandboxUserIds: [], + sandboxEntitlesAll: false, + }, ncmec: { ...apiConfig.ncmec, enabled: true, diff --git a/fluxer_api/src/api/test/msw/handlers/AppStoreServerApiHandlers.ts b/fluxer_api/src/api/test/msw/handlers/AppStoreServerApiHandlers.ts new file mode 100644 index 000000000..2246bd13e --- /dev/null +++ b/fluxer_api/src/api/test/msw/handlers/AppStoreServerApiHandlers.ts @@ -0,0 +1,389 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import {createPublicKey, randomUUID} from 'node:crypto'; +import {readFile} from 'node:fs/promises'; +import {Config} from '@app/api/Config'; +import {jwtVerify} from 'jose'; +import {HttpResponse, http, type RequestHandler} from 'msw'; + +type AppStoreFakeEnvironment = 'production' | 'sandbox'; + +const APP_STORE_FAKE_HOSTS: Record = { + production: 'https://api.storekit.apple.com', + sandbox: 'https://api.storekit-sandbox.apple.com', +}; +const APP_STORE_FAKE_ENVIRONMENT_NAMES: Record = { + production: 'Production', + sandbox: 'Sandbox', +}; +const DEFAULT_SUBSCRIPTION_GROUP = '21000000'; +const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +export type AppStoreFakePayload = Record; + +export interface AppStoreFakeTransaction extends AppStoreFakePayload { + transactionId: string; + originalTransactionId: string; + productId: string; +} + +export interface AppStoreFakeSubscription { + originalTransactionId: string; + status: number; + latestTransactionId: string; + subscriptionGroupIdentifier?: string; + customerId?: string; + renewalInfo?: AppStoreFakePayload | null; +} + +export interface AppStoreFakeError { + status: number; + errorCode?: number; + errorMessage?: string; + retryAfter?: string; +} + +export interface AppStoreFakeErrorMatch { + environment?: AppStoreFakeEnvironment; + method?: string; + path?: string; +} + +export interface AppStoreFakeRequest { + environment: AppStoreFakeEnvironment; + method: string; + path: string; + body: unknown; + bundleId: string | null; +} + +export interface AppStoreServerApiFakeOptions { + sign: (payload: AppStoreFakePayload) => Promise; + bundleId?: string; + appAppleId?: number; +} + +export interface AppStoreServerApiFake { + handlers: Array; + requests: Array; + addTransaction(environment: AppStoreFakeEnvironment, transaction: AppStoreFakeTransaction): void; + updateTransaction(environment: AppStoreFakeEnvironment, transactionId: string, patch: AppStoreFakePayload): void; + getTransaction(environment: AppStoreFakeEnvironment, transactionId: string): AppStoreFakeTransaction | null; + putSubscription(environment: AppStoreFakeEnvironment, subscription: AppStoreFakeSubscription): void; + getSubscription(environment: AppStoreFakeEnvironment, originalTransactionId: string): AppStoreFakeSubscription | null; + failNext(match: AppStoreFakeErrorMatch, error: AppStoreFakeError): void; + addNotificationHistory(environment: AppStoreFakeEnvironment, entry: AppStoreFakeNotificationHistoryEntry): void; +} + +interface AppStoreFakeNotificationHistoryEntry { + signedPayload: string; + notificationType: string; + sentAt: number; + failed: boolean; +} + +const NOTIFICATION_HISTORY_PAGE_SIZE = 20; + +interface QueuedError { + match: AppStoreFakeErrorMatch; + error: AppStoreFakeError; +} + +function errorResponse(error: AppStoreFakeError): Response { + const headers: Record = {}; + if (error.retryAfter !== undefined) { + headers['Retry-After'] = error.retryAfter; + } + if (error.errorCode === undefined) { + return new HttpResponse(null, {status: error.status, headers}); + } + return HttpResponse.json( + {errorCode: error.errorCode, errorMessage: error.errorMessage ?? 'App Store fake error'}, + {status: error.status, headers}, + ); +} + +async function configuredPrivateKey(): Promise { + if (Config.appStore.privateKey) { + return Config.appStore.privateKey.replaceAll('\\n', '\n'); + } + if (Config.appStore.privateKeyPath) { + return await readFile(Config.appStore.privateKeyPath, 'utf8'); + } + return null; +} + +async function authorizedBundleId(request: Request): Promise { + const header = request.headers.get('authorization'); + const privateKey = await configuredPrivateKey(); + if (!header?.startsWith('Bearer ') || !privateKey) { + return null; + } + try { + const publicKey = createPublicKey(privateKey); + const {payload, protectedHeader} = await jwtVerify(header.slice('Bearer '.length), publicKey, { + algorithms: ['ES256'], + issuer: Config.appStore.issuerId, + audience: 'appstoreconnect-v1', + typ: 'JWT', + }); + if (protectedHeader.kid !== Config.appStore.keyId || typeof payload.bid !== 'string') { + return null; + } + if (typeof payload.iat !== 'number' || typeof payload.exp !== 'number' || payload.exp - payload.iat > 3600) { + return null; + } + return payload.bid; + } catch { + return null; + } +} + +export function createAppStoreServerApiFake(options: AppStoreServerApiFakeOptions): AppStoreServerApiFake { + const bundleId = options.bundleId ?? 'com.fluxer'; + const appAppleId = options.appAppleId ?? 1234567890; + const transactions: Record> = { + production: new Map(), + sandbox: new Map(), + }; + const subscriptions: Record> = { + production: new Map(), + sandbox: new Map(), + }; + const queuedErrors: Array = []; + const requests: Array = []; + const notificationHistory: Record> = { + production: [], + sandbox: [], + }; + + function takeQueuedError(environment: AppStoreFakeEnvironment, method: string, path: string): Response | null { + const index = queuedErrors.findIndex( + ({match}) => + (match.environment === undefined || match.environment === environment) && + (match.method === undefined || match.method === method) && + (match.path === undefined || path.includes(match.path)), + ); + if (index === -1) { + return null; + } + const [queued] = queuedErrors.splice(index, 1); + return errorResponse(queued.error); + } + + async function signTransaction( + environment: AppStoreFakeEnvironment, + transaction: AppStoreFakeTransaction, + ): Promise { + return await options.sign({ + bundleId, + environment: APP_STORE_FAKE_ENVIRONMENT_NAMES[environment], + type: 'Auto-Renewable Subscription', + inAppOwnershipType: 'PURCHASED', + purchaseDate: Date.now(), + ...transaction, + signedDate: typeof transaction.signedDate === 'number' ? transaction.signedDate : Date.now(), + }); + } + + async function signRenewalInfo( + environment: AppStoreFakeEnvironment, + transaction: AppStoreFakeTransaction, + renewalInfo: AppStoreFakePayload, + ): Promise { + return await options.sign({ + originalTransactionId: transaction.originalTransactionId, + productId: transaction.productId, + autoRenewProductId: transaction.productId, + autoRenewStatus: 1, + environment: APP_STORE_FAKE_ENVIRONMENT_NAMES[environment], + ...renewalInfo, + signedDate: typeof renewalInfo.signedDate === 'number' ? renewalInfo.signedDate : Date.now(), + }); + } + + function notFound(errorCode = 4040010): Response { + return errorResponse({status: 404, errorCode, errorMessage: 'Transaction id not found.'}); + } + + function route( + environment: AppStoreFakeEnvironment, + method: 'get' | 'put' | 'post', + path: string, + handle: (params: Record, body: unknown) => Promise, + ): RequestHandler { + return http[method](`${APP_STORE_FAKE_HOSTS[environment]}${path}`, async ({request, params}) => { + const url = new URL(request.url); + const raw = request.method === 'GET' ? '' : await request.text(); + const body: unknown = raw.length > 0 ? JSON.parse(raw) : null; + const authorized = await authorizedBundleId(request); + requests.push({environment, method: request.method, path: url.pathname, body, bundleId: authorized}); + if (!authorized) { + return new HttpResponse(null, {status: 401}); + } + const queued = takeQueuedError(environment, request.method, url.pathname); + if (queued) { + return queued; + } + const stringParams: Record = {}; + const paginationToken = url.searchParams.get('paginationToken'); + if (paginationToken !== null) { + stringParams['paginationToken'] = paginationToken; + } + for (const [key, value] of Object.entries(params)) { + if (typeof value === 'string') { + stringParams[key] = value; + } + } + return await handle(stringParams, body); + }); + } + + function findTransaction( + environment: AppStoreFakeEnvironment, + transactionId: string, + ): AppStoreFakeTransaction | undefined { + return transactions[environment].get(transactionId); + } + + function environmentHandlers(environment: AppStoreFakeEnvironment): Array { + return [ + route(environment, 'get', '/inApps/v1/subscriptions/:transactionId', async ({transactionId}) => { + const transaction = findTransaction(environment, transactionId); + if (!transaction) { + return notFound(); + } + const owner = subscriptions[environment].get(transaction.originalTransactionId); + const customerId = owner?.customerId ?? transaction.originalTransactionId; + const groups = new Map>(); + for (const subscription of subscriptions[environment].values()) { + if ((subscription.customerId ?? subscription.originalTransactionId) !== customerId) { + continue; + } + const latest = findTransaction(environment, subscription.latestTransactionId); + if (!latest) { + continue; + } + const groupId = subscription.subscriptionGroupIdentifier ?? DEFAULT_SUBSCRIPTION_GROUP; + const items = groups.get(groupId) ?? []; + items.push({ + originalTransactionId: subscription.originalTransactionId, + status: subscription.status, + signedTransactionInfo: await signTransaction(environment, latest), + ...(subscription.renewalInfo === null + ? {} + : {signedRenewalInfo: await signRenewalInfo(environment, latest, subscription.renewalInfo ?? {})}), + }); + groups.set(groupId, items); + } + return HttpResponse.json({ + environment: APP_STORE_FAKE_ENVIRONMENT_NAMES[environment], + bundleId, + ...(environment === 'production' ? {appAppleId} : {}), + data: [...groups.entries()].map(([subscriptionGroupIdentifier, lastTransactions]) => ({ + subscriptionGroupIdentifier, + lastTransactions, + })), + }); + }), + route(environment, 'get', '/inApps/v1/transactions/:transactionId', async ({transactionId}) => { + const transaction = findTransaction(environment, transactionId); + if (!transaction) { + return notFound(); + } + return HttpResponse.json({signedTransactionInfo: await signTransaction(environment, transaction)}); + }), + route( + environment, + 'put', + '/inApps/v1/transactions/:originalTransactionId/appAccountToken', + async ({originalTransactionId}, body) => { + const token = + body !== null && typeof body === 'object' && 'appAccountToken' in body ? body.appAccountToken : null; + if (typeof token !== 'string' || !UUID_PATTERN.test(token)) { + return errorResponse({status: 400, errorCode: 4000183}); + } + const transaction = findTransaction(environment, originalTransactionId); + if (!transaction) { + return notFound(4040005); + } + if (transaction.originalTransactionId !== originalTransactionId) { + return errorResponse({status: 400, errorCode: 4000187}); + } + if (transaction.inAppOwnershipType === 'FAMILY_SHARED') { + return errorResponse({status: 400, errorCode: 4000185}); + } + const subscription = subscriptions[environment].get(originalTransactionId); + const latest = subscription ? findTransaction(environment, subscription.latestTransactionId) : transaction; + if (latest) { + latest.appAccountToken = token; + } + if (subscription && subscription.renewalInfo !== null) { + subscription.renewalInfo = {...subscription.renewalInfo, appAccountToken: token}; + } + return new HttpResponse(null, {status: 200}); + }, + ), + route(environment, 'post', '/inApps/v1/notifications/history', async ({paginationToken}, body) => { + const filter = (body ?? {}) as { + startDate?: number; + endDate?: number; + notificationType?: string; + onlyFailures?: boolean; + }; + if (typeof filter.startDate !== 'number' || typeof filter.endDate !== 'number') { + return errorResponse({status: 400, errorCode: 4000028}); + } + const matching = notificationHistory[environment].filter( + (entry) => + entry.sentAt >= filter.startDate! && + entry.sentAt <= filter.endDate! && + (filter.notificationType === undefined || entry.notificationType === filter.notificationType) && + (!filter.onlyFailures || entry.failed), + ); + const offset = paginationToken ? Number(paginationToken) : 0; + const page = matching.slice(offset, offset + NOTIFICATION_HISTORY_PAGE_SIZE); + const nextOffset = offset + NOTIFICATION_HISTORY_PAGE_SIZE; + const hasMore = nextOffset < matching.length; + return HttpResponse.json({ + notificationHistory: page.map((entry) => ({signedPayload: entry.signedPayload})), + hasMore, + ...(hasMore ? {paginationToken: String(nextOffset)} : {}), + }); + }), + route(environment, 'post', '/inApps/v1/notifications/test', async () => + HttpResponse.json({testNotificationToken: `${randomUUID()}_${Date.now()}`}), + ), + ]; + } + + return { + handlers: [...environmentHandlers('production'), ...environmentHandlers('sandbox')], + requests, + addTransaction(environment, transaction) { + transactions[environment].set(transaction.transactionId, {...transaction}); + }, + updateTransaction(environment, transactionId, patch) { + const existing = transactions[environment].get(transactionId); + if (!existing) { + throw new Error(`Unknown fake App Store transaction ${transactionId}`); + } + Object.assign(existing, patch); + }, + getTransaction(environment, transactionId) { + return transactions[environment].get(transactionId) ?? null; + }, + putSubscription(environment, subscription) { + subscriptions[environment].set(subscription.originalTransactionId, {...subscription}); + }, + getSubscription(environment, originalTransactionId) { + return subscriptions[environment].get(originalTransactionId) ?? null; + }, + failNext(match, error) { + queuedErrors.push({match, error}); + }, + addNotificationHistory(environment, entry) { + notificationHistory[environment].push({...entry}); + }, + }; +} diff --git a/fluxer_api/src/api/test/msw/handlers/GooglePlayDeveloperApiHandlers.ts b/fluxer_api/src/api/test/msw/handlers/GooglePlayDeveloperApiHandlers.ts new file mode 100644 index 000000000..a452f7f1d --- /dev/null +++ b/fluxer_api/src/api/test/msw/handlers/GooglePlayDeveloperApiHandlers.ts @@ -0,0 +1,646 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import {createPublicKey, generateKeyPairSync, type KeyObject} from 'node:crypto'; +import {Config} from '@app/api/Config'; +import { + GOOGLE_DEFAULT_TOKEN_URI, + GOOGLE_PLAY_ANDROIDPUBLISHER_SCOPE, +} from '@app/api/store_billing/google_play/GooglePlayAccessTokenProvider'; +import { + GOOGLE_PLAY_API_BASE_URL, + type GooglePlayVoidedPurchase, + type ProductPurchaseV2, + type SubscriptionPurchaseV2, +} from '@app/api/store_billing/google_play/GooglePlayDeveloperApiClient'; +import {GOOGLE_OIDC_JWKS_URL} from '@app/api/store_billing/google_play/GooglePlayPushVerifier'; +import {type JWTPayload, jwtVerify, SignJWT} from 'jose'; +import {HttpResponse, http, type RequestHandler} from 'msw'; + +export type GooglePlayFakeOperation = + | 'token' + | 'jwks' + | 'subscriptions.get' + | 'subscriptions.cancel' + | 'subscriptions.revoke' + | 'subscriptions.acknowledge' + | 'products.get' + | 'products.consume' + | 'orders.refund' + | 'voidedpurchases.list'; + +export interface GooglePlayFakeFailure { + status: number; + reason?: string; + retryAfter?: string; +} + +export interface GooglePlayFakeSpies { + tokenAssertions: Array<{header: Record; claims: JWTPayload}>; + apiRequests: Array<{operation: GooglePlayFakeOperation; method: string; packageName: string; authorization: string}>; + acknowledged: Array<{packageName: string; productId: string; purchaseToken: string; body: unknown}>; + consumed: Array<{packageName: string; productId: string; purchaseToken: string}>; + canceled: Array<{packageName: string; purchaseToken: string; body: unknown}>; + revoked: Array<{packageName: string; purchaseToken: string; body: unknown}>; + refunded: Array<{packageName: string; orderId: string; revoke: boolean}>; + voidedQueries: Array>; + jwksFetches: number; +} + +interface StoredPurchase { + packageName: string; + purchase: T; +} + +interface StoredVoidedPurchase { + packageName: string; + voided: GooglePlayVoidedPurchase; +} + +interface SignPushTokenOptions { + claims?: Record; + omitClaims?: Array; + signer?: 'trusted' | 'untrusted'; + kid?: string; +} + +interface FakeGooglePlayOptions { + jwksUrl?: string; + tokenUri?: string; +} + +const API_PATH_PATTERN = new RegExp(`^${escapeRegExp(new URL(GOOGLE_PLAY_API_BASE_URL).pathname)}/([^/]+)(/.*)$`, 'u'); +const TRUSTED_KID = 'fake-google-oidc-key'; + +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/gu, '\\$&'); +} + +function emptySpies(): GooglePlayFakeSpies { + return { + tokenAssertions: [], + apiRequests: [], + acknowledged: [], + consumed: [], + canceled: [], + revoked: [], + refunded: [], + voidedQueries: [], + jwksFetches: 0, + }; +} + +function googleError(status: number, reason: string, headers: Record = {}): HttpResponse { + return new HttpResponse( + JSON.stringify({error: {code: status, message: reason, status: reason, errors: [{reason, domain: 'global'}]}}), + {status, headers: {'Content-Type': 'application/json', ...headers}}, + ); +} + +function json(body: unknown, status = 200): HttpResponse { + return new HttpResponse(JSON.stringify(body), {status, headers: {'Content-Type': 'application/json'}}); +} + +async function readJson(request: Request): Promise { + const text = await request.text(); + if (text.length === 0) { + return null; + } + try { + return JSON.parse(text); + } catch { + return null; + } +} + +function futureIso(ms: number): string { + return new Date(Date.now() + ms).toISOString(); +} + +export function buildFakeSubscriptionPurchase(overrides: Partial = {}): SubscriptionPurchaseV2 { + return { + kind: 'androidpublisher#subscriptionPurchaseV2', + regionCode: 'US', + startTime: new Date(Date.now() - 60_000).toISOString(), + subscriptionState: 'SUBSCRIPTION_STATE_ACTIVE', + acknowledgementState: 'ACKNOWLEDGEMENT_STATE_PENDING', + lineItems: [ + { + productId: 'plutonium', + expiryTime: futureIso(30 * 24 * 60 * 60 * 1000), + latestSuccessfulOrderId: 'GPA.1111-2222-3333-44444', + autoRenewingPlan: {autoRenewEnabled: true}, + offerDetails: {basePlanId: 'monthly'}, + }, + ], + ...overrides, + }; +} + +export function buildFakeProductPurchase(overrides: Partial = {}): ProductPurchaseV2 { + return { + kind: 'androidpublisher#productPurchaseV2', + regionCode: 'US', + orderId: 'GPA.5555-6666-7777-88888', + purchaseCompletionTime: new Date(Date.now() - 60_000).toISOString(), + purchaseStateContext: {purchaseState: 'PURCHASED'}, + acknowledgementState: 'ACKNOWLEDGEMENT_STATE_PENDING', + productLineItem: [ + { + productId: 'gift_1_month', + productOfferDetails: {quantity: 1, consumptionState: 'CONSUMPTION_STATE_YET_TO_BE_CONSUMED'}, + }, + ], + ...overrides, + }; +} + +export function encodeGooglePlayDeveloperNotification(notification: Record): string { + return Buffer.from(JSON.stringify(notification), 'utf8').toString('base64'); +} + +export function buildGooglePlayPushEnvelope( + notification: Record, + messageId = '136969346945', +): Record { + return { + message: { + attributes: {}, + data: encodeGooglePlayDeveloperNotification(notification), + messageId, + message_id: messageId, + publishTime: new Date().toISOString(), + }, + subscription: 'projects/fluxer-test/subscriptions/play-rtdn', + }; +} + +export class FakeGooglePlayDeveloperApi { + readonly spies: GooglePlayFakeSpies = emptySpies(); + readonly jwksUrl: string; + readonly tokenUri: string; + private readonly subscriptions = new Map>(); + private readonly products = new Map>(); + private readonly goneTokens = new Set(); + private readonly voided: Array = []; + private readonly failures = new Map>(); + private readonly accessTokens = new Map(); + private accessTokenCounter = 0; + private accessTokenExpiresIn: number | string = 3600; + private serviceAccountKey: KeyObject | null = null; + private readonly trustedKeys = generateKeyPairSync('rsa', {modulusLength: 2048}); + private readonly untrustedKeys = generateKeyPairSync('rsa', {modulusLength: 2048}); + + constructor(options: FakeGooglePlayOptions = {}) { + this.jwksUrl = options.jwksUrl ?? GOOGLE_OIDC_JWKS_URL; + this.tokenUri = options.tokenUri ?? GOOGLE_DEFAULT_TOKEN_URI; + } + + get handlers(): Array { + const apiOrigin = new URL(GOOGLE_PLAY_API_BASE_URL).origin; + return [ + http.post(this.tokenUri, ({request}) => this.handleToken(request)), + http.get(this.jwksUrl, () => this.handleJwks()), + http.all(`${apiOrigin}/*`, ({request}) => this.handleApi(request)), + ]; + } + + reset(): void { + this.subscriptions.clear(); + this.products.clear(); + this.goneTokens.clear(); + this.voided.length = 0; + this.failures.clear(); + this.accessTokens.clear(); + this.accessTokenCounter = 0; + this.accessTokenExpiresIn = 3600; + this.serviceAccountKey = null; + Object.assign(this.spies, emptySpies()); + } + + setSubscription(packageName: string, purchaseToken: string, purchase: SubscriptionPurchaseV2): void { + this.subscriptions.set(purchaseToken, {packageName, purchase: structuredClone(purchase)}); + } + + getSubscription(purchaseToken: string): SubscriptionPurchaseV2 | null { + return this.subscriptions.get(purchaseToken)?.purchase ?? null; + } + + setProduct(packageName: string, purchaseToken: string, purchase: ProductPurchaseV2): void { + this.products.set(purchaseToken, {packageName, purchase: structuredClone(purchase)}); + } + + getProduct(purchaseToken: string): ProductPurchaseV2 | null { + return this.products.get(purchaseToken)?.purchase ?? null; + } + + markTokenGone(purchaseToken: string): void { + this.goneTokens.add(purchaseToken); + } + + addVoidedPurchase(packageName: string, voided: GooglePlayVoidedPurchase): void { + this.voided.push({packageName, voided}); + } + + failNext(operation: GooglePlayFakeOperation, failure: GooglePlayFakeFailure): void { + const queue = this.failures.get(operation) ?? []; + queue.push(failure); + this.failures.set(operation, queue); + } + + setAccessTokenExpiresIn(value: number | string): void { + this.accessTokenExpiresIn = value; + } + + setServiceAccountPrivateKey(pem: string): void { + this.serviceAccountKey = createPublicKey(pem); + } + + revokeAccessTokens(): void { + this.accessTokens.clear(); + } + + async signPushToken(options: SignPushTokenOptions = {}): Promise { + const nowSeconds = Math.floor(Date.now() / 1000); + const claims: Record = { + iss: 'https://accounts.google.com', + aud: Config.googlePlay.pushAudience, + azp: '112233445566778899000', + sub: '112233445566778899000', + email: Config.googlePlay.pushServiceAccountEmail, + email_verified: true, + iat: nowSeconds, + exp: nowSeconds + 3600, + ...options.claims, + }; + for (const key of options.omitClaims ?? []) { + delete claims[key]; + } + const keys = options.signer === 'untrusted' ? this.untrustedKeys : this.trustedKeys; + return await new SignJWT(claims) + .setProtectedHeader({alg: 'RS256', typ: 'JWT', kid: options.kid ?? TRUSTED_KID}) + .sign(keys.privateKey); + } + + private takeFailure(operation: GooglePlayFakeOperation): HttpResponse | null { + const failure = this.failures.get(operation)?.shift(); + if (!failure) { + return null; + } + return googleError( + failure.status, + failure.reason ?? `fake_${failure.status}`, + failure.retryAfter ? {'Retry-After': failure.retryAfter} : {}, + ); + } + + private handleJwks(): HttpResponse { + this.spies.jwksFetches += 1; + const failure = this.takeFailure('jwks'); + if (failure) { + return failure; + } + const jwk = this.trustedKeys.publicKey.export({format: 'jwk'}); + return json({keys: [{...jwk, kid: TRUSTED_KID, alg: 'RS256', use: 'sig'}]}); + } + + private resolveServiceAccountKey(): KeyObject | null { + if (this.serviceAccountKey) { + return this.serviceAccountKey; + } + const pem = Config.googlePlay.privateKey; + return pem ? createPublicKey(pem.replaceAll('\\n', '\n')) : null; + } + + private async handleToken(request: Request): Promise> { + const failure = this.takeFailure('token'); + if (failure) { + return failure; + } + const form = new URLSearchParams(await request.text()); + if (form.get('grant_type') !== 'urn:ietf:params:oauth:grant-type:jwt-bearer') { + return json({error: 'unsupported_grant_type'}, 400); + } + const assertion = form.get('assertion'); + const key = this.resolveServiceAccountKey(); + if (!assertion || !key) { + return json({error: 'invalid_request'}, 400); + } + try { + const {payload, protectedHeader} = await jwtVerify(assertion, key, { + algorithms: ['RS256'], + audience: this.tokenUri, + }); + if (payload['scope'] !== GOOGLE_PLAY_ANDROIDPUBLISHER_SCOPE || typeof payload.iss !== 'string') { + return json({error: 'invalid_scope'}, 400); + } + if (payload.iat === undefined || payload.exp === undefined || payload.exp - payload.iat > 3600) { + return json({error: 'invalid_grant', error_description: 'Invalid JWT'}, 400); + } + this.spies.tokenAssertions.push({header: {...protectedHeader}, claims: payload}); + } catch { + return json({error: 'invalid_grant', error_description: 'Invalid JWT Signature.'}, 400); + } + this.accessTokenCounter += 1; + const token = `ya29.fake-${this.accessTokenCounter}`; + this.accessTokens.set(token, Date.now() + 3600 * 1000); + return json({access_token: token, expires_in: this.accessTokenExpiresIn, token_type: 'Bearer'}); + } + + private async handleApi(request: Request): Promise> { + const url = new URL(request.url); + const match = API_PATH_PATTERN.exec(url.pathname); + if (!match) { + return googleError(404, 'notFound'); + } + const packageName = decodeURIComponent(match[1]); + const rest = match[2]; + const route = this.route(request.method, rest); + if (!route) { + return googleError(404, 'notFound'); + } + const authorization = request.headers.get('authorization') ?? ''; + this.spies.apiRequests.push({operation: route.operation, method: request.method, packageName, authorization}); + const bearer = authorization.startsWith('Bearer ') ? authorization.slice('Bearer '.length) : ''; + const expiry = this.accessTokens.get(bearer); + if (expiry === undefined || expiry <= Date.now()) { + return googleError(401, 'authError'); + } + const failure = this.takeFailure(route.operation); + if (failure) { + return failure; + } + switch (route.operation) { + case 'subscriptions.get': + return this.handleSubscriptionGet(packageName, route.token); + case 'subscriptions.cancel': + return this.handleSubscriptionCancel(packageName, route.token, await readJson(request)); + case 'subscriptions.revoke': + return this.handleSubscriptionRevoke(packageName, route.token, await readJson(request)); + case 'subscriptions.acknowledge': + return this.handleSubscriptionAcknowledge(packageName, route.productId, route.token, await readJson(request)); + case 'products.get': + return this.handleProductGet(packageName, route.token); + case 'products.consume': + return this.handleProductConsume(packageName, route.productId, route.token); + case 'orders.refund': + return this.handleRefund(packageName, route.token, url.searchParams.get('revoke') === 'true'); + case 'voidedpurchases.list': + return this.handleVoidedList(packageName, url.searchParams); + default: + return googleError(404, 'notFound'); + } + } + + private route( + method: string, + rest: string, + ): {operation: GooglePlayFakeOperation; token: string; productId: string} | null { + const patterns: Array<{method: string; pattern: RegExp; operation: GooglePlayFakeOperation}> = [ + {method: 'GET', pattern: /^\/purchases\/subscriptionsv2\/tokens\/([^/:]+)$/u, operation: 'subscriptions.get'}, + { + method: 'POST', + pattern: /^\/purchases\/subscriptionsv2\/tokens\/([^/:]+):cancel$/u, + operation: 'subscriptions.cancel', + }, + { + method: 'POST', + pattern: /^\/purchases\/subscriptionsv2\/tokens\/([^/:]+):revoke$/u, + operation: 'subscriptions.revoke', + }, + { + method: 'POST', + pattern: /^\/purchases\/subscriptions\/([^/]+)\/tokens\/([^/:]+):acknowledge$/u, + operation: 'subscriptions.acknowledge', + }, + {method: 'GET', pattern: /^\/purchases\/productsv2\/tokens\/([^/:]+)$/u, operation: 'products.get'}, + { + method: 'POST', + pattern: /^\/purchases\/products\/([^/]+)\/tokens\/([^/:]+):consume$/u, + operation: 'products.consume', + }, + {method: 'POST', pattern: /^\/orders\/([^/:]+):refund$/u, operation: 'orders.refund'}, + {method: 'GET', pattern: /^\/purchases\/voidedpurchases$/u, operation: 'voidedpurchases.list'}, + ]; + for (const entry of patterns) { + if (entry.method !== method) { + continue; + } + const match = entry.pattern.exec(rest); + if (!match) { + continue; + } + if (match.length === 3) { + return { + operation: entry.operation, + productId: decodeURIComponent(match[1]), + token: decodeURIComponent(match[2]), + }; + } + return {operation: entry.operation, productId: '', token: match[1] ? decodeURIComponent(match[1]) : ''}; + } + return null; + } + + private findSubscription( + packageName: string, + token: string, + ): {record: StoredPurchase} | HttpResponse { + if (this.goneTokens.has(token)) { + return googleError(410, 'purchaseTokenNoLongerValid'); + } + const record = this.subscriptions.get(token); + if (!record || record.packageName !== packageName) { + return googleError(404, 'purchaseTokenNotFound'); + } + return {record}; + } + + private findProduct( + packageName: string, + token: string, + ): {record: StoredPurchase} | HttpResponse { + if (this.goneTokens.has(token)) { + return googleError(410, 'purchaseTokenNoLongerValid'); + } + const record = this.products.get(token); + if (!record || record.packageName !== packageName) { + return googleError(404, 'purchaseTokenNotFound'); + } + return {record}; + } + + private handleSubscriptionGet(packageName: string, token: string): HttpResponse { + const found = this.findSubscription(packageName, token); + if (found instanceof HttpResponse) { + return found; + } + return json(found.record.purchase); + } + + private handleSubscriptionCancel(packageName: string, token: string, body: unknown): HttpResponse { + const found = this.findSubscription(packageName, token); + if (found instanceof HttpResponse) { + return found; + } + this.spies.canceled.push({packageName, purchaseToken: token, body}); + const purchase = found.record.purchase; + const developerStop = + JSON.stringify(body ?? {}).includes('DEVELOPER_REQUESTED_STOP_PAYMENTS') || + purchase.subscriptionState !== 'SUBSCRIPTION_STATE_ACTIVE'; + purchase.subscriptionState = 'SUBSCRIPTION_STATE_CANCELED'; + purchase.canceledStateContext = developerStop + ? {developerInitiatedCancellation: {}} + : {userInitiatedCancellation: {cancelTime: new Date().toISOString()}}; + for (const lineItem of purchase.lineItems ?? []) { + if (lineItem.autoRenewingPlan) { + lineItem.autoRenewingPlan.autoRenewEnabled = false; + } + } + return json({}); + } + + private handleSubscriptionRevoke(packageName: string, token: string, body: unknown): HttpResponse { + const found = this.findSubscription(packageName, token); + if (found instanceof HttpResponse) { + return found; + } + this.spies.revoked.push({packageName, purchaseToken: token, body}); + this.expireSubscription(found.record.purchase); + return json({}); + } + + private expireSubscription(purchase: SubscriptionPurchaseV2): void { + purchase.subscriptionState = 'SUBSCRIPTION_STATE_EXPIRED'; + purchase.canceledStateContext = {developerInitiatedCancellation: {}}; + for (const lineItem of purchase.lineItems ?? []) { + lineItem.expiryTime = new Date().toISOString(); + if (lineItem.autoRenewingPlan) { + lineItem.autoRenewingPlan.autoRenewEnabled = false; + } + } + } + + private handleSubscriptionAcknowledge( + packageName: string, + productId: string, + token: string, + body: unknown, + ): HttpResponse { + const found = this.findSubscription(packageName, token); + if (found instanceof HttpResponse) { + return found; + } + const purchase = found.record.purchase; + if (!(purchase.lineItems ?? []).some((lineItem) => lineItem.productId === productId)) { + return googleError(400, 'productNotOwnedByUser'); + } + if (purchase.acknowledgementState === 'ACKNOWLEDGEMENT_STATE_ACKNOWLEDGED') { + return googleError(400, 'invalidPurchaseState'); + } + if (purchase.subscriptionState === 'SUBSCRIPTION_STATE_PENDING') { + return googleError(400, 'invalidPurchaseState'); + } + purchase.acknowledgementState = 'ACKNOWLEDGEMENT_STATE_ACKNOWLEDGED'; + delete purchase.outOfAppPurchaseContext; + this.spies.acknowledged.push({packageName, productId, purchaseToken: token, body}); + return json({}); + } + + private handleProductGet(packageName: string, token: string): HttpResponse { + const found = this.findProduct(packageName, token); + if (found instanceof HttpResponse) { + return found; + } + return json(found.record.purchase); + } + + private handleProductConsume(packageName: string, productId: string, token: string): HttpResponse { + const found = this.findProduct(packageName, token); + if (found instanceof HttpResponse) { + return found; + } + const purchase = found.record.purchase; + const lineItem = (purchase.productLineItem ?? []).find((item) => item.productId === productId); + if (!lineItem) { + return googleError(400, 'productNotOwnedByUser'); + } + if (purchase.purchaseStateContext?.purchaseState !== 'PURCHASED') { + return googleError(400, 'invalidPurchaseState'); + } + if (lineItem.productOfferDetails?.consumptionState === 'CONSUMPTION_STATE_CONSUMED') { + return googleError(400, 'purchaseAlreadyConsumed'); + } + lineItem.productOfferDetails = {...lineItem.productOfferDetails, consumptionState: 'CONSUMPTION_STATE_CONSUMED'}; + purchase.acknowledgementState = 'ACKNOWLEDGEMENT_STATE_ACKNOWLEDGED'; + this.spies.consumed.push({packageName, productId, purchaseToken: token}); + return json({}); + } + + private handleRefund(packageName: string, orderId: string, revoke: boolean): HttpResponse { + const subscription = [...this.subscriptions.entries()].find( + ([, record]) => + record.packageName === packageName && + (record.purchase.lineItems ?? []).some((lineItem) => lineItem.latestSuccessfulOrderId === orderId), + ); + const product = [...this.products.entries()].find( + ([, record]) => record.packageName === packageName && record.purchase.orderId === orderId, + ); + const match = subscription ?? product; + if (!match) { + return googleError(404, 'orderNotFound'); + } + this.spies.refunded.push({packageName, orderId, revoke}); + const [purchaseToken] = match; + if (revoke) { + if (subscription) { + this.expireSubscription(subscription[1].purchase); + } + if (product) { + product[1].purchase.purchaseStateContext = {purchaseState: 'CANCELLED'}; + } + } + if (!this.voided.some((entry) => entry.voided.orderId === orderId)) { + const now = String(Date.now()); + this.voided.push({ + packageName, + voided: { + kind: 'androidpublisher#voidedPurchase', + purchaseToken, + orderId, + purchaseTimeMillis: now, + voidedTimeMillis: now, + voidedSource: 1, + voidedReason: 0, + }, + }); + } + return json({}); + } + + private handleVoidedList(packageName: string, params: URLSearchParams): HttpResponse { + this.spies.voidedQueries.push(Object.fromEntries(params.entries())); + const startTime = Number(params.get('startTime') ?? '0'); + const endTime = params.has('endTime') ? Number(params.get('endTime')) : Number.POSITIVE_INFINITY; + const includeSubscriptions = params.get('type') === '1'; + const matching = this.voided + .filter((entry) => entry.packageName === packageName) + .filter((entry) => { + const voidedAt = Number(entry.voided.voidedTimeMillis ?? '0'); + return voidedAt >= startTime && voidedAt <= endTime; + }) + .filter((entry) => includeSubscriptions || !this.subscriptions.has(entry.voided.purchaseToken)) + .map((entry) => entry.voided); + const offset = Number(params.get('token') ?? '0'); + const pageSize = Number(params.get('maxResults') ?? '1000'); + const page = matching.slice(offset, offset + pageSize); + const nextOffset = offset + pageSize; + return json({ + voidedPurchases: page, + ...(nextOffset < matching.length ? {tokenPagination: {nextPageToken: String(nextOffset)}} : {}), + }); + } +} + +export function createGooglePlayDeveloperApiHandlers(options: FakeGooglePlayOptions = {}): FakeGooglePlayDeveloperApi { + return new FakeGooglePlayDeveloperApi(options); +} diff --git a/fluxer_api/src/api/types/HonoEnv.ts b/fluxer_api/src/api/types/HonoEnv.ts index 21156c4c8..b246c8d9b 100644 --- a/fluxer_api/src/api/types/HonoEnv.ts +++ b/fluxer_api/src/api/types/HonoEnv.ts @@ -57,6 +57,7 @@ import type {ReportRequestService} from '@app/api/report/ReportRequestService'; import type {ReportService} from '@app/api/report/ReportService'; import type {RpcService} from '@app/api/rpc/RpcService'; import type {SearchService} from '@app/api/search/SearchService'; +import type {StoreEntitlementService} from '@app/api/store_billing/StoreEntitlementService'; import type {StripeService} from '@app/api/stripe/StripeService'; import type {AgeVerificationService} from '@app/api/stripe/services/AgeVerificationService'; import type {ThemeService} from '@app/api/theme/ThemeService'; @@ -176,6 +177,7 @@ export interface HonoEnv { webhookRequestService: WebhookRequestService; workerService: IWorkerService; stripeService: StripeService; + storeEntitlementService: StoreEntitlementService; ageVerificationService: AgeVerificationService; applicationService: ApplicationService; oauth2Service: OAuth2Service; diff --git a/fluxer_api/src/api/user/controllers/UserAccountController.ts b/fluxer_api/src/api/user/controllers/UserAccountController.ts index c22e07bf3..dbf9d3891 100644 --- a/fluxer_api/src/api/user/controllers/UserAccountController.ts +++ b/fluxer_api/src/api/user/controllers/UserAccountController.ts @@ -1276,6 +1276,7 @@ export function UserAccountController(app: HonoApp) { if (!(user.flags & UserFlags.STAFF)) { throw new MissingAccessError(); } + await ctx.get('storeEntitlementService').unbindAllForUser(user.id); await ctx.get('userService').accountService.resetCurrentUserPremiumState(user); return ctx.body(null, 204); }, diff --git a/fluxer_api/src/api/user/repositories/GiftCodeRepository.ts b/fluxer_api/src/api/user/repositories/GiftCodeRepository.ts index 9ecb78ca0..2692602a8 100644 --- a/fluxer_api/src/api/user/repositories/GiftCodeRepository.ts +++ b/fluxer_api/src/api/user/repositories/GiftCodeRepository.ts @@ -1,7 +1,13 @@ // SPDX-License-Identifier: AGPL-3.0-or-later import type {UserID} from '@app/api/BrandedTypes'; -import {BatchBuilder, fetchMany, fetchOne, upsertOne} from '@app/api/database/CassandraQueryExecution'; +import { + BatchBuilder, + executeConditional, + fetchMany, + fetchOne, + upsertOne, +} from '@app/api/database/CassandraQueryExecution'; import {Db, type DbOp} from '@app/api/database/CassandraTypes'; import type {GiftCodeRow} from '@app/api/database/types/PaymentTypes'; import {GiftCode, mapGiftCodeDurationToMonths, mapGiftDurationMonthsToFields} from '@app/api/models/GiftCode'; @@ -48,6 +54,7 @@ function normaliseGiftCodeRowForWrite(data: GiftCodeRow): GiftCodeRow { duration_quantity: durationQuantity, duration_months: durationMonths, revoked_at: data.revoked_at ?? null, + premium_reversed_seconds: data.premium_reversed_seconds ?? null, }; } @@ -176,6 +183,30 @@ export class GiftCodeRepository { await upsertOne(GiftCodes.patchByPk({code}, {revoked_at: Db.set(new Date())})); } + async unrevokeGiftCode(code: string): Promise { + await upsertOne(GiftCodes.patchByPk({code}, {revoked_at: Db.set(null)})); + } + + async markGiftPremiumReversed(gift: GiftCode, seconds: number): Promise { + return executeConditional( + GiftCodes.conditionalPatchByPk( + {code: gift.code}, + {premium_reversed_seconds: Db.set(seconds)}, + {created_by_user_id: gift.createdByUserId, premium_reversed_seconds: null}, + ), + ); + } + + async clearGiftPremiumReversed(code: string, seconds: number): Promise { + return executeConditional( + GiftCodes.conditionalPatchByPk( + {code}, + {premium_reversed_seconds: Db.set(null)}, + {premium_reversed_seconds: seconds}, + ), + ); + } + async updateGiftCode(code: string, data: Partial): Promise { const batch = new BatchBuilder(); const patch: Record> = {}; diff --git a/fluxer_api/src/api/user/repositories/IUserContentRepository.ts b/fluxer_api/src/api/user/repositories/IUserContentRepository.ts index 9205b1495..c60ac7f0a 100644 --- a/fluxer_api/src/api/user/repositories/IUserContentRepository.ts +++ b/fluxer_api/src/api/user/repositories/IUserContentRepository.ts @@ -39,6 +39,9 @@ export interface IUserContentRepository { redeemGiftCode(code: string, userId: UserID): Promise; unredeemGiftCode(code: string, userId: UserID): Promise; revokeGiftCode(code: string): Promise; + unrevokeGiftCode(code: string): Promise; + markGiftPremiumReversed(gift: GiftCode, seconds: number): Promise; + clearGiftPremiumReversed(code: string, seconds: number): Promise; updateGiftCode(code: string, data: Partial): Promise; linkGiftCodeToCheckoutSession(code: string, checkoutSessionId: string): Promise; listPushSubscriptions(userId: UserID): Promise>; diff --git a/fluxer_api/src/api/user/repositories/UserContentRepository.ts b/fluxer_api/src/api/user/repositories/UserContentRepository.ts index b750cfc18..1fc920cd1 100644 --- a/fluxer_api/src/api/user/repositories/UserContentRepository.ts +++ b/fluxer_api/src/api/user/repositories/UserContentRepository.ts @@ -66,6 +66,18 @@ export class UserContentRepository implements IUserContentRepository { return this.giftCodeRepository.revokeGiftCode(code); } + async unrevokeGiftCode(code: string): Promise { + return this.giftCodeRepository.unrevokeGiftCode(code); + } + + async markGiftPremiumReversed(gift: GiftCode, seconds: number): Promise { + return this.giftCodeRepository.markGiftPremiumReversed(gift, seconds); + } + + async clearGiftPremiumReversed(code: string, seconds: number): Promise { + return this.giftCodeRepository.clearGiftPremiumReversed(code, seconds); + } + async updateGiftCode(code: string, data: Partial): Promise { return this.giftCodeRepository.updateGiftCode(code, data); } diff --git a/fluxer_api/src/api/user/repositories/UserRepository.ts b/fluxer_api/src/api/user/repositories/UserRepository.ts index c7481cfdb..db4d85367 100644 --- a/fluxer_api/src/api/user/repositories/UserRepository.ts +++ b/fluxer_api/src/api/user/repositories/UserRepository.ts @@ -654,6 +654,18 @@ export class UserRepository implements IUserRepositoryAggregate { return this.contentRepo.revokeGiftCode(code); } + async unrevokeGiftCode(code: string): Promise { + return this.contentRepo.unrevokeGiftCode(code); + } + + async markGiftPremiumReversed(gift: GiftCode, seconds: number): Promise { + return this.contentRepo.markGiftPremiumReversed(gift, seconds); + } + + async clearGiftPremiumReversed(code: string, seconds: number): Promise { + return this.contentRepo.clearGiftPremiumReversed(code, seconds); + } + async updateGiftCode(code: string, data: Partial): Promise { return this.contentRepo.updateGiftCode(code, data); } diff --git a/fluxer_api/src/api/user/services/UserDeletionService.ts b/fluxer_api/src/api/user/services/UserDeletionService.ts index 1b4440e51..8781af5b6 100644 --- a/fluxer_api/src/api/user/services/UserDeletionService.ts +++ b/fluxer_api/src/api/user/services/UserDeletionService.ts @@ -21,6 +21,7 @@ import {createRequestCache} from '@app/api/middleware/RequestCacheMiddleware'; import {getBillingRepository} from '@app/api/middleware/ServiceRegistry'; import type {ApplicationRepository} from '@app/api/oauth/repositories/ApplicationRepository'; import type {OAuth2TokenRepository} from '@app/api/oauth/repositories/OAuth2TokenRepository'; +import type {StoreEntitlementService} from '@app/api/store_billing/StoreEntitlementService'; import type {UserRepository} from '@app/api/user/repositories/UserRepository'; import {isPendingDeletionBlocked} from '@app/api/user/services/PendingDeletionCoordinator'; import type {WorkerTaskName} from '@app/api/worker/WorkerLaneConfig'; @@ -55,6 +56,7 @@ interface UserDeletionDependencies { applicationRepository: ApplicationRepository; workerService: IWorkerService; connectionRepository: IConnectionRepository; + storeEntitlementService: StoreEntitlementService; } export async function processUserDeletion( @@ -78,6 +80,7 @@ export async function processUserDeletion( applicationRepository, workerService, connectionRepository, + storeEntitlementService, } = deps; Logger.debug({userId, deletionReasonCode}, 'Starting user account deletion'); const scheduledUser = await userRepository.findUnique(userId); @@ -155,6 +158,7 @@ export async function processUserDeletion( Logger.info({userId, pendingDeletionAt}, 'Account deletion schedule is no longer eligible'); return; } + await storeEntitlementService.stopBillingForDeletedUser(userId); await connectionRepository.sealAndDeleteForUser(userId); const deletedUserId = createUserID(await snowflakeService.generate()); Logger.debug({userId, deletedUserId}, 'Creating dedicated deleted user record'); diff --git a/fluxer_api/src/api/user/tests/UserPremiumReset.test.ts b/fluxer_api/src/api/user/tests/UserPremiumReset.test.ts index 66f6f5b09..30a48a0fa 100644 --- a/fluxer_api/src/api/user/tests/UserPremiumReset.test.ts +++ b/fluxer_api/src/api/user/tests/UserPremiumReset.test.ts @@ -1,10 +1,14 @@ // SPDX-License-Identifier: AGPL-3.0-or-later import {createTestAccount} from '@app/api/auth/tests/AuthTestUtils'; +import {createUserID} from '@app/api/BrandedTypes'; +import {getStoreBillingRepository} from '@app/api/middleware/ServiceSingletons'; +import {seedStorePurchase} from '@app/api/store_billing/tests/StoreBillingTestUtils'; import {type ApiTestHarness, createApiTestHarness} from '@app/api/test/ApiTestHarness'; import {HTTP_STATUS} from '@app/api/test/TestConstants'; import {createBuilder} from '@app/api/test/TestRequestBuilder'; import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes'; +import type {StorePurchaseResponse} from '@fluxer/schema/src/domains/premium/StoreBillingSchemas'; import {afterEach, beforeEach, describe, expect, test} from 'vitest'; interface CurrentUserPremiumState { @@ -66,6 +70,26 @@ describe('User premium reset endpoint', () => { expect(me.premium_lifetime_sequence).toBeNull(); expect(me.premium_enabled_override).toBe(false); }); + test('staff reset unbinds the account store purchases', async () => { + const account = await createTestAccount(harness); + const userId = createUserID(BigInt(account.userId)); + await createBuilder(harness, account.token) + .post(`/test/users/${account.userId}/security-flags`) + .body({set_flags: ['STAFF']}) + .expect(HTTP_STATUS.OK) + .execute(); + const row = await seedStorePurchase(userId); + await createBuilder(harness, account.token) + .post('/users/@me/premium/reset') + .expect(HTTP_STATUS.NO_CONTENT) + .execute(); + const purchases = await createBuilder>(harness, account.token) + .get('/premium/store/purchases') + .expect(HTTP_STATUS.OK) + .execute(); + expect(purchases).toEqual([]); + expect((await getStoreBillingRepository().findPurchase(row.store_key))?.user_id).toBeNull(); + }); test('non-staff user cannot reset premium state', async () => { const account = await createTestAccount(harness); await createBuilder(harness, account.token) diff --git a/fluxer_api/src/api/worker/WorkerDependencies.ts b/fluxer_api/src/api/worker/WorkerDependencies.ts index e9563b512..7722be092 100644 --- a/fluxer_api/src/api/worker/WorkerDependencies.ts +++ b/fluxer_api/src/api/worker/WorkerDependencies.ts @@ -97,6 +97,8 @@ import type {OAuth2TokenRepository} from '@app/api/oauth/repositories/OAuth2Toke import type {ReadStateRepository} from '@app/api/read_state/ReadStateRepository'; import type {ReadStateService} from '@app/api/read_state/ReadStateService'; import type {ReportRepository} from '@app/api/report/ReportRepository'; +import type {StoreEntitlementService} from '@app/api/store_billing/StoreEntitlementService'; +import {createStoreEntitlementService} from '@app/api/store_billing/StoreEntitlementServiceFactory'; import {getStripeClient} from '@app/api/stripe/StripeClient'; import {PaymentRepository} from '@app/api/user/repositories/PaymentRepository'; import type {UserRepository} from '@app/api/user/repositories/UserRepository'; @@ -168,6 +170,7 @@ export interface WorkerDependencies { donationRepository: IDonationRepository; guildService: GuildService; billingRepository: BillingRepository; + storeEntitlementService: StoreEntitlementService; stripe: Stripe | null; } @@ -263,6 +266,14 @@ export async function initializeWorkerDependencies(snowflakeService: ISnowflakeS ipInfoService, }); const billingRepository = new BillingRepository(snowflakeService, kvClient); + const storeEntitlementService = createStoreEntitlementService({ + userRepository, + userCacheService, + gatewayService, + kvClient, + snowflakeService, + premiumStateReconciliationQueueService, + }); Logger.info('Worker dependencies initialized successfully'); return { kvClient, @@ -314,6 +325,7 @@ export async function initializeWorkerDependencies(snowflakeService: ISnowflakeS guildService, donationRepository, billingRepository, + storeEntitlementService, guildAuditLogService, contactChangeLogService, ncmecSubmissionService, diff --git a/fluxer_api/src/api/worker/WorkerLaneConfig.ts b/fluxer_api/src/api/worker/WorkerLaneConfig.ts index 81af7be99..9b396eaf9 100644 --- a/fluxer_api/src/api/worker/WorkerLaneConfig.ts +++ b/fluxer_api/src/api/worker/WorkerLaneConfig.ts @@ -49,6 +49,9 @@ const LANE_CONFIG = { 'harvestUserData', 'batchGuildAuditLogMessageDeletes', 'reconcileUserPayments', + 'processAppStoreNotification', + 'processGooglePlayNotification', + 'refreshStorePurchase', 'bulkUpdateUserFlags', 'bulkUpdateSuspiciousActivityFlags', 'bulkScheduleUserDeletion', @@ -77,6 +80,9 @@ const LANE_CONFIG = { 'processInactivityDeletions', 'processPendingBulkMessageDeletions', 'processPremiumStateReconciliationQueue', + 'processStorePurchaseRefreshQueue', + 'pollGooglePlayVoidedPurchases', + 'pollAppStoreNotificationHistory', 'prunePostgresKvTtl', 'refreshSearchIndex', 'syncDiscoveryIndex', diff --git a/fluxer_api/src/api/worker/WorkerMain.ts b/fluxer_api/src/api/worker/WorkerMain.ts index 615b2b35e..3e0eeb6c3 100644 --- a/fluxer_api/src/api/worker/WorkerMain.ts +++ b/fluxer_api/src/api/worker/WorkerMain.ts @@ -68,6 +68,15 @@ function registerCronJobs(cron: CronScheduler, jobsStreamMaxAgeMs: number): void ledger: false, }); cron.upsert('processExpiredPremiumSweep', 'processExpiredPremiumSweep', {}, '0 0 * * * *', {ledger: false}); + if (!Config.instance.selfHosted) { + cron.upsert('processStorePurchaseRefreshQueue', 'processStorePurchaseRefreshQueue', {}, '0 */5 * * * *', { + ledger: false, + }); + cron.upsert('pollGooglePlayVoidedPurchases', 'pollGooglePlayVoidedPurchases', {}, '0 30 4 * * *', {ledger: false}); + cron.upsert('pollAppStoreNotificationHistory', 'pollAppStoreNotificationHistory', {}, '0 45 4 * * *', { + ledger: false, + }); + } cron.upsert('processInactivityDeletions', 'processInactivityDeletions', {}, '0 0 */6 * * *', {ledger: false}); cron.upsert('expireAttachments', 'expireAttachments', {}, '0 0 */12 * * *', {ledger: false}); if (jobsStreamMaxAgeMs > 0 && jobsStreamMaxAgeMs <= JOBS_STREAM_MAX_AGE_MS) { diff --git a/fluxer_api/src/api/worker/WorkerTaskRegistry.ts b/fluxer_api/src/api/worker/WorkerTaskRegistry.ts index 64fb95630..d92b4be34 100644 --- a/fluxer_api/src/api/worker/WorkerTaskRegistry.ts +++ b/fluxer_api/src/api/worker/WorkerTaskRegistry.ts @@ -26,16 +26,22 @@ import harvestUserData from '@app/api/worker/tasks/HarvestUserData'; import indexChannelMessages from '@app/api/worker/tasks/IndexChannelMessages'; import indexGuildMembers from '@app/api/worker/tasks/IndexGuildMembers'; import messageShred from '@app/api/worker/tasks/MessageShred'; +import pollAppStoreNotificationHistory from '@app/api/worker/tasks/PollAppStoreNotificationHistory'; +import pollGooglePlayVoidedPurchases from '@app/api/worker/tasks/PollGooglePlayVoidedPurchases'; +import processAppStoreNotification from '@app/api/worker/tasks/ProcessAppStoreNotification'; import processAssetDeletionQueue from '@app/api/worker/tasks/ProcessAssetDeletionQueue'; import processCachePurgeQueue from '@app/api/worker/tasks/ProcessCachePurgeQueue'; import processExpiredPremiumSweep from '@app/api/worker/tasks/ProcessExpiredPremiumSweep'; +import processGooglePlayNotification from '@app/api/worker/tasks/ProcessGooglePlayNotification'; import processInactivityDeletions from '@app/api/worker/tasks/ProcessInactivityDeletions'; import processPendingBulkMessageDeletions from '@app/api/worker/tasks/ProcessPendingBulkMessageDeletions'; import processPremiumStateReconciliationQueue from '@app/api/worker/tasks/ProcessPremiumStateReconciliationQueue'; +import processStorePurchaseRefreshQueue from '@app/api/worker/tasks/ProcessStorePurchaseRefreshQueue'; import processStripeWebhook from '@app/api/worker/tasks/ProcessStripeWebhook'; import prunePostgresKvTtl from '@app/api/worker/tasks/PrunePostgresKvTtl'; import reconcileUserPayments from '@app/api/worker/tasks/ReconcileUserPayments'; import refreshSearchIndex from '@app/api/worker/tasks/RefreshSearchIndex'; +import refreshStorePurchase from '@app/api/worker/tasks/RefreshStorePurchase'; import {sendSystemDm} from '@app/api/worker/tasks/SendSystemDm'; import syncDiscoveryIndex from '@app/api/worker/tasks/SyncDiscoveryIndex'; import syncFileShaBlocklists from '@app/api/worker/tasks/SyncFileShaBlocklists'; @@ -79,6 +85,12 @@ export const workerTasks: Record = { processPendingBulkMessageDeletions, processPremiumStateReconciliationQueue, reconcileUserPayments, + processAppStoreNotification, + processGooglePlayNotification, + refreshStorePurchase, + processStorePurchaseRefreshQueue, + pollGooglePlayVoidedPurchases, + pollAppStoreNotificationHistory, prunePostgresKvTtl, refreshSearchIndex, sendSystemDm, diff --git a/fluxer_api/src/api/worker/tasks/HarvestUserData.test.ts b/fluxer_api/src/api/worker/tasks/HarvestUserData.test.ts index 3ef07f799..52d66af3a 100644 --- a/fluxer_api/src/api/worker/tasks/HarvestUserData.test.ts +++ b/fluxer_api/src/api/worker/tasks/HarvestUserData.test.ts @@ -1,7 +1,11 @@ // SPDX-License-Identifier: AGPL-3.0-or-later import {type ChannelID, createChannelID, createMessageID, createUserID, type MessageID} from '@app/api/BrandedTypes'; -import {harvestMessages} from '@app/api/worker/tasks/HarvestUserData'; +import {EMPTY_USER_ROW} from '@app/api/database/types/UserTypes'; +import {User} from '@app/api/models/User'; +import {mapStorePurchaseToResponse} from '@app/api/store_billing/StoreBillingMappers'; +import {buildStorePurchaseRow} from '@app/api/store_billing/tests/StoreBillingTestUtils'; +import {buildUserDataJson, harvestMessages} from '@app/api/worker/tasks/HarvestUserData'; import {describe, expect, it} from 'vitest'; const AUTHOR = createUserID(1000000000000000000n); @@ -66,3 +70,35 @@ describe('harvestMessages', () => { expect(result.channelMessagesMap.size).toBe(0); }); }); + +describe('buildUserDataJson', () => { + it('exports the store purchases in their public shape', () => { + const purchase = buildStorePurchaseRow({user_id: AUTHOR}); + const data = buildUserDataJson({ + user: new User({...EMPTY_USER_ROW, user_id: AUTHOR, username: 'exporter', discriminator: 1}), + userId: AUTHOR, + productName: 'Fluxer', + authSessions: [], + relationships: [], + userNotes: new Map(), + userSettings: null, + guildMemberships: [], + guildSettings: [], + savedMessages: [], + privateChannels: [], + favoriteMemes: [], + pushSubscriptions: [], + webAuthnCredentials: [], + mfaBackupCodes: [], + createdGiftCodes: [], + payments: [], + storePurchases: [purchase], + oauthClients: [], + connections: [], + pinnedDms: [], + authorizedIps: [], + activityData: {last_active_at: null, last_active_ip: null}, + }); + expect(data.store_purchases).toEqual([mapStorePurchaseToResponse(purchase)]); + }); +}); diff --git a/fluxer_api/src/api/worker/tasks/HarvestUserData.ts b/fluxer_api/src/api/worker/tasks/HarvestUserData.ts index aebfa0b5d..809699f97 100644 --- a/fluxer_api/src/api/worker/tasks/HarvestUserData.ts +++ b/fluxer_api/src/api/worker/tasks/HarvestUserData.ts @@ -29,6 +29,7 @@ import { type SelfMessageFilter, } from '@app/api/channel/services/message/SelfMessageFilter'; import type {UserConnectionRow} from '@app/api/database/types/ConnectionTypes'; +import type {StorePurchaseRow} from '@app/api/database/types/StoreBillingTypes'; import type {IStorageService} from '@app/api/infrastructure/IStorageService'; import {Logger} from '@app/api/Logger'; import type {Application} from '@app/api/models/Application'; @@ -48,6 +49,7 @@ import type {User} from '@app/api/models/User'; import type {UserGuildSettings} from '@app/api/models/UserGuildSettings'; import type {UserSettings} from '@app/api/models/UserSettings'; import type {WebAuthnCredential} from '@app/api/models/WebAuthnCredential'; +import {mapStorePurchaseToResponse} from '@app/api/store_billing/StoreBillingMappers'; import {buildHarvestDownloadUrl} from '@app/api/user/services/HarvestDownloadUrl'; import {mapWithConcurrency} from '@app/api/utils/ConcurrencyUtils'; import {resolveSessionClientInfo} from '@app/api/utils/SessionClientIdentity'; @@ -132,6 +134,7 @@ interface UserDataJsonParams { mfaBackupCodes: Array; createdGiftCodes: Array; payments: Array; + storePurchases: Array; oauthClients: Array; connections: Array; pinnedDms: Array<{ @@ -407,7 +410,7 @@ export async function harvestMessages( return {channelMessagesMap, totalMessages}; } -function buildUserDataJson(params: UserDataJsonParams) { +export function buildUserDataJson(params: UserDataJsonParams) { const { user, userId, @@ -426,6 +429,7 @@ function buildUserDataJson(params: UserDataJsonParams) { mfaBackupCodes, createdGiftCodes, payments, + storePurchases, oauthClients, connections, pinnedDms, @@ -630,6 +634,7 @@ function buildUserDataJson(params: UserDataJsonParams) { stripe_payment_intent_id: gift.stripePaymentIntentId, })), payments: payments.map(mapPayment), + store_purchases: storePurchases.map(mapStorePurchaseToResponse), oauth_applications: oauthClients.map(mapOAuthApplication), connections: connections.map((connection) => ({ id: connection.connection_id, @@ -775,6 +780,7 @@ const harvestUserData: ArchiveTaskHandler = async (payload, helpers, attempt) => storageService, emailService, instanceConfigRepository, + storeEntitlementService, } = dependencies; const adminRequestedBy = validated.adminRequestedBy ? BigInt(validated.adminRequestedBy) : null; const isAdminArchive = adminRequestedBy !== null; @@ -846,6 +852,7 @@ const harvestUserData: ArchiveTaskHandler = async (payload, helpers, attempt) => mfaBackupCodes, createdGiftCodes, payments, + storePurchases, oauthClients, connections, pinnedDms, @@ -865,6 +872,7 @@ const harvestUserData: ArchiveTaskHandler = async (payload, helpers, attempt) => userRepository.listMfaBackupCodes(userId), userRepository.findGiftCodesByCreator(userId), paymentRepository.findPaymentsByUserId(userId), + storeEntitlementService.listStorePurchases(userId), applicationRepository.listApplicationsByOwner(userId), connectionRepository.findByUserId(userId), userRepository.getPinnedDmsWithDetails(userId), @@ -900,6 +908,7 @@ const harvestUserData: ArchiveTaskHandler = async (payload, helpers, attempt) => mfaBackupCodes, createdGiftCodes, payments, + storePurchases, oauthClients, connections, pinnedDms, diff --git a/fluxer_api/src/api/worker/tasks/PollAppStoreNotificationHistory.ts b/fluxer_api/src/api/worker/tasks/PollAppStoreNotificationHistory.ts new file mode 100644 index 000000000..d348790b3 --- /dev/null +++ b/fluxer_api/src/api/worker/tasks/PollAppStoreNotificationHistory.ts @@ -0,0 +1,57 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import {Config} from '@app/api/Config'; +import {getAppStoreServerApiClient} from '@app/api/middleware/ServiceSingletons'; +import {isAppStoreConfigured} from '@app/api/store_billing/StoreBillingConfig'; +import {getWorkerDependencies} from '@app/api/worker/WorkerContext'; +import type {WorkerTaskHandler} from '@pkgs/worker/src/contracts/WorkerTask'; +import {ms} from 'itty-time'; + +const HISTORY_WINDOW_MS = ms('2 days'); +const MAX_PAGES_PER_QUERY = 50; +const HISTORY_QUERIES: ReadonlyArray<{notificationType?: string; onlyFailures?: boolean}> = [ + {onlyFailures: true}, + {notificationType: 'REFUND'}, + {notificationType: 'REFUND_REVERSED'}, + {notificationType: 'REVOKE'}, +]; + +const pollAppStoreNotificationHistory: WorkerTaskHandler = async (_payload, helpers) => { + if (Config.instance.selfHosted || !isAppStoreConfigured()) { + return; + } + const {workerService} = getWorkerDependencies(); + const client = getAppStoreServerApiClient(); + const endDate = new Date(); + const startDate = new Date(endDate.getTime() - HISTORY_WINDOW_MS); + const seen = new Set(); + for (const app of Config.appStore.apps) { + for (const query of HISTORY_QUERIES) { + let paginationToken: string | null = null; + for (let page = 0; page < MAX_PAGES_PER_QUERY; page++) { + const result = await client.getNotificationHistory({ + environment: 'production', + bundleId: app.bundleId, + startDate, + endDate, + paginationToken, + ...query, + }); + for (const signedPayload of result.signedPayloads) { + if (seen.has(signedPayload)) { + continue; + } + seen.add(signedPayload); + await workerService.addJob('processAppStoreNotification', {signedPayload}); + } + if (!result.paginationToken) { + break; + } + paginationToken = result.paginationToken; + } + } + } + helpers.logger.info({enqueued: seen.size}, 'Polled App Store notification history'); +}; + +export default pollAppStoreNotificationHistory; diff --git a/fluxer_api/src/api/worker/tasks/PollGooglePlayVoidedPurchases.ts b/fluxer_api/src/api/worker/tasks/PollGooglePlayVoidedPurchases.ts new file mode 100644 index 000000000..def459401 --- /dev/null +++ b/fluxer_api/src/api/worker/tasks/PollGooglePlayVoidedPurchases.ts @@ -0,0 +1,52 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import {Config} from '@app/api/Config'; +import {getGooglePlayDeveloperApiClient} from '@app/api/middleware/ServiceSingletons'; +import {isGooglePlayConfigured} from '@app/api/store_billing/StoreBillingConfig'; +import {getWorkerDependencies} from '@app/api/worker/WorkerContext'; +import type {WorkerTaskHandler} from '@pkgs/worker/src/contracts/WorkerTask'; +import {ms} from 'itty-time'; + +const VOIDED_WINDOW_MS = ms('2 days'); +const MAX_PAGES_PER_PACKAGE = 50; + +const pollGooglePlayVoidedPurchases: WorkerTaskHandler = async (_payload, helpers) => { + if (Config.instance.selfHosted || !isGooglePlayConfigured()) { + return; + } + const {storeEntitlementService} = getWorkerDependencies(); + const client = getGooglePlayDeveloperApiClient(); + const endTime = new Date(); + const startTime = new Date(endTime.getTime() - VOIDED_WINDOW_MS); + let refreshed = 0; + for (const packageName of Config.googlePlay.packages) { + let pageToken: string | undefined; + for (let page = 0; page < MAX_PAGES_PER_PACKAGE; page++) { + const result = await client.listVoidedPurchases(packageName, {startTime, endTime, pageToken}); + for (const voided of result.voidedPurchases) { + try { + const voidedAt = voided.voidedTimeMillis ? new Date(Number(voided.voidedTimeMillis)) : null; + const applied = await storeEntitlementService.applyGooglePlayVoided({ + packageName, + purchaseToken: voided.purchaseToken, + orderId: voided.orderId ?? null, + productType: null, + voidedAt: voidedAt && !Number.isNaN(voidedAt.getTime()) ? voidedAt : null, + }); + if (applied) { + refreshed += 1; + } + } catch (error) { + helpers.logger.warn({error, orderId: voided.orderId}, 'Failed to refresh a voided Google Play purchase'); + } + } + if (!result.nextPageToken) { + break; + } + pageToken = result.nextPageToken; + } + } + helpers.logger.info({refreshed}, 'Polled Google Play voided purchases'); +}; + +export default pollGooglePlayVoidedPurchases; diff --git a/fluxer_api/src/api/worker/tasks/ProcessAppStoreNotification.ts b/fluxer_api/src/api/worker/tasks/ProcessAppStoreNotification.ts new file mode 100644 index 000000000..2ec2775d9 --- /dev/null +++ b/fluxer_api/src/api/worker/tasks/ProcessAppStoreNotification.ts @@ -0,0 +1,112 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import {Config} from '@app/api/Config'; +import { + AppStoreJwsVerificationError, + type AppStoreVerifiedNotification, + toStoreEnvironment, + verifyNotification, +} from '@app/api/store_billing/app_store/AppStoreJwsVerifier'; +import {getAppStoreProductSlot, isAppStoreConfigured} from '@app/api/store_billing/StoreBillingConfig'; +import type {StoreEntitlementService} from '@app/api/store_billing/StoreEntitlementService'; +import { + appStoreNotificationClaimKey, + rescheduleStoreNotification, + StoreNotificationClaims, +} from '@app/api/store_billing/StoreNotificationClaims'; +import {getWorkerDependencies} from '@app/api/worker/WorkerContext'; +import {StorePurchaseInvalidError} from '@fluxer/errors/src/domains/payment/StorePurchaseInvalidError'; +import type {LoggerInterface} from '@fluxer/logger/src/LoggerInterface'; +import type {WorkerTaskHandler} from '@pkgs/worker/src/contracts/WorkerTask'; +import {z} from 'zod'; + +const PayloadSchema = z.object({ + signedPayload: z.string().min(1), + retry: z.number().int().min(0).optional(), +}); + +async function handleNotification( + notification: AppStoreVerifiedNotification, + storeEntitlementService: StoreEntitlementService, + logger: LoggerInterface, +): Promise { + const context = { + notificationType: notification.notificationType, + subtype: notification.subtype, + notificationUUID: notification.notificationUUID, + environment: notification.environment, + }; + if (notification.notificationType === 'TEST') { + logger.info(context, 'Received App Store test notification'); + return; + } + const transaction = notification.transaction; + if (!transaction) { + logger.info(context, 'Acknowledged App Store notification without a transaction'); + return; + } + if (!getAppStoreProductSlot(transaction.productId)) { + logger.info({...context, productId: transaction.productId}, 'Ignored App Store notification for another product'); + return; + } + try { + await storeEntitlementService.syncAppStorePurchase({ + environment: toStoreEnvironment(notification.environment), + bundleId: transaction.bundleId, + productId: transaction.productId, + transactionId: transaction.transactionId, + originalTransactionId: transaction.originalTransactionId, + hintAccountToken: notification.renewalInfo?.appAccountToken ?? transaction.appAccountToken ?? null, + }); + } catch (error) { + if (error instanceof StorePurchaseInvalidError) { + logger.warn(context, 'App Store notification refers to a purchase that could not be verified'); + return; + } + throw error; + } +} + +const processAppStoreNotification: WorkerTaskHandler = async (payload, helpers) => { + const {signedPayload, retry = 0} = PayloadSchema.parse(payload); + if (Config.instance.selfHosted || !isAppStoreConfigured()) { + helpers.logger.warn('App Store billing is not configured; discarding notification'); + return; + } + let notification: AppStoreVerifiedNotification; + try { + notification = await verifyNotification(signedPayload); + } catch (error) { + if (error instanceof AppStoreJwsVerificationError) { + helpers.logger.warn({reason: error.reason}, 'Discarded App Store notification that failed verification'); + return; + } + throw error; + } + const {kvClient, storeEntitlementService} = getWorkerDependencies(); + const claims = new StoreNotificationClaims(kvClient); + const claimKey = appStoreNotificationClaimKey(notification.notificationUUID); + const claim = await claims.tryClaim(claimKey); + if (claim === 'already_processed') { + return; + } + try { + if (claim === 'in_flight') { + throw new Error('App Store notification is in flight, retrying'); + } + try { + await handleNotification(notification, storeEntitlementService, helpers.logger); + await claims.markProcessed(claimKey); + } catch (error) { + await claims.releaseClaim(claimKey); + throw error; + } + } catch (error) { + if (await rescheduleStoreNotification(helpers, 'processAppStoreNotification', {signedPayload}, retry, error)) { + return; + } + throw error; + } +}; + +export default processAppStoreNotification; diff --git a/fluxer_api/src/api/worker/tasks/ProcessExpiredPremiumSweep.ts b/fluxer_api/src/api/worker/tasks/ProcessExpiredPremiumSweep.ts index 4093a4438..d909c0f3a 100644 --- a/fluxer_api/src/api/worker/tasks/ProcessExpiredPremiumSweep.ts +++ b/fluxer_api/src/api/worker/tasks/ProcessExpiredPremiumSweep.ts @@ -1,5 +1,6 @@ // SPDX-License-Identifier: AGPL-3.0-or-later +import type {StorePurchaseRow} from '@app/api/database/types/StoreBillingTypes'; import {mapGuildMemberToResponse} from '@app/api/guild/GuildModel'; import type {IGuildRepositoryAggregate} from '@app/api/guild/repositories/IGuildRepositoryAggregate'; import type {IGatewayService} from '@app/api/infrastructure/IGatewayService'; @@ -8,21 +9,35 @@ import type {UserCacheService} from '@app/api/infrastructure/UserCacheService'; import {Logger} from '@app/api/Logger'; import {createRequestCache} from '@app/api/middleware/RequestCacheMiddleware'; import type {User} from '@app/api/models/User'; +import { + isAppStoreConfigured, + isGooglePlayConfigured, + isGooglePlayPackageConfigured, +} from '@app/api/store_billing/StoreBillingConfig'; +import {resolveStoreAccessEnd} from '@app/api/store_billing/StoreBillingMappers'; +import {isTerminalStorePurchaseState} from '@app/api/store_billing/StoreBillingTypes'; +import type {StoreEntitlementService} from '@app/api/store_billing/StoreEntitlementService'; import {isPremiumTieringActive} from '@app/api/stripe/BillingConfigCache'; import type {IUserRepository} from '@app/api/user/IUserRepository'; import {checkIsPremium, createPremiumClearPatch, shouldStripExpiredPremium} from '@app/api/user/UserHelpers'; import {mapUserToPrivateResponse} from '@app/api/user/UserMappers'; import {getWorkerDependencies} from '@app/api/worker/WorkerContext'; +import type {WorkerTaskName} from '@app/api/worker/WorkerLaneConfig'; import {PremiumFlags, UserPremiumTypes} from '@fluxer/constants/src/UserConstants'; +import type {IWorkerService} from '@pkgs/worker/src/contracts/IWorkerService'; import type {WorkerTaskHandler} from '@pkgs/worker/src/contracts/WorkerTask'; +import {ms} from 'itty-time'; const BATCH_SIZE = 100; +const STORE_REFRESH_STALE_MS = ms('30 minutes'); +const STORE_REFRESH_DEFER_LIMIT_MS = ms('1 day'); interface SweepResult { processed: number; stripped: number; sanitized: number; reconcileEnqueued: number; + storeRefreshEnqueued: number; skipped: number; failed: number; } @@ -33,6 +48,8 @@ interface SweepDeps { userCacheService: UserCacheService; gatewayService: IGatewayService; premiumStateReconciliationQueueService: PremiumStateReconciliationQueueService; + storeEntitlementService: StoreEntitlementService; + workerService: IWorkerService; } async function sanitizeGuildMemberPerks(user: User, deps: SweepDeps): Promise { @@ -100,6 +117,44 @@ async function sanitizeGuildMemberPerks(user: User, deps: SweepDeps): Promise now - STORE_REFRESH_DEFER_LIMIT_MS + ); +} + +async function deferToStoreRefresh(user: User, deps: SweepDeps): Promise { + const now = Date.now(); + const rows = (await deps.storeEntitlementService.listStorePurchases(user.id)).filter((row) => + shouldDeferToStoreRefresh(row, now), + ); + for (const row of rows) { + await deps.workerService.addJob( + 'refreshStorePurchase', + {storeKey: row.store_key}, + {jobKey: `refreshStorePurchase:${row.store_key}`}, + ); + } + return rows.length; +} + async function processUser(user: User, deps: SweepDeps, result: SweepResult): Promise { if (user.isBot) { result.skipped += 1; @@ -136,6 +191,12 @@ async function processUser(user: User, deps: SweepDeps, result: SweepResult): Pr result.skipped += 1; return; } + const storeRefreshes = await deferToStoreRefresh(user, deps); + if (storeRefreshes > 0) { + result.storeRefreshEnqueued += storeRefreshes; + result.skipped += 1; + return; + } try { let currentUser = await deps.userRepository.patchUpsert(user.id, createPremiumClearPatch(), user.toRow()); deps.userCacheService.setUserPartialResponseFromUserInBackground(currentUser); @@ -183,6 +244,7 @@ async function processExpiredPremiumSweepCore(deps: SweepDeps): Promise { helpers.logger.debug('Processing expired premium sweep task'); - const {userRepository, guildRepository, userCacheService, gatewayService, premiumStateReconciliationQueueService} = - getWorkerDependencies(); + const { + userRepository, + guildRepository, + userCacheService, + gatewayService, + premiumStateReconciliationQueueService, + storeEntitlementService, + workerService, + } = getWorkerDependencies(); await processExpiredPremiumSweepCore({ userRepository, guildRepository, userCacheService, gatewayService, premiumStateReconciliationQueueService, + storeEntitlementService, + workerService, }); }; diff --git a/fluxer_api/src/api/worker/tasks/ProcessGooglePlayNotification.ts b/fluxer_api/src/api/worker/tasks/ProcessGooglePlayNotification.ts new file mode 100644 index 000000000..2fe89aa48 --- /dev/null +++ b/fluxer_api/src/api/worker/tasks/ProcessGooglePlayNotification.ts @@ -0,0 +1,115 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import {Config} from '@app/api/Config'; +import { + decodeGooglePlayDeveloperNotification, + type GooglePlayDeveloperNotification, +} from '@app/api/store_billing/google_play/GooglePlayPushVerifier'; +import {isGooglePlayConfigured, isGooglePlayPackageConfigured} from '@app/api/store_billing/StoreBillingConfig'; +import type {StoreEntitlementService} from '@app/api/store_billing/StoreEntitlementService'; +import { + googlePlayMessageClaimKey, + rescheduleStoreNotification, + StoreNotificationClaims, +} from '@app/api/store_billing/StoreNotificationClaims'; +import {getWorkerDependencies} from '@app/api/worker/WorkerContext'; +import {StorePurchaseInvalidError} from '@fluxer/errors/src/domains/payment/StorePurchaseInvalidError'; +import type {LoggerInterface} from '@fluxer/logger/src/LoggerInterface'; +import type {WorkerTaskHandler} from '@pkgs/worker/src/contracts/WorkerTask'; +import {z} from 'zod'; + +const PayloadSchema = z.object({ + messageId: z.string().min(1), + data: z.string().min(1), + retry: z.number().int().min(0).optional(), +}); + +async function handleNotification( + notification: GooglePlayDeveloperNotification, + storeEntitlementService: StoreEntitlementService, + logger: LoggerInterface, +): Promise { + const {packageName, event, eventTime} = notification; + switch (event.type) { + case 'test': + logger.info({packageName}, 'Received Google Play test notification'); + return; + case 'subscription': + await storeEntitlementService.syncGooglePlaySubscription({packageName, purchaseToken: event.purchaseToken}); + return; + case 'one_time_product': + await storeEntitlementService.syncGooglePlayProduct({ + packageName, + purchaseToken: event.purchaseToken, + productId: event.productId, + }); + return; + case 'voided_purchase': + await storeEntitlementService.applyGooglePlayVoided({ + packageName, + purchaseToken: event.purchaseToken, + orderId: event.orderId, + productType: event.productType, + voidedAt: eventTime, + }); + return; + case 'pending_refund_review': + logger.warn({packageName, orderId: event.orderId}, 'Google Play opened a refund review for an order'); + return; + default: + logger.info({packageName}, 'Ignored Google Play notification of an unknown type'); + } +} + +const processGooglePlayNotification: WorkerTaskHandler = async (payload, helpers) => { + const {messageId, data, retry = 0} = PayloadSchema.parse(payload); + if (Config.instance.selfHosted || !isGooglePlayConfigured()) { + helpers.logger.warn('Google Play billing is not configured; discarding notification'); + return; + } + const notification = decodeGooglePlayDeveloperNotification(data); + if (!notification) { + helpers.logger.warn({messageId}, 'Discarded a Google Play notification that could not be decoded'); + return; + } + if (!isGooglePlayPackageConfigured(notification.packageName)) { + helpers.logger.warn( + {messageId, packageName: notification.packageName}, + 'Discarded a Google Play notification for another package', + ); + return; + } + const {kvClient, storeEntitlementService} = getWorkerDependencies(); + const claims = new StoreNotificationClaims(kvClient); + const claimKey = googlePlayMessageClaimKey(messageId); + const claim = await claims.tryClaim(claimKey); + if (claim === 'already_processed') { + return; + } + try { + if (claim === 'in_flight') { + throw new Error('Google Play notification is in flight, retrying'); + } + try { + try { + await handleNotification(notification, storeEntitlementService, helpers.logger); + } catch (error) { + if (!(error instanceof StorePurchaseInvalidError)) { + throw error; + } + helpers.logger.warn({messageId}, 'Google Play notification refers to a purchase that could not be verified'); + } + await claims.markProcessed(claimKey); + } catch (error) { + await claims.releaseClaim(claimKey); + throw error; + } + } catch (error) { + if (await rescheduleStoreNotification(helpers, 'processGooglePlayNotification', {messageId, data}, retry, error)) { + return; + } + throw error; + } +}; + +export default processGooglePlayNotification; diff --git a/fluxer_api/src/api/worker/tasks/ProcessPremiumStateReconciliationQueue.ts b/fluxer_api/src/api/worker/tasks/ProcessPremiumStateReconciliationQueue.ts index 295d465a8..9a725d495 100644 --- a/fluxer_api/src/api/worker/tasks/ProcessPremiumStateReconciliationQueue.ts +++ b/fluxer_api/src/api/worker/tasks/ProcessPremiumStateReconciliationQueue.ts @@ -22,7 +22,14 @@ import type {WorkerTaskHandler} from '@pkgs/worker/src/contracts/WorkerTask'; import type Stripe from 'stripe'; interface ReconcileResult { - status: 'patched' | 'no_change' | 'skipped' | 'no_active_subscription' | 'stripped_no_subscription' | 'missing_user'; + status: + | 'patched' + | 'no_change' + | 'skipped' + | 'no_active_subscription' + | 'stripped_no_subscription' + | 'store_entitled' + | 'missing_user'; patchedFields: Array; } @@ -274,6 +281,11 @@ async function reconcileUserPremiumStateFromStripe(params: {userId: UserID; stri user, ); if (!subscription) { + const {storeEntitlementService} = getWorkerDependencies(); + if (await storeEntitlementService.getActiveStoreEntitlement(user.id)) { + await storeEntitlementService.applyStoreEntitlementToUser(user.id); + return {status: 'store_entitled', patchedFields: []}; + } const hasStalePremium = user.premiumType === UserPremiumTypes.SUBSCRIPTION; const hasNonStripePremium = (Config.instance.selfHosted && !isBillingActive()) || (user.premiumFlags & PremiumFlags.ENABLED_OVERRIDE) !== 0; @@ -355,6 +367,7 @@ const processPremiumStateReconciliationQueue: WorkerTaskHandler = async (_payloa let skippedCount = 0; let noActiveSubscriptionCount = 0; let strippedNoSubscriptionCount = 0; + let storeEntitledCount = 0; let failedCount = 0; let requeuedCount = 0; let claimedElsewhereCount = 0; @@ -393,6 +406,8 @@ const processPremiumStateReconciliationQueue: WorkerTaskHandler = async (_payloa }, 'Stripped expired premium with no active Stripe subscription via worker queue', ); + } else if (result.status === 'store_entitled') { + storeEntitledCount += 1; } else if (result.status === 'no_change') { noChangeCount += 1; } else if (result.status === 'no_active_subscription') { @@ -426,6 +441,7 @@ const processPremiumStateReconciliationQueue: WorkerTaskHandler = async (_payloa skippedCount, noActiveSubscriptionCount, strippedNoSubscriptionCount, + storeEntitledCount, failedCount, requeuedCount, claimedElsewhereCount, diff --git a/fluxer_api/src/api/worker/tasks/ProcessStorePurchaseRefreshQueue.ts b/fluxer_api/src/api/worker/tasks/ProcessStorePurchaseRefreshQueue.ts new file mode 100644 index 000000000..ac4c1fffa --- /dev/null +++ b/fluxer_api/src/api/worker/tasks/ProcessStorePurchaseRefreshQueue.ts @@ -0,0 +1,44 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import {Config} from '@app/api/Config'; +import {isAppStoreConfigured, isGooglePlayConfigured} from '@app/api/store_billing/StoreBillingConfig'; +import {redactStoreKey} from '@app/api/store_billing/StoreBillingTypes'; +import {STORE_PURCHASE_REFRESH_QUEUE_KEY} from '@app/api/store_billing/StorePurchaseRefresh'; +import {getWorkerDependencies} from '@app/api/worker/WorkerContext'; +import type {WorkerTaskHandler} from '@pkgs/worker/src/contracts/WorkerTask'; +import {ms} from 'itty-time'; + +const MAX_KEYS_PER_RUN = 200; +const REFRESH_LEASE_MS = ms('10 minutes'); + +const processStorePurchaseRefreshQueue: WorkerTaskHandler = async (_payload, helpers) => { + if (Config.instance.selfHosted || (!isAppStoreConfigured() && !isGooglePlayConfigured())) { + return; + } + const {kvClient, workerService} = getWorkerDependencies(); + const now = Date.now(); + const dueKeys = await kvClient.zrangebyscore( + STORE_PURCHASE_REFRESH_QUEUE_KEY, + '-inf', + now, + 'LIMIT', + 0, + MAX_KEYS_PER_RUN, + ); + if (dueKeys.length === 0) { + return; + } + let enqueued = 0; + for (const storeKey of dueKeys) { + await kvClient.zadd(STORE_PURCHASE_REFRESH_QUEUE_KEY, now + REFRESH_LEASE_MS, storeKey); + try { + await workerService.addJob('refreshStorePurchase', {storeKey}, {jobKey: `refreshStorePurchase:${storeKey}`}); + enqueued += 1; + } catch (error) { + helpers.logger.warn({error, storeKey: redactStoreKey(storeKey)}, 'Failed to enqueue a store purchase refresh'); + } + } + helpers.logger.info({due: dueKeys.length, enqueued}, 'Enqueued store purchase refreshes'); +}; + +export default processStorePurchaseRefreshQueue; diff --git a/fluxer_api/src/api/worker/tasks/ProcessStripeWebhook.ts b/fluxer_api/src/api/worker/tasks/ProcessStripeWebhook.ts index dd0be465b..ce4ceb669 100644 --- a/fluxer_api/src/api/worker/tasks/ProcessStripeWebhook.ts +++ b/fluxer_api/src/api/worker/tasks/ProcessStripeWebhook.ts @@ -50,6 +50,7 @@ const processStripeWebhook: WorkerTaskHandler = async (payload, helpers) => { productRegistry, deps.cacheService, deps.gatewayService, + deps.storeEntitlementService, ); const giftService = new StripeGiftService( stripe, @@ -59,6 +60,7 @@ const processStripeWebhook: WorkerTaskHandler = async (payload, helpers) => { checkoutService, premiumService, subscriptionService, + deps.storeEntitlementService, ); const ageVerificationService = new AgeVerificationService( stripe, @@ -87,6 +89,7 @@ const processStripeWebhook: WorkerTaskHandler = async (payload, helpers) => { deps.snowflakeService, deps.billingRepository, refundService, + deps.storeEntitlementService, ); await webhookService.handleWebhook({body, signature}); }; diff --git a/fluxer_api/src/api/worker/tasks/RefreshStorePurchase.ts b/fluxer_api/src/api/worker/tasks/RefreshStorePurchase.ts new file mode 100644 index 000000000..d27c77bf4 --- /dev/null +++ b/fluxer_api/src/api/worker/tasks/RefreshStorePurchase.ts @@ -0,0 +1,30 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import {Config} from '@app/api/Config'; +import {redactStoreKey} from '@app/api/store_billing/StoreBillingTypes'; +import {getWorkerDependencies} from '@app/api/worker/WorkerContext'; +import {StorePurchaseInvalidError} from '@fluxer/errors/src/domains/payment/StorePurchaseInvalidError'; +import type {WorkerTaskHandler} from '@pkgs/worker/src/contracts/WorkerTask'; +import {z} from 'zod'; + +const PayloadSchema = z.object({ + storeKey: z.string().min(1), +}); + +const refreshStorePurchase: WorkerTaskHandler = async (payload, helpers) => { + const {storeKey} = PayloadSchema.parse(payload); + if (Config.instance.selfHosted) { + return; + } + try { + await getWorkerDependencies().storeEntitlementService.refreshStorePurchase(storeKey); + } catch (error) { + if (error instanceof StorePurchaseInvalidError) { + helpers.logger.warn({storeKey: redactStoreKey(storeKey)}, 'Store purchase could not be verified during refresh'); + return; + } + throw error; + } +}; + +export default refreshStorePurchase; diff --git a/fluxer_api/src/api/worker/tasks/admin_bulk/AdminBulkServices.ts b/fluxer_api/src/api/worker/tasks/admin_bulk/AdminBulkServices.ts index e5c586b49..2ed287afe 100644 --- a/fluxer_api/src/api/worker/tasks/admin_bulk/AdminBulkServices.ts +++ b/fluxer_api/src/api/worker/tasks/admin_bulk/AdminBulkServices.ts @@ -43,6 +43,7 @@ export function createAdminBulkServices(deps: WorkerDependencies): AdminBulkServ bulkMessageDeletionQueue: deps.bulkMessageDeletionQueueService, stripe: deps.stripe, reportService: getReportServiceInstance(), + storeEntitlementService: deps.storeEntitlementService, }); const guildService = new AdminGuildService({ guildRepository: deps.guildRepository, diff --git a/fluxer_api/src/api/worker/tests/BulkScheduleUserDeletion.test.ts b/fluxer_api/src/api/worker/tests/BulkScheduleUserDeletion.test.ts index b8b85aa5a..a9752f058 100644 --- a/fluxer_api/src/api/worker/tests/BulkScheduleUserDeletion.test.ts +++ b/fluxer_api/src/api/worker/tests/BulkScheduleUserDeletion.test.ts @@ -23,6 +23,7 @@ import { import {ReportStatus} from '@app/api/report/IReportRepository'; import {ReportRepository} from '@app/api/report/ReportRepository'; import {drainSearchTasks} from '@app/api/search/SearchTaskTracker'; +import {createTestStoreEntitlementService} from '@app/api/store_billing/tests/StoreBillingTestUtils'; import {type ApiTestHarness, createApiTestHarness} from '@app/api/test/ApiTestHarness'; import {NoopLogger} from '@app/api/test/mocks/NoopLogger'; import {HTTP_STATUS} from '@app/api/test/TestConstants'; @@ -70,6 +71,7 @@ function installWorkerDependencies(): void { deletionQueueService: getKVAccountDeletionQueue(), bulkMessageDeletionQueueService: getKVBulkMessageDeletionQueue(), stripe: null, + storeEntitlementService: createTestStoreEntitlementService(), }); } diff --git a/fluxer_api/src/api/worker/tests/ProcessExpiredPremiumSweep.test.ts b/fluxer_api/src/api/worker/tests/ProcessExpiredPremiumSweep.test.ts index 2ff226c6a..d259c6590 100644 --- a/fluxer_api/src/api/worker/tests/ProcessExpiredPremiumSweep.test.ts +++ b/fluxer_api/src/api/worker/tests/ProcessExpiredPremiumSweep.test.ts @@ -1,12 +1,22 @@ // SPDX-License-Identifier: AGPL-3.0-or-later +import {createUserID} from '@app/api/BrandedTypes'; import {getConfig} from '@app/api/Config'; +import type {StorePurchaseRow} from '@app/api/database/types/StoreBillingTypes'; +import type {UserRow} from '@app/api/database/types/UserTypes'; +import {EMPTY_USER_ROW} from '@app/api/database/types/UserTypes'; import {getCachedInstancePremiumMode, setCachedInstancePremiumMode} from '@app/api/limits/InstancePremiumModeCache'; +import {User} from '@app/api/models/User'; +import type {StoreEntitlementService} from '@app/api/store_billing/StoreEntitlementService'; +import {buildStorePurchaseRow} from '@app/api/store_billing/tests/StoreBillingTestUtils'; import {NoopLogger} from '@app/api/test/mocks/NoopLogger'; import type {UserRepository} from '@app/api/user/repositories/UserRepository'; import processExpiredPremiumSweep from '@app/api/worker/tasks/ProcessExpiredPremiumSweep'; import {clearWorkerDependencies, setWorkerDependenciesForTest} from '@app/api/worker/WorkerContext'; +import {UserPremiumTypes} from '@fluxer/constants/src/UserConstants'; +import type {IWorkerService} from '@pkgs/worker/src/contracts/IWorkerService'; import type {WorkerTaskHelpers} from '@pkgs/worker/src/contracts/WorkerTask'; +import {ms} from 'itty-time'; import {afterEach, describe, expect, test} from 'vitest'; function createHarness() { @@ -21,6 +31,54 @@ function createHarness() { return {scanLimits}; } +const EXPIRED_USER_ID = createUserID(834271905123471362n); + +function createExpiredStoreUserHarness(storeRows: Array) { + const user = new User({ + ...EMPTY_USER_ROW, + user_id: EXPIRED_USER_ID, + username: 'storelapse', + discriminator: 1, + premium_type: UserPremiumTypes.SUBSCRIPTION, + premium_since: new Date(Date.now() - ms('60 days')), + premium_until: new Date(Date.now() - ms('10 days')), + premium_grace_ends_at: new Date(Date.now() - ms('10 days')), + }); + const patches: Array> = []; + const jobs: Array<{task: string; payload: unknown; jobKey: string | undefined}> = []; + const userRepository = { + async scanAllUsersPage(): Promise<{users: Array; pageState: null}> { + return {users: [user], pageState: null}; + }, + async patchUpsert(_userId: unknown, patch: Partial): Promise { + patches.push(patch); + return new User({...user.toRow(), ...patch}); + }, + async getUserGuildIds(): Promise> { + return []; + }, + } as unknown as UserRepository; + const storeEntitlementService = { + async listStorePurchases() { + return storeRows; + }, + } as unknown as StoreEntitlementService; + const workerService = { + async addJob(task: string, payload: unknown, options?: {jobKey?: string}) { + jobs.push({task, payload, jobKey: options?.jobKey}); + return 0n; + }, + } as unknown as IWorkerService; + setWorkerDependenciesForTest({ + userRepository, + storeEntitlementService, + workerService, + userCacheService: {setUserPartialResponseFromUserInBackground: () => {}} as never, + gatewayService: {dispatchPresence: async () => {}} as never, + }); + return {patches, jobs}; +} + function createHelpers(): WorkerTaskHelpers { return { logger: new NoopLogger(), @@ -79,6 +137,110 @@ describe('processExpiredPremiumSweep', () => { expect(harness.scanLimits).toEqual([100]); }); + test('refreshes a stale store subscription instead of stripping premium', async () => { + const row = buildStorePurchaseRow({ + user_id: EXPIRED_USER_ID, + expires_at: new Date(Date.now() - ms('2 hours')), + synced_at: new Date(Date.now() - ms('3 hours')), + }); + const harness = createExpiredStoreUserHarness([row]); + + await withSelfHosted(false, async () => { + await processExpiredPremiumSweep({}, createHelpers()); + }); + + expect(harness.patches).toEqual([]); + expect(harness.jobs).toEqual([ + { + task: 'refreshStorePurchase', + payload: {storeKey: row.store_key}, + jobKey: `refreshStorePurchase:${row.store_key}`, + }, + ]); + }); + + test('strips premium when the store subscription was synced recently', async () => { + const harness = createExpiredStoreUserHarness([ + buildStorePurchaseRow({ + user_id: EXPIRED_USER_ID, + expires_at: new Date(Date.now() - ms('10 days')), + synced_at: new Date(Date.now() - ms('5 minutes')), + }), + buildStorePurchaseRow({ + user_id: EXPIRED_USER_ID, + state: 'expired', + entitled: false, + synced_at: new Date(Date.now() - ms('2 days')), + }), + ]); + + await withSelfHosted(false, async () => { + await processExpiredPremiumSweep({}, createHelpers()); + }); + + expect(harness.jobs).toEqual([]); + expect(harness.patches[0]).toMatchObject({premium_type: null, premium_until: null}); + }); + + test('strips premium once a sync after the access end confirmed the store state', async () => { + const harness = createExpiredStoreUserHarness([ + buildStorePurchaseRow({ + user_id: EXPIRED_USER_ID, + state: 'billing_retry', + entitled: false, + expires_at: new Date(Date.now() - ms('3 hours')), + synced_at: new Date(Date.now() - ms('1 hour')), + }), + ]); + + await withSelfHosted(false, async () => { + await processExpiredPremiumSweep({}, createHelpers()); + }); + + expect(harness.jobs).toEqual([]); + expect(harness.patches[0]).toMatchObject({premium_type: null, premium_until: null}); + }); + + test('strips premium when the store access ended more than a day ago', async () => { + const harness = createExpiredStoreUserHarness([ + buildStorePurchaseRow({ + user_id: EXPIRED_USER_ID, + expires_at: new Date(Date.now() - ms('2 days')), + synced_at: new Date(Date.now() - ms('3 days')), + }), + ]); + + await withSelfHosted(false, async () => { + await processExpiredPremiumSweep({}, createHelpers()); + }); + + expect(harness.jobs).toEqual([]); + expect(harness.patches[0]).toMatchObject({premium_type: null, premium_until: null}); + }); + + test('strips premium when the store provider is not configured', async () => { + const harness = createExpiredStoreUserHarness([ + buildStorePurchaseRow({ + user_id: EXPIRED_USER_ID, + expires_at: new Date(Date.now() - ms('2 hours')), + synced_at: new Date(Date.now() - ms('3 hours')), + }), + ]); + const config = getConfig(); + const originalEnabled = config.appStore.enabled; + try { + config.appStore.enabled = false; + await withSelfHosted(false, async () => { + await processExpiredPremiumSweep({}, createHelpers()); + }); + } finally { + config.appStore.enabled = originalEnabled; + } + + expect(harness.jobs).toEqual([]); + expect(harness.patches[0]).toMatchObject({premium_type: null, premium_until: null}); + }); + test('scans users on a hosted instance', async () => { const harness = createHarness(); diff --git a/fluxer_api/src/api/worker/tests/ProcessPremiumStateReconciliationQueue.test.ts b/fluxer_api/src/api/worker/tests/ProcessPremiumStateReconciliationQueue.test.ts index b43cc7827..8d1471d44 100644 --- a/fluxer_api/src/api/worker/tests/ProcessPremiumStateReconciliationQueue.test.ts +++ b/fluxer_api/src/api/worker/tests/ProcessPremiumStateReconciliationQueue.test.ts @@ -1,6 +1,6 @@ // SPDX-License-Identifier: AGPL-3.0-or-later -import {createUserID} from '@app/api/BrandedTypes'; +import {createUserID, type UserID} from '@app/api/BrandedTypes'; import type {UserRow} from '@app/api/database/types/UserTypes'; import {EMPTY_USER_ROW} from '@app/api/database/types/UserTypes'; import {PremiumStateReconciliationQueueService} from '@app/api/infrastructure/PremiumStateReconciliationQueueService'; @@ -91,11 +91,16 @@ function createStripeStub(subscription: Stripe.Subscription, invoices: Array>; + storeApplications: Array; extras: Record; } { + const storeApplications: Array = []; const patches: Array> = []; const userRepository = { findUnique: async () => user, @@ -107,8 +112,25 @@ function createCapturingDeps(user: User): { const extras = { userCacheService: {setUserPartialResponseFromUserInBackground: () => {}}, gatewayService: {dispatchPresence: async () => {}}, + storeEntitlementService: { + getActiveStoreEntitlement: async () => + storeEntitlement + ? { + provider: 'app_store', + storePurchaseId: 1n, + entitledUntil: storeEntitlement.expiresAt, + expiresAt: storeEntitlement.expiresAt, + graceEndsAt: null, + autoRenew: true, + slot: 'monthly', + } + : null, + applyStoreEntitlementToUser: async (userId: UserID) => { + storeApplications.push(userId); + }, + }, }; - return {userRepository, patches, extras}; + return {userRepository, patches, storeApplications, extras}; } function createHelpers(): WorkerTaskHelpers { @@ -322,6 +344,36 @@ describe('processPremiumStateReconciliationQueue', () => { expect(patches[0].premium_until).toBeNull(); expect(patches[0].premium_since).toBeNull(); }); + test('hands a user with an active App Store subscription and a stale Stripe customer to the store', async () => { + const queueService = createQueueService(); + await queueService.enqueueUser(USER_ID, new Date(Date.now() - 1000)); + + const endedAtMs = Date.now() - 40 * ONE_DAY_MS; + const storeExpiresAt = new Date(Date.now() + 20 * ONE_DAY_MS); + const user = createPremiumUser({ + premium_until: storeExpiresAt, + premium_billing_cycle: null, + premium_will_cancel: false, + stripe_subscription_id: null, + }); + const {userRepository, patches, storeApplications, extras} = createCapturingDeps(user, { + expiresAt: storeExpiresAt, + }); + + setWorkerDependenciesForTest({ + premiumStateReconciliationQueueService: queueService, + stripe: createStripeStub(createCancelledSubscription(endedAtMs), [createPaidInvoice(endedAtMs)]), + userRepository, + ...extras, + }); + + await processPremiumStateReconciliationQueue({}, createHelpers()); + + expect(patches).toEqual([]); + expect(storeApplications).toEqual([USER_ID]); + expect(await queueService.getQueueSize()).toBe(0); + }); + test('clears the perks-sanitized latch once the subscription is active again', async () => { const queueService = createQueueService(); await queueService.enqueueUser(USER_ID, new Date(Date.now() - 1000)); diff --git a/fluxer_docs/astro.config.ts b/fluxer_docs/astro.config.ts index 66fe665b7..b3cae6193 100644 --- a/fluxer_docs/astro.config.ts +++ b/fluxer_docs/astro.config.ts @@ -237,7 +237,13 @@ export default defineConfig({ }, { label: 'Commerce', - items: ['http-api/billing', 'http-api/premium', 'http-api/gifts', 'http-api/donations'], + items: [ + 'http-api/billing', + 'http-api/premium', + 'http-api/in-app-purchases', + 'http-api/gifts', + 'http-api/donations', + ], }, { label: 'Client surfaces', diff --git a/fluxer_docs/scripts/VerifyDocsCoverage.ts b/fluxer_docs/scripts/VerifyDocsCoverage.ts index 89f8e330d..f8cfd53de 100644 --- a/fluxer_docs/scripts/VerifyDocsCoverage.ts +++ b/fluxer_docs/scripts/VerifyDocsCoverage.ts @@ -145,6 +145,22 @@ const OUT_OF_BAND_CREDENTIAL = new Map([ documentedIn: {file: 'operator/configuration.mdx', anchor: 'POST /webhooks/sweego'}, }, ], + [ + 'POST /webhooks/app-store', + { + reason: + 'an App Store Server Notification whose signedPayload must verify against the pinned Apple root before it is queued. The route is hosted-only', + documentedIn: {file: 'http-api/in-app-purchases.mdx', anchor: 'POST /webhooks/app-store'}, + }, + ], + [ + 'POST /webhooks/google-play', + { + reason: + 'a Pub/Sub push whose Google-signed OIDC token must match the configured audience and service account before it is queued. The route is hosted-only', + documentedIn: {file: 'http-api/in-app-purchases.mdx', anchor: 'POST /webhooks/google-play'}, + }, + ], [ 'GET /connections/bluesky/callback', { @@ -207,7 +223,7 @@ const EXEMPTION_RULES: ReadonlyArray = [ { name: 'out-of-band credential', justification: - 'no ordinary client holds the credential. Each entry states its guard, and three are covered in prose', + 'no ordinary client holds the credential. Each entry states its guard, and five are covered in prose', anchors: [{file: 'fluxer_api/src/api/app/ControllerRegistry.ts', anchor: 'installSmsWebhookForwarder(routes'}], covers: (shape) => OUT_OF_BAND_CREDENTIAL.has(shape), }, diff --git a/fluxer_docs/src/content/docs/admin-api/index.mdx b/fluxer_docs/src/content/docs/admin-api/index.mdx index 9201c256a..da9900096 100644 --- a/fluxer_docs/src/content/docs/admin-api/index.mdx +++ b/fluxer_docs/src/content/docs/admin-api/index.mdx @@ -295,6 +295,7 @@ An entry with one of these actions has `access` set to `read`. | list_user_guilds | The guild memberships of an account were read | | list_user_relationships | The relationships of an account were read | | list_user_sessions | An account's login sessions were read | +| list_user_store_purchases | The in-app purchases of an account were read | | list_voice_regions | Voice regions were listed | | list_voice_servers | The voice servers of a region were listed | | list_webauthn_credentials | An account's WebAuthn credentials were read | @@ -361,6 +362,7 @@ An entry with any other action has `access` set to `write`. | queue_bulk_job | A bulk job was queued | | queue_message_shred | A message shred job was queued | | queue_refresh_index | A search index rebuild was requested | +| refresh_store_purchases | The in-app purchases of an account were read again from their stores | | reject_discovery_application | A discovery application was rejected | | reject_registration | A pending registration was rejected | | reload_guild | One guild was reloaded on the main Gateway | @@ -598,7 +600,7 @@ The registry is returned in this order by [List ACLs](#list-acls). A value outsi | user:update:bot_status | Updates bot and system account state | | user:update:dob | Updates dates of birth | | user:update:email | Updates the email address, marks the address verified, resends verification, and sends a password reset | -| user:update:flags | Updates account flags and premium flags, and ends every login session of an account | +| user:update:flags | Updates account flags and premium flags, refreshes in-app purchases, and ends every login session of an account | | user:update:mfa | Reads and removes a user's WebAuthn credentials, and removes MFA state | | user:update:phone | Updates verified phone state | | user:update:profile | Clears profile fields | diff --git a/fluxer_docs/src/content/docs/admin-api/users.mdx b/fluxer_docs/src/content/docs/admin-api/users.mdx index bdd13e9f1..445f4b994 100644 --- a/fluxer_docs/src/content/docs/admin-api/users.mdx +++ b/fluxer_docs/src/content/docs/admin-api/users.mdx @@ -434,6 +434,88 @@ One recorded change to the account's email address, phone verification state, or } ``` +## Admin store purchase object + +One [in-app purchase](/http-api/in-app-purchases/) with the store state behind it. It has more fields than the public [store purchase](/http-api/in-app-purchases/#store-purchase-object) object, and still never exposes a purchase token or the account token. + +### Structure + +| Field | Type | Description | +| --- | --- | --- | +| id | snowflake | The ID of the store purchase | +| user_id | ?snowflake | The ID of the linked account, or null when unlinked | +| provider | string | Either `app_store` or `google_play` | +| kind | string | Either `subscription` or `gift` | +| slot | string | [Store slot](/http-api/in-app-purchases/#store-slots) | +| environment | string | Either `production` or `sandbox` | +| app_id | string | Bundle ID or package name of the purchasing app | +| product_id | string | Store product ID | +| base_plan_id | ?string | Google Play base plan ID, or null | +| latest_transaction_id1 | ?string | Latest store transaction or order ID | +| ownership_type2 | ?string | Store ownership type, or null | +| state | string | [Store purchase state](/http-api/in-app-purchases/#store-purchase-states) | +| store_state3 | ?string | Raw state the store reported, or null | +| entitled | boolean | Whether the purchase grants premium now | +| expires_at | ?ISO8601 timestamp | End of the paid period | +| grace_ends_at | ?ISO8601 timestamp | End of the store grace period | +| auto_renew | ?boolean | Whether the subscription renews | +| started_at | ?ISO8601 timestamp | The time the subscription or purchase started | +| purchased_at | ?ISO8601 timestamp | The time of the latest payment | +| revoked_at | ?ISO8601 timestamp | The time the store refunded or revoked it | +| revocation_reason | ?string | Reason recorded for the revocation, or null | +| superseded | boolean | Whether a newer Google Play purchase replaced it | +| acknowledged4 | boolean | Whether Fluxer acknowledged it with the store | +| gift_code | ?string | Gift code minted by a gift purchase, or null | +| bound_at | ?ISO8601 timestamp | The time the purchase was linked to its account | +| last_event_at | ?ISO8601 timestamp | Time of the latest store event applied | +| synced_at | ?ISO8601 timestamp | The time Fluxer last read it from the store | +| created_at | ISO8601 timestamp | The time Fluxer first saw the purchase | +| updated_at | ISO8601 timestamp | The time the record last changed | + +1 The App Store transaction ID, or the Google Play order ID + +2 The App Store value, such as `PURCHASED` or `FAMILY_SHARED`. Null for Google Play + +3 The App Store subscription status number as a string, or the Google Play state name. Null for an App Store gift that was never revoked + +4 Always true for the App Store. For a Google Play gift, true once the purchase was consumed + +### Example + +```json +{ + "id": "1501203318237184000", + "user_id": "1489200013322551296", + "provider": "app_store", + "kind": "subscription", + "slot": "monthly", + "environment": "production", + "app_id": "com.fluxer", + "product_id": "com.fluxer.plutonium.monthly", + "base_plan_id": null, + "latest_transaction_id": "2000000712345678", + "ownership_type": "PURCHASED", + "state": "active", + "store_state": "1", + "entitled": true, + "expires_at": "2026-10-29T09:00:00.000Z", + "grace_ends_at": null, + "auto_renew": true, + "started_at": "2026-09-29T09:00:00.000Z", + "purchased_at": "2026-09-29T09:00:00.000Z", + "revoked_at": null, + "revocation_reason": null, + "superseded": false, + "acknowledged": true, + "gift_code": null, + "bound_at": "2026-09-29T09:00:02.000Z", + "last_event_at": "2026-09-29T09:00:00.000Z", + "synced_at": "2026-09-29T09:00:02.000Z", + "created_at": "2026-09-29T09:00:02.000Z", + "updated_at": "2026-09-29T09:00:02.000Z" +} +``` + ## Message shred entry object ### Structure @@ -1181,6 +1263,75 @@ The operation records one [Admin audit entry](/admin-api/#admin-audit-entry-obje 100 requests per minute for each authenticated user, on the `admin:user:modify` bucket. +## List user store purchases + + + +Lists the [in-app purchases](/http-api/in-app-purchases/) linked to an account, newest first. Requires `user:lookup`. A self-hosted instance never serves this route. + +### Path parameters + +| Field | Type | Description | +| --- | --- | --- | +| user_id | snowflake | The ID of the target account | + +### Response body + +| Field | Type | Description | +| --- | --- | --- | +| purchases | array[[Admin store purchase](#admin-store-purchase-object) object] | The linked purchases | + +### Response + +| Status | Body | Condition | +| --- | --- | --- | +| 200 | response body | The purchases were listed | +| 404 | [error response](/admin-api/#error-response) | `UNKNOWN_USER`, because the account does not exist | + +### Side effects + +The operation records one [Admin audit entry](/admin-api/#admin-audit-entry-object) with action `list_user_store_purchases`, target type `user`, and the metadata key `result_count`. + +### Rate limit + +200 requests per minute for each authenticated user, on the `admin:lookup` bucket. + +## Refresh user store purchases + + + +Reads every purchase linked to an account again from its store, applies the result to the account, and returns the updated purchases. Requires `user:update:flags`, the same ACL as [Update user premium flags](#update-user-premium-flags). A self-hosted instance never serves this route. + +### Path parameters + +| Field | Type | Description | +| --- | --- | --- | +| user_id | snowflake | The ID of the target account | + +### Response body + +| Field | Type | Description | +| --- | --- | --- | +| purchases | array[[Admin store purchase](#admin-store-purchase-object) object] | The linked purchases after the refresh | + +### Response + +| Status | Body | Condition | +| --- | --- | --- | +| 200 | response body | The purchases were refreshed | +| 404 | [error response](/admin-api/#error-response) | `UNKNOWN_USER`, because the account does not exist | +| 503 | [error response](/admin-api/#error-response) | `STORE_BILLING_UNAVAILABLE`, because a store could not be reached | + +### Side effects + +A subscription the store no longer recognises becomes `expired` and stops granting premium. A changed premium state sends [User Update](/gateway/events/#user-update) to the account's own sessions. + +The operation records one [Admin audit entry](/admin-api/#admin-audit-entry-object) with action `refresh_store_purchases`, target type `user`, and the metadata key `purchase_count`. + +### Rate limit + +100 requests per minute for each authenticated user, on the `admin:user:modify` bucket. + ## Update user phone verification diff --git a/fluxer_docs/src/content/docs/http-api/billing.mdx b/fluxer_docs/src/content/docs/http-api/billing.mdx index c24ffd042..31a65bf77 100644 --- a/fluxer_docs/src/content/docs/http-api/billing.mdx +++ b/fluxer_docs/src/content/docs/http-api/billing.mdx @@ -214,6 +214,7 @@ Creates a recurring premium checkout session and returns a [redirect URL](#redir - An unverified email address returns 403 `PURCHASE_EMAIL_VERIFICATION_REQUIRED`. - The purchase-disabled premium flag returns 403 `PREMIUM_PURCHASE_BLOCKED` with the reason `purchase_disabled`. - A lifetime Visionary account cannot buy a recurring subscription and returns 403 `PREMIUM_PURCHASE_BLOCKED` with the reason `lifetime`. +- An account with an active [in-app purchase](/http-api/in-app-purchases/) subscription returns the same code with the reason `existing_subscription` and the store in the top-level `provider` member. Fluxer never converts this refusal. - An account whose payment provider customer already holds a subscription in the `active`, `trialing`, `past_due`, `unpaid`, `incomplete` or `paused` state returns the same code with the reason `existing_subscription`, unless Fluxer converts the purchase into a scheduled billing cycle change, as described below. `reason` is a top-level member of the error response, and an `existing_subscription` refusal also has the blocking status in the top-level `subscription_status` member. diff --git a/fluxer_docs/src/content/docs/http-api/deployment-availability.md b/fluxer_docs/src/content/docs/http-api/deployment-availability.md index 08f462591..17b13f476 100644 --- a/fluxer_docs/src/content/docs/http-api/deployment-availability.md +++ b/fluxer_docs/src/content/docs/http-api/deployment-availability.md @@ -78,6 +78,11 @@ When billing is switched off or no Stripe secret key is configured on a hosted d | POST | /v1/premium/refund-latest | [Refund latest purchase](/http-api/billing/#refund-latest-purchase) | | POST | /v1/premium/switch-to-list-price | [Switch subscription to the list price](/http-api/premium/#switch-subscription-to-the-list-price) | | POST | /v1/premium/visionary/rejoin | [Rejoin Visionary guild](/http-api/premium/#rejoin-visionary-guild) | +| GET | /v1/premium/store | [Get in-app purchase context](/http-api/in-app-purchases/#get-in-app-purchase-context) | +| POST | /v1/premium/store/app-store/transactions | [Claim App Store transaction](/http-api/in-app-purchases/#claim-app-store-transaction) | +| POST | /v1/premium/store/google-play/purchases | [Claim Google Play purchase](/http-api/in-app-purchases/#claim-google-play-purchase) | +| GET | /v1/premium/store/purchases | [List in-app purchases](/http-api/in-app-purchases/#list-in-app-purchases) | +| DELETE | /v1/premium/store/purchases/{purchase_id} | [Release in-app subscription](/http-api/in-app-purchases/#release-in-app-subscription) | 1 These are the only `/users/@me` routes a self-hosted deployment may not serve @@ -87,6 +92,8 @@ When billing is switched off or no Stripe secret key is configured on a hosted d 4 The same object appears as `billing.refund_eligibility` on [Get premium state](/http-api/premium/#get-premium-state), which every deployment serves, and there a self-hosted deployment reports `eligible` false with the reason `feature_unavailable` +The two [store notification webhooks](/http-api/in-app-purchases/#store-notification-webhooks) are never served on a self-hosted deployment either. + ## Registered routes that resolve differently A route every deployment registers can still produce a different answer on a self-hosted instance. Each operation page documents that difference. diff --git a/fluxer_docs/src/content/docs/http-api/errors.md b/fluxer_docs/src/content/docs/http-api/errors.md index deb3af633..4a82b26d2 100644 --- a/fluxer_docs/src/content/docs/http-api/errors.md +++ b/fluxer_docs/src/content/docs/http-api/errors.md @@ -920,6 +920,26 @@ Service unavailable Invalid request +### `STORE_BILLING_UNAVAILABLE` + +In-app purchases are unavailable right now + +### `STORE_NOTIFICATION_UNAUTHORIZED` + +The notification signature is invalid + +### `STORE_PURCHASE_INVALID` + +This purchase could not be verified + +### `STORE_PURCHASE_OWNED_BY_OTHER_ACCOUNT` + +This purchase is linked to a different account + +### `STORE_PURCHASE_SANDBOX_NOT_ENTITLED` + +Test purchases cannot be applied to this account + ### `STREAM_KEY_CHANNEL_MISMATCH` Stream key channel mismatch @@ -1132,6 +1152,10 @@ Role wasn't found Unknown sticker +### `UNKNOWN_STORE_PURCHASE` + +Unknown store purchase + ### `UNKNOWN_SUSPICIOUS_FLAG` Unknown suspicious flag diff --git a/fluxer_docs/src/content/docs/http-api/in-app-purchases.mdx b/fluxer_docs/src/content/docs/http-api/in-app-purchases.mdx new file mode 100644 index 000000000..03b44c3de --- /dev/null +++ b/fluxer_docs/src/content/docs/http-api/in-app-purchases.mdx @@ -0,0 +1,455 @@ +--- +# SPDX-License-Identifier: AGPL-3.0-or-later +title: In-app purchases +description: App Store and Google Play purchases, the claim routes the mobile apps call, and the store notification webhooks. +--- + +import RouteHeader from '@/components/RouteHeader.astro'; + +The mobile apps sell premium through the App Store and Google Play. The store takes the payment, and the app then hands the purchase to Fluxer through a claim route. Fluxer verifies the purchase with the store, links it to the account, and applies it to the account's premium state. The stores also send [notifications](#store-notification-webhooks) for renewals, expiries and refunds, so a purchase stays current without the app. + +A subscription purchase grants the same premium as a [Stripe subscription](/http-api/billing/#create-subscription-checkout). A gift purchase mints one [gift](/http-api/gifts/#gift-object) code. [Get premium state](/http-api/premium/#get-premium-state) reports the active store subscription in `store`, and names the platform that owns the account's recurring subscription in `subscription_provider`. A client manages the subscription only on that platform, so an app does not offer its own management for a `stripe` subscriber. + +Every route on this page except the store notification webhooks is user-only. A self-hosted deployment never serves any of them, as [deployment availability](/http-api/deployment-availability/) describes. + +| Object | Purpose | +| --- | --- | +| [In-app purchase context](#in-app-purchase-context-object) | The account token and the products the apps may sell | +| [Store purchase](#store-purchase-object) | One App Store or Google Play purchase linked to the account | +| [Store purchase claim](#store-purchase-claim-object) | The result of a claim | + +## Store providers + +| Value | Description | +| --- | --- | +| app_store | Apple App Store, with StoreKit 2 in the app | +| google_play | Google Play, with Play Billing in the app | + +## Store slots + +Each configured store product sells one Fluxer product, called its slot. + +| Value | Description | +| --- | --- | +| monthly | Premium subscription billed every month | +| yearly | Premium subscription billed every year | +| gift_1_month | One gift code for one month of premium | +| gift_1_year | One gift code for one year of premium | + +## Store purchase states + +| Value | Description | +| --- | --- | +| pending | The store has not taken the payment yet | +| active | The subscription is paid and renews | +| grace | The renewal payment failed and the store grace period is running | +| billing_retry | The renewal payment failed and the store is retrying without grace | +| on_hold | Google Play has suspended the subscription after a failed payment | +| paused | The subscriber paused the subscription in Google Play | +| canceled | The subscription is paid until `expires_at` and will not renew | +| expired | The subscription has ended | +| revoked | The store refunded or revoked the subscription | +| superseded | A newer Google Play purchase replaced this one | +| purchased | The gift is paid and its code is not minted yet | +| fulfilled | The gift code is minted | +| refunded | The store refunded the gift | + +Only `active`, `grace` and `canceled` can grant premium. + +## Purchase blocked reasons + +| Value | Description | +| --- | --- | +| lifetime | The account holds lifetime Visionary premium | +| existing_subscription | The account already has an active subscription | +| purchase_disabled | Purchases are disabled for the account | + +## In-app purchase context object + +What an app needs before it shows a paywall. [Get in-app purchase context](#get-in-app-purchase-context) returns it. + +### Structure + +| Field | Type | Description | +| --- | --- | --- | +| app_account_token1 | string | The account token, a lowercase UUID | +| app_store | [App Store settings](#app-store-settings-object) object | App Store purchase settings | +| google_play | [Google Play settings](#google-play-settings-object) object | Google Play purchase settings | +| purchase_blocked_reason2 | ?string | [Purchase blocked reason](#purchase-blocked-reasons), or null | +| blocking_provider3 | ?string | Provider of the blocking subscription, or null | + +1 Stable for the account. An App Store purchase passes it as `appAccountToken`, and a Google Play purchase passes it as `obfuscatedAccountId`. Fluxer links a purchase that has the token to the account even when the store notification arrives before the claim + +2 Null when the account can buy a subscription. Gift products stay on sale whatever the value is + +3 Either `stripe`, `app_store` or `google_play`. It has a value only when the reason is `existing_subscription` + +### Example + +```json +{ + "app_account_token": "0f7c2a1e-5b3d-4c8e-9a61-2d4f8b7e3c10", + "app_store": { + "enabled": true, + "bundle_ids": ["com.fluxer"], + "products": [ + {"product_id": "com.fluxer.plutonium.monthly", "slot": "monthly"}, + {"product_id": "com.fluxer.gift.1month", "slot": "gift_1_month"} + ] + }, + "google_play": { + "enabled": true, + "package_names": ["com.fluxer"], + "products": [ + {"product_id": "plutonium", "base_plan_id": "monthly", "slot": "monthly"}, + {"product_id": "gift_1_month", "base_plan_id": null, "slot": "gift_1_month"} + ] + }, + "purchase_blocked_reason": null, + "blocking_provider": null +} +``` + +## App Store settings object + +The App Store purchases the deployment accepts. When the App Store is not set up, `enabled` is false and both arrays are empty. + +### Structure + +| Field | Type | Description | +| --- | --- | --- | +| enabled | boolean | Whether App Store purchases are accepted | +| bundle_ids | array[string] | App bundle IDs whose purchases are accepted | +| products | array[[App Store product](#app-store-product-object) object] | Products on sale | + +## App Store product object + +### Structure + +| Field | Type | Description | +| --- | --- | --- | +| product_id | string | App Store product ID | +| slot | string | [Store slot](#store-slots) | + +## Google Play settings object + +The Google Play purchases the deployment accepts. When Google Play is not set up, `enabled` is false and both arrays are empty. + +### Structure + +| Field | Type | Description | +| --- | --- | --- | +| enabled | boolean | Whether Google Play purchases are accepted | +| package_names | array[string] | App package names whose purchases are accepted | +| products | array[[Google Play product](#google-play-product-object) object] | Products on sale | + +## Google Play product object + +A subscription product has one entry for each base plan on sale. + +### Structure + +| Field | Type | Description | +| --- | --- | --- | +| product_id | string | Google Play product ID | +| base_plan_id | ?string | Base plan ID, or null for a one-time product | +| slot | string | [Store slot](#store-slots) | + +## Store purchase object + +One purchase as Fluxer last read it from the store. It never exposes purchase tokens, transaction IDs or the account token. + +### Structure + +| Field | Type | Description | +| --- | --- | --- | +| id | snowflake | The ID of the store purchase | +| provider | string | [Store provider](#store-providers) | +| kind | string | Either `subscription` or `gift` | +| slot | string | [Store slot](#store-slots) | +| product_id | string | Store product ID | +| environment1 | string | Either `production` or `sandbox` | +| state | string | [Store purchase state](#store-purchase-states) | +| entitled2 | boolean | Whether the purchase grants premium now | +| expires_at | ?ISO8601 timestamp | End of the paid period, or null for a gift | +| entitled_until3 | ?ISO8601 timestamp | End of access from this purchase | +| will_renew | ?boolean | Whether the subscription renews, or null for a gift | +| gift_code | ?string | Gift code minted by a gift purchase, or null | +| created_at | ISO8601 timestamp | The time Fluxer first saw the purchase | + +1 `sandbox` is an App Store sandbox purchase or a Google Play license test purchase + +2 A `sandbox` purchase is false unless the account is allowed test purchases on the deployment + +3 The grace period end while `state` is `grace`, otherwise `expires_at`. Null for a gift and for a purchase that grants nothing + +### Example + +```json +{ + "id": "1501203318237184000", + "provider": "app_store", + "kind": "subscription", + "slot": "monthly", + "product_id": "com.fluxer.plutonium.monthly", + "environment": "production", + "state": "active", + "entitled": true, + "expires_at": "2026-10-29T09:00:00.000Z", + "entitled_until": "2026-10-29T09:00:00.000Z", + "will_renew": true, + "gift_code": null, + "created_at": "2026-09-29T09:00:00.000Z" +} +``` + +## Store purchase claim object + +### Structure + +| Field | Type | Description | +| --- | --- | --- | +| purchase | [store purchase](#store-purchase-object) object | The claimed purchase | +| gift_code1 | ?string | Gift code minted by a gift purchase, or null | + +1 The same value as `purchase.gift_code`. Null for a subscription and for a gift the store has not finished charging + +### Example + +```json +{ + "purchase": { + "id": "1501203318237184001", + "provider": "google_play", + "kind": "gift", + "slot": "gift_1_month", + "product_id": "gift_1_month", + "environment": "production", + "state": "fulfilled", + "entitled": true, + "expires_at": null, + "entitled_until": null, + "will_renew": null, + "gift_code": "q7Xr2mPz9LkT4vBn8cWd3HsYf6JaE1Gu", + "created_at": "2026-09-29T09:00:00.000Z" + }, + "gift_code": "q7Xr2mPz9LkT4vBn8cWd3HsYf6JaE1Gu" +} +``` + +## Get in-app purchase context + + + +Returns the [in-app purchase context](#in-app-purchase-context-object) object for the authenticated account. + +The blocked reason is decided in a fixed order. A lifetime Visionary account reports `lifetime`, then the purchase-disabled premium flag reports `purchase_disabled`, then an active store subscription reports `existing_subscription` with that store, and then an active Stripe subscription reports `existing_subscription` with `stripe`. + +### Response + +| Status | Body | Condition | +| --- | --- | --- | +| 200 | [in-app purchase context](#in-app-purchase-context-object) object | The context was returned | + +### Side effects + +The first read creates the account token. + +### Rate limit + +30 requests per 10 seconds for each authenticated user, on the `store:context` bucket. + +## Claim App Store transaction + + + +Verifies a StoreKit 2 signed transaction, links the purchase to the authenticated account, applies it, and returns a [store purchase claim](#store-purchase-claim-object) object. Repeating the claim for the same purchase returns the same result. + +The app finishes the transaction after a 200 response or any 400 or 403 error listed under limitations. It leaves the transaction unfinished after a 429, a 503 `STORE_BILLING_UNAVAILABLE`, another 5xx or a network failure. StoreKit then delivers the transaction again, so the claim can be retried later. + +### Limitations + +- A transaction that fails verification, is for an unknown product or app, or comes from Xcode or local testing returns 400 `STORE_PURCHASE_INVALID`. +- A gift bought in a quantity above one mints no gift code and returns 400 `STORE_PURCHASE_INVALID`. +- A purchase already linked to another live account returns 403 `STORE_PURCHASE_OWNED_BY_OTHER_ACCOUNT`. So does a purchase whose account token belongs to another live account. +- A sandbox purchase on an account that is not allowed test purchases returns 403 `STORE_PURCHASE_SANDBOX_NOT_ENTITLED`. +- A subscription claimed by a lifetime Visionary account returns 403 `PREMIUM_PURCHASE_BLOCKED` with the reason `lifetime` and the store in the top-level `provider` member. +- A deployment without the App Store set up, or an App Store that cannot be reached, returns 503 `STORE_BILLING_UNAVAILABLE`. + +After a test purchase or lifetime refusal the purchase is still linked to the account. It grants nothing. + +A purchase shared through Family Sharing is linked only through this route. + +### JSON body + +| Field | Type | Description | +| --- | --- | --- | +| signed_transaction | string | The signed transaction from StoreKit 2 (1-32768 characters) | + +### Response + +| Status | Body | Condition | +| --- | --- | --- | +| 200 | [store purchase claim](#store-purchase-claim-object) object | The purchase was verified and linked | +| 400 | [error response](/http-api/#error-response) | The transaction was not accepted | +| 403 | [error response](/http-api/#error-response) | The purchase belongs to another account, or it cannot grant premium to this account | +| 503 | [error response](/http-api/#error-response) | The App Store is unavailable | + +### Side effects + +Fluxer reads the purchase from the App Store. A subscription that grants premium updates the account's premium state, and a gift purchase mints its gift code once. A changed premium state sends [User Update](/gateway/events/#user-update) to every account session. + +When the purchase has no account token, or has one that belongs to no live account, Fluxer sets the claiming account's token on it. + +### Rate limit + +20 requests per minute for each authenticated user, on the `store:claim:app_store` bucket. + +## Claim Google Play purchase + + + +Verifies a Google Play purchase token, links the purchase to the authenticated account, applies it, and returns a [store purchase claim](#store-purchase-claim-object) object. Repeating the claim for the same purchase returns the same result. + +Fluxer acknowledges the purchase with Google Play. The app does not need to acknowledge it. + +### Limitations + +- A token that Google Play does not recognise, a product that is not on sale, a product that does not match the token, and a package name that is not accepted all return 400 `STORE_PURCHASE_INVALID`. +- The ownership, test purchase and lifetime refusals of [claim App Store transaction](#claim-app-store-transaction) apply unchanged, with the same codes. +- A deployment without Google Play set up, or a Google Play that cannot be reached, returns 503 `STORE_BILLING_UNAVAILABLE`. + +A lifetime Visionary account that buys a subscription through Google Play is refunded. So is a gift bought through Google Play in a quantity above one. + +### JSON body + +| Field | Type | Description | +| --- | --- | --- | +| purchase_token | string | Purchase token from Play Billing (1-1024 characters) | +| product_id | string | Google Play product ID of the purchase (1-256 characters) | +| package_name?1 | string | Package name of the app that made the purchase (1-256 characters) | + +1 One of `google_play.package_names` from the [in-app purchase context](#in-app-purchase-context-object). Defaults to the first accepted package + +### Response + +| Status | Body | Condition | +| --- | --- | --- | +| 200 | [store purchase claim](#store-purchase-claim-object) object | The purchase was verified and linked | +| 400 | [error response](/http-api/#error-response) | The purchase was not accepted | +| 403 | [error response](/http-api/#error-response) | The purchase belongs to another account, or it cannot grant premium to this account | +| 503 | [error response](/http-api/#error-response) | Google Play is unavailable | + +### Side effects + +Fluxer reads the purchase from Google Play. A subscription that grants premium updates the account's premium state, and a gift purchase mints its gift code once and is then consumed. A changed premium state sends [User Update](/gateway/events/#user-update) to every account session. + +A purchase that replaces an earlier subscription, such as an upgrade, takes over that subscription's account link. The earlier purchase becomes `superseded`. + +### Rate limit + +20 requests per minute for each authenticated user, on the `store:claim:google_play` bucket. + +## List in-app purchases + + + +Returns an array of the [store purchase](#store-purchase-object) objects linked to the authenticated account, newest first. + +### Response + +| Status | Body | Condition | +| --- | --- | --- | +| 200 | array[[store purchase](#store-purchase-object) object] | The purchases were returned | + +### Rate limit + +30 requests per 10 seconds for each authenticated user, on the `store:purchases:list` bucket. + +## Release in-app subscription + + + +Unlinks a store subscription from the authenticated account and returns 204 with an empty body. Requires sudo mode. Another account can then claim the subscription. + +Releasing does not cancel the subscription. The subscriber cancels it in the store. + +### Limitations + +- A purchase that is not linked to the account returns 404 `UNKNOWN_STORE_PURCHASE`. +- A gift purchase cannot be released and returns 400 `STORE_PURCHASE_INVALID`. + +### Path parameters + +| Field | Type | Description | +| --- | --- | --- | +| purchase_id | snowflake | The ID of the store purchase | + +### Request headers + +| Field | Type | Description | +| --- | --- | --- | +| X-Fluxer-Sudo-Mode-JWT? | string | Existing sudo mode proof | + +### JSON body + +The body is the [sudo verification fields](/http-api/guilds/#sudo-verification-fields). A request that already has a valid proof can omit it. + +| Field | Type | Description | +| --- | --- | --- | +| password? | string | Current account password | +| mfa_method? | string | MFA method, either `totp` or `webauthn` | +| mfa_code? | string | Authenticator code or unconsumed backup code when the method is `totp` (1-32 characters) | +| webauthn_response? | [WebAuthn assertion](/http-api/authentication/#webauthn-assertion) object | Assertion when the method is `webauthn` | +| webauthn_challenge? | string | Challenge bound to the WebAuthn assertion | + +### Response + +| Status | Body | Condition | +| --- | --- | --- | +| 204 | empty | The subscription was released | +| 400 | [error response](/http-api/#error-response) | The purchase is a gift, or the sudo proof was rejected | +| 403 | [error response](/http-api/#error-response) | Sudo mode is required and returns `SUDO_MODE_REQUIRED` | +| 404 | [error response](/http-api/#error-response) | The purchase is not linked to the account | + +### Side effects + +The account stops receiving premium from the subscription, and a changed premium state sends [User Update](/gateway/events/#user-update) to every account session. + +### Rate limit + +5 requests per minute for each authenticated user, on the `store:purchases:release` bucket. + +## Store notification webhooks + +The stores call these two routes. No client calls them, and they have no OpenAPI entry. Both routes are served only on the hosted deployment, and both answer 503 `STORE_BILLING_UNAVAILABLE` while their store is not set up. + +Fluxer reads the purchase from the store again after each notification, and a redelivered notification is applied once. A Google Play voided purchase is the one notification that changes a purchase by itself. It marks a gift `refunded`, or a subscription `revoked` when the voided order is its latest order. + +When a gift is refunded, Fluxer revokes its gift code and takes the gift time back from the account that redeemed it. When the store reverses the refund, the gift code and its gift time come back. + +Fluxer also reads the App Store notification history and the Google Play voided purchases once a day, so a notification that was missed is still applied. + +### App Store Server Notifications + +`POST /webhooks/app-store` receives App Store Server Notifications V2. The body is `{"signedPayload": "..."}`, signed by Apple. Fluxer verifies the signature before it answers. + +| Status | Condition | +| --- | --- | +| 200 | The notification was verified and queued | +| 401 | The body or its signature was rejected and returns `STORE_NOTIFICATION_UNAUTHORIZED` | +| 503 | The App Store is not set up | + +300 requests per minute for each client IP address, on the `store:webhook:app_store` bucket. The bucket is exempt from the global bucket. + +### Google Play real-time developer notifications + +`POST /webhooks/google-play` receives real-time developer notifications as Pub/Sub push messages. Each message is authenticated by a Google-signed token in the `Authorization` header, whose audience and service account the deployment configures. + +| Status | Condition | +| --- | --- | +| 204 | The message was queued, or it was not a Pub/Sub message and was dropped | +| 401 | The token was rejected and returns `STORE_NOTIFICATION_UNAUTHORIZED` | +| 503 | Google Play is not set up | + +300 requests per minute for each client IP address, on the `store:webhook:google_play` bucket. The bucket is exempt from the global bucket. diff --git a/fluxer_docs/src/content/docs/http-api/premium.mdx b/fluxer_docs/src/content/docs/http-api/premium.mdx index 9e33783da..e887a9745 100644 --- a/fluxer_docs/src/content/docs/http-api/premium.mdx +++ b/fluxer_docs/src/content/docs/http-api/premium.mdx @@ -23,6 +23,7 @@ Every route except [get price IDs](#get-price-ids) is user-only. A [self-hosted | [Billing invoice](#billing-invoice-object) | One mirrored invoice | | [Billing payment method](#billing-payment-method-object) | One mirrored payment method | | [Premium pricing state](#premium-pricing-state-object) | The resolved checkout catalogue | +| [Store subscription](#store-subscription-object) | The active [in-app purchase](/http-api/in-app-purchases/) subscription | :::note[Manage billing through the customer portal] [Get premium state](#get-premium-state) returns invoices, payment methods and subscription details. Use [Create customer portal](#create-customer-portal) to open the billing interface. @@ -211,6 +212,106 @@ A client gates a premium feature on `effective.is_premium`. | effective | [effective premium state](#effective-premium-state-object) object | The entitlement every premium feature check reads | | billing | [premium billing state](#premium-billing-state-object) object | The mirrored payment provider data for the account | | pricing | [premium pricing state](#premium-pricing-state-object) object | The resolved checkout prices for the requested country | +| store1 | ?[store subscription](#store-subscription-object) object | The active App Store or Google Play subscription, or null | +| subscription_provider2 | ?string | The billing platform that owns the current recurring subscription, one of `stripe`, `app_store` or `google_play`, or null | + +1 Always null on a self-hosted deployment. When the account has more than one active store subscription, this is the one whose access ends last + +2 Null for a gift, a lifetime Visionary entitlement, or no subscription. `stripe` needs a Stripe subscription with a billing cycle and a paid period that has not ended. An account can hold an active Stripe subscription and an active store subscription at once, and then this names the one paid through later. A self-hosted deployment reports only `stripe` or null. A client sends a `stripe` subscriber to [create customer portal](#create-customer-portal) and a store subscriber to `store.manage_url` + +### Example + +```json +{ + "actual": { + "premium_type": 1, + "premium_since": "2026-09-19T09:00:00.000Z", + "premium_until": "2026-10-19T09:00:00.000Z", + "premium_will_cancel": false, + "premium_billing_cycle": null, + "premium_lifetime_sequence": null, + "premium_grace_ends_at": null, + "has_active_paid_premium": true, + "is_visionary": false, + "has_ever_purchased": true + }, + "effective": { + "is_premium": true, + "premium_type": 1, + "premium_since": "2026-09-19T09:00:00.000Z", + "premium_until": "2026-10-19T09:00:00.000Z", + "premium_will_cancel": false, + "premium_billing_cycle": null, + "premium_lifetime_sequence": null, + "premium_grace_ends_at": null, + "premium_enabled_override": false, + "premium_purchase_disabled": false, + "premium_perks_disabled": false, + "self_hosted": false, + "bot": false + }, + "billing": { + "stripe_customer_id": null, + "current_subscription_price": null, + "pending_subscription_change": null, + "list_price_switch": { + "available": false, + "reason": "no_active_subscription", + "pending": false, + "current_price_id": null, + "current_amount_minor": null, + "list_price_id": null, + "list_amount_minor": null, + "currency": null, + "billing_cycle": null, + "effective_at": null + }, + "subscription": null, + "invoices": [], + "invoices_has_more": false, + "payment_methods": [], + "refund_eligibility": { + "eligible": false, + "reason": "no_refundable_purchase", + "invoice_id": null, + "invoice_amount_paid_cents": null, + "currency": null, + "paid_at": null, + "refund_window_expires_at": null, + "cooldown_expires_at": null, + "cancels_subscription": false + } + }, + "pricing": { + "country_code": "US", + "localized": { + "monthly": "price_1QaMonthlyUsd", + "yearly": "price_1QaYearlyUsd", + "gift_1_month": "price_1QaGift1MonthUsd", + "gift_1_year": "price_1QaGift1YearUsd", + "monthly_amount_minor": 499, + "yearly_amount_minor": 4999, + "gift_1_month_amount_minor": 499, + "gift_1_year_amount_minor": 4999, + "currency": "USD", + "gift_currency": "USD" + } + }, + "store": { + "provider": "app_store", + "purchase_id": "1501203318237184000", + "slot": "monthly", + "billing_cycle": "monthly", + "state": "active", + "expires_at": "2026-10-19T09:00:00.000Z", + "grace_ends_at": null, + "will_renew": true, + "manage_url": "https://apps.apple.com/account/subscriptions", + "environment": "production" + }, + "subscription_provider": "app_store" +} +``` ## Actual premium state object @@ -452,6 +553,48 @@ The checkout catalogue resolved for the request. A client can render prices with Fluxer reports an unresolvable catalogue as null here, and the request still succeeds. [Get price IDs](#get-price-ids) answers 400 `STRIPE_ERROR` for the same condition. +## Store subscription object + +The App Store or Google Play subscription that grants the account premium. The [store purchase](/http-api/in-app-purchases/#store-purchase-object) it comes from is listed by [list in-app purchases](/http-api/in-app-purchases/#list-in-app-purchases). + +While no Stripe subscription is active, a store subscription leaves `actual.premium_billing_cycle` null. A client reads the cycle here and sends the subscriber to `manage_url`, since [create customer portal](#create-customer-portal) cannot manage a store subscription. + +### Structure + +| Field | Type | Description | +| --- | --- | --- | +| provider | string | Either `app_store` or `google_play` | +| purchase_id | snowflake | The ID of the store purchase | +| slot | string | Either `monthly` or `yearly` | +| billing_cycle | string | [Billing cycle](#billing-cycles) | +| state1 | string | [Store purchase state](/http-api/in-app-purchases/#store-purchase-states) | +| expires_at | ISO8601 timestamp | End of the paid period | +| grace_ends_at | ?ISO8601 timestamp | End of the store grace period, or null outside grace | +| will_renew | boolean | Whether the subscription renews | +| manage_url2 | string | Store page where the subscriber manages or cancels it | +| environment | string | Either `production` or `sandbox` | + +1 One of `active`, `grace` or `canceled` + +2 The App Store subscriptions page, or the Google Play subscriptions page for the product and package + +### Example + +```json +{ + "provider": "google_play", + "purchase_id": "1501203318237184000", + "slot": "yearly", + "billing_cycle": "yearly", + "state": "active", + "expires_at": "2027-09-29T09:00:00.000Z", + "grace_ends_at": null, + "will_renew": true, + "manage_url": "https://play.google.com/store/account/subscriptions?sku=plutonium&package=com.fluxer", + "environment": "production" +} +``` + ## Get price IDs diff --git a/packages/config/src/ConfigLoader.ts b/packages/config/src/ConfigLoader.ts index 78b58828c..3eee32044 100644 --- a/packages/config/src/ConfigLoader.ts +++ b/packages/config/src/ConfigLoader.ts @@ -11,7 +11,7 @@ import { parsePublicOrigin, parseWebOrigin, } from '@fluxer/config/src/EndpointDerivation'; -import {CACHE_PURGE_ADAPTER_NAMES, type MasterConfig} from '@fluxer/config/src/MasterConfig'; +import {CACHE_PURGE_ADAPTER_NAMES, type MasterConfig, STORE_PRODUCT_SLOT_NAMES} from '@fluxer/config/src/MasterConfig'; let cachedConfig: MasterConfig | null = null; @@ -224,6 +224,21 @@ function defaultConfig(): MasterConfig { apps: [], }, }, + app_store: { + enabled: false, + apps: [], + products: {}, + }, + google_play: { + enabled: false, + packages: [], + token_uri: 'https://oauth2.googleapis.com/token', + products: {}, + }, + store_billing: { + sandbox_user_ids: [], + sandbox_entitles_all: false, + }, }, instance: { self_hosted: false, @@ -449,6 +464,50 @@ function validateCachePurgeConfig(config: MasterConfig): void { assertIntegerInRange(cachePurge.http.timeout_ms, 'FLUXER_CACHE_PURGE_HTTP_TIMEOUT_MS', 1_000, 10_000); } +function validateStoreProductSlots( + products: Record | undefined, + envName: string, + isValidKey: (productKey: string, slot: string) => boolean, +): void { + for (const [productKey, slot] of Object.entries(products ?? {})) { + assertOneOf(slot, STORE_PRODUCT_SLOT_NAMES, `${envName} slot for ${productKey}`); + if (!isValidKey(productKey, slot)) { + throw new Error(`${envName} key ${productKey} does not match its ${slot} slot`); + } + } +} + +function validateStoreBillingConfig(config: MasterConfig): void { + const appStore = config.integrations.app_store; + for (const [index, app] of (appStore.apps ?? []).entries()) { + if (!isConfigObject(app) || typeof app.bundle_id !== 'string' || app.bundle_id.trim().length === 0) { + throw new Error(`FLUXER_APP_STORE_APPS entry ${index + 1} must have a bundle_id`); + } + if (typeof app.app_apple_id !== 'number' || !Number.isSafeInteger(app.app_apple_id) || app.app_apple_id <= 0) { + throw new Error(`FLUXER_APP_STORE_APPS entry ${index + 1} must have a numeric app_apple_id`); + } + } + validateStoreProductSlots(appStore.products, 'FLUXER_APP_STORE_PRODUCTS', (productKey) => productKey.length > 0); + validateStoreProductSlots( + config.integrations.google_play.products, + 'FLUXER_GOOGLE_PLAY_PRODUCTS', + (productKey, slot) => { + const parts = productKey.split(':'); + const isSubscription = slot === 'monthly' || slot === 'yearly'; + if (isSubscription) { + return parts.length === 2 && parts.every((part) => part.length > 0); + } + return parts.length === 1 && productKey.length > 0; + }, + ); + for (const userId of config.integrations.store_billing.sandbox_user_ids ?? []) { + if (!/^\d+$/.test(userId)) { + throw new Error('FLUXER_STORE_BILLING_SANDBOX_USER_IDS must be a comma separated list of user ids'); + } + } + assertBoolean(config.integrations.store_billing.sandbox_entitles_all, 'FLUXER_STORE_BILLING_SANDBOX_ENTITLES_ALL'); +} + function validateDomain(value: string, envName: string): void { if (value === '') return; const parsed = URL.parse(`http://${value}/`); @@ -509,6 +568,7 @@ function normalizeConfig(config: MasterConfig): MasterConfig { validateApiWorkerConfig(config); validateStorageChangeFeedConfig(config); validateCachePurgeConfig(config); + validateStoreBillingConfig(config); normalizeAppOriginAliases(config); assertIntegerInRange(config.services.api.max_inflight_requests, 'FLUXER_API_MAX_INFLIGHT_REQUESTS', 1, 100_000); assertIntegerInRange(config.services.api.headers_timeout_ms, 'FLUXER_API_HEADERS_TIMEOUT_MS', 1_000, 3_600_000); diff --git a/packages/config/src/MasterConfig.ts b/packages/config/src/MasterConfig.ts index b87ae40d7..a822b4eb2 100644 --- a/packages/config/src/MasterConfig.ts +++ b/packages/config/src/MasterConfig.ts @@ -7,6 +7,8 @@ export type DatabaseBackend = 'postgres' | 'cassandra'; export type PublicScheme = 'http' | 'https'; export const CACHE_PURGE_ADAPTER_NAMES = ['none', 'http'] as const; export type CachePurgeAdapterName = (typeof CACHE_PURGE_ADAPTER_NAMES)[number]; +export const STORE_PRODUCT_SLOT_NAMES = ['monthly', 'yearly', 'gift_1_month', 'gift_1_year'] as const; +export type StoreProductSlotName = (typeof STORE_PRODUCT_SLOT_NAMES)[number]; export interface InstanceBrandingConfig { product_name: string; @@ -255,6 +257,34 @@ export interface MasterConfig { }>; }; }; + app_store: { + enabled: boolean; + issuer_id?: string; + key_id?: string; + private_key?: string; + private_key_path?: string; + apps?: Array<{ + bundle_id: string; + app_apple_id: number; + }>; + products?: Record; + }; + google_play: { + enabled: boolean; + packages?: Array; + client_email?: string; + private_key?: string; + private_key_path?: string; + service_account_json_path?: string; + token_uri?: string; + products?: Record; + push_audience?: string; + push_service_account_email?: string; + }; + store_billing: { + sandbox_user_ids?: Array; + sandbox_entitles_all: boolean; + }; }; instance: { self_hosted: boolean; diff --git a/packages/config/src/__tests__/ConfigLoader.test.ts b/packages/config/src/__tests__/ConfigLoader.test.ts index 784bb3671..ff16cb191 100644 --- a/packages/config/src/__tests__/ConfigLoader.test.ts +++ b/packages/config/src/__tests__/ConfigLoader.test.ts @@ -524,6 +524,86 @@ describe('ConfigLoader', () => { expect(config.instance.setup.configured).toBe(true); }); + test('leaves both stores off with no apps, packages or products by default', async () => { + stubMinimalEnv(); + const config = await loadConfig(); + expect(config.integrations.app_store).toEqual({enabled: false, apps: [], products: {}}); + expect(config.integrations.google_play).toEqual({ + enabled: false, + packages: [], + token_uri: 'https://oauth2.googleapis.com/token', + products: {}, + }); + expect(config.integrations.store_billing).toEqual({sandbox_user_ids: [], sandbox_entitles_all: false}); + }); + + test('loads a valid store catalogue', async () => { + stubMinimalEnv({ + FLUXER_APP_STORE_ENABLED: 'true', + FLUXER_APP_STORE_APPS: '[{"bundle_id":"com.fluxer","app_apple_id":1234567890}]', + FLUXER_APP_STORE_PRODUCTS: '{"com.fluxer.plutonium.monthly":"monthly","com.fluxer.gift.1month":"gift_1_month"}', + FLUXER_GOOGLE_PLAY_ENABLED: 'true', + FLUXER_GOOGLE_PLAY_PACKAGES: 'com.fluxer', + FLUXER_GOOGLE_PLAY_PRODUCTS: '{"plutonium:yearly":"yearly","gift_1_year":"gift_1_year"}', + FLUXER_STORE_BILLING_SANDBOX_USER_IDS: '1234567890123456', + }); + const config = await loadConfig(); + expect(config.integrations.app_store.apps).toEqual([{bundle_id: 'com.fluxer', app_apple_id: 1234567890}]); + expect(config.integrations.google_play.products).toEqual({ + 'plutonium:yearly': 'yearly', + gift_1_year: 'gift_1_year', + }); + expect(config.integrations.store_billing.sandbox_user_ids).toEqual(['1234567890123456']); + }); + + test('rejects an App Store app without a numeric app_apple_id', async () => { + for (const apps of [ + '[{"bundle_id":"com.fluxer"}]', + '[{"bundle_id":"com.fluxer","app_apple_id":"1234567890"}]', + '[{"bundle_id":"com.fluxer","app_apple_id":0}]', + ]) { + resetConfig(); + stubMinimalEnv({FLUXER_APP_STORE_APPS: apps}); + await expect(loadConfig()).rejects.toThrow('FLUXER_APP_STORE_APPS entry 1 must have a numeric app_apple_id'); + } + }); + + test('rejects an App Store app without a bundle id', async () => { + stubMinimalEnv({FLUXER_APP_STORE_APPS: '[{"app_apple_id":1234567890}]'}); + await expect(loadConfig()).rejects.toThrow('FLUXER_APP_STORE_APPS entry 1 must have a bundle_id'); + }); + + test('rejects an App Store product mapped to an unknown slot', async () => { + stubMinimalEnv({FLUXER_APP_STORE_PRODUCTS: '{"com.fluxer.visionary":"visionary"}'}); + await expect(loadConfig()).rejects.toThrow( + 'Invalid FLUXER_APP_STORE_PRODUCTS slot for com.fluxer.visionary: visionary', + ); + }); + + test('rejects a Google Play product mapped to an unknown slot', async () => { + stubMinimalEnv({FLUXER_GOOGLE_PLAY_PRODUCTS: '{"plutonium:weekly":"weekly"}'}); + await expect(loadConfig()).rejects.toThrow('Invalid FLUXER_GOOGLE_PLAY_PRODUCTS slot for plutonium:weekly: weekly'); + }); + + test('rejects a Google Play product key that does not fit its slot kind', async () => { + stubMinimalEnv({FLUXER_GOOGLE_PLAY_PRODUCTS: '{"plutonium":"monthly"}'}); + await expect(loadConfig()).rejects.toThrow( + 'FLUXER_GOOGLE_PLAY_PRODUCTS key plutonium does not match its monthly slot', + ); + resetConfig(); + stubMinimalEnv({FLUXER_GOOGLE_PLAY_PRODUCTS: '{"gift:one":"gift_1_month"}'}); + await expect(loadConfig()).rejects.toThrow( + 'FLUXER_GOOGLE_PLAY_PRODUCTS key gift:one does not match its gift_1_month slot', + ); + }); + + test('rejects a sandbox user id that is not a snowflake', async () => { + stubMinimalEnv({FLUXER_STORE_BILLING_SANDBOX_USER_IDS: '123,abc'}); + await expect(loadConfig()).rejects.toThrow( + 'FLUXER_STORE_BILLING_SANDBOX_USER_IDS must be a comma separated list of user ids', + ); + }); + test('defaults the cache purge adapter to none', async () => { stubMinimalEnv(); expect((await loadConfig()).integrations.cache_purge).toEqual({ diff --git a/packages/config/src/__tests__/EnvironmentOverrides.test.ts b/packages/config/src/__tests__/EnvironmentOverrides.test.ts index a19f192a6..6a5023ae1 100644 --- a/packages/config/src/__tests__/EnvironmentOverrides.test.ts +++ b/packages/config/src/__tests__/EnvironmentOverrides.test.ts @@ -223,4 +223,94 @@ describe('buildNamedFluxerEnvOverrides', () => { yearly_brl: 'price_blob_yearly_brl', }); }); + + test('maps the App Store variables onto integrations.app_store', () => { + const overrides = buildNamedFluxerEnvOverrides({ + FLUXER_APP_STORE_ENABLED: 'true', + FLUXER_APP_STORE_ISSUER_ID: 'issuer-1', + FLUXER_APP_STORE_KEY_ID: 'KEY123', + FLUXER_APP_STORE_PRIVATE_KEY: '-----BEGIN PRIVATE KEY-----\\nabc\\n-----END PRIVATE KEY-----', + FLUXER_APP_STORE_PRIVATE_KEY_PATH: '/etc/fluxer/keys/app-store.p8', + FLUXER_APP_STORE_APPS: '[{"bundle_id":"com.fluxer","app_apple_id":1234567890}]', + FLUXER_APP_STORE_PRODUCTS: '{"com.fluxer.plutonium.monthly":"monthly","com.fluxer.gift.1year":"gift_1_year"}', + }); + + expect(overrides).toEqual({ + integrations: { + app_store: { + enabled: true, + issuer_id: 'issuer-1', + key_id: 'KEY123', + private_key: '-----BEGIN PRIVATE KEY-----\\nabc\\n-----END PRIVATE KEY-----', + private_key_path: '/etc/fluxer/keys/app-store.p8', + apps: [{bundle_id: 'com.fluxer', app_apple_id: 1234567890}], + products: {'com.fluxer.plutonium.monthly': 'monthly', 'com.fluxer.gift.1year': 'gift_1_year'}, + }, + }, + }); + }); + + test('maps the Google Play variables onto integrations.google_play', () => { + const overrides = buildNamedFluxerEnvOverrides({ + FLUXER_GOOGLE_PLAY_ENABLED: 'true', + FLUXER_GOOGLE_PLAY_PACKAGES: 'com.fluxer, com.fluxer.canary', + FLUXER_GOOGLE_PLAY_CLIENT_EMAIL: 'billing@fluxer.iam.gserviceaccount.com', + FLUXER_GOOGLE_PLAY_PRIVATE_KEY: 'pem', + FLUXER_GOOGLE_PLAY_PRIVATE_KEY_PATH: '/etc/fluxer/keys/play.pem', + FLUXER_GOOGLE_PLAY_SERVICE_ACCOUNT_JSON_PATH: '/etc/fluxer/keys/play.json', + FLUXER_GOOGLE_PLAY_TOKEN_URI: 'https://oauth2.example/token', + FLUXER_GOOGLE_PLAY_PRODUCTS: '{"plutonium:monthly":"monthly","gift_1_month":"gift_1_month"}', + FLUXER_GOOGLE_PLAY_PUSH_AUDIENCE: 'https://api.fluxer.app/webhooks/google-play', + FLUXER_GOOGLE_PLAY_PUSH_SERVICE_ACCOUNT_EMAIL: 'rtdn@fluxer.iam.gserviceaccount.com', + }); + + expect(overrides).toEqual({ + integrations: { + google_play: { + enabled: true, + packages: ['com.fluxer', 'com.fluxer.canary'], + client_email: 'billing@fluxer.iam.gserviceaccount.com', + private_key: 'pem', + private_key_path: '/etc/fluxer/keys/play.pem', + service_account_json_path: '/etc/fluxer/keys/play.json', + token_uri: 'https://oauth2.example/token', + products: {'plutonium:monthly': 'monthly', gift_1_month: 'gift_1_month'}, + push_audience: 'https://api.fluxer.app/webhooks/google-play', + push_service_account_email: 'rtdn@fluxer.iam.gserviceaccount.com', + }, + }, + }); + }); + + test('maps the store billing sandbox variables onto integrations.store_billing', () => { + expect( + buildNamedFluxerEnvOverrides({ + FLUXER_STORE_BILLING_SANDBOX_USER_IDS: '1234, 5678', + FLUXER_STORE_BILLING_SANDBOX_ENTITLES_ALL: 'true', + }), + ).toEqual({ + integrations: {store_billing: {sandbox_user_ids: ['1234', '5678'], sandbox_entitles_all: true}}, + }); + }); + + test('rejects a store enabled flag that is not a boolean', () => { + expect(() => buildNamedFluxerEnvOverrides({FLUXER_APP_STORE_ENABLED: 'yes'})).toThrow( + 'FLUXER_APP_STORE_ENABLED must be true or false', + ); + expect(() => buildNamedFluxerEnvOverrides({FLUXER_GOOGLE_PLAY_ENABLED: '1'})).toThrow( + 'FLUXER_GOOGLE_PLAY_ENABLED must be true or false', + ); + }); + + test('rejects a store app list or product map with the wrong JSON shape', () => { + expect(() => buildNamedFluxerEnvOverrides({FLUXER_APP_STORE_APPS: '{"bundle_id":"com.fluxer"}'})).toThrow( + 'FLUXER_APP_STORE_APPS must be a JSON array', + ); + expect(() => buildNamedFluxerEnvOverrides({FLUXER_APP_STORE_PRODUCTS: '["monthly"]'})).toThrow( + 'FLUXER_APP_STORE_PRODUCTS must be a JSON object', + ); + expect(() => buildNamedFluxerEnvOverrides({FLUXER_GOOGLE_PLAY_PRODUCTS: '{bad'})).toThrow( + 'FLUXER_GOOGLE_PLAY_PRODUCTS must be valid JSON', + ); + }); }); diff --git a/packages/config/src/__tests__/SelfHostingComposeEnvironment.test.ts b/packages/config/src/__tests__/SelfHostingComposeEnvironment.test.ts index 3b432d76e..27a684cf9 100644 --- a/packages/config/src/__tests__/SelfHostingComposeEnvironment.test.ts +++ b/packages/config/src/__tests__/SelfHostingComposeEnvironment.test.ts @@ -28,6 +28,11 @@ const API_SETTINGS_NOT_FORWARDED: Record = { ]), ), ), + ...Object.fromEntries( + NAMED_FLUXER_ENV_NAMES.filter((name) => + ['FLUXER_APP_STORE_', 'FLUXER_GOOGLE_PLAY_', 'FLUXER_STORE_BILLING_'].some((prefix) => name.startsWith(prefix)), + ).map((name) => [name, 'in-app purchases are sold only by the hosted service']), + ), }; const INPUT_NAMES: Record = { diff --git a/packages/config/src/config_loader/EnvironmentOverrides.ts b/packages/config/src/config_loader/EnvironmentOverrides.ts index 11f5cf595..42512f14c 100644 --- a/packages/config/src/config_loader/EnvironmentOverrides.ts +++ b/packages/config/src/config_loader/EnvironmentOverrides.ts @@ -241,6 +241,30 @@ const NAMED_FLUXER_ENV_OVERRIDES: Record = { FLUXER_PUSH_APNS_PRIVATE_KEY: {path: ['integrations', 'push', 'apns', 'private_key']}, FLUXER_PUSH_APNS_PRIVATE_KEY_PATH: {path: ['integrations', 'push', 'apns', 'private_key_path']}, FLUXER_PUSH_APNS_APPS: {path: ['integrations', 'push', 'apns', 'apps'], parse: parseJsonArray}, + FLUXER_APP_STORE_ENABLED: {path: ['integrations', 'app_store', 'enabled'], parse: parseBoolean}, + FLUXER_APP_STORE_ISSUER_ID: {path: ['integrations', 'app_store', 'issuer_id']}, + FLUXER_APP_STORE_KEY_ID: {path: ['integrations', 'app_store', 'key_id']}, + FLUXER_APP_STORE_PRIVATE_KEY: {path: ['integrations', 'app_store', 'private_key']}, + FLUXER_APP_STORE_PRIVATE_KEY_PATH: {path: ['integrations', 'app_store', 'private_key_path']}, + FLUXER_APP_STORE_APPS: {path: ['integrations', 'app_store', 'apps'], parse: parseJsonArray}, + FLUXER_APP_STORE_PRODUCTS: {path: ['integrations', 'app_store', 'products'], parse: parseJsonObject}, + FLUXER_GOOGLE_PLAY_ENABLED: {path: ['integrations', 'google_play', 'enabled'], parse: parseBoolean}, + FLUXER_GOOGLE_PLAY_PACKAGES: {path: ['integrations', 'google_play', 'packages'], parse: parseCsv}, + FLUXER_GOOGLE_PLAY_CLIENT_EMAIL: {path: ['integrations', 'google_play', 'client_email']}, + FLUXER_GOOGLE_PLAY_PRIVATE_KEY: {path: ['integrations', 'google_play', 'private_key']}, + FLUXER_GOOGLE_PLAY_PRIVATE_KEY_PATH: {path: ['integrations', 'google_play', 'private_key_path']}, + FLUXER_GOOGLE_PLAY_SERVICE_ACCOUNT_JSON_PATH: {path: ['integrations', 'google_play', 'service_account_json_path']}, + FLUXER_GOOGLE_PLAY_TOKEN_URI: {path: ['integrations', 'google_play', 'token_uri']}, + FLUXER_GOOGLE_PLAY_PRODUCTS: {path: ['integrations', 'google_play', 'products'], parse: parseJsonObject}, + FLUXER_GOOGLE_PLAY_PUSH_AUDIENCE: {path: ['integrations', 'google_play', 'push_audience']}, + FLUXER_GOOGLE_PLAY_PUSH_SERVICE_ACCOUNT_EMAIL: { + path: ['integrations', 'google_play', 'push_service_account_email'], + }, + FLUXER_STORE_BILLING_SANDBOX_USER_IDS: {path: ['integrations', 'store_billing', 'sandbox_user_ids'], parse: parseCsv}, + FLUXER_STORE_BILLING_SANDBOX_ENTITLES_ALL: { + path: ['integrations', 'store_billing', 'sandbox_entitles_all'], + parse: parseBoolean, + }, FLUXER_SELF_HOSTED: {path: ['instance', 'self_hosted'], parse: parseBoolean}, FLUXER_AUTO_JOIN_INVITE_CODE: {path: ['instance', 'auto_join_invite_code']}, FLUXER_VISIONARIES_GUILD_ID: {path: ['instance', 'visionaries_guild_id']}, diff --git a/packages/constants/src/ApiErrorCodes.ts b/packages/constants/src/ApiErrorCodes.ts index 7abf66a84..73b5d48f5 100644 --- a/packages/constants/src/ApiErrorCodes.ts +++ b/packages/constants/src/ApiErrorCodes.ts @@ -195,6 +195,11 @@ export const APIErrorCodes = { SLOWMODE_RATE_LIMITED: 'SLOWMODE_RATE_LIMITED', SMS_VERIFICATION_UNAVAILABLE: 'SMS_VERIFICATION_UNAVAILABLE', SSO_REQUIRED: 'SSO_REQUIRED', + STORE_BILLING_UNAVAILABLE: 'STORE_BILLING_UNAVAILABLE', + STORE_NOTIFICATION_UNAUTHORIZED: 'STORE_NOTIFICATION_UNAUTHORIZED', + STORE_PURCHASE_INVALID: 'STORE_PURCHASE_INVALID', + STORE_PURCHASE_OWNED_BY_OTHER_ACCOUNT: 'STORE_PURCHASE_OWNED_BY_OTHER_ACCOUNT', + STORE_PURCHASE_SANDBOX_NOT_ENTITLED: 'STORE_PURCHASE_SANDBOX_NOT_ENTITLED', STREAM_KEY_CHANNEL_MISMATCH: 'STREAM_KEY_CHANNEL_MISMATCH', STREAM_KEY_SCOPE_MISMATCH: 'STREAM_KEY_SCOPE_MISMATCH', STRIPE_ERROR: 'STRIPE_ERROR', @@ -252,6 +257,7 @@ export const APIErrorCodes = { UNKNOWN_REPORT: 'UNKNOWN_REPORT', UNKNOWN_ROLE: 'UNKNOWN_ROLE', UNKNOWN_STICKER: 'UNKNOWN_STICKER', + UNKNOWN_STORE_PURCHASE: 'UNKNOWN_STORE_PURCHASE', UNKNOWN_SUSPICIOUS_FLAG: 'UNKNOWN_SUSPICIOUS_FLAG', UNKNOWN_USER_FLAG: 'UNKNOWN_USER_FLAG', UNKNOWN_USER: 'UNKNOWN_USER', diff --git a/packages/errors/src/__tests__/DomainErrors.test.ts b/packages/errors/src/__tests__/DomainErrors.test.ts index 864269d7b..10f881b55 100644 --- a/packages/errors/src/__tests__/DomainErrors.test.ts +++ b/packages/errors/src/__tests__/DomainErrors.test.ts @@ -17,6 +17,14 @@ import {ForbiddenError} from '@fluxer/errors/src/domains/core/ForbiddenError'; import {InputValidationError} from '@fluxer/errors/src/domains/core/InputValidationError'; import {InternalServerError} from '@fluxer/errors/src/domains/core/InternalServerError'; import {NotFoundError} from '@fluxer/errors/src/domains/core/NotFoundError'; +import {ServiceUnavailableError} from '@fluxer/errors/src/domains/core/ServiceUnavailableError'; +import {PremiumPurchaseBlockedError} from '@fluxer/errors/src/domains/payment/PremiumPurchaseBlockedError'; +import {StoreBillingUnavailableError} from '@fluxer/errors/src/domains/payment/StoreBillingUnavailableError'; +import {StoreNotificationUnauthorizedError} from '@fluxer/errors/src/domains/payment/StoreNotificationUnauthorizedError'; +import {StorePurchaseInvalidError} from '@fluxer/errors/src/domains/payment/StorePurchaseInvalidError'; +import {StorePurchaseOwnedByOtherAccountError} from '@fluxer/errors/src/domains/payment/StorePurchaseOwnedByOtherAccountError'; +import {StorePurchaseSandboxNotEntitledError} from '@fluxer/errors/src/domains/payment/StorePurchaseSandboxNotEntitledError'; +import {UnknownStorePurchaseError} from '@fluxer/errors/src/domains/payment/UnknownStorePurchaseError'; import {FluxerError} from '@fluxer/errors/src/FluxerError'; import {describe, expect, it} from 'vitest'; @@ -82,6 +90,32 @@ describe.each([ ], [APIErrorCodes.UNKNOWN_CHANNEL, UnknownChannelError, NotFoundError, HttpStatus.NOT_FOUND], [APIErrorCodes.UNKNOWN_MESSAGE, UnknownMessageError, NotFoundError, HttpStatus.NOT_FOUND], + [APIErrorCodes.STORE_PURCHASE_INVALID, StorePurchaseInvalidError, BadRequestError, HttpStatus.BAD_REQUEST], + [ + APIErrorCodes.STORE_PURCHASE_OWNED_BY_OTHER_ACCOUNT, + StorePurchaseOwnedByOtherAccountError, + ForbiddenError, + HttpStatus.FORBIDDEN, + ], + [ + APIErrorCodes.STORE_PURCHASE_SANDBOX_NOT_ENTITLED, + StorePurchaseSandboxNotEntitledError, + ForbiddenError, + HttpStatus.FORBIDDEN, + ], + [ + APIErrorCodes.STORE_BILLING_UNAVAILABLE, + StoreBillingUnavailableError, + ServiceUnavailableError, + HttpStatus.SERVICE_UNAVAILABLE, + ], + [APIErrorCodes.UNKNOWN_STORE_PURCHASE, UnknownStorePurchaseError, NotFoundError, HttpStatus.NOT_FOUND], + [ + APIErrorCodes.STORE_NOTIFICATION_UNAUTHORIZED, + StoreNotificationUnauthorizedError, + FluxerError, + HttpStatus.UNAUTHORIZED, + ], ] as const)('%s', (code, ErrorClass, BaseClass, status) => { it('preserves the domain inheritance and response contract', async () => { const error = new ErrorClass(); @@ -162,3 +196,36 @@ describe('InputValidationError', () => { }); }); }); + +describe('PremiumPurchaseBlockedError', () => { + it('keeps the reason and extra data of existing callers', () => { + const error = new PremiumPurchaseBlockedError('existing_subscription', {subscription_status: 'active'}); + + expect(error.status).toBe(HttpStatus.FORBIDDEN); + expect(error.toJSON()).toEqual({ + code: APIErrorCodes.PREMIUM_PURCHASE_BLOCKED, + message: APIErrorCodes.PREMIUM_PURCHASE_BLOCKED, + subscription_status: 'active', + reason: 'existing_subscription', + }); + }); + + it('carries the blocking provider when one is given', () => { + const error = new PremiumPurchaseBlockedError('existing_subscription', {provider: 'app_store'}); + + expect(error.toJSON()).toEqual({ + code: APIErrorCodes.PREMIUM_PURCHASE_BLOCKED, + message: APIErrorCodes.PREMIUM_PURCHASE_BLOCKED, + provider: 'app_store', + reason: 'existing_subscription', + }); + }); + + it('defaults to the purchase_disabled reason with no provider', () => { + expect(new PremiumPurchaseBlockedError().toJSON()).toEqual({ + code: APIErrorCodes.PREMIUM_PURCHASE_BLOCKED, + message: APIErrorCodes.PREMIUM_PURCHASE_BLOCKED, + reason: 'purchase_disabled', + }); + }); +}); diff --git a/packages/errors/src/domains/payment/PremiumPurchaseBlockedError.ts b/packages/errors/src/domains/payment/PremiumPurchaseBlockedError.ts index 85d596c73..8eae64be7 100644 --- a/packages/errors/src/domains/payment/PremiumPurchaseBlockedError.ts +++ b/packages/errors/src/domains/payment/PremiumPurchaseBlockedError.ts @@ -6,8 +6,14 @@ import type {FluxerErrorData} from '@fluxer/errors/src/FluxerError'; type PremiumPurchaseBlockedReason = 'lifetime' | 'existing_subscription' | 'purchase_disabled'; +type PremiumPurchaseBlockingProvider = 'stripe' | 'app_store' | 'google_play'; + +interface PremiumPurchaseBlockedData extends FluxerErrorData { + provider?: PremiumPurchaseBlockingProvider; +} + export class PremiumPurchaseBlockedError extends ForbiddenError { - constructor(reason: PremiumPurchaseBlockedReason = 'purchase_disabled', data: FluxerErrorData = {}) { + constructor(reason: PremiumPurchaseBlockedReason = 'purchase_disabled', data: PremiumPurchaseBlockedData = {}) { super({ code: APIErrorCodes.PREMIUM_PURCHASE_BLOCKED, data: { diff --git a/packages/errors/src/domains/payment/StoreBillingUnavailableError.ts b/packages/errors/src/domains/payment/StoreBillingUnavailableError.ts new file mode 100644 index 000000000..11881e8be --- /dev/null +++ b/packages/errors/src/domains/payment/StoreBillingUnavailableError.ts @@ -0,0 +1,12 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes'; +import {ServiceUnavailableError} from '@fluxer/errors/src/domains/core/ServiceUnavailableError'; + +export class StoreBillingUnavailableError extends ServiceUnavailableError { + constructor() { + super({ + code: APIErrorCodes.STORE_BILLING_UNAVAILABLE, + }); + } +} diff --git a/packages/errors/src/domains/payment/StoreNotificationUnauthorizedError.ts b/packages/errors/src/domains/payment/StoreNotificationUnauthorizedError.ts new file mode 100644 index 000000000..358b636e4 --- /dev/null +++ b/packages/errors/src/domains/payment/StoreNotificationUnauthorizedError.ts @@ -0,0 +1,13 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes'; +import {FluxerError} from '@fluxer/errors/src/FluxerError'; + +export class StoreNotificationUnauthorizedError extends FluxerError { + constructor() { + super({ + code: APIErrorCodes.STORE_NOTIFICATION_UNAUTHORIZED, + status: 401, + }); + } +} diff --git a/packages/errors/src/domains/payment/StorePurchaseInvalidError.ts b/packages/errors/src/domains/payment/StorePurchaseInvalidError.ts new file mode 100644 index 000000000..2cd1bd52e --- /dev/null +++ b/packages/errors/src/domains/payment/StorePurchaseInvalidError.ts @@ -0,0 +1,12 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes'; +import {BadRequestError} from '@fluxer/errors/src/domains/core/BadRequestError'; + +export class StorePurchaseInvalidError extends BadRequestError { + constructor() { + super({ + code: APIErrorCodes.STORE_PURCHASE_INVALID, + }); + } +} diff --git a/packages/errors/src/domains/payment/StorePurchaseOwnedByOtherAccountError.ts b/packages/errors/src/domains/payment/StorePurchaseOwnedByOtherAccountError.ts new file mode 100644 index 000000000..c671cf971 --- /dev/null +++ b/packages/errors/src/domains/payment/StorePurchaseOwnedByOtherAccountError.ts @@ -0,0 +1,12 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes'; +import {ForbiddenError} from '@fluxer/errors/src/domains/core/ForbiddenError'; + +export class StorePurchaseOwnedByOtherAccountError extends ForbiddenError { + constructor() { + super({ + code: APIErrorCodes.STORE_PURCHASE_OWNED_BY_OTHER_ACCOUNT, + }); + } +} diff --git a/packages/errors/src/domains/payment/StorePurchaseSandboxNotEntitledError.ts b/packages/errors/src/domains/payment/StorePurchaseSandboxNotEntitledError.ts new file mode 100644 index 000000000..6a62f050e --- /dev/null +++ b/packages/errors/src/domains/payment/StorePurchaseSandboxNotEntitledError.ts @@ -0,0 +1,12 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes'; +import {ForbiddenError} from '@fluxer/errors/src/domains/core/ForbiddenError'; + +export class StorePurchaseSandboxNotEntitledError extends ForbiddenError { + constructor() { + super({ + code: APIErrorCodes.STORE_PURCHASE_SANDBOX_NOT_ENTITLED, + }); + } +} diff --git a/packages/errors/src/domains/payment/UnknownStorePurchaseError.ts b/packages/errors/src/domains/payment/UnknownStorePurchaseError.ts new file mode 100644 index 000000000..0bc88d2d8 --- /dev/null +++ b/packages/errors/src/domains/payment/UnknownStorePurchaseError.ts @@ -0,0 +1,12 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes'; +import {NotFoundError} from '@fluxer/errors/src/domains/core/NotFoundError'; + +export class UnknownStorePurchaseError extends NotFoundError { + constructor() { + super({ + code: APIErrorCodes.UNKNOWN_STORE_PURCHASE, + }); + } +} diff --git a/packages/errors/src/i18n/ErrorCodeMappings.ts b/packages/errors/src/i18n/ErrorCodeMappings.ts index 92e9f659b..ae864174b 100644 --- a/packages/errors/src/i18n/ErrorCodeMappings.ts +++ b/packages/errors/src/i18n/ErrorCodeMappings.ts @@ -246,6 +246,11 @@ export const ErrorCodeToI18nKey = { [APIErrorCodes.SLOWMODE_RATE_LIMITED]: 'rate_limits.slowmode_rate_limited', [APIErrorCodes.SMS_VERIFICATION_UNAVAILABLE]: 'http.service_unavailable', [APIErrorCodes.SSO_REQUIRED]: 'misc.invalid_request_generic', + [APIErrorCodes.STORE_BILLING_UNAVAILABLE]: 'store_billing.unavailable', + [APIErrorCodes.STORE_NOTIFICATION_UNAUTHORIZED]: 'store_billing.notification_unauthorized', + [APIErrorCodes.STORE_PURCHASE_INVALID]: 'store_billing.purchase_invalid', + [APIErrorCodes.STORE_PURCHASE_OWNED_BY_OTHER_ACCOUNT]: 'store_billing.purchase_owned_by_other_account', + [APIErrorCodes.STORE_PURCHASE_SANDBOX_NOT_ENTITLED]: 'store_billing.purchase_sandbox_not_entitled', [APIErrorCodes.STREAM_KEY_CHANNEL_MISMATCH]: 'calls_and_voice.stream_key_channel_mismatch', [APIErrorCodes.STREAM_KEY_SCOPE_MISMATCH]: 'calls_and_voice.stream_key_scope_mismatch', [APIErrorCodes.STRIPE_ERROR]: 'stripe.error', @@ -303,6 +308,7 @@ export const ErrorCodeToI18nKey = { [APIErrorCodes.UNKNOWN_REPORT]: 'moderation_and_reports.unknown_report', [APIErrorCodes.UNKNOWN_ROLE]: 'unknown_entities.unknown_role', [APIErrorCodes.UNKNOWN_STICKER]: 'stickers_and_emojis.unknown_sticker', + [APIErrorCodes.UNKNOWN_STORE_PURCHASE]: 'store_billing.unknown_purchase', [APIErrorCodes.UNKNOWN_SUSPICIOUS_FLAG]: 'admin_and_system.unknown_suspicious_flag', [APIErrorCodes.UNKNOWN_USER_FLAG]: 'unknown_entities.unknown_user_flag', [APIErrorCodes.UNKNOWN_USER]: 'unknown_entities.unknown_user', diff --git a/packages/errors/src/i18n/ErrorI18nMessages.ts b/packages/errors/src/i18n/ErrorI18nMessages.ts index 6a7211119..34fb4cde9 100644 --- a/packages/errors/src/i18n/ErrorI18nMessages.ts +++ b/packages/errors/src/i18n/ErrorI18nMessages.ts @@ -506,6 +506,12 @@ export const ERROR_I18N_MESSAGES = { 'stickers_and_emojis.not_valid_unicode_emoji': 'Not a valid Unicode emoji.', 'stickers_and_emojis.unknown_emoji': 'Unknown emoji.', 'stickers_and_emojis.unknown_sticker': 'Unknown sticker.', + 'store_billing.notification_unauthorized': 'The notification signature is invalid.', + 'store_billing.purchase_invalid': 'This purchase could not be verified.', + 'store_billing.purchase_owned_by_other_account': 'This purchase is linked to a different account.', + 'store_billing.purchase_sandbox_not_entitled': 'Test purchases cannot be applied to this account.', + 'store_billing.unavailable': 'In-app purchases are unavailable right now. Try again later.', + 'store_billing.unknown_purchase': 'Unknown store purchase.', 'stripe.error': 'Payment processing encountered an error. Please try again or contact support.', 'stripe.invalid_product_configuration': 'Invalid product configuration.', 'stripe.invalid_product_selection': 'Invalid product selection.', diff --git a/packages/errors/src/i18n/locales/ar.ts b/packages/errors/src/i18n/locales/ar.ts index 365c38ebf..6cfb2016f 100644 --- a/packages/errors/src/i18n/locales/ar.ts +++ b/packages/errors/src/i18n/locales/ar.ts @@ -428,6 +428,12 @@ const ERROR_I18N_AR_MESSAGES = defineErrorI18nLocaleMessages({ "stickers_and_emojis.not_valid_unicode_emoji": "إيموجي يونيكود غير صالح.", "stickers_and_emojis.unknown_emoji": "إيموجي غير معروف.", "stickers_and_emojis.unknown_sticker": "ملصق غير معروف.", + "store_billing.notification_unauthorized": "توقيع الإشعار غير صالح.", + "store_billing.purchase_invalid": "تعذّر التحقق من عملية الشراء هذه.", + "store_billing.purchase_owned_by_other_account": "عملية الشراء هذه مرتبطة بحساب آخر.", + "store_billing.purchase_sandbox_not_entitled": "لا يمكن تطبيق عمليات الشراء التجريبية على هذا الحساب.", + "store_billing.unavailable": "عمليات الشراء داخل التطبيق غير متاحة حاليًا. حاول مجددًا لاحقًا.", + "store_billing.unknown_purchase": "عملية شراء غير معروفة من المتجر.", "stripe.error": "حدث خطأ في معالجة الدفع. حاول مجددًا أو تواصل مع الدعم.", "stripe.invalid_product_configuration": "تكوين منتج غير صالح.", "stripe.invalid_product_selection": "اختيار منتج غير صالح.", diff --git a/packages/errors/src/i18n/locales/bg.ts b/packages/errors/src/i18n/locales/bg.ts index 828a2b565..b3a6358c6 100644 --- a/packages/errors/src/i18n/locales/bg.ts +++ b/packages/errors/src/i18n/locales/bg.ts @@ -428,6 +428,12 @@ const ERROR_I18N_BG_MESSAGES = defineErrorI18nLocaleMessages({ "stickers_and_emojis.not_valid_unicode_emoji": "Не е валидно Unicode емоджи.", "stickers_and_emojis.unknown_emoji": "Неизвестно емоджи.", "stickers_and_emojis.unknown_sticker": "Неизвестен стикер.", + "store_billing.notification_unauthorized": "Подписът на известието е невалиден.", + "store_billing.purchase_invalid": "Тази покупка не можа да бъде потвърдена.", + "store_billing.purchase_owned_by_other_account": "Тази покупка е свързана с друг акаунт.", + "store_billing.purchase_sandbox_not_entitled": "Тестовите покупки не могат да се приложат към този акаунт.", + "store_billing.unavailable": "Покупките в приложението в момента не са достъпни. Опитай отново по-късно.", + "store_billing.unknown_purchase": "Неизвестна покупка от магазина.", "stripe.error": "Възникна грешка при обработката на плащането. Опитай отново или се свържи с поддръжката.", "stripe.invalid_product_configuration": "Невалидна конфигурация на продукт.", "stripe.invalid_product_selection": "Невалиден избор на продукт.", diff --git a/packages/errors/src/i18n/locales/cs.ts b/packages/errors/src/i18n/locales/cs.ts index 4b158bbe6..56dbd811c 100644 --- a/packages/errors/src/i18n/locales/cs.ts +++ b/packages/errors/src/i18n/locales/cs.ts @@ -428,6 +428,12 @@ const ERROR_I18N_CS_MESSAGES = defineErrorI18nLocaleMessages({ "stickers_and_emojis.not_valid_unicode_emoji": "Neplatné emoji Unicode.", "stickers_and_emojis.unknown_emoji": "Neznámé emoji.", "stickers_and_emojis.unknown_sticker": "Neznámá samolepka.", + "store_billing.notification_unauthorized": "Podpis oznámení je neplatný.", + "store_billing.purchase_invalid": "Tento nákup se nepodařilo ověřit.", + "store_billing.purchase_owned_by_other_account": "Tento nákup je propojený s jiným účtem.", + "store_billing.purchase_sandbox_not_entitled": "Testovací nákupy nelze uplatnit na tento účet.", + "store_billing.unavailable": "Nákupy v aplikaci teď nejsou k dispozici. Zkuste to znovu později.", + "store_billing.unknown_purchase": "Neznámý nákup z obchodu.", "stripe.error": "Při zpracování platby došlo k chybě. Zkuste to znovu nebo kontaktujte podporu.", "stripe.invalid_product_configuration": "Neplatná konfigurace produktu.", "stripe.invalid_product_selection": "Neplatný výběr produktu.", diff --git a/packages/errors/src/i18n/locales/da.ts b/packages/errors/src/i18n/locales/da.ts index 29e3bb7ab..914164c6a 100644 --- a/packages/errors/src/i18n/locales/da.ts +++ b/packages/errors/src/i18n/locales/da.ts @@ -428,6 +428,12 @@ const ERROR_I18N_DA_MESSAGES = defineErrorI18nLocaleMessages({ "stickers_and_emojis.not_valid_unicode_emoji": "Ikke en gyldig Unicode-emoji.", "stickers_and_emojis.unknown_emoji": "Ukendt emoji.", "stickers_and_emojis.unknown_sticker": "Ukendt klistermærke.", + "store_billing.notification_unauthorized": "Notifikationens signatur er ugyldig.", + "store_billing.purchase_invalid": "Dette køb kunne ikke bekræftes.", + "store_billing.purchase_owned_by_other_account": "Dette køb er knyttet til en anden konto.", + "store_billing.purchase_sandbox_not_entitled": "Testkøb kan ikke anvendes på denne konto.", + "store_billing.unavailable": "Køb i appen er ikke tilgængelige lige nu. Prøv igen senere.", + "store_billing.unknown_purchase": "Ukendt køb fra butikken.", "stripe.error": "Noget gik galt under betalingsbehandlingen. Prøv igen eller kontakt support.", "stripe.invalid_product_configuration": "Ugyldig produktkonfiguration.", "stripe.invalid_product_selection": "Ugyldigt produktvalg.", diff --git a/packages/errors/src/i18n/locales/de.ts b/packages/errors/src/i18n/locales/de.ts index 1760b6623..e68d4256b 100644 --- a/packages/errors/src/i18n/locales/de.ts +++ b/packages/errors/src/i18n/locales/de.ts @@ -428,6 +428,12 @@ const ERROR_I18N_DE_MESSAGES = defineErrorI18nLocaleMessages({ "stickers_and_emojis.not_valid_unicode_emoji": "Kein gültiges Unicode-Emoji.", "stickers_and_emojis.unknown_emoji": "Unbekanntes Emoji.", "stickers_and_emojis.unknown_sticker": "Unbekannter Sticker.", + "store_billing.notification_unauthorized": "Die Signatur der Benachrichtigung ist ungültig.", + "store_billing.purchase_invalid": "Dieser Kauf konnte nicht bestätigt werden.", + "store_billing.purchase_owned_by_other_account": "Dieser Kauf ist mit einem anderen Konto verknüpft.", + "store_billing.purchase_sandbox_not_entitled": "Testkäufe können nicht auf dieses Konto angewendet werden.", + "store_billing.unavailable": "In-App-Käufe sind gerade nicht verfügbar. Versuch es später noch mal.", + "store_billing.unknown_purchase": "Unbekannter Store-Kauf.", "stripe.error": "Bei der Zahlungsabwicklung ist ein Fehler aufgetreten. Versuch es noch mal oder kontaktiere den Support.", "stripe.invalid_product_configuration": "Ungültige Produktkonfiguration.", "stripe.invalid_product_selection": "Ungültige Produktauswahl.", diff --git a/packages/errors/src/i18n/locales/el.ts b/packages/errors/src/i18n/locales/el.ts index 0930af51c..d9fad8a2d 100644 --- a/packages/errors/src/i18n/locales/el.ts +++ b/packages/errors/src/i18n/locales/el.ts @@ -428,6 +428,12 @@ const ERROR_I18N_EL_MESSAGES = defineErrorI18nLocaleMessages({ "stickers_and_emojis.not_valid_unicode_emoji": "Δεν είναι έγκυρο Unicode emoji.", "stickers_and_emojis.unknown_emoji": "Άγνωστο emoji.", "stickers_and_emojis.unknown_sticker": "Άγνωστο sticker.", + "store_billing.notification_unauthorized": "Η υπογραφή της ειδοποίησης δεν είναι έγκυρη.", + "store_billing.purchase_invalid": "Δεν ήταν δυνατή η επαλήθευση αυτής της αγοράς.", + "store_billing.purchase_owned_by_other_account": "Αυτή η αγορά είναι συνδεδεμένη με άλλον λογαριασμό.", + "store_billing.purchase_sandbox_not_entitled": "Οι δοκιμαστικές αγορές δεν μπορούν να εφαρμοστούν σε αυτόν τον λογαριασμό.", + "store_billing.unavailable": "Οι αγορές εντός εφαρμογής δεν είναι διαθέσιμες αυτή τη στιγμή. Δοκίμασε ξανά αργότερα.", + "store_billing.unknown_purchase": "Άγνωστη αγορά από το κατάστημα.", "stripe.error": "Παρουσιάστηκε σφάλμα στην επεξεργασία πληρωμής. Δοκίμασε ξανά ή επικοινώνησε με την υποστήριξη.", "stripe.invalid_product_configuration": "Μη έγκυρη διαμόρφωση προϊόντος.", "stripe.invalid_product_selection": "Μη έγκυρη επιλογή προϊόντος.", diff --git a/packages/errors/src/i18n/locales/en-GB.ts b/packages/errors/src/i18n/locales/en-GB.ts index c9f62fbc5..660dcbca3 100644 --- a/packages/errors/src/i18n/locales/en-GB.ts +++ b/packages/errors/src/i18n/locales/en-GB.ts @@ -428,6 +428,12 @@ const ERROR_I18N_EN_GB_MESSAGES = defineErrorI18nLocaleMessages({ "stickers_and_emojis.not_valid_unicode_emoji": "Not a valid Unicode emoji.", "stickers_and_emojis.unknown_emoji": "Unknown emoji.", "stickers_and_emojis.unknown_sticker": "Unknown sticker.", + "store_billing.notification_unauthorized": "The notification signature is invalid.", + "store_billing.purchase_invalid": "This purchase could not be verified.", + "store_billing.purchase_owned_by_other_account": "This purchase is linked to a different account.", + "store_billing.purchase_sandbox_not_entitled": "Test purchases cannot be applied to this account.", + "store_billing.unavailable": "In-app purchases are unavailable right now. Try again later.", + "store_billing.unknown_purchase": "Unknown store purchase.", "stripe.error": "Payment processing encountered an error. Please try again or contact support.", "stripe.invalid_product_configuration": "Invalid product configuration.", "stripe.invalid_product_selection": "Invalid product selection.", diff --git a/packages/errors/src/i18n/locales/es-419.ts b/packages/errors/src/i18n/locales/es-419.ts index 5ac86a7ce..bcf688b01 100644 --- a/packages/errors/src/i18n/locales/es-419.ts +++ b/packages/errors/src/i18n/locales/es-419.ts @@ -428,6 +428,12 @@ const ERROR_I18N_ES_419_MESSAGES = defineErrorI18nLocaleMessages({ "stickers_and_emojis.not_valid_unicode_emoji": "No es un emoji Unicode válido.", "stickers_and_emojis.unknown_emoji": "Emoji desconocido.", "stickers_and_emojis.unknown_sticker": "Sticker desconocido.", + "store_billing.notification_unauthorized": "La firma de la notificación no es válida.", + "store_billing.purchase_invalid": "No se pudo verificar esta compra.", + "store_billing.purchase_owned_by_other_account": "Esta compra está vinculada a otra cuenta.", + "store_billing.purchase_sandbox_not_entitled": "Las compras de prueba no se pueden aplicar a esta cuenta.", + "store_billing.unavailable": "Las compras dentro de la app no están disponibles en este momento. Inténtalo de nuevo más tarde.", + "store_billing.unknown_purchase": "Compra de la tienda desconocida.", "stripe.error": "El procesamiento del pago tuvo un error. Inténtalo de nuevo o contacta al equipo de soporte.", "stripe.invalid_product_configuration": "Configuración de producto inválida.", "stripe.invalid_product_selection": "Selección de producto inválida.", diff --git a/packages/errors/src/i18n/locales/es-ES.ts b/packages/errors/src/i18n/locales/es-ES.ts index 428a10861..2aa780134 100644 --- a/packages/errors/src/i18n/locales/es-ES.ts +++ b/packages/errors/src/i18n/locales/es-ES.ts @@ -428,6 +428,12 @@ const ERROR_I18N_ES_ES_MESSAGES = defineErrorI18nLocaleMessages({ "stickers_and_emojis.not_valid_unicode_emoji": "No es un emoji Unicode válido.", "stickers_and_emojis.unknown_emoji": "Emoji desconocido.", "stickers_and_emojis.unknown_sticker": "Pegatina desconocida.", + "store_billing.notification_unauthorized": "La firma de la notificación no es válida.", + "store_billing.purchase_invalid": "No se ha podido verificar esta compra.", + "store_billing.purchase_owned_by_other_account": "Esta compra está vinculada a otra cuenta.", + "store_billing.purchase_sandbox_not_entitled": "Las compras de prueba no se pueden aplicar a esta cuenta.", + "store_billing.unavailable": "Las compras dentro de la aplicación no están disponibles ahora mismo. Inténtalo de nuevo más tarde.", + "store_billing.unknown_purchase": "Compra de la tienda desconocida.", "stripe.error": "El procesamiento del pago ha encontrado un error. Inténtalo de nuevo o contacta con soporte.", "stripe.invalid_product_configuration": "Configuración de producto no válida.", "stripe.invalid_product_selection": "Selección de producto no válida.", diff --git a/packages/errors/src/i18n/locales/fi.ts b/packages/errors/src/i18n/locales/fi.ts index 4c1d5df73..aece4614c 100644 --- a/packages/errors/src/i18n/locales/fi.ts +++ b/packages/errors/src/i18n/locales/fi.ts @@ -428,6 +428,12 @@ const ERROR_I18N_FI_MESSAGES = defineErrorI18nLocaleMessages({ "stickers_and_emojis.not_valid_unicode_emoji": "Ei kelvollinen Unicode-emoji.", "stickers_and_emojis.unknown_emoji": "Tuntematon emoji.", "stickers_and_emojis.unknown_sticker": "Tuntematon tarra.", + "store_billing.notification_unauthorized": "Ilmoituksen allekirjoitus on virheellinen.", + "store_billing.purchase_invalid": "Tätä ostosta ei voitu vahvistaa.", + "store_billing.purchase_owned_by_other_account": "Tämä ostos on liitetty toiseen tiliin.", + "store_billing.purchase_sandbox_not_entitled": "Testiostoja ei voi käyttää tällä tilillä.", + "store_billing.unavailable": "Sovelluksen sisäiset ostot eivät ole juuri nyt käytettävissä. Yritä myöhemmin uudelleen.", + "store_billing.unknown_purchase": "Tuntematon kauppaostos.", "stripe.error": "Maksun käsittelyssä tapahtui virhe. Yritä uudelleen tai ota yhteyttä tukeen.", "stripe.invalid_product_configuration": "Virheellinen tuotekonfiguraatio.", "stripe.invalid_product_selection": "Virheellinen tuotevalinta.", diff --git a/packages/errors/src/i18n/locales/fr.ts b/packages/errors/src/i18n/locales/fr.ts index 1ad3d35a3..8e31b79ed 100644 --- a/packages/errors/src/i18n/locales/fr.ts +++ b/packages/errors/src/i18n/locales/fr.ts @@ -428,6 +428,12 @@ const ERROR_I18N_FR_MESSAGES = defineErrorI18nLocaleMessages({ "stickers_and_emojis.not_valid_unicode_emoji": "Emoji Unicode invalide.", "stickers_and_emojis.unknown_emoji": "Emoji inconnu.", "stickers_and_emojis.unknown_sticker": "Sticker inconnu.", + "store_billing.notification_unauthorized": "La signature de la notification n'est pas valide.", + "store_billing.purchase_invalid": "Impossible de vérifier cet achat.", + "store_billing.purchase_owned_by_other_account": "Cet achat est associé à un autre compte.", + "store_billing.purchase_sandbox_not_entitled": "Les achats de test ne peuvent pas être appliqués à ce compte.", + "store_billing.unavailable": "Les achats intégrés sont indisponibles pour le moment. Réessayez plus tard.", + "store_billing.unknown_purchase": "Achat en boutique inconnu.", "stripe.error": "Une erreur est survenue lors du traitement du paiement. Réessayez ou contactez l’assistance.", "stripe.invalid_product_configuration": "Configuration de produit invalide.", "stripe.invalid_product_selection": "Sélection de produit invalide.", diff --git a/packages/errors/src/i18n/locales/he.ts b/packages/errors/src/i18n/locales/he.ts index f4655e30e..53321cb75 100644 --- a/packages/errors/src/i18n/locales/he.ts +++ b/packages/errors/src/i18n/locales/he.ts @@ -428,6 +428,12 @@ const ERROR_I18N_HE_MESSAGES = defineErrorI18nLocaleMessages({ "stickers_and_emojis.not_valid_unicode_emoji": "אימוג'י יוניקוד לא תקין.", "stickers_and_emojis.unknown_emoji": "אימוג'י לא ידוע.", "stickers_and_emojis.unknown_sticker": "סטיקר לא ידוע.", + "store_billing.notification_unauthorized": "החתימה של ההתראה אינה תקינה.", + "store_billing.purchase_invalid": "לא ניתן היה לאמת את הרכישה הזו.", + "store_billing.purchase_owned_by_other_account": "הרכישה הזו מקושרת לחשבון אחר.", + "store_billing.purchase_sandbox_not_entitled": "לא ניתן להחיל רכישות ניסיון על החשבון הזה.", + "store_billing.unavailable": "רכישות בתוך האפליקציה אינן זמינות כרגע. ניתן לנסות שוב מאוחר יותר.", + "store_billing.unknown_purchase": "רכישה לא ידועה מהחנות.", "stripe.error": "אירעה שגיאה בעיבוד התשלום. ניתן לנסות שוב או לפנות לתמיכה.", "stripe.invalid_product_configuration": "תצורת מוצר שגויה.", "stripe.invalid_product_selection": "בחירת מוצר שגויה.", diff --git a/packages/errors/src/i18n/locales/hi.ts b/packages/errors/src/i18n/locales/hi.ts index 580444cac..cd665cec3 100644 --- a/packages/errors/src/i18n/locales/hi.ts +++ b/packages/errors/src/i18n/locales/hi.ts @@ -428,6 +428,12 @@ const ERROR_I18N_HI_MESSAGES = defineErrorI18nLocaleMessages({ "stickers_and_emojis.not_valid_unicode_emoji": "मान्य यूनिकोड इमोजी नहीं है।", "stickers_and_emojis.unknown_emoji": "अज्ञात इमोजी।", "stickers_and_emojis.unknown_sticker": "अज्ञात स्टीकर।", + "store_billing.notification_unauthorized": "सूचना का सिग्नेचर अमान्य है।", + "store_billing.purchase_invalid": "इस खरीदारी को सत्यापित नहीं किया जा सका।", + "store_billing.purchase_owned_by_other_account": "यह खरीदारी किसी दूसरे खाते से जुड़ी है।", + "store_billing.purchase_sandbox_not_entitled": "टेस्ट खरीदारी इस खाते पर लागू नहीं की जा सकती।", + "store_billing.unavailable": "इन-ऐप खरीदारी अभी उपलब्ध नहीं है। कृपया बाद में फिर से प्रयास करें।", + "store_billing.unknown_purchase": "अज्ञात स्टोर खरीदारी।", "stripe.error": "पेमेंट प्रोसेसिंग में गड़बड़ी हुई। कृपया फिर से प्रयास करें या सपोर्ट से संपर्क करें।", "stripe.invalid_product_configuration": "अमान्य प्रोडक्ट कॉन्फ़िगरेशन।", "stripe.invalid_product_selection": "अमान्य प्रोडक्ट चयन।", diff --git a/packages/errors/src/i18n/locales/hr.ts b/packages/errors/src/i18n/locales/hr.ts index 5b45a1a7a..461da4cbb 100644 --- a/packages/errors/src/i18n/locales/hr.ts +++ b/packages/errors/src/i18n/locales/hr.ts @@ -428,6 +428,12 @@ const ERROR_I18N_HR_MESSAGES = defineErrorI18nLocaleMessages({ "stickers_and_emojis.not_valid_unicode_emoji": "Nije važeći Unicode emoji.", "stickers_and_emojis.unknown_emoji": "Nepoznati emoji.", "stickers_and_emojis.unknown_sticker": "Nepoznata naljepnica.", + "store_billing.notification_unauthorized": "Potpis obavijesti nije valjan.", + "store_billing.purchase_invalid": "Ovu kupnju nije moguće potvrditi.", + "store_billing.purchase_owned_by_other_account": "Ova kupnja povezana je s drugim računom.", + "store_billing.purchase_sandbox_not_entitled": "Testne kupnje ne mogu se primijeniti na ovaj račun.", + "store_billing.unavailable": "Kupnje unutar aplikacije trenutačno nisu dostupne. Pokušaj ponovno kasnije.", + "store_billing.unknown_purchase": "Nepoznata kupnja iz trgovine.", "stripe.error": "Došlo je do pogreške pri obradi plaćanja. Pokušaj ponovno ili se javi podršci.", "stripe.invalid_product_configuration": "Nevažeća konfiguracija proizvoda.", "stripe.invalid_product_selection": "Nevažeći odabir proizvoda.", diff --git a/packages/errors/src/i18n/locales/hu.ts b/packages/errors/src/i18n/locales/hu.ts index bc7db12ae..92bd59348 100644 --- a/packages/errors/src/i18n/locales/hu.ts +++ b/packages/errors/src/i18n/locales/hu.ts @@ -428,6 +428,12 @@ const ERROR_I18N_HU_MESSAGES = defineErrorI18nLocaleMessages({ "stickers_and_emojis.not_valid_unicode_emoji": "Érvénytelen Unicode-emoji.", "stickers_and_emojis.unknown_emoji": "Ismeretlen emoji.", "stickers_and_emojis.unknown_sticker": "Ismeretlen matrica.", + "store_billing.notification_unauthorized": "Az értesítés aláírása érvénytelen.", + "store_billing.purchase_invalid": "Ezt a vásárlást nem sikerült ellenőrizni.", + "store_billing.purchase_owned_by_other_account": "Ez a vásárlás egy másik fiókhoz van kapcsolva.", + "store_billing.purchase_sandbox_not_entitled": "A tesztvásárlások nem alkalmazhatók erre a fiókra.", + "store_billing.unavailable": "Az alkalmazáson belüli vásárlások most nem érhetők el. Próbáld újra később.", + "store_billing.unknown_purchase": "Ismeretlen áruházi vásárlás.", "stripe.error": "Hiba történt a fizetés feldolgozása során. Próbáld újra, vagy keresd az ügyfélszolgálatot.", "stripe.invalid_product_configuration": "Érvénytelen termékkonfiguráció.", "stripe.invalid_product_selection": "Érvénytelen termékválasztás.", diff --git a/packages/errors/src/i18n/locales/id.ts b/packages/errors/src/i18n/locales/id.ts index d8950bd86..7ef7513c8 100644 --- a/packages/errors/src/i18n/locales/id.ts +++ b/packages/errors/src/i18n/locales/id.ts @@ -428,6 +428,12 @@ const ERROR_I18N_ID_MESSAGES = defineErrorI18nLocaleMessages({ "stickers_and_emojis.not_valid_unicode_emoji": "Bukan emoji Unicode yang valid.", "stickers_and_emojis.unknown_emoji": "Emoji tidak dikenal.", "stickers_and_emojis.unknown_sticker": "Stiker tidak dikenal.", + "store_billing.notification_unauthorized": "Tanda tangan notifikasi tidak valid.", + "store_billing.purchase_invalid": "Pembelian ini tidak dapat diverifikasi.", + "store_billing.purchase_owned_by_other_account": "Pembelian ini terhubung ke akun lain.", + "store_billing.purchase_sandbox_not_entitled": "Pembelian uji coba tidak dapat diterapkan ke akun ini.", + "store_billing.unavailable": "Pembelian dalam aplikasi sedang tidak tersedia. Coba lagi nanti.", + "store_billing.unknown_purchase": "Pembelian toko tidak dikenal.", "stripe.error": "Terjadi kesalahan saat memproses pembayaran. Coba lagi atau hubungi dukungan.", "stripe.invalid_product_configuration": "Konfigurasi produk tidak valid.", "stripe.invalid_product_selection": "Pilihan produk tidak valid.", diff --git a/packages/errors/src/i18n/locales/it.ts b/packages/errors/src/i18n/locales/it.ts index 8abda9a1a..4a1041c5d 100644 --- a/packages/errors/src/i18n/locales/it.ts +++ b/packages/errors/src/i18n/locales/it.ts @@ -428,6 +428,12 @@ const ERROR_I18N_IT_MESSAGES = defineErrorI18nLocaleMessages({ "stickers_and_emojis.not_valid_unicode_emoji": "Emoji Unicode non valida.", "stickers_and_emojis.unknown_emoji": "Emoji sconosciuta.", "stickers_and_emojis.unknown_sticker": "Sticker sconosciuto.", + "store_billing.notification_unauthorized": "La firma della notifica non è valida.", + "store_billing.purchase_invalid": "Non è stato possibile verificare questo acquisto.", + "store_billing.purchase_owned_by_other_account": "Questo acquisto è collegato a un altro account.", + "store_billing.purchase_sandbox_not_entitled": "Gli acquisti di prova non possono essere applicati a questo account.", + "store_billing.unavailable": "Gli acquisti in-app non sono disponibili al momento. Riprova più tardi.", + "store_billing.unknown_purchase": "Acquisto dallo store sconosciuto.", "stripe.error": "Si è verificato un errore durante l'elaborazione del pagamento. Riprova o contatta l'assistenza.", "stripe.invalid_product_configuration": "Configurazione prodotto non valida.", "stripe.invalid_product_selection": "Selezione del prodotto non valida.", diff --git a/packages/errors/src/i18n/locales/ja.ts b/packages/errors/src/i18n/locales/ja.ts index a0d5fc058..09e3e4bf2 100644 --- a/packages/errors/src/i18n/locales/ja.ts +++ b/packages/errors/src/i18n/locales/ja.ts @@ -428,6 +428,12 @@ const ERROR_I18N_JA_MESSAGES = defineErrorI18nLocaleMessages({ "stickers_and_emojis.not_valid_unicode_emoji": "有効なUnicode絵文字ではありません。", "stickers_and_emojis.unknown_emoji": "不明な絵文字です。", "stickers_and_emojis.unknown_sticker": "不明なスタンプです。", + "store_billing.notification_unauthorized": "通知の署名が無効です。", + "store_billing.purchase_invalid": "この購入を確認できませんでした。", + "store_billing.purchase_owned_by_other_account": "この購入は別のアカウントに紐付けられています。", + "store_billing.purchase_sandbox_not_entitled": "テスト購入はこのアカウントには適用できません。", + "store_billing.unavailable": "現在、アプリ内課金をご利用いただけません。後でもう一度お試しください。", + "store_billing.unknown_purchase": "不明なストア購入です。", "stripe.error": "支払い処理でエラーが発生しました。もう一度お試しいただくか、サポートにお問い合わせください。", "stripe.invalid_product_configuration": "商品の設定が無効です。", "stripe.invalid_product_selection": "選択された商品が無効です。", diff --git a/packages/errors/src/i18n/locales/ko.ts b/packages/errors/src/i18n/locales/ko.ts index 29f7836dc..589d2578c 100644 --- a/packages/errors/src/i18n/locales/ko.ts +++ b/packages/errors/src/i18n/locales/ko.ts @@ -428,6 +428,12 @@ const ERROR_I18N_KO_MESSAGES = defineErrorI18nLocaleMessages({ "stickers_and_emojis.not_valid_unicode_emoji": "올바른 유니코드 이모지가 아니에요.", "stickers_and_emojis.unknown_emoji": "알 수 없는 이모지예요.", "stickers_and_emojis.unknown_sticker": "알 수 없는 스티커예요.", + "store_billing.notification_unauthorized": "알림 서명이 올바르지 않아요.", + "store_billing.purchase_invalid": "이 구매를 확인할 수 없어요.", + "store_billing.purchase_owned_by_other_account": "이 구매는 다른 계정에 연결되어 있어요.", + "store_billing.purchase_sandbox_not_entitled": "테스트 구매는 이 계정에 적용할 수 없어요.", + "store_billing.unavailable": "지금은 인앱 구매를 이용할 수 없어요. 나중에 다시 시도해 주세요.", + "store_billing.unknown_purchase": "알 수 없는 스토어 구매예요.", "stripe.error": "결제 처리 중 오류가 발생했어요. 다시 시도하거나 지원팀에 문의해 주세요.", "stripe.invalid_product_configuration": "올바르지 않은 상품 구성이에요.", "stripe.invalid_product_selection": "올바르지 않은 상품 선택이에요.", diff --git a/packages/errors/src/i18n/locales/lt.ts b/packages/errors/src/i18n/locales/lt.ts index 0d06aad5b..b8090d2e2 100644 --- a/packages/errors/src/i18n/locales/lt.ts +++ b/packages/errors/src/i18n/locales/lt.ts @@ -428,6 +428,12 @@ const ERROR_I18N_LT_MESSAGES = defineErrorI18nLocaleMessages({ "stickers_and_emojis.not_valid_unicode_emoji": "Netinkamas Unicode emoji.", "stickers_and_emojis.unknown_emoji": "Nežinomas emoji.", "stickers_and_emojis.unknown_sticker": "Nežinomas lipdukas.", + "store_billing.notification_unauthorized": "Pranešimo parašas netinkamas.", + "store_billing.purchase_invalid": "Šio pirkinio nepavyko patvirtinti.", + "store_billing.purchase_owned_by_other_account": "Šis pirkinys susietas su kita paskyra.", + "store_billing.purchase_sandbox_not_entitled": "Bandomųjų pirkinių negalima pritaikyti šiai paskyrai.", + "store_billing.unavailable": "Pirkiniai programėlėje šiuo metu nepasiekiami. Pabandyk vėliau.", + "store_billing.unknown_purchase": "Nežinomas parduotuvės pirkinys.", "stripe.error": "Įvyko mokėjimo apdorojimo klaida. Bandyk dar kartą arba kreipkis į palaikymo komandą.", "stripe.invalid_product_configuration": "Neteisinga produkto konfigūracija.", "stripe.invalid_product_selection": "Neteisingas produkto pasirinkimas.", diff --git a/packages/errors/src/i18n/locales/nl.ts b/packages/errors/src/i18n/locales/nl.ts index b0e49b370..af22c3580 100644 --- a/packages/errors/src/i18n/locales/nl.ts +++ b/packages/errors/src/i18n/locales/nl.ts @@ -428,6 +428,12 @@ const ERROR_I18N_NL_MESSAGES = defineErrorI18nLocaleMessages({ "stickers_and_emojis.not_valid_unicode_emoji": "Dit is geen geldige Unicode-emoji.", "stickers_and_emojis.unknown_emoji": "Onbekende emoji.", "stickers_and_emojis.unknown_sticker": "Onbekende sticker.", + "store_billing.notification_unauthorized": "De handtekening van de melding is ongeldig.", + "store_billing.purchase_invalid": "Deze aankoop kon niet worden geverifieerd.", + "store_billing.purchase_owned_by_other_account": "Deze aankoop is gekoppeld aan een ander account.", + "store_billing.purchase_sandbox_not_entitled": "Testaankopen kunnen niet op dit account worden toegepast.", + "store_billing.unavailable": "In-app-aankopen zijn op dit moment niet beschikbaar. Probeer het later opnieuw.", + "store_billing.unknown_purchase": "Onbekende winkelaankoop.", "stripe.error": "Er is een fout opgetreden bij de betaling. Probeer het opnieuw of neem contact op met support.", "stripe.invalid_product_configuration": "Ongeldige productconfiguratie.", "stripe.invalid_product_selection": "Ongeldige productselectie.", diff --git a/packages/errors/src/i18n/locales/no.ts b/packages/errors/src/i18n/locales/no.ts index 65fd6884f..80de36480 100644 --- a/packages/errors/src/i18n/locales/no.ts +++ b/packages/errors/src/i18n/locales/no.ts @@ -428,6 +428,12 @@ const ERROR_I18N_NO_MESSAGES = defineErrorI18nLocaleMessages({ "stickers_and_emojis.not_valid_unicode_emoji": "Ikke en gyldig Unicode-emoji.", "stickers_and_emojis.unknown_emoji": "Ukjent emoji.", "stickers_and_emojis.unknown_sticker": "Ukjent klistremerke.", + "store_billing.notification_unauthorized": "Signaturen på varselet er ugyldig.", + "store_billing.purchase_invalid": "Dette kjøpet kunne ikke bekreftes.", + "store_billing.purchase_owned_by_other_account": "Dette kjøpet er knyttet til en annen konto.", + "store_billing.purchase_sandbox_not_entitled": "Testkjøp kan ikke brukes på denne kontoen.", + "store_billing.unavailable": "Kjøp i appen er ikke tilgjengelig akkurat nå. Prøv igjen senere.", + "store_billing.unknown_purchase": "Ukjent kjøp fra butikken.", "stripe.error": "Det oppstod en feil under betalingsbehandlingen. Prøv igjen eller kontakt kundestøtte.", "stripe.invalid_product_configuration": "Ugyldig produktkonfigurasjon.", "stripe.invalid_product_selection": "Ugyldig produktvalg.", diff --git a/packages/errors/src/i18n/locales/pl.ts b/packages/errors/src/i18n/locales/pl.ts index f3f891dec..549b510a9 100644 --- a/packages/errors/src/i18n/locales/pl.ts +++ b/packages/errors/src/i18n/locales/pl.ts @@ -428,6 +428,12 @@ const ERROR_I18N_PL_MESSAGES = defineErrorI18nLocaleMessages({ "stickers_and_emojis.not_valid_unicode_emoji": "Nieprawidłowe emoji Unicode.", "stickers_and_emojis.unknown_emoji": "Nieznane emoji.", "stickers_and_emojis.unknown_sticker": "Nieznana naklejka.", + "store_billing.notification_unauthorized": "Podpis powiadomienia jest nieprawidłowy.", + "store_billing.purchase_invalid": "Nie udało się zweryfikować tego zakupu.", + "store_billing.purchase_owned_by_other_account": "Ten zakup jest powiązany z innym kontem.", + "store_billing.purchase_sandbox_not_entitled": "Zakupów testowych nie można zastosować do tego konta.", + "store_billing.unavailable": "Zakupy w aplikacji są teraz niedostępne. Spróbuj ponownie później.", + "store_billing.unknown_purchase": "Nieznany zakup ze sklepu.", "stripe.error": "Podczas przetwarzania płatności wystąpił błąd. Spróbuj ponownie lub skontaktuj się z pomocą techniczną.", "stripe.invalid_product_configuration": "Nieprawidłowa konfiguracja produktu.", "stripe.invalid_product_selection": "Nieprawidłowy wybór produktu.", diff --git a/packages/errors/src/i18n/locales/pt-BR.ts b/packages/errors/src/i18n/locales/pt-BR.ts index aa70b1659..d49a8dc53 100644 --- a/packages/errors/src/i18n/locales/pt-BR.ts +++ b/packages/errors/src/i18n/locales/pt-BR.ts @@ -428,6 +428,12 @@ const ERROR_I18N_PT_BR_MESSAGES = defineErrorI18nLocaleMessages({ "stickers_and_emojis.not_valid_unicode_emoji": "Não é um emoji Unicode válido.", "stickers_and_emojis.unknown_emoji": "Emoji desconhecido.", "stickers_and_emojis.unknown_sticker": "Figurinha desconhecida.", + "store_billing.notification_unauthorized": "A assinatura da notificação é inválida.", + "store_billing.purchase_invalid": "Não foi possível verificar esta compra.", + "store_billing.purchase_owned_by_other_account": "Esta compra está vinculada a outra conta.", + "store_billing.purchase_sandbox_not_entitled": "Compras de teste não podem ser aplicadas a esta conta.", + "store_billing.unavailable": "As compras no app estão indisponíveis no momento. Tente novamente mais tarde.", + "store_billing.unknown_purchase": "Compra da loja desconhecida.", "stripe.error": "Ocorreu um erro no processamento do pagamento. Tente novamente ou entre em contato com o suporte.", "stripe.invalid_product_configuration": "Configuração de produto inválida.", "stripe.invalid_product_selection": "Seleção de produto inválida.", diff --git a/packages/errors/src/i18n/locales/ro.ts b/packages/errors/src/i18n/locales/ro.ts index 9d4dc7624..3ae9a3b14 100644 --- a/packages/errors/src/i18n/locales/ro.ts +++ b/packages/errors/src/i18n/locales/ro.ts @@ -428,6 +428,12 @@ const ERROR_I18N_RO_MESSAGES = defineErrorI18nLocaleMessages({ "stickers_and_emojis.not_valid_unicode_emoji": "Nu este un emoji Unicode valid.", "stickers_and_emojis.unknown_emoji": "Emoji necunoscut.", "stickers_and_emojis.unknown_sticker": "Sticker necunoscut.", + "store_billing.notification_unauthorized": "Semnătura notificării nu este validă.", + "store_billing.purchase_invalid": "Această achiziție nu a putut fi verificată.", + "store_billing.purchase_owned_by_other_account": "Această achiziție este asociată cu alt cont.", + "store_billing.purchase_sandbox_not_entitled": "Achizițiile de test nu pot fi aplicate acestui cont.", + "store_billing.unavailable": "Achizițiile din aplicație nu sunt disponibile acum. Încearcă din nou mai târziu.", + "store_billing.unknown_purchase": "Achiziție din magazin necunoscută.", "stripe.error": "Procesarea plății a întâmpinat o eroare. Încearcă din nou sau contactează echipa de asistență.", "stripe.invalid_product_configuration": "Configurație de produs invalidă.", "stripe.invalid_product_selection": "Selecție de produs invalidă.", diff --git a/packages/errors/src/i18n/locales/ru.ts b/packages/errors/src/i18n/locales/ru.ts index 0d65d2a84..b9f791bd9 100644 --- a/packages/errors/src/i18n/locales/ru.ts +++ b/packages/errors/src/i18n/locales/ru.ts @@ -428,6 +428,12 @@ const ERROR_I18N_RU_MESSAGES = defineErrorI18nLocaleMessages({ "stickers_and_emojis.not_valid_unicode_emoji": "Некорректное Юникод-эмодзи.", "stickers_and_emojis.unknown_emoji": "Неизвестное эмодзи.", "stickers_and_emojis.unknown_sticker": "Неизвестный стикер.", + "store_billing.notification_unauthorized": "Подпись уведомления недействительна.", + "store_billing.purchase_invalid": "Не удалось подтвердить эту покупку.", + "store_billing.purchase_owned_by_other_account": "Эта покупка привязана к другому аккаунту.", + "store_billing.purchase_sandbox_not_entitled": "Тестовые покупки нельзя применить к этому аккаунту.", + "store_billing.unavailable": "Встроенные покупки сейчас недоступны. Повтори попытку позже.", + "store_billing.unknown_purchase": "Неизвестная покупка в магазине.", "stripe.error": "При обработке платежа произошла ошибка. Повтори попытку или обратись в поддержку.", "stripe.invalid_product_configuration": "Некорректная конфигурация продукта.", "stripe.invalid_product_selection": "Некорректный выбор продукта.", diff --git a/packages/errors/src/i18n/locales/sv-SE.ts b/packages/errors/src/i18n/locales/sv-SE.ts index d5bad0560..d02b87528 100644 --- a/packages/errors/src/i18n/locales/sv-SE.ts +++ b/packages/errors/src/i18n/locales/sv-SE.ts @@ -428,6 +428,12 @@ const ERROR_I18N_SV_SE_MESSAGES = defineErrorI18nLocaleMessages({ "stickers_and_emojis.not_valid_unicode_emoji": "Ogiltig Unicode-emoji.", "stickers_and_emojis.unknown_emoji": "Emojin är okänd.", "stickers_and_emojis.unknown_sticker": "Okänt klistermärke.", + "store_billing.notification_unauthorized": "Aviseringens signatur är ogiltig.", + "store_billing.purchase_invalid": "Det gick inte att verifiera köpet.", + "store_billing.purchase_owned_by_other_account": "Köpet är kopplat till ett annat konto.", + "store_billing.purchase_sandbox_not_entitled": "Testköp kan inte tillämpas på det här kontot.", + "store_billing.unavailable": "Köp i appen är inte tillgängliga just nu. Försök igen senare.", + "store_billing.unknown_purchase": "Okänt butiksköp.", "stripe.error": "Ett fel uppstod i betalningshanteringen. Försök igen eller kontakta supporten.", "stripe.invalid_product_configuration": "Felaktig produktkonfiguration.", "stripe.invalid_product_selection": "Felaktigt produktval.", diff --git a/packages/errors/src/i18n/locales/th.ts b/packages/errors/src/i18n/locales/th.ts index 9a8ad348a..ce2fe6835 100644 --- a/packages/errors/src/i18n/locales/th.ts +++ b/packages/errors/src/i18n/locales/th.ts @@ -428,6 +428,12 @@ const ERROR_I18N_TH_MESSAGES = defineErrorI18nLocaleMessages({ "stickers_and_emojis.not_valid_unicode_emoji": "ไม่ใช่อิโมจิยูนิโค้ดที่ถูกต้อง", "stickers_and_emojis.unknown_emoji": "ไม่พบอิโมจิ", "stickers_and_emojis.unknown_sticker": "ไม่พบสติกเกอร์", + "store_billing.notification_unauthorized": "ลายเซ็นของการแจ้งเตือนไม่ถูกต้อง", + "store_billing.purchase_invalid": "ไม่สามารถยืนยันการซื้อนี้ได้", + "store_billing.purchase_owned_by_other_account": "การซื้อนี้เชื่อมโยงกับบัญชีอื่น", + "store_billing.purchase_sandbox_not_entitled": "ไม่สามารถใช้การซื้อทดสอบกับบัญชีนี้ได้", + "store_billing.unavailable": "การซื้อในแอปไม่พร้อมใช้งานในขณะนี้ โปรดลองอีกครั้งในภายหลัง", + "store_billing.unknown_purchase": "ไม่พบการซื้อจากสโตร์", "stripe.error": "เกิดข้อผิดพลาดในการดำเนินการชำระเงิน โปรดลองอีกครั้งหรือติดต่อฝ่ายสนับสนุน", "stripe.invalid_product_configuration": "การกำหนดค่าสินค้าไม่ถูกต้อง", "stripe.invalid_product_selection": "การเลือกสินค้าไม่ถูกต้อง", diff --git a/packages/errors/src/i18n/locales/tr.ts b/packages/errors/src/i18n/locales/tr.ts index 91110b4ed..6e589ff0c 100644 --- a/packages/errors/src/i18n/locales/tr.ts +++ b/packages/errors/src/i18n/locales/tr.ts @@ -428,6 +428,12 @@ const ERROR_I18N_TR_MESSAGES = defineErrorI18nLocaleMessages({ "stickers_and_emojis.not_valid_unicode_emoji": "Geçerli bir Unicode emoji değil.", "stickers_and_emojis.unknown_emoji": "Bilinmeyen emoji.", "stickers_and_emojis.unknown_sticker": "Bilinmeyen çıkartma.", + "store_billing.notification_unauthorized": "Bildirim imzası geçersiz.", + "store_billing.purchase_invalid": "Bu satın alma doğrulanamadı.", + "store_billing.purchase_owned_by_other_account": "Bu satın alma başka bir hesaba bağlı.", + "store_billing.purchase_sandbox_not_entitled": "Test satın almaları bu hesaba uygulanamaz.", + "store_billing.unavailable": "Uygulama içi satın almalar şu anda kullanılamıyor. Lütfen daha sonra tekrar dene.", + "store_billing.unknown_purchase": "Bilinmeyen mağaza satın alması.", "stripe.error": "Ödeme işlemi sırasında bir hata oluştu. Lütfen tekrar dene veya destek ekibine başvur.", "stripe.invalid_product_configuration": "Geçersiz ürün yapılandırması.", "stripe.invalid_product_selection": "Geçersiz ürün seçimi.", diff --git a/packages/errors/src/i18n/locales/uk.ts b/packages/errors/src/i18n/locales/uk.ts index 53d26a4ce..a78a40065 100644 --- a/packages/errors/src/i18n/locales/uk.ts +++ b/packages/errors/src/i18n/locales/uk.ts @@ -428,6 +428,12 @@ const ERROR_I18N_UK_MESSAGES = defineErrorI18nLocaleMessages({ "stickers_and_emojis.not_valid_unicode_emoji": "Неправильне емодзі Unicode.", "stickers_and_emojis.unknown_emoji": "Невідоме емоджі.", "stickers_and_emojis.unknown_sticker": "Невідомий стікер.", + "store_billing.notification_unauthorized": "Підпис сповіщення недійсний.", + "store_billing.purchase_invalid": "Не вдалося підтвердити цю покупку.", + "store_billing.purchase_owned_by_other_account": "Ця покупка пов’язана з іншим обліковим записом.", + "store_billing.purchase_sandbox_not_entitled": "Тестові покупки не можна застосувати до цього облікового запису.", + "store_billing.unavailable": "Покупки в застосунку зараз недоступні. Спробуй пізніше.", + "store_billing.unknown_purchase": "Невідома покупка в магазині.", "stripe.error": "Сталася помилка під час обробки платежу. Спробуй ще раз або звернися до підтримки.", "stripe.invalid_product_configuration": "Неправильна конфігурація продукту.", "stripe.invalid_product_selection": "Неправильний вибір продукту.", diff --git a/packages/errors/src/i18n/locales/vi.ts b/packages/errors/src/i18n/locales/vi.ts index 8f8a1255c..d4e3e2838 100644 --- a/packages/errors/src/i18n/locales/vi.ts +++ b/packages/errors/src/i18n/locales/vi.ts @@ -428,6 +428,12 @@ const ERROR_I18N_VI_MESSAGES = defineErrorI18nLocaleMessages({ "stickers_and_emojis.not_valid_unicode_emoji": "Không phải emoji Unicode hợp lệ.", "stickers_and_emojis.unknown_emoji": "Emoji không xác định.", "stickers_and_emojis.unknown_sticker": "Sticker không xác định.", + "store_billing.notification_unauthorized": "Chữ ký của thông báo không hợp lệ.", + "store_billing.purchase_invalid": "Không thể xác minh giao dịch mua này.", + "store_billing.purchase_owned_by_other_account": "Giao dịch mua này được liên kết với một tài khoản khác.", + "store_billing.purchase_sandbox_not_entitled": "Không thể áp dụng giao dịch mua thử nghiệm cho tài khoản này.", + "store_billing.unavailable": "Hiện không thể mua hàng trong ứng dụng. Vui lòng thử lại sau.", + "store_billing.unknown_purchase": "Giao dịch mua trên cửa hàng không xác định.", "stripe.error": "Xử lý thanh toán gặp lỗi. Vui lòng thử lại hoặc liên hệ bộ phận hỗ trợ.", "stripe.invalid_product_configuration": "Cấu hình sản phẩm không hợp lệ.", "stripe.invalid_product_selection": "Lựa chọn sản phẩm không hợp lệ.", diff --git a/packages/errors/src/i18n/locales/zh-CN.ts b/packages/errors/src/i18n/locales/zh-CN.ts index 695aa41f2..573a6016a 100644 --- a/packages/errors/src/i18n/locales/zh-CN.ts +++ b/packages/errors/src/i18n/locales/zh-CN.ts @@ -428,6 +428,12 @@ const ERROR_I18N_ZH_CN_MESSAGES = defineErrorI18nLocaleMessages({ "stickers_and_emojis.not_valid_unicode_emoji": "不是有效的 Unicode 表情。", "stickers_and_emojis.unknown_emoji": "未知表情。", "stickers_and_emojis.unknown_sticker": "未知贴纸。", + "store_billing.notification_unauthorized": "通知签名无效。", + "store_billing.purchase_invalid": "无法验证此购买。", + "store_billing.purchase_owned_by_other_account": "此购买已关联到其他账号。", + "store_billing.purchase_sandbox_not_entitled": "测试购买无法应用到此账号。", + "store_billing.unavailable": "应用内购买暂时不可用。请稍后重试。", + "store_billing.unknown_purchase": "未知的商店购买。", "stripe.error": "支付处理遇到错误。请重试或联系支持团队。", "stripe.invalid_product_configuration": "产品配置无效。", "stripe.invalid_product_selection": "产品选择无效。", diff --git a/packages/errors/src/i18n/locales/zh-TW.ts b/packages/errors/src/i18n/locales/zh-TW.ts index 74a71da4d..fa0a66b36 100644 --- a/packages/errors/src/i18n/locales/zh-TW.ts +++ b/packages/errors/src/i18n/locales/zh-TW.ts @@ -428,6 +428,12 @@ const ERROR_I18N_ZH_TW_MESSAGES = defineErrorI18nLocaleMessages({ "stickers_and_emojis.not_valid_unicode_emoji": "不是有效的 Unicode 表情符號。", "stickers_and_emojis.unknown_emoji": "未知的表情符號。", "stickers_and_emojis.unknown_sticker": "未知的貼圖。", + "store_billing.notification_unauthorized": "通知簽章無效。", + "store_billing.purchase_invalid": "無法驗證此購買項目。", + "store_billing.purchase_owned_by_other_account": "此購買項目已連結至其他帳號。", + "store_billing.purchase_sandbox_not_entitled": "測試購買無法套用至此帳號。", + "store_billing.unavailable": "應用程式內購買目前無法使用。請稍後再試。", + "store_billing.unknown_purchase": "未知的商店購買項目。", "stripe.error": "付款處理時發生錯誤。請重試或聯絡客服。", "stripe.invalid_product_configuration": "無效的產品設定。", "stripe.invalid_product_selection": "無效的產品選擇。", diff --git a/packages/errors/src/i18n/weblate/locales/ar.json b/packages/errors/src/i18n/weblate/locales/ar.json index 4610a2872..46c8f720a 100644 --- a/packages/errors/src/i18n/weblate/locales/ar.json +++ b/packages/errors/src/i18n/weblate/locales/ar.json @@ -424,6 +424,12 @@ "stickers_and_emojis.not_valid_unicode_emoji": "إيموجي يونيكود غير صالح.", "stickers_and_emojis.unknown_emoji": "إيموجي غير معروف.", "stickers_and_emojis.unknown_sticker": "ملصق غير معروف.", + "store_billing.notification_unauthorized": "توقيع الإشعار غير صالح.", + "store_billing.purchase_invalid": "تعذّر التحقق من عملية الشراء هذه.", + "store_billing.purchase_owned_by_other_account": "عملية الشراء هذه مرتبطة بحساب آخر.", + "store_billing.purchase_sandbox_not_entitled": "لا يمكن تطبيق عمليات الشراء التجريبية على هذا الحساب.", + "store_billing.unavailable": "عمليات الشراء داخل التطبيق غير متاحة حاليًا. حاول مجددًا لاحقًا.", + "store_billing.unknown_purchase": "عملية شراء غير معروفة من المتجر.", "stripe.error": "حدث خطأ في معالجة الدفع. حاول مجددًا أو تواصل مع الدعم.", "stripe.invalid_product_configuration": "تكوين منتج غير صالح.", "stripe.invalid_product_selection": "اختيار منتج غير صالح.", diff --git a/packages/errors/src/i18n/weblate/locales/bg.json b/packages/errors/src/i18n/weblate/locales/bg.json index 1615f6e74..379e65e78 100644 --- a/packages/errors/src/i18n/weblate/locales/bg.json +++ b/packages/errors/src/i18n/weblate/locales/bg.json @@ -424,6 +424,12 @@ "stickers_and_emojis.not_valid_unicode_emoji": "Не е валидно Unicode емоджи.", "stickers_and_emojis.unknown_emoji": "Неизвестно емоджи.", "stickers_and_emojis.unknown_sticker": "Неизвестен стикер.", + "store_billing.notification_unauthorized": "Подписът на известието е невалиден.", + "store_billing.purchase_invalid": "Тази покупка не можа да бъде потвърдена.", + "store_billing.purchase_owned_by_other_account": "Тази покупка е свързана с друг акаунт.", + "store_billing.purchase_sandbox_not_entitled": "Тестовите покупки не могат да се приложат към този акаунт.", + "store_billing.unavailable": "Покупките в приложението в момента не са достъпни. Опитай отново по-късно.", + "store_billing.unknown_purchase": "Неизвестна покупка от магазина.", "stripe.error": "Възникна грешка при обработката на плащането. Опитай отново или се свържи с поддръжката.", "stripe.invalid_product_configuration": "Невалидна конфигурация на продукт.", "stripe.invalid_product_selection": "Невалиден избор на продукт.", diff --git a/packages/errors/src/i18n/weblate/locales/cs.json b/packages/errors/src/i18n/weblate/locales/cs.json index 74730cce5..eacf7b614 100644 --- a/packages/errors/src/i18n/weblate/locales/cs.json +++ b/packages/errors/src/i18n/weblate/locales/cs.json @@ -424,6 +424,12 @@ "stickers_and_emojis.not_valid_unicode_emoji": "Neplatné emoji Unicode.", "stickers_and_emojis.unknown_emoji": "Neznámé emoji.", "stickers_and_emojis.unknown_sticker": "Neznámá samolepka.", + "store_billing.notification_unauthorized": "Podpis oznámení je neplatný.", + "store_billing.purchase_invalid": "Tento nákup se nepodařilo ověřit.", + "store_billing.purchase_owned_by_other_account": "Tento nákup je propojený s jiným účtem.", + "store_billing.purchase_sandbox_not_entitled": "Testovací nákupy nelze uplatnit na tento účet.", + "store_billing.unavailable": "Nákupy v aplikaci teď nejsou k dispozici. Zkuste to znovu později.", + "store_billing.unknown_purchase": "Neznámý nákup z obchodu.", "stripe.error": "Při zpracování platby došlo k chybě. Zkuste to znovu nebo kontaktujte podporu.", "stripe.invalid_product_configuration": "Neplatná konfigurace produktu.", "stripe.invalid_product_selection": "Neplatný výběr produktu.", diff --git a/packages/errors/src/i18n/weblate/locales/da.json b/packages/errors/src/i18n/weblate/locales/da.json index 8827868d0..dbf5fd8d5 100644 --- a/packages/errors/src/i18n/weblate/locales/da.json +++ b/packages/errors/src/i18n/weblate/locales/da.json @@ -424,6 +424,12 @@ "stickers_and_emojis.not_valid_unicode_emoji": "Ikke en gyldig Unicode-emoji.", "stickers_and_emojis.unknown_emoji": "Ukendt emoji.", "stickers_and_emojis.unknown_sticker": "Ukendt klistermærke.", + "store_billing.notification_unauthorized": "Notifikationens signatur er ugyldig.", + "store_billing.purchase_invalid": "Dette køb kunne ikke bekræftes.", + "store_billing.purchase_owned_by_other_account": "Dette køb er knyttet til en anden konto.", + "store_billing.purchase_sandbox_not_entitled": "Testkøb kan ikke anvendes på denne konto.", + "store_billing.unavailable": "Køb i appen er ikke tilgængelige lige nu. Prøv igen senere.", + "store_billing.unknown_purchase": "Ukendt køb fra butikken.", "stripe.error": "Noget gik galt under betalingsbehandlingen. Prøv igen eller kontakt support.", "stripe.invalid_product_configuration": "Ugyldig produktkonfiguration.", "stripe.invalid_product_selection": "Ugyldigt produktvalg.", diff --git a/packages/errors/src/i18n/weblate/locales/de.json b/packages/errors/src/i18n/weblate/locales/de.json index 286089c97..9e9858ee2 100644 --- a/packages/errors/src/i18n/weblate/locales/de.json +++ b/packages/errors/src/i18n/weblate/locales/de.json @@ -424,6 +424,12 @@ "stickers_and_emojis.not_valid_unicode_emoji": "Kein gültiges Unicode-Emoji.", "stickers_and_emojis.unknown_emoji": "Unbekanntes Emoji.", "stickers_and_emojis.unknown_sticker": "Unbekannter Sticker.", + "store_billing.notification_unauthorized": "Die Signatur der Benachrichtigung ist ungültig.", + "store_billing.purchase_invalid": "Dieser Kauf konnte nicht bestätigt werden.", + "store_billing.purchase_owned_by_other_account": "Dieser Kauf ist mit einem anderen Konto verknüpft.", + "store_billing.purchase_sandbox_not_entitled": "Testkäufe können nicht auf dieses Konto angewendet werden.", + "store_billing.unavailable": "In-App-Käufe sind gerade nicht verfügbar. Versuch es später noch mal.", + "store_billing.unknown_purchase": "Unbekannter Store-Kauf.", "stripe.error": "Bei der Zahlungsabwicklung ist ein Fehler aufgetreten. Versuch es noch mal oder kontaktiere den Support.", "stripe.invalid_product_configuration": "Ungültige Produktkonfiguration.", "stripe.invalid_product_selection": "Ungültige Produktauswahl.", diff --git a/packages/errors/src/i18n/weblate/locales/el.json b/packages/errors/src/i18n/weblate/locales/el.json index 18025353c..a08ef7425 100644 --- a/packages/errors/src/i18n/weblate/locales/el.json +++ b/packages/errors/src/i18n/weblate/locales/el.json @@ -424,6 +424,12 @@ "stickers_and_emojis.not_valid_unicode_emoji": "Δεν είναι έγκυρο Unicode emoji.", "stickers_and_emojis.unknown_emoji": "Άγνωστο emoji.", "stickers_and_emojis.unknown_sticker": "Άγνωστο sticker.", + "store_billing.notification_unauthorized": "Η υπογραφή της ειδοποίησης δεν είναι έγκυρη.", + "store_billing.purchase_invalid": "Δεν ήταν δυνατή η επαλήθευση αυτής της αγοράς.", + "store_billing.purchase_owned_by_other_account": "Αυτή η αγορά είναι συνδεδεμένη με άλλον λογαριασμό.", + "store_billing.purchase_sandbox_not_entitled": "Οι δοκιμαστικές αγορές δεν μπορούν να εφαρμοστούν σε αυτόν τον λογαριασμό.", + "store_billing.unavailable": "Οι αγορές εντός εφαρμογής δεν είναι διαθέσιμες αυτή τη στιγμή. Δοκίμασε ξανά αργότερα.", + "store_billing.unknown_purchase": "Άγνωστη αγορά από το κατάστημα.", "stripe.error": "Παρουσιάστηκε σφάλμα στην επεξεργασία πληρωμής. Δοκίμασε ξανά ή επικοινώνησε με την υποστήριξη.", "stripe.invalid_product_configuration": "Μη έγκυρη διαμόρφωση προϊόντος.", "stripe.invalid_product_selection": "Μη έγκυρη επιλογή προϊόντος.", diff --git a/packages/errors/src/i18n/weblate/locales/en-GB.json b/packages/errors/src/i18n/weblate/locales/en-GB.json index edf40fafe..604d5c5d0 100644 --- a/packages/errors/src/i18n/weblate/locales/en-GB.json +++ b/packages/errors/src/i18n/weblate/locales/en-GB.json @@ -424,6 +424,12 @@ "stickers_and_emojis.not_valid_unicode_emoji": "Not a valid Unicode emoji.", "stickers_and_emojis.unknown_emoji": "Unknown emoji.", "stickers_and_emojis.unknown_sticker": "Unknown sticker.", + "store_billing.notification_unauthorized": "The notification signature is invalid.", + "store_billing.purchase_invalid": "This purchase could not be verified.", + "store_billing.purchase_owned_by_other_account": "This purchase is linked to a different account.", + "store_billing.purchase_sandbox_not_entitled": "Test purchases cannot be applied to this account.", + "store_billing.unavailable": "In-app purchases are unavailable right now. Try again later.", + "store_billing.unknown_purchase": "Unknown store purchase.", "stripe.error": "Payment processing encountered an error. Please try again or contact support.", "stripe.invalid_product_configuration": "Invalid product configuration.", "stripe.invalid_product_selection": "Invalid product selection.", diff --git a/packages/errors/src/i18n/weblate/locales/es-419.json b/packages/errors/src/i18n/weblate/locales/es-419.json index 90a0ac4a1..88dd0bbff 100644 --- a/packages/errors/src/i18n/weblate/locales/es-419.json +++ b/packages/errors/src/i18n/weblate/locales/es-419.json @@ -424,6 +424,12 @@ "stickers_and_emojis.not_valid_unicode_emoji": "No es un emoji Unicode válido.", "stickers_and_emojis.unknown_emoji": "Emoji desconocido.", "stickers_and_emojis.unknown_sticker": "Sticker desconocido.", + "store_billing.notification_unauthorized": "La firma de la notificación no es válida.", + "store_billing.purchase_invalid": "No se pudo verificar esta compra.", + "store_billing.purchase_owned_by_other_account": "Esta compra está vinculada a otra cuenta.", + "store_billing.purchase_sandbox_not_entitled": "Las compras de prueba no se pueden aplicar a esta cuenta.", + "store_billing.unavailable": "Las compras dentro de la app no están disponibles en este momento. Inténtalo de nuevo más tarde.", + "store_billing.unknown_purchase": "Compra de la tienda desconocida.", "stripe.error": "El procesamiento del pago tuvo un error. Inténtalo de nuevo o contacta al equipo de soporte.", "stripe.invalid_product_configuration": "Configuración de producto inválida.", "stripe.invalid_product_selection": "Selección de producto inválida.", diff --git a/packages/errors/src/i18n/weblate/locales/es-ES.json b/packages/errors/src/i18n/weblate/locales/es-ES.json index 56fa45ee9..b2c2daa62 100644 --- a/packages/errors/src/i18n/weblate/locales/es-ES.json +++ b/packages/errors/src/i18n/weblate/locales/es-ES.json @@ -424,6 +424,12 @@ "stickers_and_emojis.not_valid_unicode_emoji": "No es un emoji Unicode válido.", "stickers_and_emojis.unknown_emoji": "Emoji desconocido.", "stickers_and_emojis.unknown_sticker": "Pegatina desconocida.", + "store_billing.notification_unauthorized": "La firma de la notificación no es válida.", + "store_billing.purchase_invalid": "No se ha podido verificar esta compra.", + "store_billing.purchase_owned_by_other_account": "Esta compra está vinculada a otra cuenta.", + "store_billing.purchase_sandbox_not_entitled": "Las compras de prueba no se pueden aplicar a esta cuenta.", + "store_billing.unavailable": "Las compras dentro de la aplicación no están disponibles ahora mismo. Inténtalo de nuevo más tarde.", + "store_billing.unknown_purchase": "Compra de la tienda desconocida.", "stripe.error": "El procesamiento del pago ha encontrado un error. Inténtalo de nuevo o contacta con soporte.", "stripe.invalid_product_configuration": "Configuración de producto no válida.", "stripe.invalid_product_selection": "Selección de producto no válida.", diff --git a/packages/errors/src/i18n/weblate/locales/fi.json b/packages/errors/src/i18n/weblate/locales/fi.json index f8d551d4c..89e18b880 100644 --- a/packages/errors/src/i18n/weblate/locales/fi.json +++ b/packages/errors/src/i18n/weblate/locales/fi.json @@ -424,6 +424,12 @@ "stickers_and_emojis.not_valid_unicode_emoji": "Ei kelvollinen Unicode-emoji.", "stickers_and_emojis.unknown_emoji": "Tuntematon emoji.", "stickers_and_emojis.unknown_sticker": "Tuntematon tarra.", + "store_billing.notification_unauthorized": "Ilmoituksen allekirjoitus on virheellinen.", + "store_billing.purchase_invalid": "Tätä ostosta ei voitu vahvistaa.", + "store_billing.purchase_owned_by_other_account": "Tämä ostos on liitetty toiseen tiliin.", + "store_billing.purchase_sandbox_not_entitled": "Testiostoja ei voi käyttää tällä tilillä.", + "store_billing.unavailable": "Sovelluksen sisäiset ostot eivät ole juuri nyt käytettävissä. Yritä myöhemmin uudelleen.", + "store_billing.unknown_purchase": "Tuntematon kauppaostos.", "stripe.error": "Maksun käsittelyssä tapahtui virhe. Yritä uudelleen tai ota yhteyttä tukeen.", "stripe.invalid_product_configuration": "Virheellinen tuotekonfiguraatio.", "stripe.invalid_product_selection": "Virheellinen tuotevalinta.", diff --git a/packages/errors/src/i18n/weblate/locales/fr.json b/packages/errors/src/i18n/weblate/locales/fr.json index 2b94ba693..2bff7e2dc 100644 --- a/packages/errors/src/i18n/weblate/locales/fr.json +++ b/packages/errors/src/i18n/weblate/locales/fr.json @@ -424,6 +424,12 @@ "stickers_and_emojis.not_valid_unicode_emoji": "Emoji Unicode invalide.", "stickers_and_emojis.unknown_emoji": "Emoji inconnu.", "stickers_and_emojis.unknown_sticker": "Sticker inconnu.", + "store_billing.notification_unauthorized": "La signature de la notification n'est pas valide.", + "store_billing.purchase_invalid": "Impossible de vérifier cet achat.", + "store_billing.purchase_owned_by_other_account": "Cet achat est associé à un autre compte.", + "store_billing.purchase_sandbox_not_entitled": "Les achats de test ne peuvent pas être appliqués à ce compte.", + "store_billing.unavailable": "Les achats intégrés sont indisponibles pour le moment. Réessayez plus tard.", + "store_billing.unknown_purchase": "Achat en boutique inconnu.", "stripe.error": "Une erreur est survenue lors du traitement du paiement. Réessayez ou contactez l’assistance.", "stripe.invalid_product_configuration": "Configuration de produit invalide.", "stripe.invalid_product_selection": "Sélection de produit invalide.", diff --git a/packages/errors/src/i18n/weblate/locales/he.json b/packages/errors/src/i18n/weblate/locales/he.json index c866a341f..a6ccafa03 100644 --- a/packages/errors/src/i18n/weblate/locales/he.json +++ b/packages/errors/src/i18n/weblate/locales/he.json @@ -424,6 +424,12 @@ "stickers_and_emojis.not_valid_unicode_emoji": "אימוג'י יוניקוד לא תקין.", "stickers_and_emojis.unknown_emoji": "אימוג'י לא ידוע.", "stickers_and_emojis.unknown_sticker": "סטיקר לא ידוע.", + "store_billing.notification_unauthorized": "החתימה של ההתראה אינה תקינה.", + "store_billing.purchase_invalid": "לא ניתן היה לאמת את הרכישה הזו.", + "store_billing.purchase_owned_by_other_account": "הרכישה הזו מקושרת לחשבון אחר.", + "store_billing.purchase_sandbox_not_entitled": "לא ניתן להחיל רכישות ניסיון על החשבון הזה.", + "store_billing.unavailable": "רכישות בתוך האפליקציה אינן זמינות כרגע. ניתן לנסות שוב מאוחר יותר.", + "store_billing.unknown_purchase": "רכישה לא ידועה מהחנות.", "stripe.error": "אירעה שגיאה בעיבוד התשלום. ניתן לנסות שוב או לפנות לתמיכה.", "stripe.invalid_product_configuration": "תצורת מוצר שגויה.", "stripe.invalid_product_selection": "בחירת מוצר שגויה.", diff --git a/packages/errors/src/i18n/weblate/locales/hi.json b/packages/errors/src/i18n/weblate/locales/hi.json index b27332fe5..e777c19ba 100644 --- a/packages/errors/src/i18n/weblate/locales/hi.json +++ b/packages/errors/src/i18n/weblate/locales/hi.json @@ -424,6 +424,12 @@ "stickers_and_emojis.not_valid_unicode_emoji": "मान्य यूनिकोड इमोजी नहीं है।", "stickers_and_emojis.unknown_emoji": "अज्ञात इमोजी।", "stickers_and_emojis.unknown_sticker": "अज्ञात स्टीकर।", + "store_billing.notification_unauthorized": "सूचना का सिग्नेचर अमान्य है।", + "store_billing.purchase_invalid": "इस खरीदारी को सत्यापित नहीं किया जा सका।", + "store_billing.purchase_owned_by_other_account": "यह खरीदारी किसी दूसरे खाते से जुड़ी है।", + "store_billing.purchase_sandbox_not_entitled": "टेस्ट खरीदारी इस खाते पर लागू नहीं की जा सकती।", + "store_billing.unavailable": "इन-ऐप खरीदारी अभी उपलब्ध नहीं है। कृपया बाद में फिर से प्रयास करें।", + "store_billing.unknown_purchase": "अज्ञात स्टोर खरीदारी।", "stripe.error": "पेमेंट प्रोसेसिंग में गड़बड़ी हुई। कृपया फिर से प्रयास करें या सपोर्ट से संपर्क करें।", "stripe.invalid_product_configuration": "अमान्य प्रोडक्ट कॉन्फ़िगरेशन।", "stripe.invalid_product_selection": "अमान्य प्रोडक्ट चयन।", diff --git a/packages/errors/src/i18n/weblate/locales/hr.json b/packages/errors/src/i18n/weblate/locales/hr.json index a30964822..7657a732f 100644 --- a/packages/errors/src/i18n/weblate/locales/hr.json +++ b/packages/errors/src/i18n/weblate/locales/hr.json @@ -424,6 +424,12 @@ "stickers_and_emojis.not_valid_unicode_emoji": "Nije važeći Unicode emoji.", "stickers_and_emojis.unknown_emoji": "Nepoznati emoji.", "stickers_and_emojis.unknown_sticker": "Nepoznata naljepnica.", + "store_billing.notification_unauthorized": "Potpis obavijesti nije valjan.", + "store_billing.purchase_invalid": "Ovu kupnju nije moguće potvrditi.", + "store_billing.purchase_owned_by_other_account": "Ova kupnja povezana je s drugim računom.", + "store_billing.purchase_sandbox_not_entitled": "Testne kupnje ne mogu se primijeniti na ovaj račun.", + "store_billing.unavailable": "Kupnje unutar aplikacije trenutačno nisu dostupne. Pokušaj ponovno kasnije.", + "store_billing.unknown_purchase": "Nepoznata kupnja iz trgovine.", "stripe.error": "Došlo je do pogreške pri obradi plaćanja. Pokušaj ponovno ili se javi podršci.", "stripe.invalid_product_configuration": "Nevažeća konfiguracija proizvoda.", "stripe.invalid_product_selection": "Nevažeći odabir proizvoda.", diff --git a/packages/errors/src/i18n/weblate/locales/hu.json b/packages/errors/src/i18n/weblate/locales/hu.json index f50fd4a73..209e140c4 100644 --- a/packages/errors/src/i18n/weblate/locales/hu.json +++ b/packages/errors/src/i18n/weblate/locales/hu.json @@ -424,6 +424,12 @@ "stickers_and_emojis.not_valid_unicode_emoji": "Érvénytelen Unicode-emoji.", "stickers_and_emojis.unknown_emoji": "Ismeretlen emoji.", "stickers_and_emojis.unknown_sticker": "Ismeretlen matrica.", + "store_billing.notification_unauthorized": "Az értesítés aláírása érvénytelen.", + "store_billing.purchase_invalid": "Ezt a vásárlást nem sikerült ellenőrizni.", + "store_billing.purchase_owned_by_other_account": "Ez a vásárlás egy másik fiókhoz van kapcsolva.", + "store_billing.purchase_sandbox_not_entitled": "A tesztvásárlások nem alkalmazhatók erre a fiókra.", + "store_billing.unavailable": "Az alkalmazáson belüli vásárlások most nem érhetők el. Próbáld újra később.", + "store_billing.unknown_purchase": "Ismeretlen áruházi vásárlás.", "stripe.error": "Hiba történt a fizetés feldolgozása során. Próbáld újra, vagy keresd az ügyfélszolgálatot.", "stripe.invalid_product_configuration": "Érvénytelen termékkonfiguráció.", "stripe.invalid_product_selection": "Érvénytelen termékválasztás.", diff --git a/packages/errors/src/i18n/weblate/locales/id.json b/packages/errors/src/i18n/weblate/locales/id.json index a9ad12423..590177274 100644 --- a/packages/errors/src/i18n/weblate/locales/id.json +++ b/packages/errors/src/i18n/weblate/locales/id.json @@ -424,6 +424,12 @@ "stickers_and_emojis.not_valid_unicode_emoji": "Bukan emoji Unicode yang valid.", "stickers_and_emojis.unknown_emoji": "Emoji tidak dikenal.", "stickers_and_emojis.unknown_sticker": "Stiker tidak dikenal.", + "store_billing.notification_unauthorized": "Tanda tangan notifikasi tidak valid.", + "store_billing.purchase_invalid": "Pembelian ini tidak dapat diverifikasi.", + "store_billing.purchase_owned_by_other_account": "Pembelian ini terhubung ke akun lain.", + "store_billing.purchase_sandbox_not_entitled": "Pembelian uji coba tidak dapat diterapkan ke akun ini.", + "store_billing.unavailable": "Pembelian dalam aplikasi sedang tidak tersedia. Coba lagi nanti.", + "store_billing.unknown_purchase": "Pembelian toko tidak dikenal.", "stripe.error": "Terjadi kesalahan saat memproses pembayaran. Coba lagi atau hubungi dukungan.", "stripe.invalid_product_configuration": "Konfigurasi produk tidak valid.", "stripe.invalid_product_selection": "Pilihan produk tidak valid.", diff --git a/packages/errors/src/i18n/weblate/locales/it.json b/packages/errors/src/i18n/weblate/locales/it.json index d4a968788..3e73a1d8f 100644 --- a/packages/errors/src/i18n/weblate/locales/it.json +++ b/packages/errors/src/i18n/weblate/locales/it.json @@ -424,6 +424,12 @@ "stickers_and_emojis.not_valid_unicode_emoji": "Emoji Unicode non valida.", "stickers_and_emojis.unknown_emoji": "Emoji sconosciuta.", "stickers_and_emojis.unknown_sticker": "Sticker sconosciuto.", + "store_billing.notification_unauthorized": "La firma della notifica non è valida.", + "store_billing.purchase_invalid": "Non è stato possibile verificare questo acquisto.", + "store_billing.purchase_owned_by_other_account": "Questo acquisto è collegato a un altro account.", + "store_billing.purchase_sandbox_not_entitled": "Gli acquisti di prova non possono essere applicati a questo account.", + "store_billing.unavailable": "Gli acquisti in-app non sono disponibili al momento. Riprova più tardi.", + "store_billing.unknown_purchase": "Acquisto dallo store sconosciuto.", "stripe.error": "Si è verificato un errore durante l'elaborazione del pagamento. Riprova o contatta l'assistenza.", "stripe.invalid_product_configuration": "Configurazione prodotto non valida.", "stripe.invalid_product_selection": "Selezione del prodotto non valida.", diff --git a/packages/errors/src/i18n/weblate/locales/ja.json b/packages/errors/src/i18n/weblate/locales/ja.json index d37c66426..0f56d1b43 100644 --- a/packages/errors/src/i18n/weblate/locales/ja.json +++ b/packages/errors/src/i18n/weblate/locales/ja.json @@ -424,6 +424,12 @@ "stickers_and_emojis.not_valid_unicode_emoji": "有効なUnicode絵文字ではありません。", "stickers_and_emojis.unknown_emoji": "不明な絵文字です。", "stickers_and_emojis.unknown_sticker": "不明なスタンプです。", + "store_billing.notification_unauthorized": "通知の署名が無効です。", + "store_billing.purchase_invalid": "この購入を確認できませんでした。", + "store_billing.purchase_owned_by_other_account": "この購入は別のアカウントに紐付けられています。", + "store_billing.purchase_sandbox_not_entitled": "テスト購入はこのアカウントには適用できません。", + "store_billing.unavailable": "現在、アプリ内課金をご利用いただけません。後でもう一度お試しください。", + "store_billing.unknown_purchase": "不明なストア購入です。", "stripe.error": "支払い処理でエラーが発生しました。もう一度お試しいただくか、サポートにお問い合わせください。", "stripe.invalid_product_configuration": "商品の設定が無効です。", "stripe.invalid_product_selection": "選択された商品が無効です。", diff --git a/packages/errors/src/i18n/weblate/locales/ko.json b/packages/errors/src/i18n/weblate/locales/ko.json index 89218f159..712651d74 100644 --- a/packages/errors/src/i18n/weblate/locales/ko.json +++ b/packages/errors/src/i18n/weblate/locales/ko.json @@ -424,6 +424,12 @@ "stickers_and_emojis.not_valid_unicode_emoji": "올바른 유니코드 이모지가 아니에요.", "stickers_and_emojis.unknown_emoji": "알 수 없는 이모지예요.", "stickers_and_emojis.unknown_sticker": "알 수 없는 스티커예요.", + "store_billing.notification_unauthorized": "알림 서명이 올바르지 않아요.", + "store_billing.purchase_invalid": "이 구매를 확인할 수 없어요.", + "store_billing.purchase_owned_by_other_account": "이 구매는 다른 계정에 연결되어 있어요.", + "store_billing.purchase_sandbox_not_entitled": "테스트 구매는 이 계정에 적용할 수 없어요.", + "store_billing.unavailable": "지금은 인앱 구매를 이용할 수 없어요. 나중에 다시 시도해 주세요.", + "store_billing.unknown_purchase": "알 수 없는 스토어 구매예요.", "stripe.error": "결제 처리 중 오류가 발생했어요. 다시 시도하거나 지원팀에 문의해 주세요.", "stripe.invalid_product_configuration": "올바르지 않은 상품 구성이에요.", "stripe.invalid_product_selection": "올바르지 않은 상품 선택이에요.", diff --git a/packages/errors/src/i18n/weblate/locales/lt.json b/packages/errors/src/i18n/weblate/locales/lt.json index ee132dcdb..97d87ba87 100644 --- a/packages/errors/src/i18n/weblate/locales/lt.json +++ b/packages/errors/src/i18n/weblate/locales/lt.json @@ -424,6 +424,12 @@ "stickers_and_emojis.not_valid_unicode_emoji": "Netinkamas Unicode emoji.", "stickers_and_emojis.unknown_emoji": "Nežinomas emoji.", "stickers_and_emojis.unknown_sticker": "Nežinomas lipdukas.", + "store_billing.notification_unauthorized": "Pranešimo parašas netinkamas.", + "store_billing.purchase_invalid": "Šio pirkinio nepavyko patvirtinti.", + "store_billing.purchase_owned_by_other_account": "Šis pirkinys susietas su kita paskyra.", + "store_billing.purchase_sandbox_not_entitled": "Bandomųjų pirkinių negalima pritaikyti šiai paskyrai.", + "store_billing.unavailable": "Pirkiniai programėlėje šiuo metu nepasiekiami. Pabandyk vėliau.", + "store_billing.unknown_purchase": "Nežinomas parduotuvės pirkinys.", "stripe.error": "Įvyko mokėjimo apdorojimo klaida. Bandyk dar kartą arba kreipkis į palaikymo komandą.", "stripe.invalid_product_configuration": "Neteisinga produkto konfigūracija.", "stripe.invalid_product_selection": "Neteisingas produkto pasirinkimas.", diff --git a/packages/errors/src/i18n/weblate/locales/nl.json b/packages/errors/src/i18n/weblate/locales/nl.json index bdddaff74..708dd165e 100644 --- a/packages/errors/src/i18n/weblate/locales/nl.json +++ b/packages/errors/src/i18n/weblate/locales/nl.json @@ -424,6 +424,12 @@ "stickers_and_emojis.not_valid_unicode_emoji": "Dit is geen geldige Unicode-emoji.", "stickers_and_emojis.unknown_emoji": "Onbekende emoji.", "stickers_and_emojis.unknown_sticker": "Onbekende sticker.", + "store_billing.notification_unauthorized": "De handtekening van de melding is ongeldig.", + "store_billing.purchase_invalid": "Deze aankoop kon niet worden geverifieerd.", + "store_billing.purchase_owned_by_other_account": "Deze aankoop is gekoppeld aan een ander account.", + "store_billing.purchase_sandbox_not_entitled": "Testaankopen kunnen niet op dit account worden toegepast.", + "store_billing.unavailable": "In-app-aankopen zijn op dit moment niet beschikbaar. Probeer het later opnieuw.", + "store_billing.unknown_purchase": "Onbekende winkelaankoop.", "stripe.error": "Er is een fout opgetreden bij de betaling. Probeer het opnieuw of neem contact op met support.", "stripe.invalid_product_configuration": "Ongeldige productconfiguratie.", "stripe.invalid_product_selection": "Ongeldige productselectie.", diff --git a/packages/errors/src/i18n/weblate/locales/no.json b/packages/errors/src/i18n/weblate/locales/no.json index 2a8654b2e..c99a9f039 100644 --- a/packages/errors/src/i18n/weblate/locales/no.json +++ b/packages/errors/src/i18n/weblate/locales/no.json @@ -424,6 +424,12 @@ "stickers_and_emojis.not_valid_unicode_emoji": "Ikke en gyldig Unicode-emoji.", "stickers_and_emojis.unknown_emoji": "Ukjent emoji.", "stickers_and_emojis.unknown_sticker": "Ukjent klistremerke.", + "store_billing.notification_unauthorized": "Signaturen på varselet er ugyldig.", + "store_billing.purchase_invalid": "Dette kjøpet kunne ikke bekreftes.", + "store_billing.purchase_owned_by_other_account": "Dette kjøpet er knyttet til en annen konto.", + "store_billing.purchase_sandbox_not_entitled": "Testkjøp kan ikke brukes på denne kontoen.", + "store_billing.unavailable": "Kjøp i appen er ikke tilgjengelig akkurat nå. Prøv igjen senere.", + "store_billing.unknown_purchase": "Ukjent kjøp fra butikken.", "stripe.error": "Det oppstod en feil under betalingsbehandlingen. Prøv igjen eller kontakt kundestøtte.", "stripe.invalid_product_configuration": "Ugyldig produktkonfigurasjon.", "stripe.invalid_product_selection": "Ugyldig produktvalg.", diff --git a/packages/errors/src/i18n/weblate/locales/pl.json b/packages/errors/src/i18n/weblate/locales/pl.json index 3ed2c479c..23b274461 100644 --- a/packages/errors/src/i18n/weblate/locales/pl.json +++ b/packages/errors/src/i18n/weblate/locales/pl.json @@ -424,6 +424,12 @@ "stickers_and_emojis.not_valid_unicode_emoji": "Nieprawidłowe emoji Unicode.", "stickers_and_emojis.unknown_emoji": "Nieznane emoji.", "stickers_and_emojis.unknown_sticker": "Nieznana naklejka.", + "store_billing.notification_unauthorized": "Podpis powiadomienia jest nieprawidłowy.", + "store_billing.purchase_invalid": "Nie udało się zweryfikować tego zakupu.", + "store_billing.purchase_owned_by_other_account": "Ten zakup jest powiązany z innym kontem.", + "store_billing.purchase_sandbox_not_entitled": "Zakupów testowych nie można zastosować do tego konta.", + "store_billing.unavailable": "Zakupy w aplikacji są teraz niedostępne. Spróbuj ponownie później.", + "store_billing.unknown_purchase": "Nieznany zakup ze sklepu.", "stripe.error": "Podczas przetwarzania płatności wystąpił błąd. Spróbuj ponownie lub skontaktuj się z pomocą techniczną.", "stripe.invalid_product_configuration": "Nieprawidłowa konfiguracja produktu.", "stripe.invalid_product_selection": "Nieprawidłowy wybór produktu.", diff --git a/packages/errors/src/i18n/weblate/locales/pt-BR.json b/packages/errors/src/i18n/weblate/locales/pt-BR.json index e4d8576eb..33df033f0 100644 --- a/packages/errors/src/i18n/weblate/locales/pt-BR.json +++ b/packages/errors/src/i18n/weblate/locales/pt-BR.json @@ -424,6 +424,12 @@ "stickers_and_emojis.not_valid_unicode_emoji": "Não é um emoji Unicode válido.", "stickers_and_emojis.unknown_emoji": "Emoji desconhecido.", "stickers_and_emojis.unknown_sticker": "Figurinha desconhecida.", + "store_billing.notification_unauthorized": "A assinatura da notificação é inválida.", + "store_billing.purchase_invalid": "Não foi possível verificar esta compra.", + "store_billing.purchase_owned_by_other_account": "Esta compra está vinculada a outra conta.", + "store_billing.purchase_sandbox_not_entitled": "Compras de teste não podem ser aplicadas a esta conta.", + "store_billing.unavailable": "As compras no app estão indisponíveis no momento. Tente novamente mais tarde.", + "store_billing.unknown_purchase": "Compra da loja desconhecida.", "stripe.error": "Ocorreu um erro no processamento do pagamento. Tente novamente ou entre em contato com o suporte.", "stripe.invalid_product_configuration": "Configuração de produto inválida.", "stripe.invalid_product_selection": "Seleção de produto inválida.", diff --git a/packages/errors/src/i18n/weblate/locales/ro.json b/packages/errors/src/i18n/weblate/locales/ro.json index 1e58c14f8..2fecb290b 100644 --- a/packages/errors/src/i18n/weblate/locales/ro.json +++ b/packages/errors/src/i18n/weblate/locales/ro.json @@ -424,6 +424,12 @@ "stickers_and_emojis.not_valid_unicode_emoji": "Nu este un emoji Unicode valid.", "stickers_and_emojis.unknown_emoji": "Emoji necunoscut.", "stickers_and_emojis.unknown_sticker": "Sticker necunoscut.", + "store_billing.notification_unauthorized": "Semnătura notificării nu este validă.", + "store_billing.purchase_invalid": "Această achiziție nu a putut fi verificată.", + "store_billing.purchase_owned_by_other_account": "Această achiziție este asociată cu alt cont.", + "store_billing.purchase_sandbox_not_entitled": "Achizițiile de test nu pot fi aplicate acestui cont.", + "store_billing.unavailable": "Achizițiile din aplicație nu sunt disponibile acum. Încearcă din nou mai târziu.", + "store_billing.unknown_purchase": "Achiziție din magazin necunoscută.", "stripe.error": "Procesarea plății a întâmpinat o eroare. Încearcă din nou sau contactează echipa de asistență.", "stripe.invalid_product_configuration": "Configurație de produs invalidă.", "stripe.invalid_product_selection": "Selecție de produs invalidă.", diff --git a/packages/errors/src/i18n/weblate/locales/ru.json b/packages/errors/src/i18n/weblate/locales/ru.json index 7215d6d4b..bef1600fc 100644 --- a/packages/errors/src/i18n/weblate/locales/ru.json +++ b/packages/errors/src/i18n/weblate/locales/ru.json @@ -424,6 +424,12 @@ "stickers_and_emojis.not_valid_unicode_emoji": "Некорректное Юникод-эмодзи.", "stickers_and_emojis.unknown_emoji": "Неизвестное эмодзи.", "stickers_and_emojis.unknown_sticker": "Неизвестный стикер.", + "store_billing.notification_unauthorized": "Подпись уведомления недействительна.", + "store_billing.purchase_invalid": "Не удалось подтвердить эту покупку.", + "store_billing.purchase_owned_by_other_account": "Эта покупка привязана к другому аккаунту.", + "store_billing.purchase_sandbox_not_entitled": "Тестовые покупки нельзя применить к этому аккаунту.", + "store_billing.unavailable": "Встроенные покупки сейчас недоступны. Повтори попытку позже.", + "store_billing.unknown_purchase": "Неизвестная покупка в магазине.", "stripe.error": "При обработке платежа произошла ошибка. Повтори попытку или обратись в поддержку.", "stripe.invalid_product_configuration": "Некорректная конфигурация продукта.", "stripe.invalid_product_selection": "Некорректный выбор продукта.", diff --git a/packages/errors/src/i18n/weblate/locales/sv-SE.json b/packages/errors/src/i18n/weblate/locales/sv-SE.json index 39b846a58..5793eb2f0 100644 --- a/packages/errors/src/i18n/weblate/locales/sv-SE.json +++ b/packages/errors/src/i18n/weblate/locales/sv-SE.json @@ -424,6 +424,12 @@ "stickers_and_emojis.not_valid_unicode_emoji": "Ogiltig Unicode-emoji.", "stickers_and_emojis.unknown_emoji": "Emojin är okänd.", "stickers_and_emojis.unknown_sticker": "Okänt klistermärke.", + "store_billing.notification_unauthorized": "Aviseringens signatur är ogiltig.", + "store_billing.purchase_invalid": "Det gick inte att verifiera köpet.", + "store_billing.purchase_owned_by_other_account": "Köpet är kopplat till ett annat konto.", + "store_billing.purchase_sandbox_not_entitled": "Testköp kan inte tillämpas på det här kontot.", + "store_billing.unavailable": "Köp i appen är inte tillgängliga just nu. Försök igen senare.", + "store_billing.unknown_purchase": "Okänt butiksköp.", "stripe.error": "Ett fel uppstod i betalningshanteringen. Försök igen eller kontakta supporten.", "stripe.invalid_product_configuration": "Felaktig produktkonfiguration.", "stripe.invalid_product_selection": "Felaktigt produktval.", diff --git a/packages/errors/src/i18n/weblate/locales/th.json b/packages/errors/src/i18n/weblate/locales/th.json index f419540df..e174349db 100644 --- a/packages/errors/src/i18n/weblate/locales/th.json +++ b/packages/errors/src/i18n/weblate/locales/th.json @@ -424,6 +424,12 @@ "stickers_and_emojis.not_valid_unicode_emoji": "ไม่ใช่อิโมจิยูนิโค้ดที่ถูกต้อง", "stickers_and_emojis.unknown_emoji": "ไม่พบอิโมจิ", "stickers_and_emojis.unknown_sticker": "ไม่พบสติกเกอร์", + "store_billing.notification_unauthorized": "ลายเซ็นของการแจ้งเตือนไม่ถูกต้อง", + "store_billing.purchase_invalid": "ไม่สามารถยืนยันการซื้อนี้ได้", + "store_billing.purchase_owned_by_other_account": "การซื้อนี้เชื่อมโยงกับบัญชีอื่น", + "store_billing.purchase_sandbox_not_entitled": "ไม่สามารถใช้การซื้อทดสอบกับบัญชีนี้ได้", + "store_billing.unavailable": "การซื้อในแอปไม่พร้อมใช้งานในขณะนี้ โปรดลองอีกครั้งในภายหลัง", + "store_billing.unknown_purchase": "ไม่พบการซื้อจากสโตร์", "stripe.error": "เกิดข้อผิดพลาดในการดำเนินการชำระเงิน โปรดลองอีกครั้งหรือติดต่อฝ่ายสนับสนุน", "stripe.invalid_product_configuration": "การกำหนดค่าสินค้าไม่ถูกต้อง", "stripe.invalid_product_selection": "การเลือกสินค้าไม่ถูกต้อง", diff --git a/packages/errors/src/i18n/weblate/locales/tr.json b/packages/errors/src/i18n/weblate/locales/tr.json index dba4dcdeb..a6c763781 100644 --- a/packages/errors/src/i18n/weblate/locales/tr.json +++ b/packages/errors/src/i18n/weblate/locales/tr.json @@ -424,6 +424,12 @@ "stickers_and_emojis.not_valid_unicode_emoji": "Geçerli bir Unicode emoji değil.", "stickers_and_emojis.unknown_emoji": "Bilinmeyen emoji.", "stickers_and_emojis.unknown_sticker": "Bilinmeyen çıkartma.", + "store_billing.notification_unauthorized": "Bildirim imzası geçersiz.", + "store_billing.purchase_invalid": "Bu satın alma doğrulanamadı.", + "store_billing.purchase_owned_by_other_account": "Bu satın alma başka bir hesaba bağlı.", + "store_billing.purchase_sandbox_not_entitled": "Test satın almaları bu hesaba uygulanamaz.", + "store_billing.unavailable": "Uygulama içi satın almalar şu anda kullanılamıyor. Lütfen daha sonra tekrar dene.", + "store_billing.unknown_purchase": "Bilinmeyen mağaza satın alması.", "stripe.error": "Ödeme işlemi sırasında bir hata oluştu. Lütfen tekrar dene veya destek ekibine başvur.", "stripe.invalid_product_configuration": "Geçersiz ürün yapılandırması.", "stripe.invalid_product_selection": "Geçersiz ürün seçimi.", diff --git a/packages/errors/src/i18n/weblate/locales/uk.json b/packages/errors/src/i18n/weblate/locales/uk.json index 3b6e8eaeb..934f77391 100644 --- a/packages/errors/src/i18n/weblate/locales/uk.json +++ b/packages/errors/src/i18n/weblate/locales/uk.json @@ -424,6 +424,12 @@ "stickers_and_emojis.not_valid_unicode_emoji": "Неправильне емодзі Unicode.", "stickers_and_emojis.unknown_emoji": "Невідоме емоджі.", "stickers_and_emojis.unknown_sticker": "Невідомий стікер.", + "store_billing.notification_unauthorized": "Підпис сповіщення недійсний.", + "store_billing.purchase_invalid": "Не вдалося підтвердити цю покупку.", + "store_billing.purchase_owned_by_other_account": "Ця покупка пов’язана з іншим обліковим записом.", + "store_billing.purchase_sandbox_not_entitled": "Тестові покупки не можна застосувати до цього облікового запису.", + "store_billing.unavailable": "Покупки в застосунку зараз недоступні. Спробуй пізніше.", + "store_billing.unknown_purchase": "Невідома покупка в магазині.", "stripe.error": "Сталася помилка під час обробки платежу. Спробуй ще раз або звернися до підтримки.", "stripe.invalid_product_configuration": "Неправильна конфігурація продукту.", "stripe.invalid_product_selection": "Неправильний вибір продукту.", diff --git a/packages/errors/src/i18n/weblate/locales/vi.json b/packages/errors/src/i18n/weblate/locales/vi.json index 1fc29602e..310cc6c94 100644 --- a/packages/errors/src/i18n/weblate/locales/vi.json +++ b/packages/errors/src/i18n/weblate/locales/vi.json @@ -424,6 +424,12 @@ "stickers_and_emojis.not_valid_unicode_emoji": "Không phải emoji Unicode hợp lệ.", "stickers_and_emojis.unknown_emoji": "Emoji không xác định.", "stickers_and_emojis.unknown_sticker": "Sticker không xác định.", + "store_billing.notification_unauthorized": "Chữ ký của thông báo không hợp lệ.", + "store_billing.purchase_invalid": "Không thể xác minh giao dịch mua này.", + "store_billing.purchase_owned_by_other_account": "Giao dịch mua này được liên kết với một tài khoản khác.", + "store_billing.purchase_sandbox_not_entitled": "Không thể áp dụng giao dịch mua thử nghiệm cho tài khoản này.", + "store_billing.unavailable": "Hiện không thể mua hàng trong ứng dụng. Vui lòng thử lại sau.", + "store_billing.unknown_purchase": "Giao dịch mua trên cửa hàng không xác định.", "stripe.error": "Xử lý thanh toán gặp lỗi. Vui lòng thử lại hoặc liên hệ bộ phận hỗ trợ.", "stripe.invalid_product_configuration": "Cấu hình sản phẩm không hợp lệ.", "stripe.invalid_product_selection": "Lựa chọn sản phẩm không hợp lệ.", diff --git a/packages/errors/src/i18n/weblate/locales/zh-CN.json b/packages/errors/src/i18n/weblate/locales/zh-CN.json index 078efcd0d..8f3c25074 100644 --- a/packages/errors/src/i18n/weblate/locales/zh-CN.json +++ b/packages/errors/src/i18n/weblate/locales/zh-CN.json @@ -424,6 +424,12 @@ "stickers_and_emojis.not_valid_unicode_emoji": "不是有效的 Unicode 表情。", "stickers_and_emojis.unknown_emoji": "未知表情。", "stickers_and_emojis.unknown_sticker": "未知贴纸。", + "store_billing.notification_unauthorized": "通知签名无效。", + "store_billing.purchase_invalid": "无法验证此购买。", + "store_billing.purchase_owned_by_other_account": "此购买已关联到其他账号。", + "store_billing.purchase_sandbox_not_entitled": "测试购买无法应用到此账号。", + "store_billing.unavailable": "应用内购买暂时不可用。请稍后重试。", + "store_billing.unknown_purchase": "未知的商店购买。", "stripe.error": "支付处理遇到错误。请重试或联系支持团队。", "stripe.invalid_product_configuration": "产品配置无效。", "stripe.invalid_product_selection": "产品选择无效。", diff --git a/packages/errors/src/i18n/weblate/locales/zh-TW.json b/packages/errors/src/i18n/weblate/locales/zh-TW.json index e4bcc5963..8b956c7dd 100644 --- a/packages/errors/src/i18n/weblate/locales/zh-TW.json +++ b/packages/errors/src/i18n/weblate/locales/zh-TW.json @@ -424,6 +424,12 @@ "stickers_and_emojis.not_valid_unicode_emoji": "不是有效的 Unicode 表情符號。", "stickers_and_emojis.unknown_emoji": "未知的表情符號。", "stickers_and_emojis.unknown_sticker": "未知的貼圖。", + "store_billing.notification_unauthorized": "通知簽章無效。", + "store_billing.purchase_invalid": "無法驗證此購買項目。", + "store_billing.purchase_owned_by_other_account": "此購買項目已連結至其他帳號。", + "store_billing.purchase_sandbox_not_entitled": "測試購買無法套用至此帳號。", + "store_billing.unavailable": "應用程式內購買目前無法使用。請稍後再試。", + "store_billing.unknown_purchase": "未知的商店購買項目。", "stripe.error": "付款處理時發生錯誤。請重試或聯絡客服。", "stripe.invalid_product_configuration": "無效的產品設定。", "stripe.invalid_product_selection": "無效的產品選擇。", diff --git a/packages/errors/src/i18n/weblate/messages.json b/packages/errors/src/i18n/weblate/messages.json index d6dcf59f6..aea2824e2 100644 --- a/packages/errors/src/i18n/weblate/messages.json +++ b/packages/errors/src/i18n/weblate/messages.json @@ -424,6 +424,12 @@ "stickers_and_emojis.not_valid_unicode_emoji": "Not a valid Unicode emoji.", "stickers_and_emojis.unknown_emoji": "Unknown emoji.", "stickers_and_emojis.unknown_sticker": "Unknown sticker.", + "store_billing.notification_unauthorized": "The notification signature is invalid.", + "store_billing.purchase_invalid": "This purchase could not be verified.", + "store_billing.purchase_owned_by_other_account": "This purchase is linked to a different account.", + "store_billing.purchase_sandbox_not_entitled": "Test purchases cannot be applied to this account.", + "store_billing.unavailable": "In-app purchases are unavailable right now. Try again later.", + "store_billing.unknown_purchase": "Unknown store purchase.", "stripe.error": "Payment processing encountered an error. Please try again or contact support.", "stripe.invalid_product_configuration": "Invalid product configuration.", "stripe.invalid_product_selection": "Invalid product selection.", diff --git a/packages/schema/src/domains/admin/AdminStoreBillingSchemas.ts b/packages/schema/src/domains/admin/AdminStoreBillingSchemas.ts new file mode 100644 index 000000000..719ae50c7 --- /dev/null +++ b/packages/schema/src/domains/admin/AdminStoreBillingSchemas.ts @@ -0,0 +1,47 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import {SnowflakeStringType} from '@fluxer/schema/src/primitives/SchemaPrimitives'; +import {z} from 'zod'; + +export const AdminStorePurchaseResponse = z.object({ + id: SnowflakeStringType.describe('The unique identifier (snowflake) for this store purchase'), + user_id: SnowflakeStringType.nullable().describe('ID of the user the purchase is bound to, null when unbound'), + provider: z.enum(['app_store', 'google_play']).describe('Store the purchase was made in'), + kind: z.enum(['subscription', 'gift']).describe('Whether the purchase is a subscription or a gift'), + slot: z.enum(['monthly', 'yearly', 'gift_1_month', 'gift_1_year']).describe('Fluxer product the purchase is for'), + environment: z.enum(['production', 'sandbox']).describe('Whether the purchase was real or a test purchase'), + app_id: z.string().describe('Bundle identifier or package name of the app that made the purchase'), + product_id: z.string().describe('Store product identifier'), + base_plan_id: z.string().nullable().describe('Google Play base plan identifier, null otherwise'), + latest_transaction_id: z + .string() + .nullable() + .describe('Latest App Store transaction ID or Google Play order ID for the purchase'), + ownership_type: z.string().nullable().describe('Store ownership type, such as PURCHASED or FAMILY_SHARED'), + state: z.string().describe('Normalized state of the purchase'), + store_state: z.string().nullable().describe('Raw state reported by the store'), + entitled: z.boolean().describe('Whether the purchase currently grants Plutonium'), + expires_at: z.iso.datetime().nullable().describe('When the paid period ends'), + grace_ends_at: z.iso.datetime().nullable().describe('When the billing grace period ends'), + auto_renew: z.boolean().nullable().describe('Whether the subscription renews automatically'), + started_at: z.iso.datetime().nullable().describe('When the subscription or purchase started'), + purchased_at: z.iso.datetime().nullable().describe('When the latest payment was made'), + revoked_at: z.iso.datetime().nullable().describe('When the store revoked or refunded the purchase'), + revocation_reason: z.string().nullable().describe('Reason the store gave for the revocation'), + superseded: z.boolean().describe('Whether a newer purchase replaced this one'), + acknowledged: z.boolean().describe('Whether the purchase was acknowledged with the store'), + gift_code: z.string().nullable().describe('Gift code minted by a gift purchase'), + bound_at: z.iso.datetime().nullable().describe('When the purchase was bound to its user'), + last_event_at: z.iso.datetime().nullable().describe('Time of the latest store event applied to the purchase'), + synced_at: z.iso.datetime().nullable().describe('When the purchase was last synced with the store'), + created_at: z.iso.datetime().describe('When Fluxer first saw the purchase'), + updated_at: z.iso.datetime().describe('When the purchase record last changed'), +}); + +export type AdminStorePurchaseResponse = z.infer; + +export const AdminStorePurchaseListResponse = z.object({ + purchases: z.array(AdminStorePurchaseResponse).describe('Store purchases bound to the user'), +}); + +export type AdminStorePurchaseListResponse = z.infer; diff --git a/packages/schema/src/domains/premium/PremiumSchemas.ts b/packages/schema/src/domains/premium/PremiumSchemas.ts index cc8fd98fb..0b3ab4f5c 100644 --- a/packages/schema/src/domains/premium/PremiumSchemas.ts +++ b/packages/schema/src/domains/premium/PremiumSchemas.ts @@ -1,6 +1,7 @@ // SPDX-License-Identifier: AGPL-3.0-or-later -import {createStringType} from '@fluxer/schema/src/primitives/SchemaPrimitives'; +import {PremiumStoreSubscriptionState} from '@fluxer/schema/src/domains/premium/StoreBillingSchemas'; +import {createStringType, withOpenApiType} from '@fluxer/schema/src/primitives/SchemaPrimitives'; import {UserPremiumTypesSchema} from '@fluxer/schema/src/primitives/UserSettingsValidators'; import {z} from 'zod'; @@ -362,11 +363,24 @@ export const PremiumPricingState = z.object({ export type PremiumPricingState = z.infer; +export const PremiumSubscriptionProvider = withOpenApiType( + z.enum(['stripe', 'app_store', 'google_play']), + 'PremiumSubscriptionProvider', +); + +export type PremiumSubscriptionProvider = z.infer; + export const PremiumStateResponse = z.object({ actual: PremiumActualState, effective: PremiumEffectiveState, billing: PremiumBillingState, pricing: PremiumPricingState, + store: PremiumStoreSubscriptionState.nullish().describe( + 'Active App Store or Google Play subscription, null when no store subscription is active', + ), + subscription_provider: PremiumSubscriptionProvider.nullable().describe( + 'Billing platform that owns the current recurring subscription, null for gift, lifetime or no subscription. When a Stripe and a store subscription are both active, the one paid through later', + ), }); export type PremiumStateResponse = z.infer; diff --git a/packages/schema/src/domains/premium/StoreBillingSchemas.ts b/packages/schema/src/domains/premium/StoreBillingSchemas.ts new file mode 100644 index 000000000..a1631369e --- /dev/null +++ b/packages/schema/src/domains/premium/StoreBillingSchemas.ts @@ -0,0 +1,166 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import { + createStringType, + SnowflakeStringType, + SnowflakeType, + withOpenApiType, +} from '@fluxer/schema/src/primitives/SchemaPrimitives'; +import {z} from 'zod'; + +const StoreProviderSchema = withOpenApiType(z.enum(['app_store', 'google_play']), 'StoreProvider'); + +const StoreSlotSchema = withOpenApiType(z.enum(['monthly', 'yearly', 'gift_1_month', 'gift_1_year']), 'StoreSlot'); + +const StorePurchaseKindSchema = withOpenApiType(z.enum(['subscription', 'gift']), 'StorePurchaseKind'); + +const StoreEnvironmentSchema = withOpenApiType(z.enum(['production', 'sandbox']), 'StoreEnvironment'); + +const StorePurchaseStateSchema = withOpenApiType( + z.enum([ + 'pending', + 'active', + 'grace', + 'billing_retry', + 'on_hold', + 'paused', + 'canceled', + 'expired', + 'revoked', + 'superseded', + 'purchased', + 'fulfilled', + 'refunded', + ]), + 'StorePurchaseState', +); + +const StorePurchaseBlockedReasonSchema = withOpenApiType( + z.enum(['lifetime', 'existing_subscription', 'purchase_disabled']), + 'StorePurchaseBlockedReason', +); + +const StoreBlockingProviderSchema = withOpenApiType( + z.enum(['stripe', 'app_store', 'google_play']), + 'StoreBlockingProvider', +); + +const StoreBillingCycleSchema = withOpenApiType(z.enum(['monthly', 'yearly']), 'StoreBillingCycle'); + +const StoreSubscriptionSlotSchema = withOpenApiType(z.enum(['monthly', 'yearly']), 'StoreSubscriptionSlot'); + +export const StoreBillingAppStoreProductResponse = z.object({ + product_id: z.string().describe('App Store product identifier'), + slot: StoreSlotSchema.describe('Fluxer product this App Store product sells'), +}); + +export type StoreBillingAppStoreProductResponse = z.infer; + +export const StoreBillingGooglePlayProductResponse = z.object({ + product_id: z.string().describe('Google Play product identifier'), + base_plan_id: z.string().nullable().describe('Google Play base plan identifier, null for one-time products'), + slot: StoreSlotSchema.describe('Fluxer product this Google Play product sells'), +}); + +export type StoreBillingGooglePlayProductResponse = z.infer; + +export const StoreBillingContextResponse = z.object({ + app_account_token: z + .string() + .describe( + 'Lowercase UUID for this account. Pass it as appAccountToken on App Store purchases and as obfuscatedAccountId on Google Play purchases', + ), + app_store: z + .object({ + enabled: z.boolean().describe('Whether App Store purchases are accepted'), + bundle_ids: z.array(z.string()).describe('App bundle identifiers whose purchases are accepted'), + products: z.array(StoreBillingAppStoreProductResponse).describe('App Store products on sale'), + }) + .describe('App Store purchase settings'), + google_play: z + .object({ + enabled: z.boolean().describe('Whether Google Play purchases are accepted'), + package_names: z.array(z.string()).describe('App package names whose purchases are accepted'), + products: z.array(StoreBillingGooglePlayProductResponse).describe('Google Play products on sale'), + }) + .describe('Google Play purchase settings'), + purchase_blocked_reason: StorePurchaseBlockedReasonSchema.nullable().describe( + 'Why this account cannot buy a subscription right now, null when it can', + ), + blocking_provider: StoreBlockingProviderSchema.nullable().describe( + 'Provider of the active subscription that blocks a new one, null when nothing blocks', + ), +}); + +export type StoreBillingContextResponse = z.infer; + +export const ClaimAppStoreTransactionRequest = z.object({ + signed_transaction: createStringType(1, 32768).describe( + 'JWS signed transaction from StoreKit 2 (Transaction.jwsRepresentation)', + ), +}); + +export type ClaimAppStoreTransactionRequest = z.infer; + +export const ClaimGooglePlayPurchaseRequest = z.object({ + purchase_token: createStringType(1, 1024).describe('Purchase token from Google Play Billing'), + product_id: createStringType(1, 256).describe('Google Play product identifier of the purchase'), + package_name: createStringType(1, 256) + .optional() + .describe('Package name of the app that made the purchase. Must be one of the accepted package names'), +}); + +export type ClaimGooglePlayPurchaseRequest = z.infer; + +export const StorePurchaseResponse = z.object({ + id: SnowflakeStringType.describe('The unique identifier (snowflake) for this store purchase'), + provider: StoreProviderSchema.describe('Store the purchase was made in'), + kind: StorePurchaseKindSchema.describe('Whether the purchase is a subscription or a gift'), + slot: StoreSlotSchema.describe('Fluxer product the purchase is for'), + product_id: z.string().describe('Store product identifier'), + environment: StoreEnvironmentSchema.describe('Whether the purchase was a real purchase or a test purchase'), + state: StorePurchaseStateSchema.describe('Current state of the purchase'), + entitled: z.boolean().describe('Whether the purchase currently grants Plutonium'), + expires_at: z.iso.datetime().nullable().describe('When the paid period ends, null for gifts'), + entitled_until: z.iso + .datetime() + .nullable() + .describe('When access from this purchase ends, including any grace period, null when not entitled'), + will_renew: z.boolean().nullable().describe('Whether the subscription renews automatically, null for gifts'), + gift_code: z.string().nullable().describe('Gift code minted by a gift purchase, null otherwise'), + created_at: z.iso.datetime().describe('When Fluxer first saw the purchase'), +}); + +export type StorePurchaseResponse = z.infer; + +export const StorePurchaseClaimResponse = z.object({ + purchase: StorePurchaseResponse.describe('The claimed purchase'), + gift_code: z.string().nullable().describe('Gift code minted by a gift purchase, null otherwise'), +}); + +export type StorePurchaseClaimResponse = z.infer; + +export const StorePurchaseListResponse = z.array(StorePurchaseResponse); + +export type StorePurchaseListResponse = z.infer; + +export const StorePurchaseIdParam = z.object({ + purchase_id: SnowflakeType.describe('The ID of the store purchase'), +}); + +export type StorePurchaseIdParam = z.infer; + +export const PremiumStoreSubscriptionState = z.object({ + provider: StoreProviderSchema.describe('Store that bills the subscription'), + purchase_id: SnowflakeStringType.describe('ID of the store purchase that grants the subscription'), + slot: StoreSubscriptionSlotSchema.describe('Fluxer subscription product'), + billing_cycle: StoreBillingCycleSchema.describe('Billing cycle of the subscription'), + state: StorePurchaseStateSchema.describe('Current state of the store subscription'), + expires_at: z.iso.datetime().describe('When the paid period ends'), + grace_ends_at: z.iso.datetime().nullable().describe('When the billing grace period ends, null outside grace'), + will_renew: z.boolean().describe('Whether the subscription renews automatically'), + manage_url: z.string().describe('Store page where the subscription can be managed or canceled'), + environment: StoreEnvironmentSchema.describe('Whether the subscription is a real purchase or a test purchase'), +}); + +export type PremiumStoreSubscriptionState = z.infer; diff --git a/tools/dev/cassandra_target_schema.json b/tools/dev/cassandra_target_schema.json index 9a7cc54e2..d28a19451 100644 --- a/tools/dev/cassandra_target_schema.json +++ b/tools/dev/cassandra_target_schema.json @@ -4843,6 +4843,14 @@ "name": "checkout_session_id", "type": "text" }, + { + "name": "revoked_at", + "type": "timestamp" + }, + { + "name": "premium_reversed_seconds", + "type": "int" + }, { "name": "duration_type", "type": "text" @@ -7258,6 +7266,222 @@ "primary_key": "((user_id), message_id)", "options": "CLUSTERING ORDER BY (message_id DESC)" }, + { + "name": "store_account_tokens", + "columns": [ + { + "name": "token_", + "type": "text" + }, + { + "name": "user_id", + "type": "bigint" + }, + { + "name": "created_at", + "type": "timestamp" + } + ], + "primary_key": "((token_))", + "options": "" + }, + { + "name": "store_account_tokens_by_user", + "columns": [ + { + "name": "user_id", + "type": "bigint" + }, + { + "name": "token_", + "type": "text" + }, + { + "name": "created_at", + "type": "timestamp" + } + ], + "primary_key": "((user_id))", + "options": "" + }, + { + "name": "store_purchases", + "columns": [ + { + "name": "store_key", + "type": "text" + }, + { + "name": "id", + "type": "bigint" + }, + { + "name": "provider", + "type": "text" + }, + { + "name": "kind", + "type": "text" + }, + { + "name": "slot", + "type": "text" + }, + { + "name": "environment", + "type": "text" + }, + { + "name": "app_id", + "type": "text" + }, + { + "name": "product_id", + "type": "text" + }, + { + "name": "base_plan_id", + "type": "text" + }, + { + "name": "store_reference", + "type": "text" + }, + { + "name": "latest_transaction_id", + "type": "text" + }, + { + "name": "app_transaction_id", + "type": "text" + }, + { + "name": "user_id", + "type": "bigint" + }, + { + "name": "bound_at", + "type": "timestamp" + }, + { + "name": "released_at", + "type": "timestamp" + }, + { + "name": "account_token", + "type": "text" + }, + { + "name": "ownership_type", + "type": "text" + }, + { + "name": "state", + "type": "text" + }, + { + "name": "store_state", + "type": "text" + }, + { + "name": "entitled", + "type": "boolean" + }, + { + "name": "expires_at", + "type": "timestamp" + }, + { + "name": "grace_ends_at", + "type": "timestamp" + }, + { + "name": "auto_renew", + "type": "boolean" + }, + { + "name": "auto_renew_product_id", + "type": "text" + }, + { + "name": "started_at", + "type": "timestamp" + }, + { + "name": "purchased_at", + "type": "timestamp" + }, + { + "name": "revoked_at", + "type": "timestamp" + }, + { + "name": "revocation_reason", + "type": "text" + }, + { + "name": "linked_store_key", + "type": "text" + }, + { + "name": "superseded_by_store_key", + "type": "text" + }, + { + "name": "acknowledged", + "type": "boolean" + }, + { + "name": "gift_code", + "type": "text" + }, + { + "name": "region", + "type": "text" + }, + { + "name": "last_event_at", + "type": "timestamp" + }, + { + "name": "synced_at", + "type": "timestamp" + }, + { + "name": "created_at", + "type": "timestamp" + }, + { + "name": "updated_at", + "type": "timestamp" + }, + { + "name": "version", + "type": "int" + } + ], + "primary_key": "((store_key))", + "options": "" + }, + { + "name": "store_purchases_by_user", + "columns": [ + { + "name": "user_id", + "type": "bigint" + }, + { + "name": "store_key", + "type": "text" + }, + { + "name": "created_at", + "type": "timestamp" + } + ], + "primary_key": "((user_id), store_key)", + "options": "" + }, { "name": "user_connections", "columns": [