feat(checkpoints): Add the customer's purchases as a rules dimension - #3987
feat(checkpoints): Add the customer's purchases as a rules dimension#3987tonidero wants to merge 12 commits into
Conversation
c0a2b26 to
1eb9e0c
Compare
d4cde23 to
5cf864f
Compare
5cf864f to
77ee860
Compare
452c67b to
e96fd76
Compare
12d90d6 to
9aaad59
Compare
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## main #3987 +/- ##
==========================================
+ Coverage 83.11% 83.16% +0.05%
==========================================
Files 437 438 +1
Lines 17923 18064 +141
Branches 2688 2707 +19
==========================================
+ Hits 14896 15023 +127
- Misses 2145 2157 +12
- Partials 882 884 +2 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
Generated by 🚫 Danger |
9870378 to
2582586
Compare
da051e9 to
6005173
Compare
2582586 to
3934ed9
Compare
3934ed9 to
cbbcbe2
Compare
| .map { subscription -> subscription.record(date) } | ||
| // Already sorted by purchase date by `CustomerInfo`. | ||
| val transactions = nonSubscriptionTransactions.map { transaction -> transaction.record(date) } | ||
| return (subscriptions + transactions).sortedByDescending { record -> record.dateOrNull(KEY_PURCHASED_AT) } |
There was a problem hiding this comment.
Note how we sort by productId first, but then we resort by purchase date, so newer ones come first. Might not be needed, but felt like a good convention to have.
| * The cache when it is warm, the initial fetch otherwise. Goes through the orchestrator because | ||
| * `Purchases.awaitCustomerInfo` only exists in the `defaults` source set, while this is compiled for both flavors. | ||
| */ | ||
| private suspend fun PurchasesOrchestrator.awaitCustomerInfo(): CustomerInfo = |
There was a problem hiding this comment.
Had to keep this since for the CEC mode, we don't have the awaitCustomerInfo function in the public API. Otherwise, I could just call Purchases.sharedInstance.awaitCustomerInfo.
A flat dimension per audience question has to pick one purchase to describe — the latest subscription, which cannot answer anything about the one before it, and cannot describe a trial that has since converted. So `customerInfo` hands the engine the whole graph instead: `purchases` and `entitlements` are collections of records the iteration operators walk, and "is in a trial ending this week" or "has ever bought this base plan" become predicates over the same values rather than dimensions the SDK ships one at a time. A record carries the facts a predicate cannot work out for itself, and no more. `periodType` already answers "is in a trial", so there is no boolean for it; `isActive` and `willRenew` are derivations no predicate could reproduce, so they are there. Entitlement verification is not exposed at all: it describes the response the records were parsed out of rather than anything about the customer, and it is not something an audience is keyed on. A predicate inside an iteration operator sees only the record it is looking at, with no access to the scope around it, so each record carries the evaluation instant it is compared against. For the same reason activity is a boolean on the record rather than something a predicate derives from `expiresAt`: an absent date reads as 0, so a one-time purchase would otherwise compare as expired in 1970. Purchases come out newest first, so `purchases.0` is the most recent one of either kind and a rule about it needs no iteration. Sandbox purchases count, unlike the dashboard, which evaluates the same audience over production purchases only: a rule has to be testable in a debug build. Each record carries `isSandbox` so a predicate that wants only real money can say so. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Every other test in the package covers one dimension source in isolation, so "what can a predicate actually read?" was a survey of five providers rather than something you could look up. This wires all of them together the way `PurchasesFactory` does and asserts the entire resolved scope as JSON, which is the form a predicate author thinks in. The customer is deliberately not a realistic one — refunded and renewing, in a trial and paused — so that every key appears once, including the ones a real customer would have left out. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The golden scope wires the providers `PurchasesFactory` does, so it has to grow the `subscriberAttributes` root too, or "what can a rule read?" stops being answerable from one file. The fixture is chosen so the blob records the omissions as well as what is there: a deleted attribute and a name a dot-path could not reach are both set, and neither appears. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ecycle A rule that wanted "in a trial", "being served through a billing issue" or "lapsed" had to assemble it out of `periodType`, `isActive`, `gracePeriodExpiresAt` and `autoResumeAt`, and get the precedence between them right. `status` is that one value, read most specific first: paused whatever the dates say, then a grace period the store is still serving through, then the trial or renewal it would otherwise be. It shares its grace-period predicate with `isInGracePeriod`, so the flag and the status cannot disagree. `in_billing_retry` and `incomplete` belong to the same vocabulary but are never reported. Once a grace period is over, whether the store is still retrying is something it tells the backend and not this device, which only sees that access lapsed — so a lapsed subscription is `expired` whether or not a billing issue preceded it, and a predicate that needs the difference reads `billingIssueDetectedAt` itself. Nothing in the payload speaks to an incomplete setup at all. Only subscriptions carry it: a one-time purchase has no lifecycle to be anywhere in. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
cbbcbe2 to
964d7d9
Compare
…dAt to the scope root The app user ID was read once for the reported `appUserId` and again inside `PurchasesOrchestrator.getCustomerInfo`. An app that logs in, logs out, or switches user while that request is in flight made the snapshot file one customer's purchases under another customer's ID. The provider now reads the ID once and passes it into the fetch, through a new orchestrator overload that takes an app user ID; an empty ID skips the fetch rather than asking about nobody. Same fix as iOS 0700e43cd. `evaluatedAt` was stamped on every purchase, entitlement and subscriber attribute record, and on the `customerInfo` namespace, always with the same instant. `RulesDimensionResolver` already owned that instant, so it now puts a single one at the root of the scope. An iteration operator rebinds `var` to the current item with no parent scope, so the root instant is not readable from inside one: a predicate comparing a purchase against it reads that purchase by index, as `customerInfo.purchases.0.expiresAt`. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, have a team admin enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit 3abca83. Configure here.
Pinning the request to one app user is not enough to be answered about them. On a cold cache `CustomerInfoHelper` syncs pending purchases first, and `PostPendingTransactionsHelper.syncPendingPurchaseQueue` reads the current app user for itself rather than the one it was called about, so the answer can describe whoever is current by the time it lands. Filing those purchases under the id this evaluation started with would describe a customer who does not exist, so the id is checked again once the answer is in and a mismatch drops the customer info dimensions, leaving the evaluation with the reported id and nothing else. Same degradation as a read that failed outright. Narrows the orchestrator overload's contract to what it delivers: the cache lookup and the backend request use the given id, which is not the same as the answer describing that customer. `CustomerInfoUpdateHandler.cacheAndNotifyListeners` caches under the current app user rather than the one the fetch was issued for, so a fetch that lands after a login writes the old customer into the new customer's cache slot and notifies listeners with it. That predates this work and affects every `getCustomerInfo` caller, so it is left for its own change. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…nderneath it
Dropping the customer info dimensions left the namespace absent, and an absence rule
such as `{"none": [{"var": "customerInfo.purchases"}, ...]}` then reads as a definite
yes for a customer who may well have purchases. A user change is not a condition the
SDK cannot know the answer to, it is one where the answer moved, so it asks again.
`RulesDimensionResolver` reads the app user id before and after collecting every
provider, and discards a collection the id moved underneath, taking it again against
a fresh reference instant. Watching the whole collection rather than the customer
info read alone also covers the providers that read the id independently:
`SubscriberAttributesDimensionProvider` could otherwise pair one customer's
attributes with another's purchases, since `StoreDimensionProvider` suspends before
it.
There is exactly one retry. A second change back to back is not something a third
attempt fixes and more likely says something else is wrong, so it fails the snapshot
with `AppUserChanged` and the checkpoint reports CONFIGURATION_UNAVAILABLE, which is
already distinguished from NO_MATCH. A provider failure or a path conflict is not
retried: both are configuration bugs.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
220d1cd to
66d7eac
Compare
| ) : RulesDimensionResolutionException("two dimension providers supplied '$path'") | ||
|
|
||
| internal object AppUserChanged : RulesDimensionResolutionException( | ||
| "the app user kept changing while the dimensions were being collected", |
There was a problem hiding this comment.
| "the app user kept changing while the dimensions were being collected", | |
| the app user changed while the dimensions were being collected |
| private val providers: List<RulesDimensionProvider>, | ||
| private val dateProvider: DateProvider = DefaultDateProvider(), | ||
| /** No identity source means nothing can change: the same value the SDK reports for an uncached user. */ | ||
| private val currentAppUserId: () -> String = { "" }, |
There was a problem hiding this comment.
following up on the slack thread so it's not lost here: what if the resolver reads the app user once and passes it down, instead of each provider reading the live value?
internal data class RulesDimensionContext(val date: Date, val appUserId: String)dimensions(date) becomes dimensions(context), and then there's nothing to compare, no retry and no extra error reason. same shape as ios DimensionContext. or is there a case where the retry earns it that I'm not seeing? 🤔
| return Result.failure(RulesDimensionResolutionException.AppUserChanged) | ||
| } | ||
|
|
||
| /** One pass over every provider, against one reference instant of its own. */ |
There was a problem hiding this comment.
not a real problem right now, just to have it in mind: the retry redoes device and store too, and those can't change with the user. one more small reason to pass the id in instead
| * with no parent scope, so a predicate comparing a purchase against it reads that purchase by index, as | ||
| * `customerInfo.purchases.0.expiresAt`. | ||
| */ | ||
| const val KEY_EVALUATED_AT = "evaluatedAt" |
There was a problem hiding this comment.
better at the root than copied onto every record. I'll follow on ios so it reads the same on both sides
| * purchases first, and that sync reads the current app user for itself. Catching that is | ||
| * [RulesDimensionResolver]'s job, since it watches the whole collection rather than this read alone, and it | ||
| * can take the snapshot again instead of leaving a customer half-described. | ||
| */ |
There was a problem hiding this comment.
Can we make this comment a bit more concrete? It's a bit verbose. I had a bit of trouble following it.
| putString(KEY_ORIGINAL_APP_USER_ID, originalAppUserId) | ||
| putDate(KEY_FIRST_SEEN_AT, firstSeen) | ||
| // The date the backend last answered us, which is when it last saw this customer through this device. | ||
| putDate(KEY_LAST_SEEN_AT, requestDate) |
There was a problem hiding this comment.
I am unsure this is what we want to do.requestDate is when the backend last answered this device, not when the customer was last seen, so I think this ends up meaning something different from what the name promises, as it means "the moment the user opens the app" since we refresh CustomerInfo on app foreground. Any rule evaluated in that session will see "seen just now". The only way to get an old value is to be offline which is when we would use a cached CustomerInfo with an older request date, or for the checkpoint to beat the startup refresh.
Also as a developer what I'd want from lastSeenAt is "how long since they last opened the app", so lastSeenAt older than 7 days targets a user that hasn't opened the app in a while.
Looking at the backend, it looks like we are actually sending subscriber.last_seen is in the payload, we just don't parse it into CustomerInfo. Would that work better?
There was a problem hiding this comment.
Hmm you're indeed right... I think it makes sense, will do those changes 🙏
…s underneath it Simpler than the retry it replaces. A collection the app user moved underneath is not reported either way, and the retry bought one extra chance of resolving at the cost of re-running every provider, including device and store, which cannot change with the user. Failing straight away says the same thing with less machinery: the checkpoint reports CONFIGURATION_UNAVAILABLE, which is already distinguished from NO_MATCH. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Per review: the resolver reads the app user once into a `RulesDimensionContext(date, appUserId)` and passes it down, rather than every provider reading the live value for itself. Same shape as iOS `DimensionContext`. The providers can no longer disagree about who they are describing, which is what `SubscriberAttributesDimensionProvider` could do until now: it read the id itself, after `StoreDimensionProvider` had already suspended, so a login part-way through could pair one customer's attributes with another's purchases. It now reads the attributes for the id the snapshot is about, and `CustomerInfoDimensionProvider` drops its identity lambda entirely. The closing comparison and `AppUserChanged` stay. Pinning the id fixes who gets *reported*, not who gets *answered about*: on a cold cache the SDK syncs pending purchases first, and `PostPendingTransactionsHelper.syncPendingPurchaseQueue` reads the current app user for itself, so the customer info handed back can still belong to someone else. Nothing else catches that. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… the request date Per review: `requestDate` is when the backend last answered *this device*, and the SDK refreshes customer info on every foreground, so `lastSeenAt` read as "seen just now" for a whole session. `lastSeenAt older than 7 days` could only ever match a customer who was offline or who hit a checkpoint before the startup refresh, which is not what the name promises or what a rule author wants from it. The backend already sends `subscriber.last_seen` and the SDK never parsed it. It is parsed now, alongside `first_seen`, and surfaced as an internal `CustomerInfo.lastSeen` so the public API does not change. Absent from a payload cached by a version that did not read it, which omits the dimension. Excluded from equality, like `requestDate`: it moves with every response, and counting it would make `CustomerInfoUpdateHandler` notify its listeners on every refresh. Also trims the `customerInfoDimensions` comment, which was hard to follow. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
We're taking a different approach here. We might need something similar in the future, but for now, will close it and can reopen later if/when needed |

Description
This PR adds a new dimension provider for customer info information that will contain all purchases facts themselves, without any processing, so the rules that come from the backend can use this info to process the data as needed.
The evaluation instant is a single
evaluatedAtat the root of the scope, alongside the namespaces, rather than repeated on every record. Note thatsome/all/none/filter/maprebindvarto the current item with no parent scope, so a predicate comparing a purchase against the instant reads that purchase by index (customerInfo.purchases.0.expiresAt) instead of iterating.The app user ID is read once per evaluation and used both to request the customer info and as the reported
appUserId, so a login or logout while the request is in flight cannot file one customer's purchases under another's ID. Same fix as iOS: RevenueCat/purchases-ios#7461.This ends up added to the rules engine like:
Note
Medium Risk
Touches checkpoint targeting and CustomerInfo lookup during rule evaluation, including identity pinning so a login mid-snapshot cannot mix two customers. Failures degrade to missing customer dimensions rather than changing purchase or auth flows.
Overview
Local checkpoint rules can now target the current customer: identity, last-seen, purchases (newest first), and entitlements, under
customerInfo. Fetch failures omit those fields instead of failing the whole snapshot;appUserIdis still available without a network round-trip.Snapshots pin one app user and one
evaluatedAtat the root. Subscriber attributes are read for that same ID, and a login/logout mid-collection discards the snapshot.last_seenis parsed internally (not part ofCustomerInfoequality) so “time since last open” is not the SDK’s own refresh time.Reviewed by Cursor Bugbot for commit 4876e0b. Bugbot is set up for automated code reviews on this repo. Configure here.