feat(membership): confirm Apple IAP subscriptions - #2813
Conversation
SafeDep Report SummaryPackage Details
This report is generated by SafeDep GitHub App |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: ccbf245661
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| rawType: 'apple.confirm', | ||
| }) | ||
|
|
||
| if (!applied.applied && byReader?.provider === 'apple') { |
There was a problem hiding this comment.
Rebind inactive non-Apple rows before applying confirmation
When a reader has an expired or cancelled Dodo subscription (or an existing manual row), the live-subscription guard is skipped, but applyMembershipState refuses to reuse the row because its provider differs from Apple. This fallback only handles existing Apple rows, so the Apple event is marked processed while the paid reader keeps the old inactive membership. Reset or explicitly rebind inactive non-Apple rows before applying the confirmation.
Useful? React with 👍 / 👎.
| plan, | ||
| status: 'active', | ||
| currentPeriodEnd: new Date(input.decoded.expiresDate), |
There was a problem hiding this comment.
Preserve cancellations when a transaction is replayed
When a REFUND or REVOKE webhook has cancelled an Apple membership, replaying the originally confirmed JWS makes applyEvent return applied: false because that transaction ID was already processed. This fallback then unconditionally writes status: 'active' with the original expiration date, allowing a refunded or revoked subscription whose period end is still in the future to regain entitlement.
Useful? React with 👍 / 👎.
| nonEmpty(monthlyProductId) && | ||
| nonEmpty(yearlyProductId) |
There was a problem hiding this comment.
Require App Apple ID before advertising IAP
When appleAppAppleId is omitted, this availability check can still expose Apple IAP as enabled, even though AppleProvider.environmentsToTry then includes only the sandbox verifier. A production client can consequently display the products and complete payment, only for /apple/confirm to reject the production JWS. Include a valid App Apple ID in the production-readiness check or otherwise ensure production verification remains available.
Useful? React with 👍 / 👎.
| type, | ||
| customerId: decoded.appAccountToken ?? decoded.originalTransactionId, | ||
| subscriptionId: decoded.originalTransactionId, | ||
| currentPeriodEnd: new Date(decoded.expiresDate), | ||
| readerId: '', |
There was a problem hiding this comment.
Propagate Apple product changes into the membership plan
For a renewal after switching between monthly and yearly products, the decoded transaction contains the new productId, but the normalized event discards it and supplies no plan. applyMembershipState therefore retains existing.plan for DID_RENEW, while DID_CHANGE_RENEWAL_PREF performs no update unless event.plan exists, leaving the membership API reporting the previous plan indefinitely.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: fa46138159
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| customerId: decoded.appAccountToken ?? decoded.originalTransactionId, | ||
| subscriptionId: decoded.originalTransactionId, | ||
| plan, | ||
| currentPeriodEnd: new Date(decoded.expiresDate), |
There was a problem hiding this comment.
Use Apple's grace-period expiration for failed renewals
When a DID_FAIL_TO_RENEW notification has the GRACE_PERIOD subtype, the transaction's expiresDate is the already-ended subscription period; the usable entitlement deadline is gracePeriodExpiresDate from the signed renewal info. Storing the former here makes effectiveMembershipStatus immediately report expired despite mapping the event to on_hold, so paying readers lose access throughout Apple's billing grace period. Verify/decode the renewal info and use its grace-period expiration for this subtype.
Useful? React with 👍 / 👎.
| return { | ||
| appAccountToken: decoded.appAccountToken, | ||
| expiresDate: decoded.expiresDate, | ||
| originalTransactionId: decoded.originalTransactionId, | ||
| productId: decoded.productId, |
There was a problem hiding this comment.
Reject revoked transactions during confirmation
When the submitted signed transaction represents a refunded or revoked purchase whose original expiresDate is still in the future, verification succeeds and this projection discards Apple's revocationDate; confirmAppleTransaction then emits an activated event and grants entitlement until the original period end. This permits a revoked receipt to regain access whenever the cancellation webhook has not yet been delivered, so propagate and reject the revocation fields before activating the membership.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 191fae9498
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| webhookSigningKeyConfigured: false, | ||
| } | ||
| const checks = getMembershipSetupChecks(props.value, status) | ||
| const appleChecks = getAppleIapSetupChecks(props.value, status) |
There was a problem hiding this comment.
Let a complete Apple setup satisfy the enable gate
On a fresh Apple-only installation, appleChecks is calculated but never included in setupComplete; that value still reflects only the Dodo provider, API key, product, and webhook checks. Consequently the Enable toggle remains disabled unless Dodo is also configured, while resolveAppleIapAvailability requires the shared enabled flag, making the newly added Apple IAP flow impossible to enable through this editor without unrelated Dodo credentials.
Useful? React with 👍 / 👎.
| return { | ||
| bundleId: Boolean(config.appleBundleId?.trim()), | ||
| issuerId: Boolean(config.appleIssuerId?.trim()), | ||
| keyId: Boolean(config.appleKeyId?.trim()), | ||
| monthlyProductId: Boolean(config.appleMonthlyProductId?.trim()), |
There was a problem hiding this comment.
Include the App Apple ID in Apple setup validation
When the other Apple fields are populated but appleAppAppleId is blank, non-numeric, or non-positive, this helper reports every Apple check as complete even though the backend's resolveAppleIapAvailability explicitly rejects that configuration. The editor therefore displays a misleading 6/6 completion state while /plans advertises Apple IAP as disabled and confirmation is rejected; validate the App Apple ID here with the same positive-integer rule.
Useful? React with 👍 / 👎.
| REFUND: 'cancelled', | ||
| REVOKE: 'cancelled', | ||
| GRACE_PERIOD_EXPIRED: 'cancelled', | ||
| DID_CHANGE_RENEWAL_PREF: 'plan_changed', |
There was a problem hiding this comment.
Apply Apple renewal-extension notifications
When Apple sends RENEWAL_EXTENDED, the signed transaction carries the extended subscription deadline, but the notification is absent from this map and is consequently returned as unsupported_event. The membership retains its old currentPeriodEnd, so effectiveMembershipStatus expires the reader at the original renewal date even though Apple has extended their paid entitlement; normalize this notification as a renewal so the new expiration is persisted.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 90f82564aa
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| if (!membershipComplete && appleComplete) { | ||
| return { | ||
| completedCount: appleCompletedCount, | ||
| setupComplete: true, | ||
| totalCount: appleValues.length, |
There was a problem hiding this comment.
Count Apple availability when gating premium content
The new Apple-only success branch allows an installation without Dodo credentials to enable membership, but EntitlementService.isMembershipPurchasable() still consults only resolveMembershipAvailability(), which returns false without a registered web provider and its credentials. In that Apple-only configuration, PostController.applyPaywall() skips the paywall and isPremiumLocked() returns false, exposing premium content to nonmembers even though Apple IAP is enabled; backend entitlement availability must also include resolveAppleIapAvailability().
Useful? React with 👍 / 👎.
| EXPIRED: 'cancelled', | ||
| REFUND: 'cancelled', | ||
| REVOKE: 'cancelled', | ||
| GRACE_PERIOD_EXPIRED: 'cancelled', |
There was a problem hiding this comment.
Restore entitlement on REFUND_REVERSED
When Apple sends REFUND_REVERSED after a previously processed REFUND, this map returns no event type, so the webhook endpoint acknowledges it as unsupported_event and leaves the membership cancelled. A purchaser whose refund was reversed therefore remains locked out even when the restored transaction has an unexpired period; normalize this notification as an activation or renewal so its current expiration is applied.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: d3e4d03e28
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| if (!bound) { | ||
| return { | ||
| ok: true, | ||
| applied: false, | ||
| ignored: 'missing_reader_metadata', | ||
| } |
There was a problem hiding this comment.
Retain unmatched Apple revocation notifications
When a REFUND or REVOKE notification arrives before /apple/confirm has created the subscription row, this branch acknowledges it with 200 and stores no tombstone or pending state. The reader can later submit the original purchase JWS—which was signed before the refund and therefore has no revocationDate—and confirmAppleTransaction will activate the refunded subscription. Fresh evidence beyond the earlier revoked-JWS issue is that this unbound-webhook path discards the only newer revocation evidence, so persist terminal events for later binding or query Apple's current subscription state during confirmation.
Useful? React with 👍 / 👎.
| eventId: notificationUUID, | ||
| provider: 'apple', | ||
| type, | ||
| customerId: decoded.appAccountToken ?? decoded.originalTransactionId, | ||
| subscriptionId: decoded.originalTransactionId, |
There was a problem hiding this comment.
Order Apple state changes by signed event time
Apple notifications are deduplicated only by their distinct notificationUUID and carry no ordering metadata into applyMembershipState, which overwrites the row in arrival order. If a delayed DID_RENEW is delivered after a newer REFUND or REVOKE, the renewal is accepted as a new event and changes the cancelled membership back to active until its period end. Propagate the notification's signed/event time and reject state changes older than the latest applied Apple event.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 1cc682f759
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| } | ||
|
|
||
| export function appleAccountTokenForReader(readerId: string): string { | ||
| const digest = createHmac('sha256', SECURITY.jwtSecret || ENCRYPT.key) |
There was a problem hiding this comment.
Keep Apple account tokens stable across secret rotations
When JWT_SECRET is rotated—or, without an explicit secret, the service moves to a host with a different machine-derived encryption key—this HMAC produces a different account token for every existing reader. Apple transactions retain the appAccountToken supplied with the original purchase, so subsequent restore or confirmation requests fail the token comparison in confirmAppleTransaction, including renewals/upgrades for otherwise valid subscriptions. Persist a stable per-reader token or derive it from a dedicated, non-rotating membership secret.
Useful? React with 👍 / 👎.




Summary
membershipsrow the web checkout already uses (provider = apple).appleIaponGET /membership/plans, addPOST /membership/apple/confirm, and bind ASSN V2 webhooks byoriginalTransactionId.Companion iOS PR: Innei/Yohaku
feat/apple-iap-membership.Test plan
pnpm exec vitest run test/src/modules/membershipinapps/core(121 passed)GET /membership/statusmatchesoriginalTransactionIdon another reader returns 409MEMBERSHIP_APPLE_ALREADY_BOUND…/membership/webhook/apple