Skip to content

fix(entitlements): resolve each subscription group independently - #518

Open
yusuftor wants to merge 3 commits into
developfrom
feat/per-group-entitlement-resolution
Open

fix(entitlements): resolve each subscription group independently#518
yusuftor wants to merge 3 commits into
developfrom
feat/per-group-entitlement-resolution

Conversation

@yusuftor

@yusuftor yusuftor commented Sep 7, 2026

Copy link
Copy Markdown
Collaborator

Description

An entitlement backed by more than one App Store subscription group was resolved from a single most-recently-purchased transaction. Transaction.subscriptionStatus only 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: state and willRenew from one, expiresAt from another.

Real-world case this fixes:

  • Jul 18 — user starts a 7-day trial on a monthly product (group A).
  • Jul 25 — trial ends, billing fails, Apple puts it in billing retry.
  • Aug 2 — user buys a yearly product (group B), valid until Aug 2027. Same entitlement, different subscription group.
  • Aug 15 — Apple's retry on the group A sub goes through and charges.
  • Aug 16 — Apple refunds that charge.

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

  • Transactions are 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.
  • Scalar fields are taken from the source actually granting access (lifetime first, 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 authoritative in both directions for its own group, so a lapsed subscription in its grace period is reported active rather than 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 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 PerGroupEntitlementResolutionTests suite (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

  • All unit tests pass.
  • All UI tests pass.
  • Demo project builds and runs on iOS.
  • Demo project builds and runs on Mac Catalyst.
  • Demo project builds and runs on visionOS.
  • I added/updated tests or detailed why my change isn't tested.
  • I added an entry to the CHANGELOG.md for any breaking changes, enhancements, or bug fixes.
  • I have run swiftlint in the main directory and fixed any issues.
  • I have updated the SDK documentation as well as the online docs.
  • I have reviewed the contributing guide

🤖 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.

  • Prevents a refund or expiry in one subscription group from cancelling access granted by another group.
  • Treats grace-period status as active while retaining date-based handling for billing retry.
  • Adds comprehensive per-group resolution tests and registers the new suite in the Xcode project.
  • The experimental “latest subscription” callback now selects by remaining entitlement duration rather than purchase recency, which can publish incorrect audience-filter properties.

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

Filename Overview
Sources/SuperwallKit/StoreKit/Products/Receipt Manager/EntitlementProcessor.swift Introduces per-group grant-source resolution, but selects experimental “latest subscription” metadata using expiration duration instead of purchase recency.
Tests/SuperwallKitTests/StoreKit/Products/Receipt Manager/PerGroupEntitlementResolutionTests.swift Adds broad coverage for independent groups, refunds, grace periods, billing retry, lifetime purchases, and status caching, but does not cover the latest-subscription callback when multiple groups are active.
Tests/SuperwallKitTests/StoreKit/Products/Receipt Manager/EntitlementProcessorTests.swift Updates the subscription-status mock to support per-group responses and recorded lookups.
SuperwallKit.xcodeproj/project.pbxproj Registers the new per-group entitlement test file with the test target.
CHANGELOG.md Documents the cross-group refund and grace-period fixes.

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]
Loading
Prompt To Fix All With AI
### Issue 1
Sources/SuperwallKit/StoreKit/Products/Receipt Manager/EntitlementProcessor.swift:466-472
**Latest subscription selection is wrong**

When one entitlement has multiple active subscription groups, `representativeSource` selects the group with the furthest expiration rather than the most recently purchased subscription. For example, an older yearly subscription can override a newer monthly subscription. The callback then stores the older source in the global `latestSubscription*` device variables, causing audience filters to evaluate the wrong state, renewal flag, and offer type.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Reviews (1): Last reviewed commit: "fix(entitlements): resolve each subscrip..." | Re-trigger Greptile

Greptile also left 1 inline comment on this PR.

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>
Comment thread Sources/SuperwallKit/StoreKit/Products/Receipt Manager/EntitlementProcessor.swift Outdated

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 resolutiongrantSources(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 sourcerepresentativeSource(from:) prefers an active lifetime, then the active source with the furthest expiresAt, falling back to the most recent purchase when nothing is active; it supplies latestProductId, expiresAt, renewedAt, willRenew, state and offerType.
  • Live status is authoritative in both directions.subscribed/.inGracePeriod force a source active, .revoked/.expired force it inactive, .inBillingRetryPeriod/nil defer to dates. Pre-PR the override only ever forced inactive.
  • SubscriptionStatusProvider collapsed to one methodresolveStatus(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

  • startsAt changed from transactions.last?.originalPurchaseDate to transactions.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 asserts startsAt for a multi-transaction entitlement — testProcessRenewalTracking and testProcessTransactionsWithExtremeDates are both single-transaction, where .last and .min() coincide.
  • Coverage gaps in the new suite worth closing while the logic is fresh: no test asserts renewedAt, none asserts latestProductId for 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/.inGracePeriod status.
  • The comment at EntitlementProcessor.swift:401-404 says 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.

Pullfrog  | Fix all ➔Fix 👍s ➔View workflow run | Using Claude Opus𝕏

Comment thread Sources/SuperwallKit/StoreKit/Products/Receipt Manager/EntitlementProcessor.swift Outdated
Comment thread Sources/SuperwallKit/StoreKit/Products/Receipt Manager/EntitlementProcessor.swift Outdated
yusuftor and others added 2 commits September 10, 2026 14:35
…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>
@yusuftor

yusuftor commented Sep 10, 2026

Copy link
Copy Markdown
Collaborator Author

Addressing the review findings

Pushed cfb87386b and 4be51bd36. All 1025 tests in 101 suites pass; scripts/lint.sh is clean apart from a pre-existing function_body_length violation in SK1ReceiptManager.swift.

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:

  1. expiresAt moves with the grant. ResolvedSubscriptionStatus now carries activeUntilgracePeriodExpirationDate when there is one, else renewalDate — and the .subscribed/.inGracePeriod override raises expiresAt to it. It only ever moves forward, so a live status can't shorten a period the user has already paid for. A grace-period entitlement now reports the end of the grace period, which renewsAt and the AutomaticPurchaseController expiry gate both read correctly.

  2. Purchase.isActive follows the corrected entitlement. The expiry fix alone wasn't enough: syncSubscriptionStatus(withPurchases:) builds its entitlement set from active purchases, and a grace-period product had none, so it still landed in the non-answer branch and failed the activeProductIds check. The correction block in SK2ReceiptManager is now bidirectional and extracted as correctPurchases(_:using:) so it's testable: all-inactive entitlements still deactivate a purchase, and a purchase that is an active entitlement's latestProductId is activated. Gating the upward direction on latestProductId is what stops a refunded subscription looking active because a different group unlocks the same entitlement — four tests cover it in SK2ReceiptManagerTests.

So to answer the open question directly: Purchase.isActive is meant to track the corrected entitlement state, and the divergence was a bug.

Nitpicks

All three taken:

  • startsAt now filters out revoked transactions and consumables before .min(), with a test.
  • Coverage gaps closed: renewedAt across groups, latestProductId where the most recent purchase isn't the furthest-expiring, and a revoked transaction paired with .subscribed.
  • The cache comment no longer claims one lookup per group — it's keyed on the transaction the group was resolved from.

Note for whoever runs the tests locally

Nothing to fix in this repo, but worth knowing: Xcode 27 can no longer build for iOS 13. Its SDK declares the floor itself — iPhoneSimulator.sdk/SDKSettings.plist has MinimumDeploymentTarget = 15.0 — so xcodebuild rejects the project before compiling anything:

error: The iOS Simulator deployment target 'IPHONEOS_DEPLOYMENT_TARGET' is set to 13.0,
but the range of supported deployment target versions is 15.0 to 27.0.x.

The SDK's 13.0 floor is deliberate and consistent across project.yml, Package.swift and the podspec, so I left it alone and passed IPHONEOS_DEPLOYMENT_TARGET=15.0 on the command line for the test run. Our Xcode workflows pin xcode-version: latest-stable, so they'll hit this whenever that flips to 27 — that's a decision about the SDK's support floor rather than anything to change here.

🤖 Generated with Claude Code

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 overrideGrantSource.hasUnrevokedTransaction now gates the .subscribed/.inGracePeriod branch, so a group whose every transaction is revoked can no longer be resurrected by its group status.
  • Carried the real expiry onto an activated sourceResolvedSubscriptionStatus.activeUntil (grace-period end, else renewalInfo.renewalDate on iOS 17.2+) moves expiresAt forward when the live status forces a source active, so an active entitlement no longer advertises a date that has already passed.
  • Made correctPurchases two-directional and testable — extracted to a static on SK2ReceiptManager; a purchase is now forced active when it is the latestProductId of an active entitlement, which is what lets the grace-period fix reach AutomaticPurchaseController'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 / willRenew docs 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, three activeUntil cases, renewedAt/startsAt) and four in SK2ReceiptManagerTests for correctPurchases.

ℹ️ 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, per renewedAtComesFromTheGrantingGroup — so its nil-conditions list should say so too.
  • No test covers startsAt when every transaction is revoked, which now yields nil where 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".

Pullfrog  | Fix all ➔Fix 👍s ➔View workflow run | Using Claude Opus𝕏

Comment on lines +83 to +85
let isGranting = productEntitlements.contains {
$0.isActive && $0.latestProductId == purchase.id
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +129 to +132
if #available(iOS 17.2, macOS 14.2, tvOS 17.2, watchOS 10.2, visionOS 1.1, *) {
return info.renewalDate
}
return nil

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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+.

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 invariantResolvedSubscriptionStatus gained activeUntil (grace-period end, else renewalDate), and the .subscribed/.inGracePeriod override raises GrantSource.expiresAt to it under a strict > guard so a live status can only ever move the date forward.
  • Gated the upward override on an unrevoked transactionGrantSource.hasUnrevokedTransaction is now required before .subscribed/.inGracePeriod can flip a source active, so a group status can't resurrect a fully-refunded source. revokedSourceDoesNotDescribeTheEntitlement also pins the expiresAt == nil.distantFuture amplifier that used to let such a source outrank a paying one.
  • Made Purchase.isActive bidirectional — the correction block moved out of loadPurchases into a testable SK2ReceiptManager.correctPurchases(_:using:); a purchase that is an active entitlement's latestProductId is now forced active, gated on latestProductId so 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-period Purchase entries is harmless.
  • Reselected the latest-subscription source by purchase recencylatestSubscriptionSource(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, narrowed startsAtrenewedAt is now the max across all sources rather than the granting source's, and startsAt filters revoked transactions and consumables before .min().
  • Documented the granting-source semanticslatestProductId, expiresAt and willRenew doc comments rewritten, with two matching CHANGELOG bullets under 4.17.0 (which matches Constants.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 unlike latestProductId/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..<10 in latestSubscriptionIsPickedAcrossEntitlements ("a last-one-wins pick would only sometimes land on the wrong one") overstates what the loop buys. Swift seeds Dictionary hashing 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 == nil while latestProductId and state still name the refunded product, so the object simultaneously reads as "nothing ever unlocked this" and "this product did". Consistent with startsAt's doc as written, but the pairing is odd enough to be worth a deliberate decision.

Pullfrog  | Fix all ➔Fix 👍s ➔View workflow run | Using Claude Opus𝕏

Comment on lines +298 to +303
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 }

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.
Suggested change
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
}

Comment on lines +129 to +132
if #available(iOS 17.2, macOS 14.2, tvOS 17.2, watchOS 10.2, visionOS 1.1, *) {
return info.renewalDate
}
return nil

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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+.

Suggested change
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),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
"group_1": ResolvedSubscriptionStatus(state: .expired, willRenew: false, offerType: .promotional),
"group_1": ResolvedSubscriptionStatus(state: .subscribed, willRenew: false, offerType: .promotional),

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant