From e463540440f34d00bf28f68eabbb3ce23c1e9fdd Mon Sep 17 00:00:00 2001 From: Innei Date: Thu, 20 Aug 2026 18:04:06 +0800 Subject: [PATCH 01/12] feat(membership): add Apple IAP availability and config fields --- .../src/modules/configs/configs.default.ts | 6 +++ .../src/modules/configs/configs.schema.ts | 15 ++++++++ .../modules/membership/membership.types.ts | 33 +++++++++++++++- .../membership/membership.types.spec.ts | 38 +++++++++++++++++++ 4 files changed, 91 insertions(+), 1 deletion(-) diff --git a/apps/core/src/modules/configs/configs.default.ts b/apps/core/src/modules/configs/configs.default.ts index 63b82b41e44..7bd6a8bdba2 100644 --- a/apps/core/src/modules/configs/configs.default.ts +++ b/apps/core/src/modules/configs/configs.default.ts @@ -213,5 +213,11 @@ export const generateDefaultConfig: () => IConfig = () => ({ apiKey: '', webhookSigningKey: '', environment: 'live_mode', + appleBundleId: '', + appleKeyId: '', + appleIssuerId: '', + applePrivateKey: '', + appleMonthlyProductId: '', + appleYearlyProductId: '', }, }) diff --git a/apps/core/src/modules/configs/configs.schema.ts b/apps/core/src/modules/configs/configs.schema.ts index 83dd4ed1a2e..5f2910db4c8 100644 --- a/apps/core/src/modules/configs/configs.schema.ts +++ b/apps/core/src/modules/configs/configs.schema.ts @@ -1163,6 +1163,21 @@ export const MembershipSchema = section('Membership', { { label: 'Test mode', value: 'test_mode' }, ], ), + appleBundleId: field.halfGrid(z.string().optional(), 'Apple bundle ID'), + appleKeyId: field.halfGrid(z.string().optional(), 'Apple key ID'), + appleIssuerId: field.plain(z.string().optional(), 'Apple issuer ID'), + applePrivateKey: field.password( + z.string().optional(), + 'Apple .p8 private key', + ), + appleMonthlyProductId: field.halfGrid( + z.string().optional(), + 'Apple monthly product ID', + ), + appleYearlyProductId: field.halfGrid( + z.string().optional(), + 'Apple yearly product ID', + ), }) export class MembershipDto extends createZodDto(MembershipSchema) {} export type MembershipConfig = z.infer diff --git a/apps/core/src/modules/membership/membership.types.ts b/apps/core/src/modules/membership/membership.types.ts index 5b4b172b275..4542217a7ca 100644 --- a/apps/core/src/modules/membership/membership.types.ts +++ b/apps/core/src/modules/membership/membership.types.ts @@ -1,7 +1,7 @@ import type { EntityId } from '~/shared/id/entity-id' export type MembershipProvider = - 'dodo' | 'creem' | 'lemonsqueezy' | 'stripe' | 'manual' + 'dodo' | 'creem' | 'lemonsqueezy' | 'stripe' | 'manual' | 'apple' export const REGISTERED_PAYMENT_PROVIDERS: readonly string[] = ['dodo'] @@ -59,6 +59,37 @@ export function resolveMembershipAvailability(config: { return { enabled, plans: enabled ? plans : [] } } +export interface AppleIapAvailability { + enabled: boolean + monthlyProductId?: string + yearlyProductId?: string +} + +const nonEmpty = (value?: string) => Boolean(value?.trim()) + +export function resolveAppleIapAvailability(config: { + enabled?: boolean + appleBundleId?: string + appleKeyId?: string + appleIssuerId?: string + applePrivateKey?: string + appleMonthlyProductId?: string + appleYearlyProductId?: string +}): AppleIapAvailability { + const monthlyProductId = config.appleMonthlyProductId?.trim() + const yearlyProductId = config.appleYearlyProductId?.trim() + const enabled = + !!config.enabled && + nonEmpty(config.appleBundleId) && + nonEmpty(config.appleKeyId) && + nonEmpty(config.appleIssuerId) && + nonEmpty(config.applePrivateKey) && + nonEmpty(monthlyProductId) && + nonEmpty(yearlyProductId) + if (!enabled) return { enabled: false } + return { enabled: true, monthlyProductId, yearlyProductId } +} + export function resolveMembershipReturnUrl( returnPath: string | undefined, webUrl: string | undefined, diff --git a/apps/core/test/src/modules/membership/membership.types.spec.ts b/apps/core/test/src/modules/membership/membership.types.spec.ts index 873d061d83e..b16ed2707a7 100644 --- a/apps/core/test/src/modules/membership/membership.types.spec.ts +++ b/apps/core/test/src/modules/membership/membership.types.spec.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest' import { + resolveAppleIapAvailability, resolveMembershipAvailability, resolveMembershipReturnUrl, } from '~/modules/membership/membership.types' @@ -115,3 +116,40 @@ describe('resolveMembershipReturnUrl', () => { ).toBeUndefined() }) }) + +const appleCredentials = { + appleBundleId: 'dev.yohaku.app', + appleKeyId: 'KEYID', + appleIssuerId: 'ISSUER', + applePrivateKey: '-----BEGIN PRIVATE KEY-----\nX\n-----END PRIVATE KEY-----', + appleMonthlyProductId: 'yohaku.membership.monthly', + appleYearlyProductId: 'yohaku.membership.yearly', +} + +describe('resolveAppleIapAvailability', () => { + it('is enabled when the master switch and all Apple fields are set', () => { + expect( + resolveAppleIapAvailability({ enabled: true, ...appleCredentials }), + ).toEqual({ + enabled: true, + monthlyProductId: 'yohaku.membership.monthly', + yearlyProductId: 'yohaku.membership.yearly', + }) + }) + + it('is disabled when the master switch is off', () => { + expect( + resolveAppleIapAvailability({ enabled: false, ...appleCredentials }), + ).toEqual({ enabled: false }) + }) + + it('is disabled when any Apple field is missing', () => { + expect( + resolveAppleIapAvailability({ + enabled: true, + ...appleCredentials, + appleKeyId: '', + }), + ).toEqual({ enabled: false }) + }) +}) From 75c556424afe99664bd5d75442318d43307be0ec Mon Sep 17 00:00:00 2001 From: Innei Date: Thu, 20 Aug 2026 18:04:38 +0800 Subject: [PATCH 02/12] feat(membership): map Apple product ids and notification types --- .../membership/providers/apple-transaction.ts | 56 +++++++++++++++++ .../membership/apple-transaction.spec.ts | 63 +++++++++++++++++++ 2 files changed, 119 insertions(+) create mode 100644 apps/core/src/modules/membership/providers/apple-transaction.ts create mode 100644 apps/core/test/src/modules/membership/apple-transaction.spec.ts diff --git a/apps/core/src/modules/membership/providers/apple-transaction.ts b/apps/core/src/modules/membership/providers/apple-transaction.ts new file mode 100644 index 00000000000..7937ce6039c --- /dev/null +++ b/apps/core/src/modules/membership/providers/apple-transaction.ts @@ -0,0 +1,56 @@ +import type { MembershipPlan } from '../membership.types' +import type { NormalizedBillingEvent } from './provider.interface' + +export interface AppleDecodedTransaction { + appAccountToken?: string + expiresDate: number + originalTransactionId: string + productId: string + transactionId: string +} + +export function planFromAppleProductId( + productId: string, + products: { monthlyProductId: string; yearlyProductId: string }, +): MembershipPlan | null { + if (productId === products.monthlyProductId) return 'monthly' + if (productId === products.yearlyProductId) return 'yearly' + return null +} + +export function appleActivatedEvent( + decoded: AppleDecodedTransaction, + readerId: string, + plan: MembershipPlan, +): NormalizedBillingEvent { + return { + eventId: decoded.transactionId, + provider: 'apple', + type: 'activated', + customerId: decoded.appAccountToken ?? decoded.originalTransactionId, + subscriptionId: decoded.originalTransactionId, + plan, + currentPeriodEnd: new Date(decoded.expiresDate), + readerId, + } +} + +const NOTIFICATION_TYPE_MAP: Record< + string, + NormalizedBillingEvent['type'] | undefined +> = { + SUBSCRIBED: 'activated', + DID_RENEW: 'renewed', + DID_FAIL_TO_RENEW: 'on_hold', + EXPIRED: 'cancelled', + REFUND: 'cancelled', + REVOKE: 'cancelled', + GRACE_PERIOD_EXPIRED: 'cancelled', + DID_CHANGE_RENEWAL_PREF: 'plan_changed', +} + +export function appleNotificationEventType( + notificationType: string, +): NormalizedBillingEvent['type'] | undefined { + return NOTIFICATION_TYPE_MAP[notificationType] +} diff --git a/apps/core/test/src/modules/membership/apple-transaction.spec.ts b/apps/core/test/src/modules/membership/apple-transaction.spec.ts new file mode 100644 index 00000000000..630f58347d3 --- /dev/null +++ b/apps/core/test/src/modules/membership/apple-transaction.spec.ts @@ -0,0 +1,63 @@ +import { describe, expect, it } from 'vitest' + +import { + appleActivatedEvent, + appleNotificationEventType, + planFromAppleProductId, +} from '~/modules/membership/providers/apple-transaction' + +const products = { + monthlyProductId: 'yohaku.membership.monthly', + yearlyProductId: 'yohaku.membership.yearly', +} + +describe('planFromAppleProductId', () => { + it('maps configured product ids', () => { + expect(planFromAppleProductId('yohaku.membership.monthly', products)).toBe( + 'monthly', + ) + expect(planFromAppleProductId('yohaku.membership.yearly', products)).toBe( + 'yearly', + ) + }) + + it('returns null for an unknown product', () => { + expect(planFromAppleProductId('other.sku', products)).toBeNull() + }) +}) + +describe('appleActivatedEvent', () => { + it('uses originalTransactionId as the subscription key', () => { + const event = appleActivatedEvent( + { + expiresDate: Date.parse('2026-09-01T00:00:00.000Z'), + originalTransactionId: 'orig-1', + productId: 'yohaku.membership.yearly', + transactionId: 'txn-1', + }, + 'reader-1', + 'yearly', + ) + expect(event).toMatchObject({ + eventId: 'txn-1', + provider: 'apple', + type: 'activated', + customerId: 'orig-1', + subscriptionId: 'orig-1', + plan: 'yearly', + readerId: 'reader-1', + }) + expect(event.currentPeriodEnd.toISOString()).toBe( + '2026-09-01T00:00:00.000Z', + ) + }) +}) + +describe('appleNotificationEventType', () => { + it('maps App Store notification names', () => { + expect(appleNotificationEventType('DID_RENEW')).toBe('renewed') + expect(appleNotificationEventType('EXPIRED')).toBe('cancelled') + expect(appleNotificationEventType('REFUND')).toBe('cancelled') + expect(appleNotificationEventType('TEST')).toBeUndefined() + }) +}) From 10e1b30ada9c6ea856a165142423d2a3296f044e Mon Sep 17 00:00:00 2001 From: Innei Date: Thu, 20 Aug 2026 18:06:54 +0800 Subject: [PATCH 03/12] feat(membership): verify Apple transactions and register the adapter --- apps/core/package.json | 1 + apps/core/src/common/errors/app-error-code.ts | 2 + .../common/errors/app-error-definitions.ts | 8 + .../src/common/errors/app-error-payload.ts | 2 + .../src/modules/configs/configs.default.ts | 1 + .../src/modules/configs/configs.schema.ts | 1 + .../modules/membership/membership.module.ts | 2 + .../membership/providers/apple-root-cas.ts | 49 ++++ .../membership/providers/apple.provider.ts | 244 ++++++++++++++++++ .../membership/providers/provider.registry.ts | 9 +- .../modules/membership/apple.provider.spec.ts | 24 ++ pnpm-lock.yaml | 61 ++++- 12 files changed, 400 insertions(+), 4 deletions(-) create mode 100644 apps/core/src/modules/membership/providers/apple-root-cas.ts create mode 100644 apps/core/src/modules/membership/providers/apple.provider.ts create mode 100644 apps/core/test/src/modules/membership/apple.provider.spec.ts diff --git a/apps/core/package.json b/apps/core/package.json index cd47b6fc3a4..8f27ae60378 100644 --- a/apps/core/package.json +++ b/apps/core/package.json @@ -56,6 +56,7 @@ }, "dependencies": { "@antfu/install-pkg": "2.0.1", + "@apple/app-store-server-library": "^3.1.0", "@babel/core": "8.0.1", "@babel/plugin-transform-modules-commonjs": "8.0.1", "@babel/plugin-transform-typescript": "8.0.1", diff --git a/apps/core/src/common/errors/app-error-code.ts b/apps/core/src/common/errors/app-error-code.ts index 54ade700a32..315bdaa1d30 100644 --- a/apps/core/src/common/errors/app-error-code.ts +++ b/apps/core/src/common/errors/app-error-code.ts @@ -172,6 +172,8 @@ export enum AppErrorCode { MEMBERSHIP_PROVIDER_NOT_CONFIGURED = 'MEMBERSHIP_PROVIDER_NOT_CONFIGURED', MEMBERSHIP_PROVIDER_NOT_SUPPORTED = 'MEMBERSHIP_PROVIDER_NOT_SUPPORTED', MEMBERSHIP_ALREADY_ACTIVE = 'MEMBERSHIP_ALREADY_ACTIVE', + MEMBERSHIP_APPLE_TRANSACTION_INVALID = 'MEMBERSHIP_APPLE_TRANSACTION_INVALID', + MEMBERSHIP_APPLE_ALREADY_BOUND = 'MEMBERSHIP_APPLE_ALREADY_BOUND', PREMIUM_REQUIRES_LEXICAL = 'PREMIUM_REQUIRES_LEXICAL', // page diff --git a/apps/core/src/common/errors/app-error-definitions.ts b/apps/core/src/common/errors/app-error-definitions.ts index 96002f756d0..5f834b2de82 100644 --- a/apps/core/src/common/errors/app-error-definitions.ts +++ b/apps/core/src/common/errors/app-error-definitions.ts @@ -630,6 +630,14 @@ export const APP_ERROR_DEFINITIONS = { status: 409, message: 'Reader already has an active membership', }, + [AppErrorCode.MEMBERSHIP_APPLE_TRANSACTION_INVALID]: { + status: 400, + message: 'Apple transaction could not be verified', + }, + [AppErrorCode.MEMBERSHIP_APPLE_ALREADY_BOUND]: { + status: 409, + message: 'This Apple subscription is already linked to another reader', + }, [AppErrorCode.PREMIUM_REQUIRES_LEXICAL]: { status: 400, message: 'Only Lexical-format posts can be marked as premium', diff --git a/apps/core/src/common/errors/app-error-payload.ts b/apps/core/src/common/errors/app-error-payload.ts index 44744e52d0c..97c6d9a1c84 100644 --- a/apps/core/src/common/errors/app-error-payload.ts +++ b/apps/core/src/common/errors/app-error-payload.ts @@ -182,6 +182,8 @@ export type AppErrorPayloadMap = { [AppErrorCode.MEMBERSHIP_PROVIDER_NOT_CONFIGURED]: undefined [AppErrorCode.MEMBERSHIP_PROVIDER_NOT_SUPPORTED]: undefined [AppErrorCode.MEMBERSHIP_ALREADY_ACTIVE]: undefined + [AppErrorCode.MEMBERSHIP_APPLE_TRANSACTION_INVALID]: undefined + [AppErrorCode.MEMBERSHIP_APPLE_ALREADY_BOUND]: undefined [AppErrorCode.PREMIUM_REQUIRES_LEXICAL]: undefined // page diff --git a/apps/core/src/modules/configs/configs.default.ts b/apps/core/src/modules/configs/configs.default.ts index 7bd6a8bdba2..019d4b5b3e1 100644 --- a/apps/core/src/modules/configs/configs.default.ts +++ b/apps/core/src/modules/configs/configs.default.ts @@ -219,5 +219,6 @@ export const generateDefaultConfig: () => IConfig = () => ({ applePrivateKey: '', appleMonthlyProductId: '', appleYearlyProductId: '', + appleAppAppleId: '', }, }) diff --git a/apps/core/src/modules/configs/configs.schema.ts b/apps/core/src/modules/configs/configs.schema.ts index 5f2910db4c8..6fc7a2a328b 100644 --- a/apps/core/src/modules/configs/configs.schema.ts +++ b/apps/core/src/modules/configs/configs.schema.ts @@ -1178,6 +1178,7 @@ export const MembershipSchema = section('Membership', { z.string().optional(), 'Apple yearly product ID', ), + appleAppAppleId: field.plain(z.string().optional(), 'Apple app Apple ID'), }) export class MembershipDto extends createZodDto(MembershipSchema) {} export type MembershipConfig = z.infer diff --git a/apps/core/src/modules/membership/membership.module.ts b/apps/core/src/modules/membership/membership.module.ts index 4112419a534..4638f9acbf4 100644 --- a/apps/core/src/modules/membership/membership.module.ts +++ b/apps/core/src/modules/membership/membership.module.ts @@ -5,6 +5,7 @@ import { EntitlementService } from './entitlement.service' import { MembershipController } from './membership.controller' import { MembershipRepository } from './membership.repository' import { MembershipService } from './membership.service' +import { AppleProvider } from './providers/apple.provider' import { DodoProvider } from './providers/dodo.provider' import { PaymentProviderRegistry } from './providers/provider.registry' @@ -15,6 +16,7 @@ import { PaymentProviderRegistry } from './providers/provider.registry' EntitlementService, MembershipRepository, BillingWebhookEventRepository, + AppleProvider, DodoProvider, PaymentProviderRegistry, ], diff --git a/apps/core/src/modules/membership/providers/apple-root-cas.ts b/apps/core/src/modules/membership/providers/apple-root-cas.ts new file mode 100644 index 00000000000..f3b60681831 --- /dev/null +++ b/apps/core/src/modules/membership/providers/apple-root-cas.ts @@ -0,0 +1,49 @@ +export const APPLE_ROOT_CA_PEMS = [ + `-----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-----`, + `-----BEGIN CERTIFICATE----- +MIIFkjCCA3qgAwIBAgIIAeDltYNno+AwDQYJKoZIhvcNAQEMBQAwZzEbMBkGA1UE +AwwSQXBwbGUgUm9vdCBDQSAtIEcyMSYwJAYDVQQLDB1BcHBsZSBDZXJ0aWZpY2F0 +aW9uIEF1dGhvcml0eTETMBEGA1UECgwKQXBwbGUgSW5jLjELMAkGA1UEBhMCVVMw +HhcNMTQwNDMwMTgxMDA5WhcNMzkwNDMwMTgxMDA5WjBnMRswGQYDVQQDDBJBcHBs +ZSBSb290IENBIC0gRzIxJjAkBgNVBAsMHUFwcGxlIENlcnRpZmljYXRpb24gQXV0 +aG9yaXR5MRMwEQYDVQQKDApBcHBsZSBJbmMuMQswCQYDVQQGEwJVUzCCAiIwDQYJ +KoZIhvcNAQEBBQADggIPADCCAgoCggIBANgREkhI2imKScUcx+xuM23+TfvgHN6s +XuI2pyT5f1BrTM65MFQn5bPW7SXmMLYFN14UIhHF6Kob0vuy0gmVOKTvKkmMXT5x +ZgM4+xb1hYjkWpIMBDLyyED7Ul+f9sDx47pFoFDVEovy3d6RhiPw9bZyLgHaC/Yu +OQhfGaFjQQscp5TBhsRTL3b2CtcM0YM/GlMZ81fVJ3/8E7j4ko380yhDPLVoACVd +J2LT3VXdRCCQgzWTxb+4Gftr49wIQuavbfqeQMpOhYV4SbHXw8EwOTKrfl+q04tv +ny0aIWhwZ7Oj8ZhBbZF8+NfbqOdfIRqMM78xdLe40fTgIvS/cjTf94FNcX1RoeKz +8NMoFnNvzcytN31O661A4T+B/fc9Cj6i8b0xlilZ3MIZgIxbdMYs0xBTJh0UT8TU +gWY8h2czJxQI6bR3hDRSj4n4aJgXv8O7qhOTH11UL6jHfPsNFL4VPSQ08prcdUFm +IrQB1guvkJ4M6mL4m1k8COKWNORj3rw31OsMiANDC1CvoDTdUE0V+1ok2Az6DGOe +HwOx4e7hqkP0ZmUoNwIx7wHHHtHMn23KVDpA287PT0aLSmWaasZobNfMmRtHsHLD +d4/E92GcdB/O/WuhwpyUgquUoue9G7q5cDmVF8Up8zlYNPXEpMZ7YLlmQ1A/bmH8 +DvmGqmAMQ0uVAgMBAAGjQjBAMB0GA1UdDgQWBBTEmRNsGAPCe8CjoA1/coB6HHcm +jTAPBgNVHRMBAf8EBTADAQH/MA4GA1UdDwEB/wQEAwIBBjANBgkqhkiG9w0BAQwF +AAOCAgEAUabz4vS4PZO/Lc4Pu1vhVRROTtHlznldgX/+tvCHM/jvlOV+3Gp5pxy+ +8JS3ptEwnMgNCnWefZKVfhidfsJxaXwU6s+DDuQUQp50DhDNqxq6EWGBeNjxtUVA +eKuowM77fWM3aPbn+6/Gw0vsHzYmE1SGlHKy6gLti23kDKaQwFd1z4xCfVzmMX3z +ybKSaUYOiPjjLUKyOKimGY3xn83uamW8GrAlvacp/fQ+onVJv57byfenHmOZ4VxG +/5IFjPoeIPmGlFYl5bRXOJ3riGQUIUkhOb9iZqmxospvPyFgxYnURTbImHy99v6Z +SYA7LNKmp4gDBDEZt7Y6YUX6yfIjyGNzv1aJMbDZfGKnexWoiIqrOEDCzBL/FePw +N983csvMmOa/orz6JopxVtfnJBtIRD6e/J/JzBrsQzwBvDR4yGn1xuZW7AYJNpDr +FEobXsmII9oDMJELuDY++ee1KG++P+w8j2Ud5cAeh6Squpj9kuNsJnfdBrRkBof0 +Tta6SqoWqPQFZ2aWuuJVecMsXUmPgEkrihLHdoBR37q9ZV0+N0djMenl9MU/S60E +inpxLK8JQzcPqOMyT/RFtm2XNuyE9QoB6he7hY1Ck3DDUOUUi78/w0EP3SIEIwiK +um1xRKtzCTrJ+VKACd+66eYWyi4uTLLT3OUEVLLUNIAytbwPF+E= +-----END CERTIFICATE-----`, +] diff --git a/apps/core/src/modules/membership/providers/apple.provider.ts b/apps/core/src/modules/membership/providers/apple.provider.ts new file mode 100644 index 00000000000..055b9ceec3c --- /dev/null +++ b/apps/core/src/modules/membership/providers/apple.provider.ts @@ -0,0 +1,244 @@ +import { + Environment, + type JWSTransactionDecodedPayload, + SignedDataVerifier, +} from '@apple/app-store-server-library' +import { Injectable, Logger } from '@nestjs/common' + +import { AppErrorCode, createAppException } from '~/common/errors' + +import { ConfigsService } from '../../configs/configs.service' +import type { MembershipPlan } from '../membership.types' +import { APPLE_ROOT_CA_PEMS } from './apple-root-cas' +import { + type AppleDecodedTransaction, + appleNotificationEventType, +} from './apple-transaction' +import type { + BillingWebhookResult, + PaymentProviderAdapter, +} from './provider.interface' + +const appleRootCaBuffers = APPLE_ROOT_CA_PEMS.map((pem) => Buffer.from(pem)) + +@Injectable() +export class AppleProvider implements PaymentProviderAdapter { + private readonly logger = new Logger(AppleProvider.name) + + constructor(private readonly configsService: ConfigsService) {} + + async createCheckout(_input: { + reader: { id: string; email?: string | null; name?: string | null } + plan: MembershipPlan + returnUrl?: string + }): Promise<{ checkoutUrl: string }> { + throw createAppException(AppErrorCode.MEMBERSHIP_PROVIDER_NOT_CONFIGURED) + } + + async verifySignedTransaction( + signedTransactionInfo: string, + ): Promise { + const membershipConfig = await this.configsService.get('membership') + const bundleId = membershipConfig.appleBundleId?.trim() + if (!bundleId) { + throw createAppException( + AppErrorCode.MEMBERSHIP_APPLE_TRANSACTION_INVALID, + ) + } + + try { + const decoded = await this.verifyTransactionWithFallback( + signedTransactionInfo, + bundleId, + membershipConfig.appleAppAppleId, + ) + if ( + !decoded.transactionId || + !decoded.originalTransactionId || + !decoded.productId || + !decoded.expiresDate + ) { + throw createAppException( + AppErrorCode.MEMBERSHIP_APPLE_TRANSACTION_INVALID, + ) + } + return { + appAccountToken: decoded.appAccountToken, + expiresDate: decoded.expiresDate, + originalTransactionId: decoded.originalTransactionId, + productId: decoded.productId, + transactionId: decoded.transactionId, + } + } catch (error) { + if ( + error instanceof Error && + 'code' in error && + error.code === AppErrorCode.MEMBERSHIP_APPLE_TRANSACTION_INVALID + ) { + throw error + } + this.logger.warn( + `Apple transaction verification failed: ${ + error instanceof Error ? error.message : String(error) + }`, + ) + throw createAppException( + AppErrorCode.MEMBERSHIP_APPLE_TRANSACTION_INVALID, + ) + } + } + + async verifyAndParseWebhook( + rawBody: Buffer | string, + _headers: Record, + ): Promise { + const membershipConfig = await this.configsService.get('membership') + const bundleId = membershipConfig.appleBundleId?.trim() + if (!bundleId) { + throw createAppException(AppErrorCode.WEBHOOK_SIGNATURE_INVALID) + } + + let signedPayload: string + try { + const parsed = JSON.parse( + typeof rawBody === 'string' ? rawBody : rawBody.toString('utf8'), + ) as { signedPayload?: string } + if (!parsed.signedPayload) { + throw new Error('missing signedPayload') + } + signedPayload = parsed.signedPayload + } catch { + throw createAppException(AppErrorCode.WEBHOOK_SIGNATURE_INVALID) + } + + let notificationType: string + let signedTransactionInfo: string | undefined + let notificationUUID: string + try { + const notification = await this.verifyNotificationWithFallback( + signedPayload, + bundleId, + membershipConfig.appleAppAppleId, + ) + notificationType = notification.notificationType ?? '' + notificationUUID = notification.notificationUUID ?? notificationType + signedTransactionInfo = notification.data?.signedTransactionInfo + } catch (error) { + this.logger.warn( + `Apple webhook verification failed: ${ + error instanceof Error ? error.message : String(error) + }`, + ) + throw createAppException(AppErrorCode.WEBHOOK_SIGNATURE_INVALID) + } + + const type = appleNotificationEventType(notificationType) + if (!type) { + return { + ignored: true, + rawType: notificationType, + reason: 'unsupported_event', + } + } + + if (!signedTransactionInfo) { + return { + ignored: true, + rawType: notificationType, + reason: 'missing_reader_metadata', + } + } + + const decoded = await this.verifyTransactionWithFallback( + signedTransactionInfo, + bundleId, + membershipConfig.appleAppAppleId, + ).catch(() => null) + if (!decoded?.originalTransactionId || !decoded.expiresDate) { + return { + ignored: true, + rawType: notificationType, + reason: 'missing_reader_metadata', + } + } + + return { + event: { + eventId: notificationUUID, + provider: 'apple', + type, + customerId: decoded.appAccountToken ?? decoded.originalTransactionId, + subscriptionId: decoded.originalTransactionId, + currentPeriodEnd: new Date(decoded.expiresDate), + readerId: '', + }, + rawType: notificationType, + rawPayload: { notificationType, decoded }, + } + } + + private async verifyTransactionWithFallback( + signedTransactionInfo: string, + bundleId: string, + appleAppAppleId?: string, + ): Promise { + const environments = this.environmentsToTry(appleAppAppleId) + let lastError: unknown + for (const { environment, appAppleId } of environments) { + try { + const verifier = new SignedDataVerifier( + appleRootCaBuffers, + true, + environment, + bundleId, + appAppleId, + ) + return await verifier.verifyAndDecodeTransaction(signedTransactionInfo) + } catch (error) { + lastError = error + } + } + throw lastError instanceof Error ? lastError : new Error('verify failed') + } + + private async verifyNotificationWithFallback( + signedPayload: string, + bundleId: string, + appleAppAppleId?: string, + ) { + const environments = this.environmentsToTry(appleAppAppleId) + let lastError: unknown + for (const { environment, appAppleId } of environments) { + try { + const verifier = new SignedDataVerifier( + appleRootCaBuffers, + true, + environment, + bundleId, + appAppleId, + ) + return await verifier.verifyAndDecodeNotification(signedPayload) + } catch (error) { + lastError = error + } + } + throw lastError instanceof Error ? lastError : new Error('verify failed') + } + + private environmentsToTry(appleAppAppleId?: string) { + const appAppleId = Number(appleAppAppleId) + const production = + Number.isFinite(appAppleId) && appAppleId > 0 + ? [ + { + environment: Environment.PRODUCTION, + appAppleId, + }, + ] + : [] + return [ + ...production, + { environment: Environment.SANDBOX, appAppleId: undefined }, + ] + } +} diff --git a/apps/core/src/modules/membership/providers/provider.registry.ts b/apps/core/src/modules/membership/providers/provider.registry.ts index c7a13c32906..920bbf394e2 100644 --- a/apps/core/src/modules/membership/providers/provider.registry.ts +++ b/apps/core/src/modules/membership/providers/provider.registry.ts @@ -1,8 +1,9 @@ -import { Injectable } from '@nestjs/common' +import { Injectable, Optional } from '@nestjs/common' import { AppErrorCode, createAppException } from '~/common/errors' import type { MembershipProvider } from '../membership.types' +import { AppleProvider } from './apple.provider' import { DodoProvider } from './dodo.provider' import type { PaymentProviderAdapter } from './provider.interface' @@ -12,8 +13,12 @@ export class PaymentProviderRegistry { Record > - constructor(dodoProvider: DodoProvider) { + constructor( + dodoProvider: DodoProvider, + @Optional() appleProvider?: AppleProvider, + ) { this.registry = { dodo: dodoProvider } + if (appleProvider) this.registry.apple = appleProvider } get(provider?: string | null): PaymentProviderAdapter | undefined { diff --git a/apps/core/test/src/modules/membership/apple.provider.spec.ts b/apps/core/test/src/modules/membership/apple.provider.spec.ts new file mode 100644 index 00000000000..ec42782f5da --- /dev/null +++ b/apps/core/test/src/modules/membership/apple.provider.spec.ts @@ -0,0 +1,24 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { AppErrorCode } from '~/common/errors' +import { AppleProvider } from '~/modules/membership/providers/apple.provider' + +const get = vi.fn() + +describe('AppleProvider.createCheckout', () => { + beforeEach(() => { + get.mockReset() + }) + + it('refuses web checkout', async () => { + const provider = new AppleProvider({ get } as any) + await expect( + provider.createCheckout({ + reader: { id: 'reader-1' }, + plan: 'monthly', + }), + ).rejects.toMatchObject({ + code: AppErrorCode.MEMBERSHIP_PROVIDER_NOT_CONFIGURED, + }) + }) +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cc93960fd79..2f77e91a4d3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -536,6 +536,9 @@ importers: '@antfu/install-pkg': specifier: 2.0.1 version: 2.0.1 + '@apple/app-store-server-library': + specifier: ^3.1.0 + version: 3.1.0(@noble/hashes@2.3.0)(encoding@0.1.13) '@babel/core': specifier: 8.0.1 version: 8.0.1 @@ -1440,6 +1443,9 @@ packages: '@antv/vendor@1.0.11': resolution: {integrity: sha512-LmhPEQ+aapk3barntaiIxJ5VHno/Tyab2JnfdcPzp5xONh/8VSfed4bo/9xKo5HcUAEydko38vYLfj6lJliLiw==} + '@apple/app-store-server-library@3.1.0': + resolution: {integrity: sha512-d26SICRz8BwCV2qPR0BSXBMxmw0NEvJLwsdREcBmCrus8NmyHw2XgOOP0fiFjcctPn/JXtmSDuGVnaHe+dqO+A==} + '@aws-crypto/sha256-browser@5.2.0': resolution: {integrity: sha512-AXfN/lGotSQwu6HNcEsIASo7kWXZ5HYWvfOmSNKDsEqC4OashTp8alTmaz+F7TC2L083SFv5RdB+qU3Vs1kZqw==} @@ -6085,6 +6091,9 @@ packages: '@types/jsonwebtoken@9.0.10': resolution: {integrity: sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==} + '@types/jsrsasign@10.5.15': + resolution: {integrity: sha512-3stUTaSRtN09PPzVWR6aySD9gNnuymz+WviNHoTb85dKu+BjaV4uBbWWGykBBJkfwPtcNZVfTn2lbX00U+yhpQ==} + '@types/luxon@3.6.2': resolution: {integrity: sha512-R/BdP7OxEMc44l2Ex5lSXHoIXTB2JLNa3y2QISIbr58U/YcsffyQrYW//hZSdrfxrjRZj3GcUoxMPGdO8gSYuw==} @@ -6100,9 +6109,15 @@ packages: '@types/ms@2.1.0': resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==} + '@types/node-fetch@2.6.13': + resolution: {integrity: sha512-QGpRVpzSaUs30JBSGPjOg4Uveu384erbHBoT1zeONvyCfwQxIkUshLAOqN/k9EjGviPRmWTTe6aH2qySWKTVSw==} + '@types/node@18.19.130': resolution: {integrity: sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==} + '@types/node@25.9.5': + resolution: {integrity: sha512-OScDchr2fwuUmWdf4kZ9h7PcJiYDVInhJizG/biAq3cAvqwYktuy/TYGGdZNMtNTFUP7rnb0NU4TUdm82kt4Rg==} + '@types/node@26.2.0': resolution: {integrity: sha512-5IviulTZeRNp2vAJ514cc/HUlY5nZ9fCbq9DMyC52BrhFZACo3nI0R7qBxhQmo/d27NFe96ur/b7Wwxklda+kg==} @@ -6962,6 +6977,10 @@ packages: resolution: {integrity: sha512-lGe34o6EHj9y3Kts9R4ZYs/Gr+6N7MCaMlIFA3F1R2O5/m7K06AxfSeO5530PEERE6/WyEg3lsuyw4GHlPZHog==} engines: {node: ^4.5.0 || >= 5.9} + base64url@3.0.1: + resolution: {integrity: sha512-ir1UPr3dkwexU7FdV8qBBbNDRUhMmIekYMFZfi+C/sLNnRESKPl23nB9b2pltqfOQNnGzsDdId90AEtG5tCx4A==} + engines: {node: '>=6.0.0'} + baseline-browser-mapping@2.10.38: resolution: {integrity: sha512-31/02mVB4yuQU6adKk5SlY6m+mxDwUq5KZkyYgnLrrKl7TEm1+3PyDtDBz2kOv/wxZz41GHsvV1A/u6RmiyBvw==} engines: {node: '>=6.0.0'} @@ -9449,6 +9468,10 @@ packages: resolution: {integrity: sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==} engines: {node: '>=12', npm: '>=6'} + jsrsasign@11.1.5: + resolution: {integrity: sha512-i6mAjey0/4UQmlOaNS2vROl8EkWV8Ei436reJAby2OPQ3lF6uXSs1VYpnM8hndmmCMFIzNc04Uw2GF+0JVLFbg==} + deprecated: This package is no longer maintained. + jsx-ast-utils@3.3.5: resolution: {integrity: sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==} engines: {node: '>=4.0'} @@ -11974,6 +11997,9 @@ packages: undici-types@5.26.5: resolution: {integrity: sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==} + undici-types@7.24.6: + resolution: {integrity: sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==} + undici-types@8.3.0: resolution: {integrity: sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==} @@ -12847,6 +12873,20 @@ snapshots: d3-time: 3.1.0 d3-timer: 3.0.1 + '@apple/app-store-server-library@3.1.0(@noble/hashes@2.3.0)(encoding@0.1.13)': + dependencies: + '@types/jsonwebtoken': 9.0.10 + '@types/jsrsasign': 10.5.15 + '@types/node': 25.9.5 + '@types/node-fetch': 2.6.13 + base64url: 3.0.1 + jsonwebtoken: 9.0.3 + jsrsasign: 11.1.5 + node-fetch: 2.7.0(@noble/hashes@2.3.0)(encoding@0.1.13) + transitivePeerDependencies: + - '@noble/hashes' + - encoding + '@aws-crypto/sha256-browser@5.2.0': dependencies: '@aws-crypto/sha256-js': 5.2.0 @@ -17933,6 +17973,8 @@ snapshots: '@types/ms': 2.1.0 '@types/node': 26.2.0 + '@types/jsrsasign@10.5.15': {} + '@types/luxon@3.6.2': {} '@types/luxon@3.7.2': {} @@ -17945,10 +17987,19 @@ snapshots: '@types/ms@2.1.0': {} + '@types/node-fetch@2.6.13': + dependencies: + '@types/node': 26.2.0 + form-data: 4.0.6 + '@types/node@18.19.130': dependencies: undici-types: 5.26.5 + '@types/node@25.9.5': + dependencies: + undici-types: 7.24.6 + '@types/node@26.2.0': dependencies: undici-types: 8.3.0 @@ -18836,6 +18887,8 @@ snapshots: base64id@2.0.0: optional: true + base64url@3.0.1: {} + baseline-browser-mapping@2.10.38: {} bcrypt-pbkdf@1.0.2: @@ -21572,7 +21625,7 @@ snapshots: isomorphic-fetch@2.2.1(encoding@0.1.13): dependencies: - node-fetch: 2.7.0(encoding@0.1.13) + node-fetch: 2.7.0(@noble/hashes@2.3.0)(encoding@0.1.13) whatwg-fetch: 3.6.20 transitivePeerDependencies: - '@noble/hashes' @@ -21711,6 +21764,8 @@ snapshots: ms: 2.1.3 semver: 7.8.5 + jsrsasign@11.1.5: {} + jsx-ast-utils@3.3.5: dependencies: array-includes: 3.1.9 @@ -22660,7 +22715,7 @@ snapshots: node-fetch-native@1.6.7: {} - node-fetch@2.7.0(encoding@0.1.13): + node-fetch@2.7.0(@noble/hashes@2.3.0)(encoding@0.1.13): dependencies: whatwg-url: 17.1.0(@noble/hashes@2.3.0) optionalDependencies: @@ -24667,6 +24722,8 @@ snapshots: undici-types@5.26.5: {} + undici-types@7.24.6: {} + undici-types@8.3.0: {} undici@8.10.0: {} From 8449575935fd6ded3767b4ac40b97d4df2335a73 Mon Sep 17 00:00:00 2001 From: Innei Date: Thu, 20 Aug 2026 18:07:40 +0800 Subject: [PATCH 04/12] feat(membership): confirm Apple transactions onto the membership row --- .../modules/membership/membership.service.ts | 98 ++++++++++++++++++- .../membership/membership.service.spec.ts | 87 ++++++++++++++++ 2 files changed, 181 insertions(+), 4 deletions(-) diff --git a/apps/core/src/modules/membership/membership.service.ts b/apps/core/src/modules/membership/membership.service.ts index 191f57c2246..424c933512d 100644 --- a/apps/core/src/modules/membership/membership.service.ts +++ b/apps/core/src/modules/membership/membership.service.ts @@ -4,11 +4,18 @@ import { AppErrorCode, createAppException } from '~/common/errors' import { BillingWebhookEventRepository } from './billing-webhook-event.repository' import { MembershipRepository } from './membership.repository' -import type { - MembershipPlan, - MembershipProvider, - MembershipRow, +import { + effectiveMembershipStatus, + type MembershipPlan, + type MembershipProvider, + type MembershipRow, + type MembershipStatus, } from './membership.types' +import { + appleActivatedEvent, + type AppleDecodedTransaction, + planFromAppleProductId, +} from './providers/apple-transaction' import type { NormalizedBillingEvent, VerifiedBillingEvent, @@ -31,6 +38,72 @@ export class MembershipService { return this.membershipRepository.findByReaderId(readerId) } + async confirmAppleTransaction(input: { + decoded: AppleDecodedTransaction + monthlyProductId: string + readerId: string + yearlyProductId: string + }): Promise< + | { status: 'none' } + | { + currentPeriodEnd: Date + plan: MembershipPlan + provider: MembershipProvider + status: MembershipStatus + } + > { + const plan = planFromAppleProductId(input.decoded.productId, { + monthlyProductId: input.monthlyProductId, + yearlyProductId: input.yearlyProductId, + }) + if (!plan) { + throw createAppException( + AppErrorCode.MEMBERSHIP_APPLE_TRANSACTION_INVALID, + ) + } + + const bySub = await this.membershipRepository.findByProviderSubscriptionId( + input.decoded.originalTransactionId, + ) + if (bySub && bySub.readerId !== input.readerId) { + throw createAppException(AppErrorCode.MEMBERSHIP_APPLE_ALREADY_BOUND) + } + + const byReader = await this.membershipRepository.findByReaderId( + input.readerId, + ) + if ( + byReader && + isLiveProviderSubscription(byReader) && + byReader.provider !== 'apple' + ) { + return this.toStatusResult(byReader) + } + + const event = appleActivatedEvent(input.decoded, input.readerId, plan) + const applied = await this.applyEvent({ + event, + rawPayload: input.decoded, + rawType: 'apple.confirm', + }) + + if (!applied.applied && byReader?.provider === 'apple') { + await this.membershipRepository.update(byReader.id, { + provider: 'apple', + providerCustomerId: + input.decoded.appAccountToken ?? input.decoded.originalTransactionId, + providerSubscriptionId: input.decoded.originalTransactionId, + plan, + status: 'active', + currentPeriodEnd: new Date(input.decoded.expiresDate), + }) + } + + return this.toStatusResult( + await this.membershipRepository.findByReaderId(input.readerId), + ) + } + async listMembers(page: number, size: number) { return this.membershipRepository.listMembers(page, size) } @@ -197,6 +270,23 @@ export class MembershipService { return updated! } + private toStatusResult(row: MembershipRow | null): + | { status: 'none' } + | { + currentPeriodEnd: Date + plan: MembershipPlan + provider: MembershipProvider + status: MembershipStatus + } { + if (!row) return { status: 'none' } + return { + currentPeriodEnd: row.currentPeriodEnd, + plan: row.plan, + provider: row.provider, + status: effectiveMembershipStatus(row), + } + } + private async assertReaderExists(readerId: string): Promise { const exists = await this.membershipRepository.readerExists(readerId) if (!exists) { diff --git a/apps/core/test/src/modules/membership/membership.service.spec.ts b/apps/core/test/src/modules/membership/membership.service.spec.ts index 9f9a57131b4..522051dbb65 100644 --- a/apps/core/test/src/modules/membership/membership.service.spec.ts +++ b/apps/core/test/src/modules/membership/membership.service.spec.ts @@ -1,6 +1,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { createPgRepositoryMock, now } from '@/helper/pg-repository-mock' +import { AppErrorCode } from '~/common/errors' import type { BillingWebhookEventRepository } from '~/modules/membership/billing-webhook-event.repository' import type { MembershipRepository } from '~/modules/membership/membership.repository' import { MembershipService } from '~/modules/membership/membership.service' @@ -486,4 +487,90 @@ describe('MembershipService', () => { expect(membershipRepository.update).not.toHaveBeenCalled() }) }) + + describe('confirmAppleTransaction', () => { + const decoded = { + expiresDate: now.getTime() + 86_400_000, + originalTransactionId: 'orig-apple', + productId: 'yohaku.membership.monthly', + transactionId: 'txn-apple', + } + const products = { + monthlyProductId: 'yohaku.membership.monthly', + yearlyProductId: 'yohaku.membership.yearly', + } + + it('creates an apple membership for a new reader', async () => { + const { service, membershipRepository } = createService() + const created = createMembership({ + provider: 'apple', + providerSubscriptionId: 'orig-apple', + readerId: 'reader-1', + }) + membershipRepository.create.mockResolvedValue(created) + membershipRepository.findByReaderId + .mockResolvedValueOnce(null) + .mockResolvedValueOnce(null) + .mockResolvedValueOnce(created) + + const result = await service.confirmAppleTransaction({ + decoded, + readerId: 'reader-1', + ...products, + }) + + expect(membershipRepository.create).toHaveBeenCalled() + expect(result).toMatchObject({ + status: 'active', + plan: 'monthly', + provider: 'apple', + }) + }) + + it('returns the existing live Dodo membership without rewriting it', async () => { + const { service, membershipRepository } = createService() + const live = createMembership({ provider: 'dodo' }) + membershipRepository.findByReaderId.mockResolvedValue(live) + + const result = await service.confirmAppleTransaction({ + decoded, + readerId: 'reader-1', + ...products, + }) + + expect(membershipRepository.create).not.toHaveBeenCalled() + expect(membershipRepository.update).not.toHaveBeenCalled() + expect(result).toMatchObject({ provider: 'dodo', status: 'active' }) + }) + + it('rejects when the originalTransactionId is bound to another reader', async () => { + const { service, membershipRepository } = createService() + membershipRepository.findByProviderSubscriptionId.mockResolvedValue( + createMembership({ readerId: 'other-reader', provider: 'apple' }), + ) + + await expect( + service.confirmAppleTransaction({ + decoded, + readerId: 'reader-1', + ...products, + }), + ).rejects.toMatchObject({ + code: AppErrorCode.MEMBERSHIP_APPLE_ALREADY_BOUND, + }) + }) + + it('rejects an unknown product id', async () => { + const { service } = createService() + await expect( + service.confirmAppleTransaction({ + decoded: { ...decoded, productId: 'unknown.sku' }, + readerId: 'reader-1', + ...products, + }), + ).rejects.toMatchObject({ + code: AppErrorCode.MEMBERSHIP_APPLE_TRANSACTION_INVALID, + }) + }) + }) }) From 3053f8abf0fb50e7cecb436ede5a4cb20b31c3f3 Mon Sep 17 00:00:00 2001 From: Innei Date: Thu, 20 Aug 2026 18:09:04 +0800 Subject: [PATCH 05/12] feat(membership): expose Apple confirm and appleIap on plans --- .../membership/membership.controller.ts | 58 +++++++++- .../modules/membership/membership.service.ts | 8 ++ .../membership.controller.e2e-spec.ts | 104 +++++++++++++++++- 3 files changed, 167 insertions(+), 3 deletions(-) diff --git a/apps/core/src/modules/membership/membership.controller.ts b/apps/core/src/modules/membership/membership.controller.ts index 96ab6a06deb..55e3151da50 100644 --- a/apps/core/src/modules/membership/membership.controller.ts +++ b/apps/core/src/modules/membership/membership.controller.ts @@ -30,9 +30,11 @@ import { MembershipService } from './membership.service' import { effectiveMembershipStatus, REGISTERED_PAYMENT_PROVIDERS, + resolveAppleIapAvailability, resolveMembershipAvailability, resolveMembershipReturnUrl, } from './membership.types' +import { AppleProvider } from './providers/apple.provider' import { isIgnoredBillingEvent } from './providers/provider.interface' import { PaymentProviderRegistry } from './providers/provider.registry' @@ -59,6 +61,11 @@ const ReaderIdParamSchema = z.object({ }) class ReaderIdParamDto extends createZodDto(ReaderIdParamSchema) {} +const AppleConfirmSchema = z.object({ + signedTransactionInfo: z.string().min(1), +}) +class AppleConfirmDto extends createZodDto(AppleConfirmSchema) {} + const assertNotDemoMode = () => { if (DEMO_MODE) { throw createAppException(AppErrorCode.DEMO_FORBIDDEN) @@ -71,6 +78,7 @@ export class MembershipController { private readonly membershipService: MembershipService, private readonly configsService: ConfigsService, private readonly providers: PaymentProviderRegistry, + private readonly appleProvider: AppleProvider, ) {} @ReaderAuth() @@ -114,7 +122,10 @@ export class MembershipController { async plans() { const membershipConfig = await this.configsService.get('membership') const availability = resolveMembershipAvailability(membershipConfig) - if (!availability.enabled) return { enabled: false, plans: [] } + const appleIap = resolveAppleIapAvailability(membershipConfig) + if (!availability.enabled) { + return { enabled: false, plans: [], appleIap } + } const adapter = this.providers.get(membershipConfig.provider) const productIdByPlan: Record = { @@ -133,7 +144,37 @@ export class MembershipController { }), ) - return { enabled: true, plans } + return { enabled: true, plans, appleIap } + } + + @ReaderAuth() + @Post('/apple/confirm') + @HttpCode(200) + async confirmApple( + @Body() body: AppleConfirmDto, + @CurrentUser() user: SessionUser, + ) { + assertNotDemoMode() + + const membershipConfig = await this.configsService.get('membership') + const appleIap = resolveAppleIapAvailability(membershipConfig) + if ( + !appleIap.enabled || + !appleIap.monthlyProductId || + !appleIap.yearlyProductId + ) { + throw createAppException(AppErrorCode.MEMBERSHIP_PROVIDER_NOT_CONFIGURED) + } + + const decoded = await this.appleProvider.verifySignedTransaction( + body.signedTransactionInfo, + ) + return this.membershipService.confirmAppleTransaction({ + decoded, + monthlyProductId: appleIap.monthlyProductId, + readerId: user.id, + yearlyProductId: appleIap.yearlyProductId, + }) } @Auth() @@ -184,6 +225,19 @@ export class MembershipController { if (isIgnoredBillingEvent(verified)) { return { ok: true, applied: false, ignored: verified.reason } } + if (!verified.event.readerId) { + const bound = await this.membershipService.getByProviderSubscriptionId( + verified.event.subscriptionId, + ) + if (!bound) { + return { + ok: true, + applied: false, + ignored: 'missing_reader_metadata', + } + } + verified.event.readerId = bound.readerId + } const result = await this.membershipService.applyEvent(verified) return { ok: true, applied: result.applied } } diff --git a/apps/core/src/modules/membership/membership.service.ts b/apps/core/src/modules/membership/membership.service.ts index 424c933512d..44020ec57c3 100644 --- a/apps/core/src/modules/membership/membership.service.ts +++ b/apps/core/src/modules/membership/membership.service.ts @@ -38,6 +38,14 @@ export class MembershipService { return this.membershipRepository.findByReaderId(readerId) } + async getByProviderSubscriptionId( + providerSubscriptionId: string, + ): Promise { + return this.membershipRepository.findByProviderSubscriptionId( + providerSubscriptionId, + ) + } + async confirmAppleTransaction(input: { decoded: AppleDecodedTransaction monthlyProductId: string diff --git a/apps/core/test/src/modules/membership/membership.controller.e2e-spec.ts b/apps/core/test/src/modules/membership/membership.controller.e2e-spec.ts index 621163375dd..1848cf585bd 100644 --- a/apps/core/test/src/modules/membership/membership.controller.e2e-spec.ts +++ b/apps/core/test/src/modules/membership/membership.controller.e2e-spec.ts @@ -22,6 +22,7 @@ import { EntitlementService } from '~/modules/membership/entitlement.service' import { MembershipController } from '~/modules/membership/membership.controller' import { MembershipRepository } from '~/modules/membership/membership.repository' import { MembershipService } from '~/modules/membership/membership.service' +import { AppleProvider } from '~/modules/membership/providers/apple.provider' import { DodoProvider } from '~/modules/membership/providers/dodo.provider' import { PaymentProviderRegistry } from '~/modules/membership/providers/provider.registry' import type { AppDatabase } from '~/processors/database/postgres.provider' @@ -37,6 +38,7 @@ const liveSubReaderId = snowflake.nextId() const expiredReaderId = snowflake.nextId() const checkoutActiveReaderId = snowflake.nextId() const checkoutExpiredReaderId = snowflake.nextId() +const appleConfirmReaderId = snowflake.nextId() const readerUser = { id: readerId, @@ -66,6 +68,13 @@ const checkoutExpiredReaderUser = { role: 'reader' as const, } +const appleConfirmReaderUser = { + id: appleConfirmReaderId, + email: 'apple-confirm@example.com', + name: 'Reader Apple Confirm', + role: 'reader' as const, +} + const membershipConfig: { enabled: boolean provider: string | undefined @@ -113,6 +122,12 @@ const authServiceMock = { session: { token: 'checkout-expired-token' }, } } + if (header === 'apple-confirm') { + return { + user: appleConfirmReaderUser, + session: { token: 'apple-confirm-token' }, + } + } return null }), } @@ -166,6 +181,12 @@ const dodoProviderMock = { getPlanPricing: getPlanPricingMock, } +const verifySignedTransactionMock = vi.fn() + +const appleProviderMock = { + verifySignedTransaction: verifySignedTransactionMock, +} + const membershipModule: ModuleMetadata = { controllers: [MembershipController], providers: [ @@ -175,6 +196,7 @@ const membershipModule: ModuleMetadata = { EntitlementService, { provide: SnowflakeService, useValue: snowflake }, { provide: DodoProvider, useValue: dodoProviderMock }, + { provide: AppleProvider, useValue: appleProviderMock }, PaymentProviderRegistry, { provide: AuthService, useValue: authServiceMock }, { provide: ConfigsService, useValue: configsServiceMock }, @@ -201,6 +223,7 @@ beforeAll(async () => { { id: expiredReaderId, name: 'Reader Expired', role: 'reader' }, { id: checkoutActiveReaderId, name: 'Checkout Active', role: 'reader' }, { id: checkoutExpiredReaderId, name: 'Checkout Expired', role: 'reader' }, + { id: appleConfirmReaderId, name: 'Apple Confirm', role: 'reader' }, ]) }, 120_000) @@ -217,9 +240,18 @@ describe('MembershipController (e2e)', () => { membershipConfig.enabled = true membershipConfig.provider = 'dodo' membershipConfig.webhookSigningKey = 'webhook-key' + delete (membershipConfig as { appleBundleId?: string }).appleBundleId + delete (membershipConfig as { appleKeyId?: string }).appleKeyId + delete (membershipConfig as { appleIssuerId?: string }).appleIssuerId + delete (membershipConfig as { applePrivateKey?: string }).applePrivateKey + delete (membershipConfig as { appleMonthlyProductId?: string }) + .appleMonthlyProductId + delete (membershipConfig as { appleYearlyProductId?: string }) + .appleYearlyProductId urlConfig.webUrl = undefined createCheckoutMock.mockClear() verifyAndParseWebhookMock.mockClear() + verifySignedTransactionMock.mockReset() }) describe('GET /membership/config-status', () => { @@ -439,6 +471,7 @@ describe('MembershipController (e2e)', () => { expect(res.statusCode).toBe(200) expect(res.json()).toEqual({ data: { + apple_iap: { enabled: false }, enabled: true, plans: [ { @@ -487,7 +520,76 @@ describe('MembershipController (e2e)', () => { }) expect(res.statusCode).toBe(200) - expect(res.json()).toEqual({ data: { enabled: false, plans: [] } }) + expect(res.json()).toEqual({ + data: { apple_iap: { enabled: false }, enabled: false, plans: [] }, + }) + }) + + it('reports appleIap when Apple fields are configured', async () => { + Object.assign(membershipConfig, { + appleBundleId: 'dev.yohaku.app', + appleKeyId: 'KEYID', + appleIssuerId: 'ISSUER', + applePrivateKey: + '-----BEGIN PRIVATE KEY-----\\nX\\n-----END PRIVATE KEY-----', + appleMonthlyProductId: 'yohaku.membership.monthly', + appleYearlyProductId: 'yohaku.membership.yearly', + }) + + const res = await proxy.app.inject({ + method: 'GET', + url: '/membership/plans', + }) + + expect(res.statusCode).toBe(200) + expect(res.json().data.apple_iap).toEqual({ + enabled: true, + monthly_product_id: 'yohaku.membership.monthly', + yearly_product_id: 'yohaku.membership.yearly', + }) + }) + }) + + describe('POST /membership/apple/confirm', () => { + it('returns the new apple membership for a signed-in reader', async () => { + Object.assign(membershipConfig, { + appleBundleId: 'dev.yohaku.app', + appleKeyId: 'KEYID', + appleIssuerId: 'ISSUER', + applePrivateKey: + '-----BEGIN PRIVATE KEY-----\\nX\\n-----END PRIVATE KEY-----', + appleMonthlyProductId: 'yohaku.membership.monthly', + appleYearlyProductId: 'yohaku.membership.yearly', + }) + verifySignedTransactionMock.mockResolvedValueOnce({ + expiresDate: Date.now() + 86_400_000, + originalTransactionId: 'orig-e2e', + productId: 'yohaku.membership.monthly', + transactionId: 'txn-e2e', + }) + + const res = await proxy.app.inject({ + method: 'POST', + url: '/membership/apple/confirm', + headers: { 'x-test-reader': 'apple-confirm' }, + payload: { signedTransactionInfo: 'jws' }, + }) + + expect(res.statusCode).toBe(200) + expect(res.json().data).toMatchObject({ + plan: 'monthly', + provider: 'apple', + status: 'active', + }) + }) + + it('returns 401 without a reader session', async () => { + const res = await proxy.app.inject({ + method: 'POST', + url: '/membership/apple/confirm', + payload: { signedTransactionInfo: 'jws' }, + }) + expect(res.statusCode).toBe(401) }) }) From ccbf2456611898337cbf594926636658d1adb40b Mon Sep 17 00:00:00 2001 From: Innei Date: Thu, 20 Aug 2026 18:11:05 +0800 Subject: [PATCH 06/12] feat(membership): surface Apple IAP settings and client confirm --- apps/admin/src/api/membership.ts | 1 + .../MembershipConfigEditor.test.tsx | 3 + .../membership/MembershipConfigEditor.tsx | 111 +++++++++++++++++- .../settings/utils/membership.test.ts | 36 ++++++ .../src/features/settings/utils/membership.ts | 25 ++++ apps/admin/src/i18n/resources/en-US.ts | 24 ++++ apps/admin/src/i18n/resources/zh-CN.ts | 24 ++++ .../membership/membership.controller.ts | 1 + .../membership.controller.e2e-spec.ts | 1 + .../__tests__/controllers/membership.test.ts | 21 ++++ packages/api-client/controllers/membership.ts | 6 + packages/api-client/models/membership.ts | 9 +- 12 files changed, 257 insertions(+), 5 deletions(-) diff --git a/apps/admin/src/api/membership.ts b/apps/admin/src/api/membership.ts index 1249004d604..8f79951336b 100644 --- a/apps/admin/src/api/membership.ts +++ b/apps/admin/src/api/membership.ts @@ -2,6 +2,7 @@ import { getJson } from './http' export interface MembershipConfigStatus { apiKeyConfigured: boolean + applePrivateKeyConfigured?: boolean supportedProviders: string[] webhookSigningKeyConfigured: boolean } diff --git a/apps/admin/src/features/settings/components/membership/MembershipConfigEditor.test.tsx b/apps/admin/src/features/settings/components/membership/MembershipConfigEditor.test.tsx index b66d73455c1..56678c4dcd2 100644 --- a/apps/admin/src/features/settings/components/membership/MembershipConfigEditor.test.tsx +++ b/apps/admin/src/features/settings/components/membership/MembershipConfigEditor.test.tsx @@ -104,6 +104,9 @@ describe('MembershipConfigEditor', () => { expect(harness.container.textContent).toContain( 'https://mx.example.com/api/v3/membership/webhook/dodo', ) + expect(harness.container.textContent).toContain( + 'https://mx.example.com/api/v3/membership/webhook/apple', + ) for (const event of MEMBERSHIP_WEBHOOK_EVENTS) { expect(harness.container.textContent).toContain(event) } diff --git a/apps/admin/src/features/settings/components/membership/MembershipConfigEditor.tsx b/apps/admin/src/features/settings/components/membership/MembershipConfigEditor.tsx index a2a60b7943e..176b8dbd959 100644 --- a/apps/admin/src/features/settings/components/membership/MembershipConfigEditor.tsx +++ b/apps/admin/src/features/settings/components/membership/MembershipConfigEditor.tsx @@ -9,6 +9,7 @@ import { KeyRound, PackageOpen, PlugZap, + Smartphone, Webhook, } from 'lucide-react' import { toast } from 'sonner' @@ -26,6 +27,7 @@ import { TextInput } from '~/ui/primitives/text-field' import { buildMembershipWebhookUrl, + getAppleIapSetupChecks, getMembershipSetupChecks, MEMBERSHIP_WEBHOOK_EVENTS, type MembershipConfigValue, @@ -135,10 +137,12 @@ export function MembershipConfigEditor(props: { const environment = props.value.environment || 'live_mode' const status = statusQuery.data ?? { apiKeyConfigured: false, + applePrivateKeyConfigured: false, supportedProviders: ['dodo'], webhookSigningKeyConfigured: false, } const checks = getMembershipSetupChecks(props.value, status) + const appleChecks = getAppleIapSetupChecks(props.value, status) const completedCount = Object.values(checks).filter(Boolean).length const totalCount = Object.keys(checks).length const setupComplete = completedCount === totalCount @@ -148,10 +152,17 @@ export function MembershipConfigEditor(props: { const webhookKeyConfigured = Boolean(props.value.webhookSigningKey?.trim()) || status.webhookSigningKeyConfigured + const applePrivateKeyConfigured = + Boolean(props.value.applePrivateKey?.trim()) || + Boolean(status.applePrivateKeyConfigured) const webhookUrl = buildMembershipWebhookUrl( API_URL || location.origin, provider, ) + const appleWebhookUrl = buildMembershipWebhookUrl( + API_URL || location.origin, + 'apple', + ) const rawProviderOptions = getStringOptions(props.fields, 'provider') const providerOptions = rawProviderOptions.map((option) => ({ ...option, @@ -177,9 +188,9 @@ export function MembershipConfigEditor(props: { value: MembershipConfigValue[TKey], ) => props.onChange({ ...props.value, [key]: value }) - const copyWebhookUrl = async () => { + const copyWebhookUrl = async (url: string) => { try { - await navigator.clipboard.writeText(webhookUrl) + await navigator.clipboard.writeText(url) toast.success(t('settings.membership.webhook.copySuccess')) } catch { toast.error(t('settings.membership.webhook.copyFailed')) @@ -324,7 +335,7 @@ export function MembershipConfigEditor(props: { -
+
void copyWebhookUrl()} + onClick={() => void copyWebhookUrl(webhookUrl)} type="button" variant="secondary" > @@ -416,6 +427,98 @@ export function MembershipConfigEditor(props: {
+ +
+
+ +

+ {Object.values(appleChecks).filter(Boolean).length}/ + {Object.keys(appleChecks).length} +

+
+
+
+ update('appleBundleId', value)} + placeholder={t('settings.membership.apple.bundleId.placeholder')} + value={props.value.appleBundleId ?? ''} + /> + update('appleKeyId', value)} + placeholder={t('settings.membership.apple.keyId.placeholder')} + value={props.value.appleKeyId ?? ''} + /> + update('appleIssuerId', value)} + placeholder={t('settings.membership.apple.issuerId.placeholder')} + value={props.value.appleIssuerId ?? ''} + /> + update('appleAppAppleId', value)} + placeholder={t( + 'settings.membership.apple.appAppleId.placeholder', + )} + value={props.value.appleAppAppleId ?? ''} + /> +
+

+ {t('settings.membership.apple.appAppleId.description')} +

+ update('applePrivateKey', value)} + placeholder={t('settings.membership.apple.privateKey.placeholder')} + value={props.value.applePrivateKey ?? ''} + /> +
+ update('appleMonthlyProductId', value)} + placeholder={t('settings.membership.apple.monthly.placeholder')} + value={props.value.appleMonthlyProductId ?? ''} + /> + update('appleYearlyProductId', value)} + placeholder={t('settings.membership.apple.yearly.placeholder')} + value={props.value.appleYearlyProductId ?? ''} + /> +
+
+

+ {t('settings.membership.webhook.endpointLabel')} +

+
+ + {appleWebhookUrl} + + +
+

+ {t('settings.membership.apple.webhook.description')} +

+
+
+
) } diff --git a/apps/admin/src/features/settings/utils/membership.test.ts b/apps/admin/src/features/settings/utils/membership.test.ts index 1cf357bb884..bca4f7330a5 100644 --- a/apps/admin/src/features/settings/utils/membership.test.ts +++ b/apps/admin/src/features/settings/utils/membership.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest' import { buildMembershipWebhookUrl, + getAppleIapSetupChecks, getMembershipSetupChecks, } from './membership' @@ -11,6 +12,12 @@ describe('buildMembershipWebhookUrl', () => { buildMembershipWebhookUrl('https://mx.example.com/api/v3/', 'dodo'), ).toBe('https://mx.example.com/api/v3/membership/webhook/dodo') }) + + it('builds the Apple webhook URL', () => { + expect( + buildMembershipWebhookUrl('https://mx.example.com/api/v3/', 'apple'), + ).toBe('https://mx.example.com/api/v3/membership/webhook/apple') + }) }) describe('getMembershipSetupChecks', () => { @@ -53,3 +60,32 @@ describe('getMembershipSetupChecks', () => { expect(Object.values(checks).every(Boolean)).toBe(true) }) }) + +describe('getAppleIapSetupChecks', () => { + it('is complete when all Apple fields or persisted private key are present', () => { + expect( + getAppleIapSetupChecks( + { + appleBundleId: 'dev.yohaku.app', + appleIssuerId: 'ISSUER', + appleKeyId: 'KEYID', + appleMonthlyProductId: 'monthly', + appleYearlyProductId: 'yearly', + }, + { + apiKeyConfigured: false, + applePrivateKeyConfigured: true, + supportedProviders: ['dodo'], + webhookSigningKeyConfigured: false, + }, + ), + ).toEqual({ + bundleId: true, + issuerId: true, + keyId: true, + monthlyProductId: true, + privateKey: true, + yearlyProductId: true, + }) + }) +}) diff --git a/apps/admin/src/features/settings/utils/membership.ts b/apps/admin/src/features/settings/utils/membership.ts index ee8b5e90cf8..f49d93c8600 100644 --- a/apps/admin/src/features/settings/utils/membership.ts +++ b/apps/admin/src/features/settings/utils/membership.ts @@ -9,6 +9,13 @@ export const MEMBERSHIP_WEBHOOK_EVENTS = [ export interface MembershipConfigValue { apiKey?: string + appleAppAppleId?: string + appleBundleId?: string + appleIssuerId?: string + appleKeyId?: string + appleMonthlyProductId?: string + applePrivateKey?: string + appleYearlyProductId?: string enabled?: boolean environment?: string monthlyProductId?: string @@ -19,6 +26,7 @@ export interface MembershipConfigValue { export interface MembershipCredentialStatus { apiKeyConfigured: boolean + applePrivateKeyConfigured?: boolean supportedProviders: string[] webhookSigningKeyConfigured: boolean } @@ -47,3 +55,20 @@ export function getMembershipSetupChecks( webhookSigningKey: hasWebhookSigningKey, } } + +export function getAppleIapSetupChecks( + config: MembershipConfigValue, + status?: MembershipCredentialStatus, +) { + const hasPrivateKey = + Boolean(config.applePrivateKey?.trim()) || + Boolean(status?.applePrivateKeyConfigured) + return { + bundleId: Boolean(config.appleBundleId?.trim()), + issuerId: Boolean(config.appleIssuerId?.trim()), + keyId: Boolean(config.appleKeyId?.trim()), + monthlyProductId: Boolean(config.appleMonthlyProductId?.trim()), + privateKey: hasPrivateKey, + yearlyProductId: Boolean(config.appleYearlyProductId?.trim()), + } +} diff --git a/apps/admin/src/i18n/resources/en-US.ts b/apps/admin/src/i18n/resources/en-US.ts index b652d9bd6d0..5da75e37565 100644 --- a/apps/admin/src/i18n/resources/en-US.ts +++ b/apps/admin/src/i18n/resources/en-US.ts @@ -2648,6 +2648,30 @@ export const enUS = { 'settings.membership.webhook.signingKeyPlaceholder': 'Paste the endpoint Secret Key', 'settings.membership.webhook.title': 'Webhook delivery', + 'settings.membership.apple.title': 'App Store in-app purchase', + 'settings.membership.apple.description': + 'iOS subscriptions use StoreKit, not the web checkout provider. Fill every field to expose the in-app buy buttons.', + 'settings.membership.apple.bundleId.label': 'Bundle ID', + 'settings.membership.apple.bundleId.placeholder': 'dev.yohaku.app', + 'settings.membership.apple.keyId.label': 'Key ID', + 'settings.membership.apple.keyId.placeholder': 'Ten-character key ID', + 'settings.membership.apple.issuerId.label': 'Issuer ID', + 'settings.membership.apple.issuerId.placeholder': 'App Store Connect issuer', + 'settings.membership.apple.privateKey.label': '.p8 private key', + 'settings.membership.apple.privateKey.description': + 'Full contents of the In-App Purchase key, including the BEGIN and END lines.', + 'settings.membership.apple.privateKey.placeholder': + '-----BEGIN PRIVATE KEY-----', + 'settings.membership.apple.monthly.label': 'Monthly product ID', + 'settings.membership.apple.monthly.placeholder': 'yohaku.membership.monthly', + 'settings.membership.apple.yearly.label': 'Yearly product ID', + 'settings.membership.apple.yearly.placeholder': 'yohaku.membership.yearly', + 'settings.membership.apple.appAppleId.label': 'App Apple ID', + 'settings.membership.apple.appAppleId.description': + 'Numeric App Store app id. Required to verify production transactions.', + 'settings.membership.apple.appAppleId.placeholder': '1234567890', + 'settings.membership.apple.webhook.description': + 'Point App Store Server Notifications V2 at this URL.', 'settings.oauth.action.save': 'Save config', 'settings.oauth.apple.bundleIdHelp': 'Bundle ID of the native app, used only to verify id tokens signed for iOS. Leave blank for web-only sign-in.', diff --git a/apps/admin/src/i18n/resources/zh-CN.ts b/apps/admin/src/i18n/resources/zh-CN.ts index afeec7f07aa..dc86cfb282a 100644 --- a/apps/admin/src/i18n/resources/zh-CN.ts +++ b/apps/admin/src/i18n/resources/zh-CN.ts @@ -2511,6 +2511,30 @@ export const zhCN = { 'settings.membership.webhook.signingKeyPlaceholder': '粘贴该 Endpoint 的 Secret Key', 'settings.membership.webhook.title': 'Webhook 回调', + 'settings.membership.apple.title': 'App Store 内购', + 'settings.membership.apple.description': + 'iOS 订阅走 StoreKit,不占用网页结账渠道。六项都填齐后,App 才会显示购买入口。', + 'settings.membership.apple.bundleId.label': 'Bundle ID', + 'settings.membership.apple.bundleId.placeholder': 'dev.yohaku.app', + 'settings.membership.apple.keyId.label': 'Key ID', + 'settings.membership.apple.keyId.placeholder': '10 位密钥 ID', + 'settings.membership.apple.issuerId.label': 'Issuer ID', + 'settings.membership.apple.issuerId.placeholder': 'App Store Connect Issuer', + 'settings.membership.apple.privateKey.label': '.p8 私钥', + 'settings.membership.apple.privateKey.description': + '粘贴 In-App Purchase 密钥全文,含 BEGIN PRIVATE KEY 与 END PRIVATE KEY。', + 'settings.membership.apple.privateKey.placeholder': + '-----BEGIN PRIVATE KEY-----', + 'settings.membership.apple.monthly.label': '月付商品 ID', + 'settings.membership.apple.monthly.placeholder': 'yohaku.membership.monthly', + 'settings.membership.apple.yearly.label': '年付商品 ID', + 'settings.membership.apple.yearly.placeholder': 'yohaku.membership.yearly', + 'settings.membership.apple.appAppleId.label': 'App Apple ID', + 'settings.membership.apple.appAppleId.description': + 'App Store 数字应用 ID。正式环境验签需要填写。', + 'settings.membership.apple.appAppleId.placeholder': '1234567890', + 'settings.membership.apple.webhook.description': + '将 App Store Server Notifications V2 指向此地址。', 'settings.oauth.action.save': '保存配置', 'settings.oauth.apple.bundleIdHelp': '原生 App 的 Bundle ID,仅用于校验 iOS 端签发的 id token。纯 Web 登录可留空。', diff --git a/apps/core/src/modules/membership/membership.controller.ts b/apps/core/src/modules/membership/membership.controller.ts index 55e3151da50..f7fe94b67ad 100644 --- a/apps/core/src/modules/membership/membership.controller.ts +++ b/apps/core/src/modules/membership/membership.controller.ts @@ -184,6 +184,7 @@ export class MembershipController { return { apiKeyConfigured: Boolean(membershipConfig.apiKey), + applePrivateKeyConfigured: Boolean(membershipConfig.applePrivateKey), supportedProviders: REGISTERED_PAYMENT_PROVIDERS, webhookSigningKeyConfigured: Boolean(membershipConfig.webhookSigningKey), } diff --git a/apps/core/test/src/modules/membership/membership.controller.e2e-spec.ts b/apps/core/test/src/modules/membership/membership.controller.e2e-spec.ts index 1848cf585bd..f37c8f06882 100644 --- a/apps/core/test/src/modules/membership/membership.controller.e2e-spec.ts +++ b/apps/core/test/src/modules/membership/membership.controller.e2e-spec.ts @@ -266,6 +266,7 @@ describe('MembershipController (e2e)', () => { expect(res.json()).toEqual({ data: { api_key_configured: true, + apple_private_key_configured: false, supported_providers: ['dodo'], webhook_signing_key_configured: true, }, diff --git a/packages/api-client/__tests__/controllers/membership.test.ts b/packages/api-client/__tests__/controllers/membership.test.ts index f85ef15aded..fe5111f8c7e 100644 --- a/packages/api-client/__tests__/controllers/membership.test.ts +++ b/packages/api-client/__tests__/controllers/membership.test.ts @@ -55,4 +55,25 @@ describe('test Membership client', () => { const data = await client.membership.status() expect(data).toEqual({ status: 'none' }) }) + + test('POST /membership/apple/confirm', async () => { + mockResponse( + '/membership/apple/confirm', + { + status: 'active', + plan: 'monthly', + provider: 'apple', + current_period_end: '2027-08-20T00:00:00.000Z', + }, + 'post', + { signedTransactionInfo: 'jws' }, + ) + + const data = await client.membership.confirmApple('jws') + expect(data).toMatchObject({ + status: 'active', + plan: 'monthly', + provider: 'apple', + }) + }) }) diff --git a/packages/api-client/controllers/membership.ts b/packages/api-client/controllers/membership.ts index 0d410d4055a..9abea0f9b22 100644 --- a/packages/api-client/controllers/membership.ts +++ b/packages/api-client/controllers/membership.ts @@ -45,4 +45,10 @@ export class MembershipController implements IController { plans() { return this.proxy.plans.get() } + + confirmApple(signedTransactionInfo: string) { + return this.proxy.apple.confirm.post({ + data: { signedTransactionInfo }, + }) + } } diff --git a/packages/api-client/models/membership.ts b/packages/api-client/models/membership.ts index cd4cc370d32..6493913eb41 100644 --- a/packages/api-client/models/membership.ts +++ b/packages/api-client/models/membership.ts @@ -1,5 +1,5 @@ export type MembershipProvider = - 'dodo' | 'creem' | 'lemonsqueezy' | 'stripe' | 'manual' + 'dodo' | 'creem' | 'lemonsqueezy' | 'stripe' | 'manual' | 'apple' export type MembershipPlan = 'monthly' | 'yearly' @@ -21,7 +21,14 @@ export interface MembershipPlanInfo { pricing?: MembershipPlanPricing } +export interface MembershipAppleIap { + enabled: boolean + monthlyProductId?: string + yearlyProductId?: string +} + export interface MembershipPlansResult { + appleIap: MembershipAppleIap enabled: boolean plans: MembershipPlanInfo[] } From fa46138159dea8adb70c5f8482a9da4ae359b80f Mon Sep 17 00:00:00 2001 From: Innei Date: Thu, 20 Aug 2026 19:53:36 +0800 Subject: [PATCH 07/12] fix(membership): address Apple IAP review feedback --- .../modules/membership/membership.service.ts | 28 +++--- .../modules/membership/membership.types.ts | 7 ++ .../membership/providers/apple.provider.ts | 14 ++- .../modules/membership/apple.provider.spec.ts | 41 +++++++++ .../membership.controller.e2e-spec.ts | 3 + .../membership/membership.service.spec.ts | 92 +++++++++++++++++++ .../membership/membership.types.spec.ts | 14 +++ 7 files changed, 181 insertions(+), 18 deletions(-) diff --git a/apps/core/src/modules/membership/membership.service.ts b/apps/core/src/modules/membership/membership.service.ts index 44020ec57c3..d7272e33868 100644 --- a/apps/core/src/modules/membership/membership.service.ts +++ b/apps/core/src/modules/membership/membership.service.ts @@ -27,6 +27,11 @@ const isLiveProviderSubscription = (row: MembershipRow): boolean => { return row.currentPeriodEnd.getTime() > Date.now() } +const hasCurrentEntitlement = (row: MembershipRow): boolean => { + const status = effectiveMembershipStatus(row) + return status === 'active' || status === 'on_hold' +} + @Injectable() export class MembershipService { constructor( @@ -82,31 +87,19 @@ export class MembershipService { ) if ( byReader && - isLiveProviderSubscription(byReader) && + hasCurrentEntitlement(byReader) && byReader.provider !== 'apple' ) { return this.toStatusResult(byReader) } const event = appleActivatedEvent(input.decoded, input.readerId, plan) - const applied = await this.applyEvent({ + await this.applyEvent({ event, rawPayload: input.decoded, rawType: 'apple.confirm', }) - if (!applied.applied && byReader?.provider === 'apple') { - await this.membershipRepository.update(byReader.id, { - provider: 'apple', - providerCustomerId: - input.decoded.appAccountToken ?? input.decoded.originalTransactionId, - providerSubscriptionId: input.decoded.originalTransactionId, - plan, - status: 'active', - currentPeriodEnd: new Date(input.decoded.expiresDate), - }) - } - return this.toStatusResult( await this.membershipRepository.findByReaderId(input.readerId), ) @@ -165,9 +158,10 @@ export class MembershipService { ) if (byReader) { const canBindInitialSubscription = - byReader.provider === event.provider && - byReader.providerSubscriptionId === null && - event.type === 'activated' + event.type === 'activated' && + ((byReader.provider === event.provider && + byReader.providerSubscriptionId === null) || + !hasCurrentEntitlement(byReader)) if (!canBindInitialSubscription) return false existing = byReader } diff --git a/apps/core/src/modules/membership/membership.types.ts b/apps/core/src/modules/membership/membership.types.ts index 4542217a7ca..d406ba48b20 100644 --- a/apps/core/src/modules/membership/membership.types.ts +++ b/apps/core/src/modules/membership/membership.types.ts @@ -67,9 +67,15 @@ export interface AppleIapAvailability { const nonEmpty = (value?: string) => Boolean(value?.trim()) +const positiveInteger = (value?: string) => { + const parsed = Number(value?.trim()) + return Number.isSafeInteger(parsed) && parsed > 0 +} + export function resolveAppleIapAvailability(config: { enabled?: boolean appleBundleId?: string + appleAppAppleId?: string appleKeyId?: string appleIssuerId?: string applePrivateKey?: string @@ -81,6 +87,7 @@ export function resolveAppleIapAvailability(config: { const enabled = !!config.enabled && nonEmpty(config.appleBundleId) && + positiveInteger(config.appleAppAppleId) && nonEmpty(config.appleKeyId) && nonEmpty(config.appleIssuerId) && nonEmpty(config.applePrivateKey) && diff --git a/apps/core/src/modules/membership/providers/apple.provider.ts b/apps/core/src/modules/membership/providers/apple.provider.ts index 055b9ceec3c..7e03c29f68a 100644 --- a/apps/core/src/modules/membership/providers/apple.provider.ts +++ b/apps/core/src/modules/membership/providers/apple.provider.ts @@ -13,6 +13,7 @@ import { APPLE_ROOT_CA_PEMS } from './apple-root-cas' import { type AppleDecodedTransaction, appleNotificationEventType, + planFromAppleProductId, } from './apple-transaction' import type { BillingWebhookResult, @@ -162,6 +163,16 @@ export class AppleProvider implements PaymentProviderAdapter { } } + const monthlyProductId = membershipConfig.appleMonthlyProductId?.trim() + const yearlyProductId = membershipConfig.appleYearlyProductId?.trim() + const plan = + decoded.productId && monthlyProductId && yearlyProductId + ? (planFromAppleProductId(decoded.productId, { + monthlyProductId, + yearlyProductId, + }) ?? undefined) + : undefined + return { event: { eventId: notificationUUID, @@ -169,6 +180,7 @@ export class AppleProvider implements PaymentProviderAdapter { type, customerId: decoded.appAccountToken ?? decoded.originalTransactionId, subscriptionId: decoded.originalTransactionId, + plan, currentPeriodEnd: new Date(decoded.expiresDate), readerId: '', }, @@ -228,7 +240,7 @@ export class AppleProvider implements PaymentProviderAdapter { private environmentsToTry(appleAppAppleId?: string) { const appAppleId = Number(appleAppAppleId) const production = - Number.isFinite(appAppleId) && appAppleId > 0 + Number.isSafeInteger(appAppleId) && appAppleId > 0 ? [ { environment: Environment.PRODUCTION, diff --git a/apps/core/test/src/modules/membership/apple.provider.spec.ts b/apps/core/test/src/modules/membership/apple.provider.spec.ts index ec42782f5da..7eba357a90e 100644 --- a/apps/core/test/src/modules/membership/apple.provider.spec.ts +++ b/apps/core/test/src/modules/membership/apple.provider.spec.ts @@ -21,4 +21,45 @@ describe('AppleProvider.createCheckout', () => { code: AppErrorCode.MEMBERSHIP_PROVIDER_NOT_CONFIGURED, }) }) + + it.each([ + ['DID_RENEW', 'renewed'], + ['DID_CHANGE_RENEWAL_PREF', 'plan_changed'], + ] as const)( + 'maps the current Apple product during %s', + async (notificationType, expectedType) => { + get.mockResolvedValue({ + appleAppAppleId: '1234567890', + appleBundleId: 'dev.yohaku.app', + appleMonthlyProductId: 'yohaku.membership.monthly', + appleYearlyProductId: 'yohaku.membership.yearly', + }) + const provider = new AppleProvider({ get } as any) + vi.spyOn( + provider as any, + 'verifyNotificationWithFallback', + ).mockResolvedValue({ + data: { signedTransactionInfo: 'signed-transaction' }, + notificationType, + notificationUUID: 'notification-1', + }) + vi.spyOn( + provider as any, + 'verifyTransactionWithFallback', + ).mockResolvedValue({ + expiresDate: Date.parse('2026-09-01T00:00:00.000Z'), + originalTransactionId: 'original-1', + productId: 'yohaku.membership.yearly', + }) + + await expect( + provider.verifyAndParseWebhook( + JSON.stringify({ signedPayload: 'notification-jws' }), + {}, + ), + ).resolves.toMatchObject({ + event: { plan: 'yearly', type: expectedType }, + }) + }, + ) }) diff --git a/apps/core/test/src/modules/membership/membership.controller.e2e-spec.ts b/apps/core/test/src/modules/membership/membership.controller.e2e-spec.ts index f37c8f06882..58606bf9d27 100644 --- a/apps/core/test/src/modules/membership/membership.controller.e2e-spec.ts +++ b/apps/core/test/src/modules/membership/membership.controller.e2e-spec.ts @@ -240,6 +240,7 @@ describe('MembershipController (e2e)', () => { membershipConfig.enabled = true membershipConfig.provider = 'dodo' membershipConfig.webhookSigningKey = 'webhook-key' + delete (membershipConfig as { appleAppAppleId?: string }).appleAppAppleId delete (membershipConfig as { appleBundleId?: string }).appleBundleId delete (membershipConfig as { appleKeyId?: string }).appleKeyId delete (membershipConfig as { appleIssuerId?: string }).appleIssuerId @@ -528,6 +529,7 @@ describe('MembershipController (e2e)', () => { it('reports appleIap when Apple fields are configured', async () => { Object.assign(membershipConfig, { + appleAppAppleId: '1234567890', appleBundleId: 'dev.yohaku.app', appleKeyId: 'KEYID', appleIssuerId: 'ISSUER', @@ -554,6 +556,7 @@ describe('MembershipController (e2e)', () => { describe('POST /membership/apple/confirm', () => { it('returns the new apple membership for a signed-in reader', async () => { Object.assign(membershipConfig, { + appleAppAppleId: '1234567890', appleBundleId: 'dev.yohaku.app', appleKeyId: 'KEYID', appleIssuerId: 'ISSUER', diff --git a/apps/core/test/src/modules/membership/membership.service.spec.ts b/apps/core/test/src/modules/membership/membership.service.spec.ts index 522051dbb65..f1a43baac3c 100644 --- a/apps/core/test/src/modules/membership/membership.service.spec.ts +++ b/apps/core/test/src/modules/membership/membership.service.spec.ts @@ -543,6 +543,98 @@ describe('MembershipService', () => { expect(result).toMatchObject({ provider: 'dodo', status: 'active' }) }) + it('returns an existing active manual membership without rewriting it', async () => { + const { service, membershipRepository } = createService() + const live = createMembership({ + provider: 'manual', + providerCustomerId: null, + providerSubscriptionId: null, + }) + membershipRepository.findByReaderId.mockResolvedValue(live) + + const result = await service.confirmAppleTransaction({ + decoded, + readerId: 'reader-1', + ...products, + }) + + expect(membershipRepository.create).not.toHaveBeenCalled() + expect(membershipRepository.update).not.toHaveBeenCalled() + expect(result).toMatchObject({ provider: 'manual', status: 'active' }) + }) + + it('rebinds an inactive non-Apple row to the confirmed Apple subscription', async () => { + const { service, membershipRepository } = createService() + const inactive = createMembership({ + provider: 'dodo', + status: 'cancelled', + currentPeriodEnd: new Date(now.getTime() - 1_000), + }) + const rebound = createMembership({ + provider: 'apple', + providerCustomerId: 'orig-apple', + providerSubscriptionId: 'orig-apple', + plan: 'monthly', + status: 'active', + currentPeriodEnd: new Date(decoded.expiresDate), + }) + membershipRepository.findByReaderId + .mockResolvedValueOnce(inactive) + .mockResolvedValueOnce(inactive) + .mockResolvedValueOnce(rebound) + + const result = await service.confirmAppleTransaction({ + decoded, + readerId: 'reader-1', + ...products, + }) + + expect(membershipRepository.update).toHaveBeenCalledWith( + inactive.id, + expect.objectContaining({ + plan: 'monthly', + provider: 'apple', + providerSubscriptionId: 'orig-apple', + status: 'active', + }), + ) + expect(result).toMatchObject({ provider: 'apple', status: 'active' }) + }) + + it('does not reactivate a cancelled Apple membership when confirmation is replayed', async () => { + const { service, membershipRepository, billingWebhookEventRepository } = + createService() + const cancelled = createMembership({ + provider: 'apple', + providerCustomerId: 'orig-apple', + providerSubscriptionId: 'orig-apple', + status: 'cancelled', + }) + membershipRepository.findByProviderSubscriptionId.mockResolvedValue( + cancelled, + ) + membershipRepository.findByReaderId.mockResolvedValue(cancelled) + billingWebhookEventRepository.create.mockResolvedValue(null) + billingWebhookEventRepository.findByProviderAndEventId.mockResolvedValue({ + id: 'event-apple' as any, + provider: 'apple', + eventId: decoded.transactionId, + type: 'apple.confirm', + payload: decoded, + processedAt: now, + receivedAt: now, + }) + + const result = await service.confirmAppleTransaction({ + decoded, + readerId: 'reader-1', + ...products, + }) + + expect(membershipRepository.update).not.toHaveBeenCalled() + expect(result).toMatchObject({ provider: 'apple', status: 'cancelled' }) + }) + it('rejects when the originalTransactionId is bound to another reader', async () => { const { service, membershipRepository } = createService() membershipRepository.findByProviderSubscriptionId.mockResolvedValue( diff --git a/apps/core/test/src/modules/membership/membership.types.spec.ts b/apps/core/test/src/modules/membership/membership.types.spec.ts index b16ed2707a7..c5899d15e96 100644 --- a/apps/core/test/src/modules/membership/membership.types.spec.ts +++ b/apps/core/test/src/modules/membership/membership.types.spec.ts @@ -118,6 +118,7 @@ describe('resolveMembershipReturnUrl', () => { }) const appleCredentials = { + appleAppAppleId: '1234567890', appleBundleId: 'dev.yohaku.app', appleKeyId: 'KEYID', appleIssuerId: 'ISSUER', @@ -152,4 +153,17 @@ describe('resolveAppleIapAvailability', () => { }), ).toEqual({ enabled: false }) }) + + it.each([undefined, '', 'not-a-number', '1.5', '0'])( + 'is disabled when the App Apple ID is not a positive integer (%s)', + (appleAppAppleId) => { + expect( + resolveAppleIapAvailability({ + enabled: true, + ...appleCredentials, + appleAppAppleId, + }), + ).toEqual({ enabled: false }) + }, + ) }) From 191fae9498679cc325911f7b3ed2da7ffda06f78 Mon Sep 17 00:00:00 2001 From: Innei Date: Fri, 21 Aug 2026 00:13:11 +0800 Subject: [PATCH 08/12] fix(membership): validate Apple renewal state --- .../modules/membership/membership.service.ts | 6 ++ .../membership/providers/apple-transaction.ts | 1 + .../membership/providers/apple.provider.ts | 66 +++++++++++++++- .../modules/membership/apple.provider.spec.ts | 75 ++++++++++++++++++- .../membership/membership.service.spec.ts | 19 +++++ 5 files changed, 164 insertions(+), 3 deletions(-) diff --git a/apps/core/src/modules/membership/membership.service.ts b/apps/core/src/modules/membership/membership.service.ts index d7272e33868..a85fb29543b 100644 --- a/apps/core/src/modules/membership/membership.service.ts +++ b/apps/core/src/modules/membership/membership.service.ts @@ -65,6 +65,12 @@ export class MembershipService { status: MembershipStatus } > { + if (input.decoded.revocationDate !== undefined) { + throw createAppException( + AppErrorCode.MEMBERSHIP_APPLE_TRANSACTION_INVALID, + ) + } + const plan = planFromAppleProductId(input.decoded.productId, { monthlyProductId: input.monthlyProductId, yearlyProductId: input.yearlyProductId, diff --git a/apps/core/src/modules/membership/providers/apple-transaction.ts b/apps/core/src/modules/membership/providers/apple-transaction.ts index 7937ce6039c..1b140712c57 100644 --- a/apps/core/src/modules/membership/providers/apple-transaction.ts +++ b/apps/core/src/modules/membership/providers/apple-transaction.ts @@ -6,6 +6,7 @@ export interface AppleDecodedTransaction { expiresDate: number originalTransactionId: string productId: string + revocationDate?: number transactionId: string } diff --git a/apps/core/src/modules/membership/providers/apple.provider.ts b/apps/core/src/modules/membership/providers/apple.provider.ts index 7e03c29f68a..711f2a01bf9 100644 --- a/apps/core/src/modules/membership/providers/apple.provider.ts +++ b/apps/core/src/modules/membership/providers/apple.provider.ts @@ -1,5 +1,6 @@ import { Environment, + type JWSRenewalInfoDecodedPayload, type JWSTransactionDecodedPayload, SignedDataVerifier, } from '@apple/app-store-server-library' @@ -68,6 +69,7 @@ export class AppleProvider implements PaymentProviderAdapter { expiresDate: decoded.expiresDate, originalTransactionId: decoded.originalTransactionId, productId: decoded.productId, + revocationDate: decoded.revocationDate, transactionId: decoded.transactionId, } } catch (error) { @@ -113,6 +115,8 @@ export class AppleProvider implements PaymentProviderAdapter { } let notificationType: string + let notificationSubtype: string + let signedRenewalInfo: string | undefined let signedTransactionInfo: string | undefined let notificationUUID: string try { @@ -122,7 +126,9 @@ export class AppleProvider implements PaymentProviderAdapter { membershipConfig.appleAppAppleId, ) notificationType = notification.notificationType ?? '' + notificationSubtype = notification.subtype ?? '' notificationUUID = notification.notificationUUID ?? notificationType + signedRenewalInfo = notification.data?.signedRenewalInfo signedTransactionInfo = notification.data?.signedTransactionInfo } catch (error) { this.logger.warn( @@ -173,6 +179,33 @@ export class AppleProvider implements PaymentProviderAdapter { }) ?? undefined) : undefined + let currentPeriodEnd = decoded.expiresDate + let renewalInfo: JWSRenewalInfoDecodedPayload | undefined + if ( + notificationType === 'DID_FAIL_TO_RENEW' && + notificationSubtype === 'GRACE_PERIOD' + ) { + renewalInfo = signedRenewalInfo + ? await this.verifyRenewalInfoWithFallback( + signedRenewalInfo, + bundleId, + membershipConfig.appleAppAppleId, + ).catch(() => undefined) + : undefined + if ( + !renewalInfo?.gracePeriodExpiresDate || + (renewalInfo.originalTransactionId && + renewalInfo.originalTransactionId !== decoded.originalTransactionId) + ) { + return { + ignored: true, + rawType: notificationType, + reason: 'missing_reader_metadata', + } + } + currentPeriodEnd = renewalInfo.gracePeriodExpiresDate + } + return { event: { eventId: notificationUUID, @@ -181,11 +214,16 @@ export class AppleProvider implements PaymentProviderAdapter { customerId: decoded.appAccountToken ?? decoded.originalTransactionId, subscriptionId: decoded.originalTransactionId, plan, - currentPeriodEnd: new Date(decoded.expiresDate), + currentPeriodEnd: new Date(currentPeriodEnd), readerId: '', }, rawType: notificationType, - rawPayload: { notificationType, decoded }, + rawPayload: { + notificationType, + notificationSubtype, + decoded, + renewalInfo, + }, } } @@ -237,6 +275,30 @@ export class AppleProvider implements PaymentProviderAdapter { throw lastError instanceof Error ? lastError : new Error('verify failed') } + private async verifyRenewalInfoWithFallback( + signedRenewalInfo: string, + bundleId: string, + appleAppAppleId?: string, + ): Promise { + const environments = this.environmentsToTry(appleAppAppleId) + let lastError: unknown + for (const { environment, appAppleId } of environments) { + try { + const verifier = new SignedDataVerifier( + appleRootCaBuffers, + true, + environment, + bundleId, + appAppleId, + ) + return await verifier.verifyAndDecodeRenewalInfo(signedRenewalInfo) + } catch (error) { + lastError = error + } + } + throw lastError instanceof Error ? lastError : new Error('verify failed') + } + private environmentsToTry(appleAppAppleId?: string) { const appAppleId = Number(appleAppAppleId) const production = diff --git a/apps/core/test/src/modules/membership/apple.provider.spec.ts b/apps/core/test/src/modules/membership/apple.provider.spec.ts index 7eba357a90e..dbf3ad30ca6 100644 --- a/apps/core/test/src/modules/membership/apple.provider.spec.ts +++ b/apps/core/test/src/modules/membership/apple.provider.spec.ts @@ -5,7 +5,7 @@ import { AppleProvider } from '~/modules/membership/providers/apple.provider' const get = vi.fn() -describe('AppleProvider.createCheckout', () => { +describe('AppleProvider', () => { beforeEach(() => { get.mockReset() }) @@ -22,6 +22,30 @@ describe('AppleProvider.createCheckout', () => { }) }) + it('preserves revocation metadata from a verified transaction', async () => { + get.mockResolvedValue({ + appleAppAppleId: '1234567890', + appleBundleId: 'dev.yohaku.app', + }) + const provider = new AppleProvider({ get } as any) + vi.spyOn( + provider as any, + 'verifyTransactionWithFallback', + ).mockResolvedValue({ + expiresDate: Date.parse('2026-09-01T00:00:00.000Z'), + originalTransactionId: 'original-1', + productId: 'yohaku.membership.monthly', + revocationDate: Date.parse('2026-08-21T00:00:00.000Z'), + transactionId: 'transaction-1', + }) + + await expect( + provider.verifySignedTransaction('signed-transaction'), + ).resolves.toMatchObject({ + revocationDate: Date.parse('2026-08-21T00:00:00.000Z'), + }) + }) + it.each([ ['DID_RENEW', 'renewed'], ['DID_CHANGE_RENEWAL_PREF', 'plan_changed'], @@ -62,4 +86,53 @@ describe('AppleProvider.createCheckout', () => { }) }, ) + + it('uses the signed renewal grace-period deadline after a failed renewal', async () => { + get.mockResolvedValue({ + appleAppAppleId: '1234567890', + appleBundleId: 'dev.yohaku.app', + appleMonthlyProductId: 'yohaku.membership.monthly', + appleYearlyProductId: 'yohaku.membership.yearly', + }) + const provider = new AppleProvider({ get } as any) + vi.spyOn( + provider as any, + 'verifyNotificationWithFallback', + ).mockResolvedValue({ + data: { + signedRenewalInfo: 'signed-renewal', + signedTransactionInfo: 'signed-transaction', + }, + notificationType: 'DID_FAIL_TO_RENEW', + notificationUUID: 'notification-1', + subtype: 'GRACE_PERIOD', + }) + vi.spyOn( + provider as any, + 'verifyTransactionWithFallback', + ).mockResolvedValue({ + expiresDate: Date.parse('2026-08-20T00:00:00.000Z'), + originalTransactionId: 'original-1', + productId: 'yohaku.membership.monthly', + }) + vi.spyOn( + provider as any, + 'verifyRenewalInfoWithFallback', + ).mockResolvedValue({ + gracePeriodExpiresDate: Date.parse('2026-08-27T00:00:00.000Z'), + originalTransactionId: 'original-1', + }) + + const result = await provider.verifyAndParseWebhook( + JSON.stringify({ signedPayload: 'notification-jws' }), + {}, + ) + + expect(result).toMatchObject({ + event: { + currentPeriodEnd: new Date('2026-08-27T00:00:00.000Z'), + type: 'on_hold', + }, + }) + }) }) diff --git a/apps/core/test/src/modules/membership/membership.service.spec.ts b/apps/core/test/src/modules/membership/membership.service.spec.ts index f1a43baac3c..afc50a84e7e 100644 --- a/apps/core/test/src/modules/membership/membership.service.spec.ts +++ b/apps/core/test/src/modules/membership/membership.service.spec.ts @@ -664,5 +664,24 @@ describe('MembershipService', () => { code: AppErrorCode.MEMBERSHIP_APPLE_TRANSACTION_INVALID, }) }) + + it('rejects a revoked Apple transaction before granting membership', async () => { + const { service, membershipRepository, billingWebhookEventRepository } = + createService() + + await expect( + service.confirmAppleTransaction({ + decoded: { ...decoded, revocationDate: now.getTime() }, + readerId: 'reader-1', + ...products, + }), + ).rejects.toMatchObject({ + code: AppErrorCode.MEMBERSHIP_APPLE_TRANSACTION_INVALID, + }) + + expect(membershipRepository.create).not.toHaveBeenCalled() + expect(membershipRepository.update).not.toHaveBeenCalled() + expect(billingWebhookEventRepository.create).not.toHaveBeenCalled() + }) }) }) From 90f82564aabe1a00a57989501ff6a306047c51dd Mon Sep 17 00:00:00 2001 From: Innei Date: Fri, 21 Aug 2026 01:22:51 +0800 Subject: [PATCH 09/12] fix(membership): complete Apple setup handling --- .../membership/MembershipConfigEditor.tsx | 6 +-- .../settings/utils/membership.test.ts | 54 +++++++++++++++++++ .../src/features/settings/utils/membership.ts | 31 +++++++++++ .../membership/providers/apple-transaction.ts | 1 + .../modules/membership/apple.provider.spec.ts | 1 + 5 files changed, 90 insertions(+), 3 deletions(-) diff --git a/apps/admin/src/features/settings/components/membership/MembershipConfigEditor.tsx b/apps/admin/src/features/settings/components/membership/MembershipConfigEditor.tsx index 176b8dbd959..c34064cbdd4 100644 --- a/apps/admin/src/features/settings/components/membership/MembershipConfigEditor.tsx +++ b/apps/admin/src/features/settings/components/membership/MembershipConfigEditor.tsx @@ -29,6 +29,7 @@ import { buildMembershipWebhookUrl, getAppleIapSetupChecks, getMembershipSetupChecks, + getMembershipSetupProgress, MEMBERSHIP_WEBHOOK_EVENTS, type MembershipConfigValue, } from '../../utils/membership' @@ -143,9 +144,8 @@ export function MembershipConfigEditor(props: { } const checks = getMembershipSetupChecks(props.value, status) const appleChecks = getAppleIapSetupChecks(props.value, status) - const completedCount = Object.values(checks).filter(Boolean).length - const totalCount = Object.keys(checks).length - const setupComplete = completedCount === totalCount + const { completedCount, setupComplete, totalCount } = + getMembershipSetupProgress(checks, appleChecks) const providerSupported = checks.provider const apiKeyConfigured = Boolean(props.value.apiKey?.trim()) || status.apiKeyConfigured diff --git a/apps/admin/src/features/settings/utils/membership.test.ts b/apps/admin/src/features/settings/utils/membership.test.ts index bca4f7330a5..72dcabf5f86 100644 --- a/apps/admin/src/features/settings/utils/membership.test.ts +++ b/apps/admin/src/features/settings/utils/membership.test.ts @@ -4,6 +4,7 @@ import { buildMembershipWebhookUrl, getAppleIapSetupChecks, getMembershipSetupChecks, + getMembershipSetupProgress, } from './membership' describe('buildMembershipWebhookUrl', () => { @@ -66,6 +67,7 @@ describe('getAppleIapSetupChecks', () => { expect( getAppleIapSetupChecks( { + appleAppAppleId: '1234567890', appleBundleId: 'dev.yohaku.app', appleIssuerId: 'ISSUER', appleKeyId: 'KEYID', @@ -80,6 +82,7 @@ describe('getAppleIapSetupChecks', () => { }, ), ).toEqual({ + appAppleId: true, bundleId: true, issuerId: true, keyId: true, @@ -88,4 +91,55 @@ describe('getAppleIapSetupChecks', () => { yearlyProductId: true, }) }) + + it.each(['', '0', '-1', '1.5', 'not-a-number'])( + 'rejects an invalid App Apple ID: %s', + (appleAppAppleId) => { + const checks = getAppleIapSetupChecks( + { + appleAppAppleId, + appleBundleId: 'dev.yohaku.app', + appleIssuerId: 'ISSUER', + appleKeyId: 'KEYID', + appleMonthlyProductId: 'monthly', + applePrivateKey: 'private-key', + appleYearlyProductId: 'yearly', + }, + { + apiKeyConfigured: false, + supportedProviders: ['dodo'], + webhookSigningKeyConfigured: false, + }, + ) + + expect(checks.appAppleId).toBe(false) + }, + ) + + it('lets a complete Apple-only setup satisfy the enable gate', () => { + const status = { + apiKeyConfigured: false, + supportedProviders: ['dodo'], + webhookSigningKeyConfigured: false, + } + const membershipChecks = getMembershipSetupChecks({}, status) + const appleChecks = getAppleIapSetupChecks( + { + appleAppAppleId: '1234567890', + appleBundleId: 'dev.yohaku.app', + appleIssuerId: 'ISSUER', + appleKeyId: 'KEYID', + appleMonthlyProductId: 'monthly', + applePrivateKey: 'private-key', + appleYearlyProductId: 'yearly', + }, + status, + ) + + expect(getMembershipSetupProgress(membershipChecks, appleChecks)).toEqual({ + completedCount: 7, + setupComplete: true, + totalCount: 7, + }) + }) }) diff --git a/apps/admin/src/features/settings/utils/membership.ts b/apps/admin/src/features/settings/utils/membership.ts index f49d93c8600..1c095e632a0 100644 --- a/apps/admin/src/features/settings/utils/membership.ts +++ b/apps/admin/src/features/settings/utils/membership.ts @@ -31,6 +31,35 @@ export interface MembershipCredentialStatus { webhookSigningKeyConfigured: boolean } +type SetupChecks = Record + +export function getMembershipSetupProgress( + membershipChecks: SetupChecks, + appleChecks: SetupChecks, +) { + const membershipValues = Object.values(membershipChecks) + const appleValues = Object.values(appleChecks) + const membershipCompletedCount = membershipValues.filter(Boolean).length + const appleCompletedCount = appleValues.filter(Boolean).length + const membershipComplete = + membershipCompletedCount === membershipValues.length + const appleComplete = appleCompletedCount === appleValues.length + + if (!membershipComplete && appleComplete) { + return { + completedCount: appleCompletedCount, + setupComplete: true, + totalCount: appleValues.length, + } + } + + return { + completedCount: membershipCompletedCount, + setupComplete: membershipComplete, + totalCount: membershipValues.length, + } +} + export function buildMembershipWebhookUrl(apiUrl: string, provider = 'dodo') { return `${apiUrl.replace(/\/+$/, '')}/membership/webhook/${encodeURIComponent(provider)}` } @@ -60,10 +89,12 @@ export function getAppleIapSetupChecks( config: MembershipConfigValue, status?: MembershipCredentialStatus, ) { + const appAppleId = Number(config.appleAppAppleId?.trim()) const hasPrivateKey = Boolean(config.applePrivateKey?.trim()) || Boolean(status?.applePrivateKeyConfigured) return { + appAppleId: Number.isSafeInteger(appAppleId) && appAppleId > 0, bundleId: Boolean(config.appleBundleId?.trim()), issuerId: Boolean(config.appleIssuerId?.trim()), keyId: Boolean(config.appleKeyId?.trim()), diff --git a/apps/core/src/modules/membership/providers/apple-transaction.ts b/apps/core/src/modules/membership/providers/apple-transaction.ts index 1b140712c57..d24ee452048 100644 --- a/apps/core/src/modules/membership/providers/apple-transaction.ts +++ b/apps/core/src/modules/membership/providers/apple-transaction.ts @@ -42,6 +42,7 @@ const NOTIFICATION_TYPE_MAP: Record< > = { SUBSCRIBED: 'activated', DID_RENEW: 'renewed', + RENEWAL_EXTENDED: 'renewed', DID_FAIL_TO_RENEW: 'on_hold', EXPIRED: 'cancelled', REFUND: 'cancelled', diff --git a/apps/core/test/src/modules/membership/apple.provider.spec.ts b/apps/core/test/src/modules/membership/apple.provider.spec.ts index dbf3ad30ca6..3e596cf2852 100644 --- a/apps/core/test/src/modules/membership/apple.provider.spec.ts +++ b/apps/core/test/src/modules/membership/apple.provider.spec.ts @@ -48,6 +48,7 @@ describe('AppleProvider', () => { it.each([ ['DID_RENEW', 'renewed'], + ['RENEWAL_EXTENDED', 'renewed'], ['DID_CHANGE_RENEWAL_PREF', 'plan_changed'], ] as const)( 'maps the current Apple product during %s', From 78e147b56f53ad1c67dadda375f12592cd43e7dc Mon Sep 17 00:00:00 2001 From: Innei Date: Fri, 21 Aug 2026 01:44:28 +0800 Subject: [PATCH 10/12] fix(membership): bind Apple purchases to readers --- .../membership/membership.controller.ts | 7 +++ .../modules/membership/membership.service.ts | 10 ++++ .../membership/providers/apple-transaction.ts | 26 +++++++++ .../membership/apple-transaction.spec.ts | 13 +++++ .../membership.controller.e2e-spec.ts | 57 +++++++++++++++++++ .../membership/membership.service.spec.ts | 41 +++++++++++++ 6 files changed, 154 insertions(+) diff --git a/apps/core/src/modules/membership/membership.controller.ts b/apps/core/src/modules/membership/membership.controller.ts index f7fe94b67ad..2bc35142897 100644 --- a/apps/core/src/modules/membership/membership.controller.ts +++ b/apps/core/src/modules/membership/membership.controller.ts @@ -35,6 +35,7 @@ import { resolveMembershipReturnUrl, } from './membership.types' import { AppleProvider } from './providers/apple.provider' +import { appleAccountTokenForReader } from './providers/apple-transaction' import { isIgnoredBillingEvent } from './providers/provider.interface' import { PaymentProviderRegistry } from './providers/provider.registry' @@ -147,6 +148,12 @@ export class MembershipController { return { enabled: true, plans, appleIap } } + @ReaderAuth() + @Get('/apple/account-token') + appleAccountToken(@CurrentUser() user: SessionUser) { + return { accountToken: appleAccountTokenForReader(user.id) } + } + @ReaderAuth() @Post('/apple/confirm') @HttpCode(200) diff --git a/apps/core/src/modules/membership/membership.service.ts b/apps/core/src/modules/membership/membership.service.ts index a85fb29543b..3b4bceef3b6 100644 --- a/apps/core/src/modules/membership/membership.service.ts +++ b/apps/core/src/modules/membership/membership.service.ts @@ -12,6 +12,7 @@ import { type MembershipStatus, } from './membership.types' import { + appleAccountTokenForReader, appleActivatedEvent, type AppleDecodedTransaction, planFromAppleProductId, @@ -65,6 +66,15 @@ export class MembershipService { status: MembershipStatus } > { + if ( + input.decoded.appAccountToken?.toLowerCase() !== + appleAccountTokenForReader(input.readerId) + ) { + throw createAppException( + AppErrorCode.MEMBERSHIP_APPLE_TRANSACTION_INVALID, + ) + } + if (input.decoded.revocationDate !== undefined) { throw createAppException( AppErrorCode.MEMBERSHIP_APPLE_TRANSACTION_INVALID, diff --git a/apps/core/src/modules/membership/providers/apple-transaction.ts b/apps/core/src/modules/membership/providers/apple-transaction.ts index d24ee452048..d236ed66f22 100644 --- a/apps/core/src/modules/membership/providers/apple-transaction.ts +++ b/apps/core/src/modules/membership/providers/apple-transaction.ts @@ -1,6 +1,12 @@ +import { createHmac } from 'node:crypto' + +import { ENCRYPT, SECURITY } from '~/app.config' + import type { MembershipPlan } from '../membership.types' import type { NormalizedBillingEvent } from './provider.interface' +const APPLE_ACCOUNT_TOKEN_DOMAIN = 'yohaku-membership:apple-account-token:v1\0' + export interface AppleDecodedTransaction { appAccountToken?: string expiresDate: number @@ -10,6 +16,26 @@ export interface AppleDecodedTransaction { transactionId: string } +export function appleAccountTokenForReader(readerId: string): string { + const digest = createHmac('sha256', SECURITY.jwtSecret || ENCRYPT.key) + .update(APPLE_ACCOUNT_TOKEN_DOMAIN, 'utf8') + .update(readerId, 'utf8') + .digest() + + // StoreKit requires a UUID. Version 8 identifies this as an application- + // defined UUID while the RFC 4122 variant keeps it universally parseable. + digest[6] = (digest[6] % 16) + 128 + digest[8] = (digest[8] % 64) + 128 + const hex = digest.subarray(0, 16).toString('hex') + return [ + hex.slice(0, 8), + hex.slice(8, 12), + hex.slice(12, 16), + hex.slice(16, 20), + hex.slice(20), + ].join('-') +} + export function planFromAppleProductId( productId: string, products: { monthlyProductId: string; yearlyProductId: string }, diff --git a/apps/core/test/src/modules/membership/apple-transaction.spec.ts b/apps/core/test/src/modules/membership/apple-transaction.spec.ts index 630f58347d3..b2b2efb2a98 100644 --- a/apps/core/test/src/modules/membership/apple-transaction.spec.ts +++ b/apps/core/test/src/modules/membership/apple-transaction.spec.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest' import { + appleAccountTokenForReader, appleActivatedEvent, appleNotificationEventType, planFromAppleProductId, @@ -11,6 +12,18 @@ const products = { yearlyProductId: 'yohaku.membership.yearly', } +describe('appleAccountTokenForReader', () => { + it('returns a stable UUID scoped to the reader account', () => { + const token = appleAccountTokenForReader('reader-1') + + expect(token).toMatch( + /^[\da-f]{8}-[\da-f]{4}-8[\da-f]{3}-[89ab][\da-f]{3}-[\da-f]{12}$/, + ) + expect(appleAccountTokenForReader('reader-1')).toBe(token) + expect(appleAccountTokenForReader('reader-2')).not.toBe(token) + }) +}) + describe('planFromAppleProductId', () => { it('maps configured product ids', () => { expect(planFromAppleProductId('yohaku.membership.monthly', products)).toBe( diff --git a/apps/core/test/src/modules/membership/membership.controller.e2e-spec.ts b/apps/core/test/src/modules/membership/membership.controller.e2e-spec.ts index 58606bf9d27..c9c886abb7b 100644 --- a/apps/core/test/src/modules/membership/membership.controller.e2e-spec.ts +++ b/apps/core/test/src/modules/membership/membership.controller.e2e-spec.ts @@ -23,6 +23,7 @@ import { MembershipController } from '~/modules/membership/membership.controller import { MembershipRepository } from '~/modules/membership/membership.repository' import { MembershipService } from '~/modules/membership/membership.service' import { AppleProvider } from '~/modules/membership/providers/apple.provider' +import { appleAccountTokenForReader } from '~/modules/membership/providers/apple-transaction' import { DodoProvider } from '~/modules/membership/providers/dodo.provider' import { PaymentProviderRegistry } from '~/modules/membership/providers/provider.registry' import type { AppDatabase } from '~/processors/database/postgres.provider' @@ -553,6 +554,29 @@ describe('MembershipController (e2e)', () => { }) }) + describe('GET /membership/apple/account-token', () => { + it('returns the authenticated reader token', async () => { + const res = await proxy.app.inject({ + method: 'GET', + url: '/membership/apple/account-token', + headers: { 'x-test-reader': 'apple-confirm' }, + }) + + expect(res.statusCode).toBe(200) + expect(res.json().data).toEqual({ + account_token: appleAccountTokenForReader(appleConfirmReaderId), + }) + }) + + it('returns 401 without a reader session', async () => { + const res = await proxy.app.inject({ + method: 'GET', + url: '/membership/apple/account-token', + }) + expect(res.statusCode).toBe(401) + }) + }) + describe('POST /membership/apple/confirm', () => { it('returns the new apple membership for a signed-in reader', async () => { Object.assign(membershipConfig, { @@ -566,6 +590,7 @@ describe('MembershipController (e2e)', () => { appleYearlyProductId: 'yohaku.membership.yearly', }) verifySignedTransactionMock.mockResolvedValueOnce({ + appAccountToken: appleAccountTokenForReader(appleConfirmReaderId), expiresDate: Date.now() + 86_400_000, originalTransactionId: 'orig-e2e', productId: 'yohaku.membership.monthly', @@ -587,6 +612,38 @@ describe('MembershipController (e2e)', () => { }) }) + it('rejects a signed transaction owned by another reader', async () => { + Object.assign(membershipConfig, { + appleAppAppleId: '1234567890', + appleBundleId: 'dev.yohaku.app', + appleKeyId: 'KEYID', + appleIssuerId: 'ISSUER', + applePrivateKey: + '-----BEGIN PRIVATE KEY-----\\nX\\n-----END PRIVATE KEY-----', + appleMonthlyProductId: 'yohaku.membership.monthly', + appleYearlyProductId: 'yohaku.membership.yearly', + }) + verifySignedTransactionMock.mockResolvedValueOnce({ + appAccountToken: appleAccountTokenForReader(otherReaderId), + expiresDate: Date.now() + 86_400_000, + originalTransactionId: 'orig-other-reader', + productId: 'yohaku.membership.monthly', + transactionId: 'txn-other-reader', + }) + + const res = await proxy.app.inject({ + method: 'POST', + url: '/membership/apple/confirm', + headers: { 'x-test-reader': 'apple-confirm' }, + payload: { signedTransactionInfo: 'jws' }, + }) + + expect(res.statusCode).toBe(400) + expect(res.json()).toMatchObject({ + error: { code: AppErrorCode.MEMBERSHIP_APPLE_TRANSACTION_INVALID }, + }) + }) + it('returns 401 without a reader session', async () => { const res = await proxy.app.inject({ method: 'POST', diff --git a/apps/core/test/src/modules/membership/membership.service.spec.ts b/apps/core/test/src/modules/membership/membership.service.spec.ts index afc50a84e7e..9d04b7ae823 100644 --- a/apps/core/test/src/modules/membership/membership.service.spec.ts +++ b/apps/core/test/src/modules/membership/membership.service.spec.ts @@ -6,6 +6,7 @@ import type { BillingWebhookEventRepository } from '~/modules/membership/billing import type { MembershipRepository } from '~/modules/membership/membership.repository' import { MembershipService } from '~/modules/membership/membership.service' import type { MembershipRow } from '~/modules/membership/membership.types' +import { appleAccountTokenForReader } from '~/modules/membership/providers/apple-transaction' import type { NormalizedBillingEvent, VerifiedBillingEvent, @@ -490,6 +491,7 @@ describe('MembershipService', () => { describe('confirmAppleTransaction', () => { const decoded = { + appAccountToken: appleAccountTokenForReader('reader-1'), expiresDate: now.getTime() + 86_400_000, originalTransactionId: 'orig-apple', productId: 'yohaku.membership.monthly', @@ -665,6 +667,45 @@ describe('MembershipService', () => { }) }) + it('rejects a transaction purchased for another reader account', async () => { + const { service, membershipRepository, billingWebhookEventRepository } = + createService() + + await expect( + service.confirmAppleTransaction({ + decoded: { + ...decoded, + appAccountToken: appleAccountTokenForReader('reader-2'), + }, + readerId: 'reader-1', + ...products, + }), + ).rejects.toMatchObject({ + code: AppErrorCode.MEMBERSHIP_APPLE_TRANSACTION_INVALID, + }) + + expect(membershipRepository.create).not.toHaveBeenCalled() + expect(membershipRepository.update).not.toHaveBeenCalled() + expect(billingWebhookEventRepository.create).not.toHaveBeenCalled() + }) + + it('rejects a transaction without an account token', async () => { + const { service, membershipRepository } = createService() + + await expect( + service.confirmAppleTransaction({ + decoded: { ...decoded, appAccountToken: undefined }, + readerId: 'reader-1', + ...products, + }), + ).rejects.toMatchObject({ + code: AppErrorCode.MEMBERSHIP_APPLE_TRANSACTION_INVALID, + }) + + expect(membershipRepository.create).not.toHaveBeenCalled() + expect(membershipRepository.update).not.toHaveBeenCalled() + }) + it('rejects a revoked Apple transaction before granting membership', async () => { const { service, membershipRepository, billingWebhookEventRepository } = createService() From d3e4d03e280b2270cfda999a8d1eb58fe3bfd432 Mon Sep 17 00:00:00 2001 From: Innei Date: Fri, 21 Aug 2026 01:47:52 +0800 Subject: [PATCH 11/12] fix(membership): complete Apple entitlement handling --- .../modules/membership/entitlement.service.ts | 7 +++++- .../membership/providers/apple-transaction.ts | 1 + .../membership/apple-transaction.spec.ts | 1 + .../modules/membership/apple.provider.spec.ts | 1 + .../membership/entitlement.service.spec.ts | 24 +++++++++++++++++++ 5 files changed, 33 insertions(+), 1 deletion(-) diff --git a/apps/core/src/modules/membership/entitlement.service.ts b/apps/core/src/modules/membership/entitlement.service.ts index b50994b2406..7692e26dd36 100644 --- a/apps/core/src/modules/membership/entitlement.service.ts +++ b/apps/core/src/modules/membership/entitlement.service.ts @@ -4,6 +4,7 @@ import { ConfigsService } from '../configs/configs.service' import { MembershipRepository } from './membership.repository' import { type MembershipAvailability, + resolveAppleIapAvailability, resolveMembershipAvailability, } from './membership.types' @@ -62,6 +63,10 @@ export class EntitlementService { } async isMembershipPurchasable(): Promise { - return (await this.getAvailability()).enabled + const config = await this.configsService.get('membership') + return ( + resolveMembershipAvailability(config).enabled || + resolveAppleIapAvailability(config).enabled + ) } } diff --git a/apps/core/src/modules/membership/providers/apple-transaction.ts b/apps/core/src/modules/membership/providers/apple-transaction.ts index d236ed66f22..23b1df95563 100644 --- a/apps/core/src/modules/membership/providers/apple-transaction.ts +++ b/apps/core/src/modules/membership/providers/apple-transaction.ts @@ -72,6 +72,7 @@ const NOTIFICATION_TYPE_MAP: Record< DID_FAIL_TO_RENEW: 'on_hold', EXPIRED: 'cancelled', REFUND: 'cancelled', + REFUND_REVERSED: 'renewed', REVOKE: 'cancelled', GRACE_PERIOD_EXPIRED: 'cancelled', DID_CHANGE_RENEWAL_PREF: 'plan_changed', diff --git a/apps/core/test/src/modules/membership/apple-transaction.spec.ts b/apps/core/test/src/modules/membership/apple-transaction.spec.ts index b2b2efb2a98..a17cf309c48 100644 --- a/apps/core/test/src/modules/membership/apple-transaction.spec.ts +++ b/apps/core/test/src/modules/membership/apple-transaction.spec.ts @@ -71,6 +71,7 @@ describe('appleNotificationEventType', () => { expect(appleNotificationEventType('DID_RENEW')).toBe('renewed') expect(appleNotificationEventType('EXPIRED')).toBe('cancelled') expect(appleNotificationEventType('REFUND')).toBe('cancelled') + expect(appleNotificationEventType('REFUND_REVERSED')).toBe('renewed') expect(appleNotificationEventType('TEST')).toBeUndefined() }) }) diff --git a/apps/core/test/src/modules/membership/apple.provider.spec.ts b/apps/core/test/src/modules/membership/apple.provider.spec.ts index 3e596cf2852..9a393769214 100644 --- a/apps/core/test/src/modules/membership/apple.provider.spec.ts +++ b/apps/core/test/src/modules/membership/apple.provider.spec.ts @@ -49,6 +49,7 @@ describe('AppleProvider', () => { it.each([ ['DID_RENEW', 'renewed'], ['RENEWAL_EXTENDED', 'renewed'], + ['REFUND_REVERSED', 'renewed'], ['DID_CHANGE_RENEWAL_PREF', 'plan_changed'], ] as const)( 'maps the current Apple product during %s', diff --git a/apps/core/test/src/modules/membership/entitlement.service.spec.ts b/apps/core/test/src/modules/membership/entitlement.service.spec.ts index 8a7f1cbb433..801851bba9c 100644 --- a/apps/core/test/src/modules/membership/entitlement.service.spec.ts +++ b/apps/core/test/src/modules/membership/entitlement.service.spec.ts @@ -177,6 +177,30 @@ describe('EntitlementService.getAvailability', () => { }) }) + it('locks premium content when Apple IAP is the only purchase path', async () => { + const { service, membershipRepository } = createService({ + enabled: true, + appleAppAppleId: '1234567890', + appleBundleId: 'dev.yohaku.app', + appleIssuerId: 'ISSUER', + appleKeyId: 'KEYID', + appleMonthlyProductId: 'yohaku.membership.monthly', + applePrivateKey: + '-----BEGIN PRIVATE KEY-----\nX\n-----END PRIVATE KEY-----', + appleYearlyProductId: 'yohaku.membership.yearly', + }) + membershipRepository.findByReaderId.mockResolvedValue(null) + + expect(await service.isMembershipPurchasable()).toBe(true) + expect( + await service.isPremiumLocked({ + isOwner: false, + isPremium: true, + readerId: 'reader-1', + }), + ).toBe(true) + }) + it('is not purchasable when disabled', async () => { const { service } = createService({ enabled: false, From 1cc682f75943588521c329b7d31e28bd29d16ca4 Mon Sep 17 00:00:00 2001 From: Innei Date: Fri, 21 Aug 2026 02:02:49 +0800 Subject: [PATCH 12/12] fix(membership): order Apple subscription events --- .../billing-webhook-event.repository.ts | 49 ++++++- .../membership/membership.controller.ts | 3 + .../modules/membership/membership.service.ts | 119 +++++++++++++++- .../membership/providers/apple-transaction.ts | 2 + .../membership/providers/apple.provider.ts | 14 +- .../providers/provider.interface.ts | 1 + .../membership/apple-transaction.spec.ts | 2 + .../modules/membership/apple.provider.spec.ts | 4 + .../membership.controller.e2e-spec.ts | 2 + .../membership/membership.service.spec.ts | 130 ++++++++++++++++++ 10 files changed, 322 insertions(+), 4 deletions(-) diff --git a/apps/core/src/modules/membership/billing-webhook-event.repository.ts b/apps/core/src/modules/membership/billing-webhook-event.repository.ts index 00447b0c0f4..446b0332fce 100644 --- a/apps/core/src/modules/membership/billing-webhook-event.repository.ts +++ b/apps/core/src/modules/membership/billing-webhook-event.repository.ts @@ -1,5 +1,5 @@ import { Inject, Injectable } from '@nestjs/common' -import { and, eq } from 'drizzle-orm' +import { and, asc, desc, eq, isNotNull, isNull, sql } from 'drizzle-orm' import { PG_DB_TOKEN } from '~/constants/system.constant' import { billingWebhookEvents } from '~/database/schema' @@ -76,6 +76,53 @@ export class BillingWebhookEventRepository extends BaseRepository { return row ? mapRow(row) : null } + async findPendingByProviderSubscriptionId( + provider: string, + providerSubscriptionId: string, + ): Promise { + const rows = await this.db + .select() + .from(billingWebhookEvents) + .where( + and( + eq(billingWebhookEvents.provider, provider), + isNull(billingWebhookEvents.processedAt), + sql`${billingWebhookEvents.payload}->'_normalizedMembershipEvent'->>'subscriptionId' = ${providerSubscriptionId}`, + )!, + ) + .orderBy( + asc( + sql`${billingWebhookEvents.payload}->'_normalizedMembershipEvent'->>'occurredAt'`, + ), + asc(billingWebhookEvents.receivedAt), + ) + return rows.map(mapRow) + } + + async findLatestProcessedByProviderSubscriptionId( + provider: string, + providerSubscriptionId: string, + ): Promise { + const [row] = await this.db + .select() + .from(billingWebhookEvents) + .where( + and( + eq(billingWebhookEvents.provider, provider), + isNotNull(billingWebhookEvents.processedAt), + sql`${billingWebhookEvents.payload}->'_normalizedMembershipEvent'->>'subscriptionId' = ${providerSubscriptionId}`, + )!, + ) + .orderBy( + desc( + sql`${billingWebhookEvents.payload}->'_normalizedMembershipEvent'->>'occurredAt'`, + ), + desc(billingWebhookEvents.receivedAt), + ) + .limit(1) + return row ? mapRow(row) : null + } + async markProcessed( id: EntityId | string, processedAt: Date, diff --git a/apps/core/src/modules/membership/membership.controller.ts b/apps/core/src/modules/membership/membership.controller.ts index 2bc35142897..eb6c03c5384 100644 --- a/apps/core/src/modules/membership/membership.controller.ts +++ b/apps/core/src/modules/membership/membership.controller.ts @@ -238,6 +238,9 @@ export class MembershipController { verified.event.subscriptionId, ) if (!bound) { + if (verified.event.provider === 'apple') { + await this.membershipService.deferEvent(verified) + } return { ok: true, applied: false, diff --git a/apps/core/src/modules/membership/membership.service.ts b/apps/core/src/modules/membership/membership.service.ts index 3b4bceef3b6..d98b8fc9823 100644 --- a/apps/core/src/modules/membership/membership.service.ts +++ b/apps/core/src/modules/membership/membership.service.ts @@ -22,6 +22,70 @@ import type { VerifiedBillingEvent, } from './providers/provider.interface' +const NORMALIZED_EVENT_PAYLOAD_KEY = '_normalizedMembershipEvent' + +type StoredNormalizedBillingEvent = Omit< + NormalizedBillingEvent, + 'currentPeriodEnd' | 'occurredAt' +> & { + currentPeriodEnd: string + occurredAt?: string +} + +const storeBillingEventPayload = ( + event: NormalizedBillingEvent, + rawPayload: unknown, +): Record => { + const payload = + rawPayload && typeof rawPayload === 'object' && !Array.isArray(rawPayload) + ? { ...(rawPayload as Record) } + : { rawPayload } + const storedEvent: StoredNormalizedBillingEvent = { + ...event, + currentPeriodEnd: event.currentPeriodEnd.toISOString(), + occurredAt: event.occurredAt?.toISOString(), + } + payload[NORMALIZED_EVENT_PAYLOAD_KEY] = storedEvent + return payload +} + +const readStoredBillingEvent = ( + payload: unknown, +): NormalizedBillingEvent | null => { + if (!payload || typeof payload !== 'object') return null + const stored = (payload as Record)[ + NORMALIZED_EVENT_PAYLOAD_KEY + ] as Partial | undefined + if ( + !stored || + typeof stored.eventId !== 'string' || + typeof stored.provider !== 'string' || + typeof stored.type !== 'string' || + typeof stored.customerId !== 'string' || + typeof stored.subscriptionId !== 'string' || + typeof stored.currentPeriodEnd !== 'string' || + typeof stored.readerId !== 'string' + ) { + return null + } + const currentPeriodEnd = new Date(stored.currentPeriodEnd) + const occurredAt = stored.occurredAt ? new Date(stored.occurredAt) : undefined + if ( + Number.isNaN(currentPeriodEnd.getTime()) || + (occurredAt && Number.isNaN(occurredAt.getTime())) + ) { + return null + } + return { + ...(stored as Omit< + NormalizedBillingEvent, + 'currentPeriodEnd' | 'occurredAt' + >), + currentPeriodEnd, + occurredAt, + } +} + const isLiveProviderSubscription = (row: MembershipRow): boolean => { if (row.provider === 'manual') return false if (row.status !== 'active' && row.status !== 'on_hold') return false @@ -115,6 +179,11 @@ export class MembershipService { rawPayload: input.decoded, rawType: 'apple.confirm', }) + await this.replayDeferredEvents( + event.provider, + event.subscriptionId, + input.readerId, + ) return this.toStatusResult( await this.membershipRepository.findByReaderId(input.readerId), @@ -133,7 +202,7 @@ export class MembershipService { provider: event.provider, eventId: event.eventId, type: rawType, - payload: rawPayload, + payload: storeBillingEventPayload(event, rawPayload), }) if (!webhookEventRow) { @@ -161,9 +230,57 @@ export class MembershipService { return { applied } } + async deferEvent(verifiedEvent: VerifiedBillingEvent): Promise { + const { event, rawPayload, rawType } = verifiedEvent + await this.billingWebhookEventRepository.create({ + provider: event.provider, + eventId: event.eventId, + type: rawType, + payload: storeBillingEventPayload(event, rawPayload), + }) + } + + private async replayDeferredEvents( + provider: string, + subscriptionId: string, + readerId: string, + ): Promise { + const rows = + await this.billingWebhookEventRepository.findPendingByProviderSubscriptionId( + provider, + subscriptionId, + ) + for (const row of rows) { + const storedEvent = readStoredBillingEvent(row.payload) + if (storedEvent) { + await this.applyMembershipState({ ...storedEvent, readerId }) + } + await this.billingWebhookEventRepository.markProcessed(row.id, new Date()) + } + } + + private async isSupersededEvent( + event: NormalizedBillingEvent, + ): Promise { + if (!event.occurredAt) return false + const latestRow = + await this.billingWebhookEventRepository.findLatestProcessedByProviderSubscriptionId( + event.provider, + event.subscriptionId, + ) + if (!latestRow) return false + const latestEvent = readStoredBillingEvent(latestRow.payload) + return Boolean( + latestEvent?.occurredAt && + latestEvent.occurredAt.getTime() >= event.occurredAt.getTime(), + ) + } + private async applyMembershipState( event: NormalizedBillingEvent, ): Promise { + if (await this.isSupersededEvent(event)) return false + let existing = await this.membershipRepository.findByProviderSubscriptionId( event.subscriptionId, ) diff --git a/apps/core/src/modules/membership/providers/apple-transaction.ts b/apps/core/src/modules/membership/providers/apple-transaction.ts index 23b1df95563..f19fa3448f5 100644 --- a/apps/core/src/modules/membership/providers/apple-transaction.ts +++ b/apps/core/src/modules/membership/providers/apple-transaction.ts @@ -13,6 +13,7 @@ export interface AppleDecodedTransaction { originalTransactionId: string productId: string revocationDate?: number + signedDate: number transactionId: string } @@ -59,6 +60,7 @@ export function appleActivatedEvent( plan, currentPeriodEnd: new Date(decoded.expiresDate), readerId, + occurredAt: new Date(decoded.signedDate), } } diff --git a/apps/core/src/modules/membership/providers/apple.provider.ts b/apps/core/src/modules/membership/providers/apple.provider.ts index 711f2a01bf9..ed30d1613d4 100644 --- a/apps/core/src/modules/membership/providers/apple.provider.ts +++ b/apps/core/src/modules/membership/providers/apple.provider.ts @@ -58,7 +58,8 @@ export class AppleProvider implements PaymentProviderAdapter { !decoded.transactionId || !decoded.originalTransactionId || !decoded.productId || - !decoded.expiresDate + !decoded.expiresDate || + !decoded.signedDate ) { throw createAppException( AppErrorCode.MEMBERSHIP_APPLE_TRANSACTION_INVALID, @@ -70,6 +71,7 @@ export class AppleProvider implements PaymentProviderAdapter { originalTransactionId: decoded.originalTransactionId, productId: decoded.productId, revocationDate: decoded.revocationDate, + signedDate: decoded.signedDate, transactionId: decoded.transactionId, } } catch (error) { @@ -119,6 +121,7 @@ export class AppleProvider implements PaymentProviderAdapter { let signedRenewalInfo: string | undefined let signedTransactionInfo: string | undefined let notificationUUID: string + let notificationSignedDate: number | undefined try { const notification = await this.verifyNotificationWithFallback( signedPayload, @@ -128,6 +131,7 @@ export class AppleProvider implements PaymentProviderAdapter { notificationType = notification.notificationType ?? '' notificationSubtype = notification.subtype ?? '' notificationUUID = notification.notificationUUID ?? notificationType + notificationSignedDate = notification.signedDate signedRenewalInfo = notification.data?.signedRenewalInfo signedTransactionInfo = notification.data?.signedTransactionInfo } catch (error) { @@ -161,7 +165,12 @@ export class AppleProvider implements PaymentProviderAdapter { bundleId, membershipConfig.appleAppAppleId, ).catch(() => null) - if (!decoded?.originalTransactionId || !decoded.expiresDate) { + const occurredAt = notificationSignedDate ?? decoded?.signedDate + if ( + !decoded?.originalTransactionId || + !decoded.expiresDate || + !Number.isFinite(occurredAt) + ) { return { ignored: true, rawType: notificationType, @@ -216,6 +225,7 @@ export class AppleProvider implements PaymentProviderAdapter { plan, currentPeriodEnd: new Date(currentPeriodEnd), readerId: '', + occurredAt: new Date(occurredAt!), }, rawType: notificationType, rawPayload: { diff --git a/apps/core/src/modules/membership/providers/provider.interface.ts b/apps/core/src/modules/membership/providers/provider.interface.ts index 29008c9dfa6..b61ddbeb89e 100644 --- a/apps/core/src/modules/membership/providers/provider.interface.ts +++ b/apps/core/src/modules/membership/providers/provider.interface.ts @@ -9,6 +9,7 @@ export interface NormalizedBillingEvent { plan?: MembershipPlan currentPeriodEnd: Date readerId: string + occurredAt?: Date } export interface VerifiedBillingEvent { diff --git a/apps/core/test/src/modules/membership/apple-transaction.spec.ts b/apps/core/test/src/modules/membership/apple-transaction.spec.ts index a17cf309c48..ccd5895f66c 100644 --- a/apps/core/test/src/modules/membership/apple-transaction.spec.ts +++ b/apps/core/test/src/modules/membership/apple-transaction.spec.ts @@ -46,6 +46,7 @@ describe('appleActivatedEvent', () => { expiresDate: Date.parse('2026-09-01T00:00:00.000Z'), originalTransactionId: 'orig-1', productId: 'yohaku.membership.yearly', + signedDate: Date.parse('2026-08-01T00:00:00.000Z'), transactionId: 'txn-1', }, 'reader-1', @@ -63,6 +64,7 @@ describe('appleActivatedEvent', () => { expect(event.currentPeriodEnd.toISOString()).toBe( '2026-09-01T00:00:00.000Z', ) + expect(event.occurredAt?.toISOString()).toBe('2026-08-01T00:00:00.000Z') }) }) diff --git a/apps/core/test/src/modules/membership/apple.provider.spec.ts b/apps/core/test/src/modules/membership/apple.provider.spec.ts index 9a393769214..a5569763491 100644 --- a/apps/core/test/src/modules/membership/apple.provider.spec.ts +++ b/apps/core/test/src/modules/membership/apple.provider.spec.ts @@ -36,6 +36,7 @@ describe('AppleProvider', () => { originalTransactionId: 'original-1', productId: 'yohaku.membership.monthly', revocationDate: Date.parse('2026-08-21T00:00:00.000Z'), + signedDate: Date.parse('2026-08-01T00:00:00.000Z'), transactionId: 'transaction-1', }) @@ -68,6 +69,7 @@ describe('AppleProvider', () => { data: { signedTransactionInfo: 'signed-transaction' }, notificationType, notificationUUID: 'notification-1', + signedDate: Date.parse('2026-08-21T00:00:00.000Z'), }) vi.spyOn( provider as any, @@ -107,6 +109,7 @@ describe('AppleProvider', () => { }, notificationType: 'DID_FAIL_TO_RENEW', notificationUUID: 'notification-1', + signedDate: Date.parse('2026-08-21T00:00:00.000Z'), subtype: 'GRACE_PERIOD', }) vi.spyOn( @@ -133,6 +136,7 @@ describe('AppleProvider', () => { expect(result).toMatchObject({ event: { currentPeriodEnd: new Date('2026-08-27T00:00:00.000Z'), + occurredAt: new Date('2026-08-21T00:00:00.000Z'), type: 'on_hold', }, }) diff --git a/apps/core/test/src/modules/membership/membership.controller.e2e-spec.ts b/apps/core/test/src/modules/membership/membership.controller.e2e-spec.ts index c9c886abb7b..e9129baa0aa 100644 --- a/apps/core/test/src/modules/membership/membership.controller.e2e-spec.ts +++ b/apps/core/test/src/modules/membership/membership.controller.e2e-spec.ts @@ -594,6 +594,7 @@ describe('MembershipController (e2e)', () => { expiresDate: Date.now() + 86_400_000, originalTransactionId: 'orig-e2e', productId: 'yohaku.membership.monthly', + signedDate: Date.now(), transactionId: 'txn-e2e', }) @@ -628,6 +629,7 @@ describe('MembershipController (e2e)', () => { expiresDate: Date.now() + 86_400_000, originalTransactionId: 'orig-other-reader', productId: 'yohaku.membership.monthly', + signedDate: Date.now(), transactionId: 'txn-other-reader', }) diff --git a/apps/core/test/src/modules/membership/membership.service.spec.ts b/apps/core/test/src/modules/membership/membership.service.spec.ts index 9d04b7ae823..8712e143445 100644 --- a/apps/core/test/src/modules/membership/membership.service.spec.ts +++ b/apps/core/test/src/modules/membership/membership.service.spec.ts @@ -59,6 +59,12 @@ const createService = () => { membershipRepository.findByReaderId.mockResolvedValue(null) membershipRepository.readerExists.mockResolvedValue(true) billingWebhookEventRepository.findByProviderAndEventId.mockResolvedValue(null) + billingWebhookEventRepository.findLatestProcessedByProviderSubscriptionId.mockResolvedValue( + null, + ) + billingWebhookEventRepository.findPendingByProviderSubscriptionId.mockResolvedValue( + [], + ) billingWebhookEventRepository.create.mockResolvedValue({ id: 'event-1' as any, provider: 'dodo', @@ -495,6 +501,7 @@ describe('MembershipService', () => { expiresDate: now.getTime() + 86_400_000, originalTransactionId: 'orig-apple', productId: 'yohaku.membership.monthly', + signedDate: now.getTime(), transactionId: 'txn-apple', } const products = { @@ -724,5 +731,128 @@ describe('MembershipService', () => { expect(membershipRepository.update).not.toHaveBeenCalled() expect(billingWebhookEventRepository.create).not.toHaveBeenCalled() }) + + it('replays a newer deferred refund before returning confirmed status', async () => { + const { service, membershipRepository, billingWebhookEventRepository } = + createService() + const active = createMembership({ + provider: 'apple', + providerCustomerId: decoded.appAccountToken, + providerSubscriptionId: decoded.originalTransactionId, + status: 'active', + }) + const cancelled = createMembership({ + ...active, + status: 'cancelled', + }) + const refundOccurredAt = new Date(decoded.signedDate + 1_000) + const refund = createEvent({ + customerId: decoded.appAccountToken, + eventId: 'refund-before-confirm', + provider: 'apple', + readerId: '', + subscriptionId: decoded.originalTransactionId, + type: 'cancelled', + occurredAt: refundOccurredAt, + }) + const pendingRefund = { + id: 'pending-refund' as any, + provider: 'apple', + eventId: refund.event.eventId, + type: 'REFUND', + payload: { + _normalizedMembershipEvent: { + ...refund.event, + currentPeriodEnd: refund.event.currentPeriodEnd.toISOString(), + occurredAt: refundOccurredAt.toISOString(), + }, + }, + processedAt: null, + receivedAt: refundOccurredAt, + } + + await service.deferEvent(refund) + billingWebhookEventRepository.findPendingByProviderSubscriptionId.mockResolvedValue( + [pendingRefund], + ) + membershipRepository.findByProviderSubscriptionId + .mockResolvedValueOnce(null) + .mockResolvedValueOnce(null) + .mockResolvedValueOnce(active) + membershipRepository.findByReaderId + .mockResolvedValueOnce(null) + .mockResolvedValueOnce(null) + .mockResolvedValueOnce(cancelled) + membershipRepository.create.mockResolvedValue(active) + membershipRepository.update.mockResolvedValue(cancelled) + + const result = await service.confirmAppleTransaction({ + decoded, + readerId: 'reader-1', + ...products, + }) + + expect(billingWebhookEventRepository.create).toHaveBeenCalledWith( + expect.objectContaining({ + eventId: 'refund-before-confirm', + payload: expect.objectContaining({ + _normalizedMembershipEvent: expect.objectContaining({ + occurredAt: refundOccurredAt.toISOString(), + subscriptionId: decoded.originalTransactionId, + type: 'cancelled', + }), + }), + }), + ) + expect(membershipRepository.update).toHaveBeenCalledWith( + active.id, + expect.objectContaining({ status: 'cancelled' }), + ) + expect(result).toMatchObject({ provider: 'apple', status: 'cancelled' }) + }) + }) + + it('does not apply an Apple renewal older than the latest processed refund', async () => { + const { service, membershipRepository, billingWebhookEventRepository } = + createService() + const refundOccurredAt = new Date(now.getTime() - 1_000) + billingWebhookEventRepository.findLatestProcessedByProviderSubscriptionId.mockResolvedValue( + { + id: 'latest-refund' as any, + provider: 'apple', + eventId: 'refund-latest', + type: 'REFUND', + payload: { + _normalizedMembershipEvent: { + eventId: 'refund-latest', + provider: 'apple', + type: 'cancelled', + customerId: 'customer-apple', + subscriptionId: 'subscription-apple', + currentPeriodEnd: now.toISOString(), + readerId: 'reader-1', + occurredAt: refundOccurredAt.toISOString(), + }, + }, + processedAt: refundOccurredAt, + receivedAt: refundOccurredAt, + }, + ) + + const result = await service.applyEvent( + createEvent({ + eventId: 'renewal-delayed', + provider: 'apple', + type: 'renewed', + customerId: 'customer-apple', + subscriptionId: 'subscription-apple', + occurredAt: new Date(refundOccurredAt.getTime() - 1_000), + }), + ) + + expect(result).toEqual({ applied: false }) + expect(membershipRepository.create).not.toHaveBeenCalled() + expect(membershipRepository.update).not.toHaveBeenCalled() + expect(billingWebhookEventRepository.markProcessed).toHaveBeenCalled() }) })