Skip to content

Commit fa46138

Browse files
committed
fix(membership): address Apple IAP review feedback
1 parent ccbf245 commit fa46138

7 files changed

Lines changed: 181 additions & 18 deletions

File tree

apps/core/src/modules/membership/membership.service.ts

Lines changed: 11 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,11 @@ const isLiveProviderSubscription = (row: MembershipRow): boolean => {
2727
return row.currentPeriodEnd.getTime() > Date.now()
2828
}
2929

30+
const hasCurrentEntitlement = (row: MembershipRow): boolean => {
31+
const status = effectiveMembershipStatus(row)
32+
return status === 'active' || status === 'on_hold'
33+
}
34+
3035
@Injectable()
3136
export class MembershipService {
3237
constructor(
@@ -82,31 +87,19 @@ export class MembershipService {
8287
)
8388
if (
8489
byReader &&
85-
isLiveProviderSubscription(byReader) &&
90+
hasCurrentEntitlement(byReader) &&
8691
byReader.provider !== 'apple'
8792
) {
8893
return this.toStatusResult(byReader)
8994
}
9095

9196
const event = appleActivatedEvent(input.decoded, input.readerId, plan)
92-
const applied = await this.applyEvent({
97+
await this.applyEvent({
9398
event,
9499
rawPayload: input.decoded,
95100
rawType: 'apple.confirm',
96101
})
97102

98-
if (!applied.applied && byReader?.provider === 'apple') {
99-
await this.membershipRepository.update(byReader.id, {
100-
provider: 'apple',
101-
providerCustomerId:
102-
input.decoded.appAccountToken ?? input.decoded.originalTransactionId,
103-
providerSubscriptionId: input.decoded.originalTransactionId,
104-
plan,
105-
status: 'active',
106-
currentPeriodEnd: new Date(input.decoded.expiresDate),
107-
})
108-
}
109-
110103
return this.toStatusResult(
111104
await this.membershipRepository.findByReaderId(input.readerId),
112105
)
@@ -165,9 +158,10 @@ export class MembershipService {
165158
)
166159
if (byReader) {
167160
const canBindInitialSubscription =
168-
byReader.provider === event.provider &&
169-
byReader.providerSubscriptionId === null &&
170-
event.type === 'activated'
161+
event.type === 'activated' &&
162+
((byReader.provider === event.provider &&
163+
byReader.providerSubscriptionId === null) ||
164+
!hasCurrentEntitlement(byReader))
171165
if (!canBindInitialSubscription) return false
172166
existing = byReader
173167
}

apps/core/src/modules/membership/membership.types.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,9 +67,15 @@ export interface AppleIapAvailability {
6767

6868
const nonEmpty = (value?: string) => Boolean(value?.trim())
6969

70+
const positiveInteger = (value?: string) => {
71+
const parsed = Number(value?.trim())
72+
return Number.isSafeInteger(parsed) && parsed > 0
73+
}
74+
7075
export function resolveAppleIapAvailability(config: {
7176
enabled?: boolean
7277
appleBundleId?: string
78+
appleAppAppleId?: string
7379
appleKeyId?: string
7480
appleIssuerId?: string
7581
applePrivateKey?: string
@@ -81,6 +87,7 @@ export function resolveAppleIapAvailability(config: {
8187
const enabled =
8288
!!config.enabled &&
8389
nonEmpty(config.appleBundleId) &&
90+
positiveInteger(config.appleAppAppleId) &&
8491
nonEmpty(config.appleKeyId) &&
8592
nonEmpty(config.appleIssuerId) &&
8693
nonEmpty(config.applePrivateKey) &&

apps/core/src/modules/membership/providers/apple.provider.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import { APPLE_ROOT_CA_PEMS } from './apple-root-cas'
1313
import {
1414
type AppleDecodedTransaction,
1515
appleNotificationEventType,
16+
planFromAppleProductId,
1617
} from './apple-transaction'
1718
import type {
1819
BillingWebhookResult,
@@ -162,13 +163,24 @@ export class AppleProvider implements PaymentProviderAdapter {
162163
}
163164
}
164165

166+
const monthlyProductId = membershipConfig.appleMonthlyProductId?.trim()
167+
const yearlyProductId = membershipConfig.appleYearlyProductId?.trim()
168+
const plan =
169+
decoded.productId && monthlyProductId && yearlyProductId
170+
? (planFromAppleProductId(decoded.productId, {
171+
monthlyProductId,
172+
yearlyProductId,
173+
}) ?? undefined)
174+
: undefined
175+
165176
return {
166177
event: {
167178
eventId: notificationUUID,
168179
provider: 'apple',
169180
type,
170181
customerId: decoded.appAccountToken ?? decoded.originalTransactionId,
171182
subscriptionId: decoded.originalTransactionId,
183+
plan,
172184
currentPeriodEnd: new Date(decoded.expiresDate),
173185
readerId: '',
174186
},
@@ -228,7 +240,7 @@ export class AppleProvider implements PaymentProviderAdapter {
228240
private environmentsToTry(appleAppAppleId?: string) {
229241
const appAppleId = Number(appleAppAppleId)
230242
const production =
231-
Number.isFinite(appAppleId) && appAppleId > 0
243+
Number.isSafeInteger(appAppleId) && appAppleId > 0
232244
? [
233245
{
234246
environment: Environment.PRODUCTION,

apps/core/test/src/modules/membership/apple.provider.spec.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,4 +21,45 @@ describe('AppleProvider.createCheckout', () => {
2121
code: AppErrorCode.MEMBERSHIP_PROVIDER_NOT_CONFIGURED,
2222
})
2323
})
24+
25+
it.each([
26+
['DID_RENEW', 'renewed'],
27+
['DID_CHANGE_RENEWAL_PREF', 'plan_changed'],
28+
] as const)(
29+
'maps the current Apple product during %s',
30+
async (notificationType, expectedType) => {
31+
get.mockResolvedValue({
32+
appleAppAppleId: '1234567890',
33+
appleBundleId: 'dev.yohaku.app',
34+
appleMonthlyProductId: 'yohaku.membership.monthly',
35+
appleYearlyProductId: 'yohaku.membership.yearly',
36+
})
37+
const provider = new AppleProvider({ get } as any)
38+
vi.spyOn(
39+
provider as any,
40+
'verifyNotificationWithFallback',
41+
).mockResolvedValue({
42+
data: { signedTransactionInfo: 'signed-transaction' },
43+
notificationType,
44+
notificationUUID: 'notification-1',
45+
})
46+
vi.spyOn(
47+
provider as any,
48+
'verifyTransactionWithFallback',
49+
).mockResolvedValue({
50+
expiresDate: Date.parse('2026-09-01T00:00:00.000Z'),
51+
originalTransactionId: 'original-1',
52+
productId: 'yohaku.membership.yearly',
53+
})
54+
55+
await expect(
56+
provider.verifyAndParseWebhook(
57+
JSON.stringify({ signedPayload: 'notification-jws' }),
58+
{},
59+
),
60+
).resolves.toMatchObject({
61+
event: { plan: 'yearly', type: expectedType },
62+
})
63+
},
64+
)
2465
})

apps/core/test/src/modules/membership/membership.controller.e2e-spec.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -240,6 +240,7 @@ describe('MembershipController (e2e)', () => {
240240
membershipConfig.enabled = true
241241
membershipConfig.provider = 'dodo'
242242
membershipConfig.webhookSigningKey = 'webhook-key'
243+
delete (membershipConfig as { appleAppAppleId?: string }).appleAppAppleId
243244
delete (membershipConfig as { appleBundleId?: string }).appleBundleId
244245
delete (membershipConfig as { appleKeyId?: string }).appleKeyId
245246
delete (membershipConfig as { appleIssuerId?: string }).appleIssuerId
@@ -528,6 +529,7 @@ describe('MembershipController (e2e)', () => {
528529

529530
it('reports appleIap when Apple fields are configured', async () => {
530531
Object.assign(membershipConfig, {
532+
appleAppAppleId: '1234567890',
531533
appleBundleId: 'dev.yohaku.app',
532534
appleKeyId: 'KEYID',
533535
appleIssuerId: 'ISSUER',
@@ -554,6 +556,7 @@ describe('MembershipController (e2e)', () => {
554556
describe('POST /membership/apple/confirm', () => {
555557
it('returns the new apple membership for a signed-in reader', async () => {
556558
Object.assign(membershipConfig, {
559+
appleAppAppleId: '1234567890',
557560
appleBundleId: 'dev.yohaku.app',
558561
appleKeyId: 'KEYID',
559562
appleIssuerId: 'ISSUER',

apps/core/test/src/modules/membership/membership.service.spec.ts

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -543,6 +543,98 @@ describe('MembershipService', () => {
543543
expect(result).toMatchObject({ provider: 'dodo', status: 'active' })
544544
})
545545

546+
it('returns an existing active manual membership without rewriting it', async () => {
547+
const { service, membershipRepository } = createService()
548+
const live = createMembership({
549+
provider: 'manual',
550+
providerCustomerId: null,
551+
providerSubscriptionId: null,
552+
})
553+
membershipRepository.findByReaderId.mockResolvedValue(live)
554+
555+
const result = await service.confirmAppleTransaction({
556+
decoded,
557+
readerId: 'reader-1',
558+
...products,
559+
})
560+
561+
expect(membershipRepository.create).not.toHaveBeenCalled()
562+
expect(membershipRepository.update).not.toHaveBeenCalled()
563+
expect(result).toMatchObject({ provider: 'manual', status: 'active' })
564+
})
565+
566+
it('rebinds an inactive non-Apple row to the confirmed Apple subscription', async () => {
567+
const { service, membershipRepository } = createService()
568+
const inactive = createMembership({
569+
provider: 'dodo',
570+
status: 'cancelled',
571+
currentPeriodEnd: new Date(now.getTime() - 1_000),
572+
})
573+
const rebound = createMembership({
574+
provider: 'apple',
575+
providerCustomerId: 'orig-apple',
576+
providerSubscriptionId: 'orig-apple',
577+
plan: 'monthly',
578+
status: 'active',
579+
currentPeriodEnd: new Date(decoded.expiresDate),
580+
})
581+
membershipRepository.findByReaderId
582+
.mockResolvedValueOnce(inactive)
583+
.mockResolvedValueOnce(inactive)
584+
.mockResolvedValueOnce(rebound)
585+
586+
const result = await service.confirmAppleTransaction({
587+
decoded,
588+
readerId: 'reader-1',
589+
...products,
590+
})
591+
592+
expect(membershipRepository.update).toHaveBeenCalledWith(
593+
inactive.id,
594+
expect.objectContaining({
595+
plan: 'monthly',
596+
provider: 'apple',
597+
providerSubscriptionId: 'orig-apple',
598+
status: 'active',
599+
}),
600+
)
601+
expect(result).toMatchObject({ provider: 'apple', status: 'active' })
602+
})
603+
604+
it('does not reactivate a cancelled Apple membership when confirmation is replayed', async () => {
605+
const { service, membershipRepository, billingWebhookEventRepository } =
606+
createService()
607+
const cancelled = createMembership({
608+
provider: 'apple',
609+
providerCustomerId: 'orig-apple',
610+
providerSubscriptionId: 'orig-apple',
611+
status: 'cancelled',
612+
})
613+
membershipRepository.findByProviderSubscriptionId.mockResolvedValue(
614+
cancelled,
615+
)
616+
membershipRepository.findByReaderId.mockResolvedValue(cancelled)
617+
billingWebhookEventRepository.create.mockResolvedValue(null)
618+
billingWebhookEventRepository.findByProviderAndEventId.mockResolvedValue({
619+
id: 'event-apple' as any,
620+
provider: 'apple',
621+
eventId: decoded.transactionId,
622+
type: 'apple.confirm',
623+
payload: decoded,
624+
processedAt: now,
625+
receivedAt: now,
626+
})
627+
628+
const result = await service.confirmAppleTransaction({
629+
decoded,
630+
readerId: 'reader-1',
631+
...products,
632+
})
633+
634+
expect(membershipRepository.update).not.toHaveBeenCalled()
635+
expect(result).toMatchObject({ provider: 'apple', status: 'cancelled' })
636+
})
637+
546638
it('rejects when the originalTransactionId is bound to another reader', async () => {
547639
const { service, membershipRepository } = createService()
548640
membershipRepository.findByProviderSubscriptionId.mockResolvedValue(

apps/core/test/src/modules/membership/membership.types.spec.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,7 @@ describe('resolveMembershipReturnUrl', () => {
118118
})
119119

120120
const appleCredentials = {
121+
appleAppAppleId: '1234567890',
121122
appleBundleId: 'dev.yohaku.app',
122123
appleKeyId: 'KEYID',
123124
appleIssuerId: 'ISSUER',
@@ -152,4 +153,17 @@ describe('resolveAppleIapAvailability', () => {
152153
}),
153154
).toEqual({ enabled: false })
154155
})
156+
157+
it.each([undefined, '', 'not-a-number', '1.5', '0'])(
158+
'is disabled when the App Apple ID is not a positive integer (%s)',
159+
(appleAppAppleId) => {
160+
expect(
161+
resolveAppleIapAvailability({
162+
enabled: true,
163+
...appleCredentials,
164+
appleAppAppleId,
165+
}),
166+
).toEqual({ enabled: false })
167+
},
168+
)
155169
})

0 commit comments

Comments
 (0)