Skip to content

feat(checkpoints): Add the customer's purchases as a rules dimension - #3987

Closed
tonidero wants to merge 12 commits into
mainfrom
toni/customer-info-rules-dimension
Closed

feat(checkpoints): Add the customer's purchases as a rules dimension#3987
tonidero wants to merge 12 commits into
mainfrom
toni/customer-info-rules-dimension

Conversation

@tonidero

@tonidero tonidero commented Aug 18, 2026

Copy link
Copy Markdown
Contributor

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 evaluatedAt at the root of the scope, alongside the namespaces, rather than repeated on every record. Note that some/all/none/filter/map rebind var to 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:

{
  "customerInfo": {
    "appUserId": "current_user",
    "entitlements": [
      {
        "billingIssueDetectedAt": 1714780800000,
        "expiresAt": 4102444800000,
        "identifier": "extra",
        "isActive": true,
        "isSandbox": true,
        "latestPurchasedAt": 1714521600000,
        "originalPurchasedAt": 1609459200000,
        "ownershipType": "PURCHASED",
        "periodType": "trial",
        "productIdentifier": "premium",
        "productPlanIdentifier": "monthly",
        "purchasedProductIdentifier": "premium:monthly",
        "store": "play_store",
        "unsubscribeDetectedAt": 1714694400000,
        "willRenew": false
      },
      {
        "billingIssueDetectedAt": 1714780800000,
        "expiresAt": 4102444800000,
        "identifier": "premium",
        "isActive": true,
        "isSandbox": true,
        "latestPurchasedAt": 1714521600000,
        "originalPurchasedAt": 1609459200000,
        "ownershipType": "PURCHASED",
        "periodType": "trial",
        "productIdentifier": "premium",
        "productPlanIdentifier": "monthly",
        "purchasedProductIdentifier": "premium:monthly",
        "store": "play_store",
        "unsubscribeDetectedAt": 1714694400000,
        "willRenew": false
      }
    ],
    "firstSeenAt": 1640995200000,
    "lastSeenAt": 1717200000000,
    "originalAppUserId": "original_user",
    "originalPurchasedAt": 1609459200000,
    "purchases": [
      {
        "autoResumeAt": 1717200000000,
        "billingIssueDetectedAt": 1714780800000,
        "displayName": "Premium Monthly",
        "expiresAt": 4102444800000,
        "gracePeriodExpiresAt": 1715299200000,
        "isActive": true,
        "isInGracePeriod": false,
        "isPaused": true,
        "isRefunded": true,
        "isSandbox": true,
        "kind": "subscription",
        "originalPurchasedAt": 1609459200000,
        "ownershipType": "PURCHASED",
        "periodType": "trial",
        "priceAmountMicros": 4990000,
        "priceCurrency": "USD",
        "productIdentifier": "premium",
        "productPlanIdentifier": "monthly",
        "purchasedAt": 1714521600000,
        "purchasedProductIdentifier": "premium:monthly",
        "refundedAt": 1714867200000,
        "status": "paused",
        "store": "play_store",
        "storeTransactionId": "GPA.0000-0000-0000-00000",
        "unsubscribeDetectedAt": 1714694400000,
        "willRenew": false
      },
      {
        "displayName": "100 Coins",
        "isSandbox": false,
        "kind": "nonSubscription",
        "originalPurchasedAt": 1677801600000,
        "priceAmountMicros": 1990000,
        "priceCurrency": "EUR",
        "productIdentifier": "coins",
        "purchasedAt": 1677801600000,
        "purchasedProductIdentifier": "coins",
        "store": "amazon",
        "storeTransactionId": "amzn.1234",
        "transactionIdentifier": "abc123"
      }
    ]
  },
  "evaluatedAt": 1718452800000
}

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; appUserId is still available without a network round-trip.

Snapshots pin one app user and one evaluatedAt at the root. Subscriber attributes are read for that same ID, and a login/logout mid-collection discards the snapshot. last_seen is parsed internally (not part of CustomerInfo equality) 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.

@tonidero
tonidero force-pushed the toni/customer-info-rules-dimension branch 2 times, most recently from c0a2b26 to 1eb9e0c Compare August 18, 2026 08:41
@tonidero
tonidero force-pushed the toni/customer-info-rules-dimension branch from d4cde23 to 5cf864f Compare August 18, 2026 13:39
@tonidero
tonidero changed the base branch from toni/local-rules-date-and-list-dimension-values to toni/subscriber-attributes-rules-dimension August 18, 2026 14:41
@tonidero
tonidero force-pushed the toni/customer-info-rules-dimension branch from 5cf864f to 77ee860 Compare August 18, 2026 14:42
@tonidero
tonidero force-pushed the toni/subscriber-attributes-rules-dimension branch from 452c67b to e96fd76 Compare August 18, 2026 14:45
@tonidero
tonidero force-pushed the toni/customer-info-rules-dimension branch 2 times, most recently from 12d90d6 to 9aaad59 Compare August 18, 2026 15:00
@codecov

codecov Bot commented Aug 18, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 87.66234% with 19 lines in your changes missing coverage. Please review.
✅ Project coverage is 83.16%. Comparing base (db78c45) to head (4876e0b).
⚠️ Report is 13 commits behind head on main.

Files with missing lines Patch % Lines
...otlin/com/revenuecat/purchases/PurchasesFactory.kt 14.28% 12 Missing ⚠️
...common/localrules/CustomerInfoDimensionProvider.kt 93.80% 2 Missing and 5 partials ⚠️
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.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@RevenueCat-Danger-Bot

RevenueCat-Danger-Bot commented Aug 18, 2026

Copy link
Copy Markdown
1 Message
📖 This PR changes 451 lines of production Kotlin/Java (limit 300); skipped via skip-pr-lines-changed-check label.

Generated by 🚫 Danger

@tonidero
tonidero force-pushed the toni/customer-info-rules-dimension branch from 9870378 to 2582586 Compare August 19, 2026 07:29
@tonidero
tonidero force-pushed the toni/subscriber-attributes-rules-dimension branch from da051e9 to 6005173 Compare August 19, 2026 07:37
@tonidero
tonidero force-pushed the toni/customer-info-rules-dimension branch from 2582586 to 3934ed9 Compare August 19, 2026 07:37
Base automatically changed from toni/subscriber-attributes-rules-dimension to main August 19, 2026 08:32
@tonidero
tonidero force-pushed the toni/customer-info-rules-dimension branch from 3934ed9 to cbbcbe2 Compare August 19, 2026 08:33
.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) }

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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 =

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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.

tonidero and others added 6 commits August 20, 2026 15:55
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>
@tonidero
tonidero force-pushed the toni/customer-info-rules-dimension branch from cbbcbe2 to 964d7d9 Compare August 20, 2026 13:55
…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>

@cursor cursor Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

❌ 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>
@tonidero
tonidero force-pushed the toni/customer-info-rules-dimension branch from 220d1cd to 66d7eac Compare August 24, 2026 11:01
) : RulesDimensionResolutionException("two dimension providers supplied '$path'")

internal object AppUserChanged : RulesDimensionResolutionException(
"the app user kept changing while the dimensions were being collected",

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Suggested change
"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 = { "" },

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

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. */

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

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"

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

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.
*/

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

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)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

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?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Hmm you're indeed right... I think it makes sense, will do those changes 🙏

tonidero and others added 3 commits August 24, 2026 15:07
…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>
@tonidero

Copy link
Copy Markdown
Contributor Author

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

@tonidero tonidero closed this Aug 26, 2026
@tonidero
tonidero deleted the toni/customer-info-rules-dimension branch August 26, 2026 15:25
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants