fix(entitlements): resolve each subscription group independently - #518
fix(entitlements): resolve each subscription group independently#518yusuftor wants to merge 3 commits into
Conversation
An entitlement backed by more than one subscription group was resolved from a single most-recently-purchased transaction. Because `Transaction.subscriptionStatus` reports the state of that transaction's subscription group only, a refund in one group could be applied to the whole entitlement and deactivate a paid, active subscription in another. Whether it did depended purely on which group happened to hold the newest transaction, and the scalar fields were spliced across groups: `state` and `willRenew` from one, `expiresAt` from another. Transactions are now split into independent grant sources — one per subscription group, plus one for a lifetime purchase — each resolved on its own. The entitlement is active if any source grants it, and its scalar fields are taken from the source that is actually granting access (lifetime, then the active source with the most time left), falling back to the most recent purchase only when nothing is active. Each source's live status is now authoritative in both directions for its own group, so a lapsed subscription in its grace period is active rather than being judged inactive on its expiry date alone. Billing retry still defers to the dates, since it says nothing about whether the paid-for period has run out. `SubscriptionStatusProvider` is keyed on `EntitlementTransaction` instead of `StoreKit.Transaction` so the resolution can be tested without minting real StoreKit transactions, and statuses are cached per transaction so a group shared by several entitlements costs one lookup. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Important
The per-group split itself is sound and well tested, but making live status authoritative upward breaks an invariant that downstream code relies on — Entitlement.isActive == true with expiresAt in the past is now reachable, and Purchase.isActive / AutomaticPurchaseController still gate on a future expiry. That plausibly stops the CHANGELOG's grace-period fix from taking effect end to end. Three public Entitlement fields also changed meaning without their docs being updated.
Reviewed changes — full diff of the single commit 73ef1abf4 across EntitlementProcessor.swift, both test files, CHANGELOG.md and the generated project.pbxproj, plus the production caller (SK2ReceiptManager), the Entitlement model and AutomaticPurchaseController for downstream impact.
- Grant sources replace single-transaction resolution —
grantSources(for:)buckets an entitlement's transactions by"lifetime"/"group:<id>"/"product:<id>", resolves each from dates alone, and the entitlement is active if any source grants it. - Scalar fields now come from one chosen source —
representativeSource(from:)prefers an active lifetime, then the active source with the furthestexpiresAt, falling back to the most recent purchase when nothing is active; it supplieslatestProductId,expiresAt,renewedAt,willRenew,stateandofferType. - Live status is authoritative in both directions —
.subscribed/.inGracePeriodforce a source active,.revoked/.expiredforce it inactive,.inBillingRetryPeriod/nildefer to dates. Pre-PR the override only ever forced inactive. SubscriptionStatusProvidercollapsed to one method —resolveStatus(for: any EntitlementTransaction) async -> ResolvedSubscriptionStatus?, with results cached per representative transaction ID so a group shared by several entitlements costs one lookup.- Shared enrichment path — a private
buildEntitlements(from:sourcesByEntitlement:…)is now used by both the date-only and live-status entry points, removing the previous build-then-patch two-pass structure. - New
PerGroupEntitlementResolutionTests(16 tests) — the refund-across-groups, expired-group and grace-period cases genuinely fail against the pre-PR logic, so this is real coverage rather than restated behaviour.
⚠️ An active entitlement can now carry an expiry date in the past, and the paths that gate on that weren't updated
Before this PR, an App Store entitlement could only be isActive == true if some unrevoked transaction had expirationDate > now, so isActive implied expiresAt in the future (or nil for lifetime). The grace-period fix breaks that: the override sets isActive = true without recomputing expiresAt, which still holds the lapsed date. Two consumers were built on the old invariant and are untouched by this PR, so the entitlement reads active while the SDK's user-facing status does not.
Technical details
# Grace-period activation does not survive the internal purchase-controller path
## Affected sites
- `Sources/SuperwallKit/StoreKit/Products/Receipt Manager/EntitlementProcessor.swift:439-451` —
`.inGracePeriod` (and `.subscribed`) force `sources[index].isActive = true`; only
`willRenew`, `state` and `offerType` are refreshed above, so `expiresAt` keeps the
already-passed value that `grantSources` computed from the transaction dates.
- `Sources/SuperwallKit/StoreKit/Purchase Controller/AutomaticPurchaseController.swift:71-72` —
`syncSubscriptionStatus(withPurchases:)` builds its entitlement set from
`purchases.filter { $0.isActive }`, not from the processor's entitlements. A
grace-period product yields no active purchase, so the set is empty and the code
falls into the "non-answer" branch, whose hold requires
`entitlement.isActive && (entitlement.expiresAt ?? .distantPast) > Date()`. The
grace-period entitlement fails the expiry half, `holdsStatus` is false, and
`internallySetSubscriptionStatus(to: .inactive)` runs.
- `Sources/SuperwallKit/StoreKit/Products/Receipt Manager/Receipt Manager/SK2ReceiptManager.swift:124`
and `:173-186` — `Purchase.isActive` comes from `isAnyTransactionActive` (raw dates),
and the correction block below only ever moves a purchase active → inactive, so it
never picks up the newly-active grace-period entitlement.
- `Sources/SuperwallKit/StoreKit/Products/StoreProduct/Entitlement.swift:123-131` —
`renewsAt` returns `expiresAt` whenever `isActive && willRenew == true`, so a
grace-period entitlement now advertises a renewal date in the past.
## Required outcome
- After this change, `customerInfo.entitlements` and `Superwall.subscriptionStatus`
must agree for a subscription in its billing grace period — the CHANGELOG bullet
claims the user is no longer reported inactive, and today the automatic purchase
controller still demotes them.
- Any consumer that treats `expiresAt` as "this entitlement is good until" (that
includes the wire payload and audience filters, since `expiresAt` is always encoded)
needs a defined answer for an active entitlement whose expiry has passed.
## Suggested approach (optional)
- Carry the grace-period end date onto the source when the override fires
(`Product.SubscriptionInfo.RenewalInfo.gracePeriodExpirationDate` is available on the
status the provider already fetches) and surface it as `expiresAt`, which repairs the
invariant for every downstream gate at once rather than patching each one.
## Open questions for the human
- Is `Purchase.isActive` meant to track the corrected entitlement state, or is the
divergence from `Entitlement.isActive` acceptable here?ℹ️ Nitpicks
startsAtchanged fromtransactions.last?.originalPurchaseDatetotransactions.map(\.originalPurchaseDate).min()(EntitlementProcessor.swift:336)..min()is a genuine improvement over an iteration-order-dependent read and matches the field's doc better, but it now includes revoked transactions and consumables, neither of which ever unlocked the entitlement. No test assertsstartsAtfor a multi-transaction entitlement —testProcessRenewalTrackingandtestProcessTransactionsWithExtremeDatesare both single-transaction, where.lastand.min()coincide.- Coverage gaps in the new suite worth closing while the logic is fresh: no test asserts
renewedAt, none assertslatestProductIdfor an entitlement with two active groups where the most-recent purchase is not the furthest-expiring one, and none pairs a revoked transaction with a.subscribed/.inGracePeriodstatus. - The comment at
EntitlementProcessor.swift:401-404says the cache means "each group costs one status lookup". It's keyed on the representative transaction ID, so two entitlements sharing a group but covering different product subsets within it still take two lookups. Harmless, but the comment overstates it.
Claude Opus | 𝕏
…xpiry The `latestSubscription` device variables describe the newest subscription on the device, so picking the source with the most time left on it could report a yearly bought last year over a monthly bought yesterday. They now follow purchase recency, and the winner is picked across every entitlement rather than left to dictionary iteration order. Three more things the per-group split left loose: - A group reporting `.subscribed` could resurrect a source whose every transaction had been refunded. It now needs an unrevoked transaction. - Forcing a lapsed source active left its expiry date in the past, so `Purchase.isActive` and `AutomaticPurchaseController` still read the entitlement as lapsed and demoted a subscriber in their grace period. The status now carries the date its access runs to — the end of the grace period, or the renewal date — and the purchase correction reads the granting product's entitlement in both directions. - `startsAt` no longer counts refunded purchases or consumables, neither of which ever unlocked the entitlement. Updates the `Entitlement` docs for the three fields whose meaning the split changed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ng one `renewedAt` isn't a property of the current subscription period, so taking it from the source describing the entitlement dropped a renewal that happened in another group. It's now the latest renewal across every source. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Addressing the review findingsPushed The active-entitlement-with-a-past-expiry invariant@pullfrog was right that the grace-period fix didn't survive the internal purchase-controller path, and that repairing the invariant beats patching each gate. Both halves are done:
So to answer the open question directly: NitpicksAll three taken:
Note for whoever runs the tests locallyNothing to fix in this repo, but worth knowing: Xcode 27 can no longer build for iOS 13. Its SDK declares the floor itself — The SDK's 13.0 floor is deliberate and consistent across 🤖 Generated with Claude Code |
There was a problem hiding this comment.
Important
The prior round's concerns are addressed well — hasUnrevokedTransaction closes the resurrection hole, activeUntil repairs the active-but-expired invariant, and the two-directional correctPurchases carries the grace-period fix through AutomaticPurchaseController end to end. One gap remains: GrantSource.representative is still the newest transaction in the bucket including revoked ones, and the new upward correction turns that into a refunded purchase reported as active.
Reviewed changes — the delta since 73ef1abf4: cfb87386b across EntitlementProcessor.swift, SK2ReceiptManager.swift, Entitlement.swift, CHANGELOG.md and both test suites, plus the downstream consumers of Purchase.isActive (ReceiptManager.isSubscribed(to:), getActiveProductIds(), computeActiveSubscriptionGroupIds, AutomaticPurchaseController) and Apple's Product.SubscriptionInfo.RenewalInfo contract.
- Guarded the upward live-status override —
GrantSource.hasUnrevokedTransactionnow gates the.subscribed/.inGracePeriodbranch, so a group whose every transaction is revoked can no longer be resurrected by its group status. - Carried the real expiry onto an activated source —
ResolvedSubscriptionStatus.activeUntil(grace-period end, elserenewalInfo.renewalDateon iOS 17.2+) movesexpiresAtforward when the live status forces a source active, so an active entitlement no longer advertises a date that has already passed. - Made
correctPurchasestwo-directional and testable — extracted to a static onSK2ReceiptManager; a purchase is now forced active when it is thelatestProductIdof an active entitlement, which is what lets the grace-period fix reachAutomaticPurchaseController's.active(entitlements)branch. - Separated "latest subscription" from "granting source" —
latestSubscriptionSource(from:)picks by purchase recency and the callback fires once after the loop, removing both the furthest-expiry mis-selection and the dictionary-iteration-order nondeterminism. - Narrowed
startsAt— revoked transactions and consumables no longer date the start of an entitlement. - Rewrote the
latestProductId/expiresAt/willRenewdocs to describe granting-source semantics, and added CHANGELOG entries for the grace-period expiry and scalar-field coherence. - Ten new tests across
PerGroupEntitlementResolutionTests(latest-subscription recency, revoked sources, threeactiveUntilcases,renewedAt/startsAt) and four inSK2ReceiptManagerTestsforcorrectPurchases.
ℹ️ Nitpicks
Entitlement.renewedAt's doc (Entitlement.swift:90-96) was left behind while its three siblings were rewritten. It now also comes from the granting source alone — deliberately, perrenewedAtComesFromTheGrantingGroup— so its nil-conditions list should say so too.- No test covers
startsAtwhen every transaction is revoked, which now yieldsnilwhere the field previously always carried a date. Worth pinning if that's the intended reading of "there aren't any transactions that unlock this entitlement".
Claude Opus | 𝕏
| let isGranting = productEntitlements.contains { | ||
| $0.isActive && $0.latestProductId == purchase.id | ||
| } |
There was a problem hiding this comment.
The restriction to latestProductId doesn't hold the guarantee the doc above claims, because latestProductId can itself name a refunded product. grantSources picks representative as bucket.max(by: purchaseDate) over the whole bucket (EntitlementProcessor.swift:299) while isActive/expiresAt come from unrevoked only, so a group whose newest transaction was refunded but whose older sibling is still unexpired stays active with the refunded product as its latestProductId. This branch then flips that refunded Purchase to active, and ReceiptManager.isSubscribed(to: refundedProductId) starts returning true.
Technical details
# A refunded transaction can be the representative of an active grant source
## Affected sites
- `Sources/SuperwallKit/StoreKit/Products/Receipt Manager/EntitlementProcessor.swift:299` —
`bucket.max(by: { $0.purchaseDate < $1.purchaseDate })` is not filtered for
`isRevoked`, unlike `unrevoked` on the next line which feeds `isActive`,
`expiresAt` and `renewedAt`.
- `EntitlementProcessor.swift:256` / `:317` — `latestProductId` and `willRenew` are
read off that representative, so both can describe the refunded purchase while the
entitlement is active.
- `Sources/SuperwallKit/StoreKit/Products/Receipt Manager/Receipt Manager/SK2ReceiptManager.swift:83-85`
— `isGranting` matches on `latestProductId`, so the refunded purchase is corrected
to `isActive = true`.
- `Sources/SuperwallKit/StoreKit/Products/Receipt Manager/Receipt Manager/ReceiptManager.swift:262-276`
— `isSubscribed(to:)` sorts newest-first and reads `isActive`, and
`getActiveProductIds()` unions active ids, so both then include the refunded product.
- `Sources/SuperwallKit/StoreKit/Products/StoreProduct/Entitlement.swift:68` — the doc
rewritten in this commit promises "the product currently unlocking this entitlement".
## Reachability
The bucket is one subscription group. The case needs a transaction that is newer than
a still-unexpired sibling in the same group and revoked — a family-shared subscription
alongside the user's own in that group, or a mid-cycle plan change that is later
refunded. This is the same coexistence the new `hasUnrevokedTransaction` comment
already assumes is possible.
## Required outcome
- While a grant source has any unrevoked transaction, the scalars it reports
(`latestProductId`, `willRenew`, `latestPurchaseDate`) must describe an unrevoked
transaction, and `correctPurchases` must never mark a refunded purchase active.
## Suggested approach (optional)
- In `grantSources`, pick the representative from `unrevoked` when it is non-empty and
fall back to the whole bucket only when everything is revoked (which is exactly the
`hasUnrevokedTransaction == false` case, where the source grants nothing anyway).
A test pairing a revoked newest transaction with an unexpired unrevoked sibling in
the same `subscriptionGroupId` would pin it.| if #available(iOS 17.2, macOS 14.2, tvOS 17.2, watchOS 10.2, visionOS 1.1, *) { | ||
| return info.renewalDate | ||
| } | ||
| return nil |
There was a problem hiding this comment.
On iOS 15.0–17.1 this returns nil outside a grace period, so the .subscribed-over-stale-dates case still produces an active entitlement with an expiresAt in the past — the invariant activeUntil exists to repair. Apple's current docs list renewalDate as available from iOS 15.0 via @backDeployed(before: iOS 17.0) (renewalDate), so the 17.2 gate may be droppable — worth checking against the SDK this builds with. The grace-period half is unaffected: gracePeriodExpirationDate is iOS 15.0+.
There was a problem hiding this comment.
Important
The invariant repair lands, and correctPurchases' new upward direction is what finally carries a grace-period grant through AutomaticPurchaseController — the previous review's main concern is genuinely closed end to end. Two things in the delta still need attention: GrantSource.representative is still picked without filtering revoked transactions, so the new upward correction can mark a refunded product's Purchase active, and the renewalDate half of the expiry fix is gated behind an availability check the API doesn't need.
Reviewed changes — the delta since pullfrog's last review (73ef1abf4), i.e. commits cfb87386b and 4be51bd36, read against the full PR diff for context plus every downstream consumer of Purchase.isActive (ReceiptManager.isSubscribed(to:), getActiveProductIds(), computeActiveSubscriptionGroupIds, AutomaticPurchaseController.syncSubscriptionStatus(withPurchases:)).
- Repaired the active-with-past-expiry invariant —
ResolvedSubscriptionStatusgainedactiveUntil(grace-period end, elserenewalDate), and the.subscribed/.inGracePeriodoverride raisesGrantSource.expiresAtto it under a strict>guard so a live status can only ever move the date forward. - Gated the upward override on an unrevoked transaction —
GrantSource.hasUnrevokedTransactionis now required before.subscribed/.inGracePeriodcan flip a source active, so a group status can't resurrect a fully-refunded source.revokedSourceDoesNotDescribeTheEntitlementalso pins theexpiresAt == nil→.distantFutureamplifier that used to let such a source outrank a paying one. - Made
Purchase.isActivebidirectional — the correction block moved out ofloadPurchasesinto a testableSK2ReceiptManager.correctPurchases(_:using:); a purchase that is an active entitlement'slatestProductIdis now forced active, gated onlatestProductIdso a different group's grant can't reactivate a refunded product. I traced all four readers: each collapses the set to product ids or entitlements before deciding, so the fan-out across a product's many renewal-periodPurchaseentries is harmless. - Reselected the latest-subscription source by purchase recency —
latestSubscriptionSource(from:)picks the most recently bought non-lifetime source, and the callback moved out of the per-entitlement loop so it fires exactly once instead of last-dictionary-key-wins. This restores the pre-PR recency semantics, which also answers Greptile's inline finding. - Widened
renewedAt, narrowedstartsAt—renewedAtis now the max across all sources rather than the granting source's, andstartsAtfilters revoked transactions and consumables before.min(). - Documented the granting-source semantics —
latestProductId,expiresAtandwillRenewdoc comments rewritten, with two matching CHANGELOG bullets under4.17.0(which matchesConstants.swift).
Verified against Apple's docs that activeUntil cannot overstate paid access: RenewalState documents .subscribed and .inGracePeriod as the two states "entitled to service", gracePeriodExpirationDate is nil outside a grace period, and renewalDate is "the expiration date of the most recent auto-renewable subscription purchase" rather than a speculative future date — so cancelled, expired and pending-change subscriptions all resolve to the boundary the customer already paid for.
ℹ️ Nitpicks
Entitlement.renewedAt's doc comment (Entitlement.swift:90-96) wasn't updated alongside the other three. Its semantics changed in this same delta — it is now the latest renewal in any subscription group, deliberately unlikelatestProductId/expiresAt/willRenew, which the delta's new docs scope to the granting purchase. Worth one line saying so, since the four fields now read as if they all describe the same purchase.- The comment above
for _ in 0..<10inlatestSubscriptionIsPickedAcrossEntitlements("a last-one-wins pick would only sometimes land on the wrong one") overstates what the loop buys. Swift seedsDictionaryhashing once per process, so all ten iterations observe the same ordering — the loop doesn't sample orders, and against the old code the test would fail or pass consistently within a run depending on that process's seed. The test is still worth keeping; the comment just describes a guarantee it doesn't have. - A fully-refunded entitlement now reports
startsAt == nilwhilelatestProductIdandstatestill name the refunded product, so the object simultaneously reads as "nothing ever unlocked this" and "this product did". Consistent withstartsAt's doc as written, but the pairing is odd enough to be worth a deliberate decision.
Claude Opus | 𝕏
| guard let bucket = buckets[key], | ||
| let representative = bucket.max(by: { $0.purchaseDate < $1.purchaseDate }) else { | ||
| return nil | ||
| } | ||
| let isLifetime = key == lifetimeKey | ||
| let unrevoked = bucket.filter { !$0.isRevoked } |
There was a problem hiding this comment.
representative is bucket.max(by: purchaseDate) over the whole bucket, while expiresAt three lines below correctly uses unrevoked. In a group holding an older unrevoked transaction plus a newer revoked one, latestProductId therefore names the refunded product while expiresAt describes the other one — which makes the doc line this delta added to Entitlement.expiresAt ("the expiry of the same purchase that latestProductId names") false.
The new upward direction in correctPurchases turns that into a behavioural bug rather than a cosmetic one: the refunded product id becomes an active Purchase, so it reaches device.activeProducts via getActiveProductIds() and makes isSubscribed(to:) answer true for a product the user was refunded for.
Technical details
# `latestProductId` can name a revoked transaction on an active source
## Affected sites
- `EntitlementProcessor.swift:299` — `representative` is the greatest `purchaseDate` in the
bucket with no revoked filter; `:256` derives `latestProductId` from it.
- `EntitlementProcessor.swift:312` — `expiresAt` uses `unrevoked` only, so the two fields
can describe different transactions within one group.
- `EntitlementProcessor.swift:512` — `hasUnrevokedTransaction` keeps the source active
(correctly: the group really is subscribed), which is what makes the mismatch reachable
on an *active* entitlement rather than only on a lapsed one.
- `SK2ReceiptManager.swift:83-85` — `isGranting` matches on `latestProductId`, so the
refunded product's `Purchase` is flipped to `isActive = true`.
- `ReceiptManager.swift:270-276` and `:262-268` — `getActiveProductIds()` (feeding
`DeviceHelper.getTemplateDevice().activeProducts`, and thence audience filters) and
`isSubscribed(to:)` (feeding `TemplateLogic`) both then report the refunded product.
- `Entitlement.swift:98-102` — the doc comment added in this delta that the mismatch
contradicts.
## Reachable state
One subscription group, product change plus a refund of the newer product, with the group
still reporting `.subscribed`: transactions are `monthly` (unrevoked, expiry still in the
future) and `yearly` (later `purchaseDate`, `revocationDate` set). `representative` is the
revoked `yearly`, so `latestProductId == "yearly"` on an active entitlement whose
`expiresAt` came from `monthly`.
## Required outcome
- The transaction that names an **active** grant source must be one that still grants, so
`latestProductId`, `expiresAt` and `willRenew` describe the same purchase — the property
this delta's docs now promise.
- No product whose every transaction in the group is revoked may end up in
`getActiveProductIds()`.
## Suggested approach (optional)
- Prefer `unrevoked` when choosing `representative`, falling back to the full bucket only
when everything is revoked (which preserves today's "report the last known state"
behaviour for a lapsed entitlement). Note this also moves `latestPurchaseDate`, so a
refunded transaction stops being able to win `latestSubscriptionSource` — worth
confirming that's the intent.| guard let bucket = buckets[key], | |
| let representative = bucket.max(by: { $0.purchaseDate < $1.purchaseDate }) else { | |
| return nil | |
| } | |
| let isLifetime = key == lifetimeKey | |
| let unrevoked = bucket.filter { !$0.isRevoked } | |
| guard let bucket = buckets[key] else { | |
| return nil | |
| } | |
| let isLifetime = key == lifetimeKey | |
| let unrevoked = bucket.filter { !$0.isRevoked } | |
| // Name the source after a transaction that still grants, so | |
| // `latestProductId` and `expiresAt` describe the same purchase. Only a | |
| // wholly revoked source falls back to reporting its last known state. | |
| guard let representative = (unrevoked.isEmpty ? bucket : unrevoked) | |
| .max(by: { $0.purchaseDate < $1.purchaseDate }) else { | |
| return nil | |
| } |
| if #available(iOS 17.2, macOS 14.2, tvOS 17.2, watchOS 10.2, visionOS 1.1, *) { | ||
| return info.renewalDate | ||
| } | ||
| return nil |
There was a problem hiding this comment.
RenewalInfo.renewalDate is available from iOS 15.0 / macOS 12.0 — it's declared @backDeployed(before: iOS 17.0, macOS 14.0, tvOS 17.0, watchOS 10.0), so the implementation ships in the app binary and works on the enclosing type's whole @available(iOS 15.0, *) range (docs). This gate looks copied from getOfferType above, where 17.2 is genuinely required because Transaction.offer is.
As written, the .subscribed-with-a-stale-Transaction.all half of the expiry fix silently doesn't apply on iOS 15.0–17.1, leaving exactly the active-with-past-expiresAt entitlement this delta set out to eliminate. The grace-period half is unaffected, since gracePeriodExpirationDate is iOS 15.0+.
| if #available(iOS 17.2, macOS 14.2, tvOS 17.2, watchOS 10.2, visionOS 1.1, *) { | |
| return info.renewalDate | |
| } | |
| return nil | |
| return info.renewalDate |
|
|
||
| let provider = MockSubscriptionStatusProvider( | ||
| statusesByGroupId: [ | ||
| "group_1": ResolvedSubscriptionStatus(state: .expired, willRenew: false, offerType: .promotional), |
There was a problem hiding this comment.
This test passes against the code it replaces, so it doesn't lock in the change. group_1 is .expired, which forces its source inactive, leaving group_2 as the only active source — and the previous representativeSource(from:)-based selection would pick group_2 too, producing the same .subscribed / true / .trial. The recency-vs-furthest-expiry tension the test name describes is never actually created.
Making group_1 active is enough to discriminate: the old code would then pick it on expiresAt (+86_400 beats group_2's +3600) and report willRenew: false / .promotional, failing the existing assertions, while latestSubscriptionSource still picks the more recently bought group_2.
| "group_1": ResolvedSubscriptionStatus(state: .expired, willRenew: false, offerType: .promotional), | |
| "group_1": ResolvedSubscriptionStatus(state: .subscribed, willRenew: false, offerType: .promotional), |

Description
An entitlement backed by more than one App Store subscription group was resolved from a single most-recently-purchased transaction.
Transaction.subscriptionStatusonly reports the state of that transaction's subscription group, so a refund in one group was applied to the whole entitlement and deactivated a paid, active subscription in another group. Whether it happened depended purely on which group held the newest transaction, and the scalar fields were spliced across groups:stateandwillRenewfrom one,expiresAtfrom another.Real-world case this fixes:
The refunded Aug 15 renewal is the most recent transaction, so it won the resolution, came back
revoked, and the SDK marked the whole entitlement inactive — even though the group B yearly was still paid for and active, and the payload listed it as active.Changes
SubscriptionStatusProvideris keyed onEntitlementTransactioninstead ofStoreKit.Transactionso resolution can be tested without minting real StoreKit transactions, and statuses are cached per transaction so a group shared by several entitlements costs one lookup.Testing
New
PerGroupEntitlementResolutionTestssuite (16 tests) covering the refund-in-one-group case, grace periods, billing retry, lifetime purchases, and scalar field selection. Full suite: 1010 tests in 101 suites passing on iPhone 17 Pro.Checklist
CHANGELOG.mdfor any breaking changes, enhancements, or bug fixes.swiftlintin the main directory and fixed any issues.🤖 Generated with Claude Code
Greptile Summary
This PR splits entitlement transactions into independent subscription-group and lifetime grant sources, resolves live StoreKit status per group, and selects entitlement metadata from an active source.
Confidence Score: 4/5
The PR should not merge until the latest-subscription callback selects the genuinely most recent StoreKit subscription rather than the source with the furthest expiration.
Entitlement access is resolved independently per group as intended, but a realistic multi-group subscription state can publish older subscription metadata into device variables used by audience filters.
Files Needing Attention: Sources/SuperwallKit/StoreKit/Products/Receipt Manager/EntitlementProcessor.swift
Important Files Changed
Flowchart
%%{init: {'theme': 'neutral'}}%% flowchart TD T[Transactions for entitlement] --> B[Bucket by subscription group] T --> L[Lifetime source] B --> S[Resolve each group's live StoreKit status] S --> A{Any source active?} L --> A A -->|Yes| R[Choose lifetime or active source with furthest expiry] A -->|No| F[Choose most recent purchase] R --> E[Build entitlement scalars] F --> E R --> D[Publish latestSubscription device variables]Prompt To Fix All With AI
Reviews (1): Last reviewed commit: "fix(entitlements): resolve each subscrip..." | Re-trigger Greptile