Skip to content

feat: first-class intro pricing APIs#1

Merged
code-execute-rishi merged 3 commits into
mainfrom
feat/intro-pricing-apis
May 9, 2026
Merged

feat: first-class intro pricing APIs#1
code-execute-rishi merged 3 commits into
mainfrom
feat/intro-pricing-apis

Conversation

@code-execute-rishi
Copy link
Copy Markdown
Owner

@code-execute-rishi code-execute-rishi commented Apr 24, 2026

Summary

  • Adds first-class intro-pricing surface (SubscriptionSpec.withIntro, isIntroEligible, getIntroPhase / getIntroPeriodIso / getIntroEndMillis, getIntroPrice / getRecurringPrice, BillingAnalytics.onIntroStarted).
  • Spec-anchored offer resolution: getIntroEndMillis and getIntroPhase trust the registered SubscriptionSpec to identify the offer the user purchased; falls back to base-plan scan only when no spec exists.
  • Trial / intro analytics events are independent — combined offers (free trial -> intro -> recurring) fire both onTrialStarted and onIntroStarted. Pure-trial / pure-intro offers fire only their respective event.

Commits

  • 5fb133d — feat: first-class intro pricing ($X first period, then base) APIs
  • 0725d69 — fix(intro): spec-anchored offer resolution + independent intro/trial events

Test plan

  • ./gradlew :library:test passes (BUILD SUCCESSFUL)
  • OfferSelectorTest +13 cases covering eligibility, combined trial+intro, multi-cycle intro, preferTrial-vs-intro precedence, base-plan fallback
  • SubscriptionSpecTest +2 cases covering withIntro happy path + null offerId
  • Manual paywall test on real Play Console offer (intro_99c_1mo shape) — recommended before tagging release
  • License-tester sanity: pure-intro purchase fires only onIntroStarted; combined offer fires both events; trial-only offer fires only onTrialStarted

Docs

  • README — new 5-step integration walkthrough for intro pricing
  • CHANGELOG — Unreleased entries reflect post-fix semantics
  • docs/GUIDE.md — scenario 4 updated (analytics expectations); §4.6 added for combined trial+intro offers

Summary by CodeRabbit

  • New Features

    • Full intro-offer support: eligibility checks, phase detection, formatted intro vs recurring prices, end-time estimation, and an opt-in preference for intro offers
    • Analytics hook to track intro-offer activations
  • Documentation

    • Expanded guides and README with end-to-end intro-pricing examples and scenarios
  • Tests

    • Added comprehensive unit tests covering intro selection, eligibility, and combined trial+intro cases
  • Chores

    • Changelog updated to publish v0.3.0 (moved items out of Unreleased)

Symmetric with the existing free-trial surface so "$1 first week, then $19/year"
and similar offers are a one-liner for paywall code.

- SubscriptionSpec.withIntro(productId, basePlanId, introOfferId) sugar
- PlayBillingWrapper.isIntroEligible / getIntroPhase / getIntroPeriodIso /
  getIntroEndMillis (+ multi-spec scan overload)
- getIntroPrice / getRecurringPrice disambiguate getFormattedPrice, which
  returns the first non-trial phase (= intro price when an intro offer is
  selected) and left no accessor for the renewal price
- OfferSelector.isIntroEligible static helper (Play omits ineligible offers,
  so presence of FINITE_RECURRING paid phase = eligibility signal)
- BillingAnalytics.onIntroStarted default hook, mutex with onTrialStarted on
  first-time delivery; includes billingCycleCount for multi-cycle intros
- Tests: +8 cases across OfferSelectorTest + SubscriptionSpecTest
- Docs: README scenarios + API tables + analytics block, GUIDE scenario 4
  rewritten to use new helpers
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 24, 2026

Warning

Rate limit exceeded

@code-execute-rishi has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 55 minutes and 43 seconds before requesting another review.

You’ve run out of usage credits. Purchase more in the billing tab.

⌛ How to resolve this issue?

After the wait time has elapsed, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout.

Please see our FAQ for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: eef6acad-e69e-4120-a02d-95723a24e722

📥 Commits

Reviewing files that changed from the base of the PR and between 0725d69 and a654e4c.

📒 Files selected for processing (4)
  • CHANGELOG.md
  • README.md
  • docs/GUIDE.md
  • library/src/main/java/com/playbillingwrapper/listener/BillingAnalytics.java
📝 Walkthrough

Walkthrough

Adds first-class intro-pricing support: eligibility and phase discovery APIs, formatted price helpers, client-side intro-end estimation, a SubscriptionSpec helper to prefer intro offers, emission of an onIntroStarted analytics hook, and corresponding docs and tests.

Changes

Intro pricing feature

Layer / File(s) Summary
API surface / Data shape
library/src/main/java/.../model/SubscriptionSpec.java
Adds SubscriptionSpec.withIntro(productId, basePlanId, introOfferId) to prefer a specific intro offer.
Selection logic / Discovery
library/src/main/java/.../OfferSelector.java
Introduces pickOffer(...) (returns SubscriptionOfferDetails), isIntroEligible(...), findOfferWithIntroPhase(...), and hasIntroPhase(...) to detect intro offers and phases.
Core wrapper / Helpers
library/src/main/java/.../PlayBillingWrapper.java
Adds public APIs: isIntroEligible(...) overloads, getIntroPhase, getIntroPeriodIso, getIntroEndMillis(...) (purchase-scoped and scan variant), and UI helpers getIntroPrice/getRecurringPrice. Implements intro-end estimation logic and integrates intro-phase resolution.
Event wiring / Analytics
library/src/main/java/.../listener/BillingAnalytics.java, library/src/main/java/.../PlayBillingWrapper.java
Adds BillingAnalytics.onIntroStarted(...) (default no-op). Wrapper now emits onIntroStarted on first-time subscription activation when an intro phase can be resolved (deduped by purchase token).
Tests
library/src/test/java/.../OfferSelectorTest.java, library/src/test/java/.../SubscriptionSpecTest.java
Adds tests for intro eligibility, offer selection/fallback rules, combined trial+intro cases, findOfferWithIntroPhase/hasIntroPhase, and SubscriptionSpec.withIntro behavior including null-safety.
Docs / Changelog
CHANGELOG.md, README.md, docs/GUIDE.md
Documents new APIs, usage guide ("Intro pricing in 5 steps"), CTA rendering examples, intro testing matrix, combined trial+intro guidance, and records changelog heading adjustment.

Sequence Diagram(s)

sequenceDiagram
    participant UI as Client / UI
    participant Wrapper as PlayBillingWrapper
    participant Selector as OfferSelector
    participant Play as Play Billing
    participant Analytics as BillingAnalytics

    UI->>Wrapper: isIntroEligible(productId, basePlanId) / getIntroPrice(...)
    Wrapper->>Selector: findOfferWithIntroPhase(productDetails, basePlanId)
    Selector-->>Wrapper: SubscriptionOfferDetails (intro phase)
    Wrapper-->>UI: formatted price / eligibility

    UI->>Play: subscribe(spec with preferredOfferId?)
    Play-->>Wrapper: PurchaseInfo (first-time subscription)
    Wrapper->>Selector: resolve chosen offer & intro phase
    alt intro phase resolved
        Wrapper->>Analytics: onIntroStarted(productId, periodIso, billingCycleCount, purchase)
    end
    Wrapper-->>UI: notify purchase/activation callbacks
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Poem

🐰

New hops of intro, tidings bright and clear,
Offers found, phase ends drawn near,
Prices shown, events softly start,
A rabbit cheers — tidy code and heart.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 46.34% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'feat: first-class intro pricing APIs' directly and concisely summarizes the main change: adding first-class support for introductory pricing with symmetric APIs.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/intro-pricing-apis

Tip

💬 Introducing Slack Agent: The best way for teams to turn conversations into code.

Slack Agent is built on CodeRabbit's deep understanding of your code, so your team can collaborate across the entire SDLC without losing context.

  • Generate code and open pull requests
  • Plan features and break down work
  • Investigate incidents and troubleshoot customer tickets together
  • Automate recurring tasks and respond to alerts with triggers
  • Summarize progress and report instantly

Built for teams:

  • Shared memory across your entire org—no repeating context
  • Per-thread sandboxes to safely plan and execute work
  • Governance built-in—scoped access, auditability, and budget controls

One agent for your entire SDLC. Right inside Slack.

👉 Get started


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 4

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
docs/GUIDE.md (1)

17-21: ⚠️ Potential issue | 🟡 Minor

Stale install coordinate in this guide.

Line 19 still pins v0.2.1, while README.md advertises v0.3.0 and the intro-pricing APIs demonstrated in §4 only exist post-v0.3.0 ("Unreleased" in CHANGELOG). Readers following this guide on v0.2.1 will hit compile errors on getIntroPrice, getRecurringPrice, getIntroPeriodIso, getIntroEndMillis, and SubscriptionSpec.withIntro.

Bump to the version that ships these APIs (or leave as a templated placeholder the auto-release workflow updates).

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@docs/GUIDE.md` around lines 17 - 21, The guide pins an outdated dependency
coordinate 'v0.2.1' which lacks the new intro-pricing APIs (getIntroPrice,
getRecurringPrice, getIntroPeriodIso, getIntroEndMillis,
SubscriptionSpec.withIntro); update the Gradle implementation line to the
release that includes these APIs (e.g., change 'v0.2.1' to 'v0.3.0') or replace
the version with a templated placeholder (e.g., ${play_billing_wrapper_version})
so the auto-release workflow can inject the correct version.
🧹 Nitpick comments (3)
CHANGELOG.md (1)

3-27: Consider calling out the behavioral nuance of getFormattedPrice(id, basePlanId).

This section is "Added" only — but integrators who start using SubscriptionSpec.withIntro(...) will observe an effective behavior change: getFormattedPrice(id, basePlanId) returns the first non-trial phase, which is now the intro price (e.g. "$1.00") rather than the recurring price ("$19.00"). Worth a one-line "Changed" bullet pointing to getRecurringPrice(...) as the migration path.

📝 Proposed addition
 - **`OfferSelector.isIntroEligible(details, basePlanId)`** -- static helper used by the
   wrapper and exposed for advanced offer routing.
+
+### Changed
+
+- **`getFormattedPrice(productId, basePlanId)`** semantics clarified: returns the first
+  non-trial phase, which is the intro price when an intro offer is selected. Paywall
+  surfaces that always want the renewal price should migrate to
+  `getRecurringPrice(productId, basePlanId)`.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@CHANGELOG.md` around lines 3 - 27, Add a one-line "Changed" bullet under
"Unreleased" explaining that getFormattedPrice(id, basePlanId) now returns the
first non-trial phase (which will be the intro price when an intro offer is
present) and point integrators to getRecurringPrice(id, basePlanId) as the
migration path to obtain the recurring price; reference the functions
getFormattedPrice and getRecurringPrice in the sentence so readers can find the
APIs easily.
library/src/main/java/com/playbillingwrapper/listener/BillingAnalytics.java (1)

10-36: Minor: class-level event list omits onIntroStarted.

The interface Javadoc (lines 17-29) enumerates every callback but was not extended with the new onIntroStarted entry. Consider adding a bullet for parity with onTrialStarted, since the PR describes them as mutually exclusive.

📝 Proposed doc addition
     <li>{`@link` `#onTrialStarted`} — a subscription with a free-trial offer was just
         activated.</li>
+    <li>{`@link` `#onIntroStarted`} — a subscription with an intro-pricing offer was
+        just activated (mutually exclusive with {`@link` `#onTrialStarted`}).</li>
     <li>{`@link` `#onSubscriptionCancelled`} — Play reports an auto-renew true→false
         transition.</li>
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@library/src/main/java/com/playbillingwrapper/listener/BillingAnalytics.java`
around lines 10 - 36, The class Javadoc for BillingAnalytics is missing the new
onIntroStarted event in the enumerated callback list; update the Javadoc block
(the bulleted list under the wrapper invocation) to include a new bullet for
{`@link` `#onIntroStarted`} (similar to the existing {`@link` `#onTrialStarted`} entry)
describing that it fires when an introductory offer is activated, so the
documentation stays consistent with the interface methods.
docs/GUIDE.md (1)

259-269: Minor: introPeriod is computed but never used in the example.

getIntroPeriodIso(...) is assigned to introPeriod at line 262 but the CTA label hard-codes "for 1 month" (line 265). Either use the period in the CTA label or drop the unused local — readers tend to copy/paste these snippets verbatim.

📝 Proposed tweak
-String introPeriod    = billing.getIntroPeriodIso("com.yourapp.premium", "monthly"); // "P1M"
-
 if (introPrice != null) {
-    ctaIntro.setText(introPrice + " for 1 month, then " + recurringPrice + " / month");
+    String introPeriod = billing.getIntroPeriodIso("com.yourapp.premium", "monthly"); // "P1M"
+    ctaIntro.setText(introPrice + " for " + prettyIso(introPeriod) + ", then " + recurringPrice + " / month");
 } else {
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@docs/GUIDE.md` around lines 259 - 269, The example computes introPeriod via
getIntroPeriodIso("com.yourapp.premium", "monthly") but never uses it; update
the CTA construction in the ctaIntro.setText call to incorporate introPeriod
instead of the hard-coded "for 1 month" when introPrice != null (using
introPrice + " for " + introPeriod + ", then " + recurringPrice + " / month"),
or remove the introPeriod local entirely if you prefer the simpler example;
adjust only the ctaIntro.setText logic and ensure introPeriod and
getIntroPeriodIso remain consistent with introPrice and recurringPrice usage.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@CHANGELOG.md`:
- Line 28: The changelog header "## v0.3.0 (post-review hardening)" is missing
its release date; update that header (the "## v0.3.0 (post-review hardening)"
line) to include the actual tag date in the same format as sibling entries
(e.g., "## v0.3.0 (post-review hardening) — YYYY-MM-DD") using the real
release/tag date so the entry is uniformly queryable.

In `@library/src/main/java/com/playbillingwrapper/PlayBillingWrapper.java`:
- Around line 1045-1068: The intro-phase metadata (iso and cycles) can mismatch
the intro end computed by getIntroEndMillis(p) because the loop picks the first
SubscriptionSpec with a non-null getIntroPhase rather than the same spec used to
compute introEndMs; to fix, resolve the SubscriptionSpec deterministically the
same way getIntroEndMillis(p) does (or refactor to a helper that returns both
intro end and the matching SubscriptionSpec), then call getIntroPhase(...) for
that same spec to derive iso and billingCycleCount before invoking analytics(a
-> a.onIntroStarted(...)); update either getIntroEndMillis to expose the
matching spec or replace the current for-loop selection with the same selection
logic used by getIntroEndMillis so introEndMs, iso and cycles come from the same
SubscriptionSpec.
- Around line 648-670: getIntroEndMillis currently scans offers itself and can
pick a different offer than getIntroPhase/getIntroPrice which honor
SubscriptionSpec.preferredOfferId; change getIntroEndMillis(PurchaseInfo,
String) to call getIntroPhase(productId, basePlanId) (the same resolution
getIntroPrice/getIntroPhase use) and compute periodMs and cycles from that
returned PricingPhase (or return -1 if null), ensuring the same offer is used;
also update dispatchPurchases to derive ISO period and billingCycleCount from
getIntroPhase(...) rather than re-scanning offers so onIntroStarted telemetry
matches the intro end time; apply the same alignment check to getTrialEndMillis
to avoid the analogous latent mismatch.

In `@README.md`:
- Around line 1037-1039: The documentation update for getFormattedPrice is
missing an explicit behavioral note for integrators: add a "Changed" or
"Behavior" entry to CHANGELOG.md stating that getFormattedPrice(productId,
basePlanId) now returns the intro price when a spec is registered via
withIntro(...) (instead of the recurring price), which may cause paywall labels
to switch from recurring → intro for consumers upgrading from v0.3.0, and
recommend using getRecurringPrice(productId, basePlanId) as the migration target
for the previous recurring value.

---

Outside diff comments:
In `@docs/GUIDE.md`:
- Around line 17-21: The guide pins an outdated dependency coordinate 'v0.2.1'
which lacks the new intro-pricing APIs (getIntroPrice, getRecurringPrice,
getIntroPeriodIso, getIntroEndMillis, SubscriptionSpec.withIntro); update the
Gradle implementation line to the release that includes these APIs (e.g., change
'v0.2.1' to 'v0.3.0') or replace the version with a templated placeholder (e.g.,
${play_billing_wrapper_version}) so the auto-release workflow can inject the
correct version.

---

Nitpick comments:
In `@CHANGELOG.md`:
- Around line 3-27: Add a one-line "Changed" bullet under "Unreleased"
explaining that getFormattedPrice(id, basePlanId) now returns the first
non-trial phase (which will be the intro price when an intro offer is present)
and point integrators to getRecurringPrice(id, basePlanId) as the migration path
to obtain the recurring price; reference the functions getFormattedPrice and
getRecurringPrice in the sentence so readers can find the APIs easily.

In `@docs/GUIDE.md`:
- Around line 259-269: The example computes introPeriod via
getIntroPeriodIso("com.yourapp.premium", "monthly") but never uses it; update
the CTA construction in the ctaIntro.setText call to incorporate introPeriod
instead of the hard-coded "for 1 month" when introPrice != null (using
introPrice + " for " + introPeriod + ", then " + recurringPrice + " / month"),
or remove the introPeriod local entirely if you prefer the simpler example;
adjust only the ctaIntro.setText logic and ensure introPeriod and
getIntroPeriodIso remain consistent with introPrice and recurringPrice usage.

In `@library/src/main/java/com/playbillingwrapper/listener/BillingAnalytics.java`:
- Around line 10-36: The class Javadoc for BillingAnalytics is missing the new
onIntroStarted event in the enumerated callback list; update the Javadoc block
(the bulleted list under the wrapper invocation) to include a new bullet for
{`@link` `#onIntroStarted`} (similar to the existing {`@link` `#onTrialStarted`} entry)
describing that it fires when an introductory offer is activated, so the
documentation stays consistent with the interface methods.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: a703eeec-997a-4ae3-acb9-733071fe615d

📥 Commits

Reviewing files that changed from the base of the PR and between 38d4475 and 5fb133d.

📒 Files selected for processing (9)
  • CHANGELOG.md
  • README.md
  • docs/GUIDE.md
  • library/src/main/java/com/playbillingwrapper/OfferSelector.java
  • library/src/main/java/com/playbillingwrapper/PlayBillingWrapper.java
  • library/src/main/java/com/playbillingwrapper/listener/BillingAnalytics.java
  • library/src/main/java/com/playbillingwrapper/model/SubscriptionSpec.java
  • library/src/test/java/com/playbillingwrapper/OfferSelectorTest.java
  • library/src/test/java/com/playbillingwrapper/SubscriptionSpecTest.java

Comment thread CHANGELOG.md Outdated
Comment thread library/src/main/java/com/playbillingwrapper/PlayBillingWrapper.java Outdated
Comment thread README.md
…events

Post-review fixes for the intro-pricing API surface introduced in 5fb133d.

Correctness:
- getIntroEndMillis(purchase, basePlanId) now resolves the offer the user purchased
  via the registered SubscriptionSpec instead of "first named offer with intro phase
  on the base plan". A preferTrial=true spec that selected a trial-only offer at
  checkout returns -1 rather than the end of an unrelated intro offer on the same
  base plan.
- getIntroPhase(productId, basePlanId) is now consistent with isIntroEligible: it
  scans the base plan and prefers the spec's offer (covers combined trial+intro
  shapes), so paywalls that branch on isIntroEligible always read a non-null phase.
- Analytics dispatch for trial / intro events is now independent. A combined offer
  (free trial -> intro week -> recurring) fires both onTrialStarted and onIntroStarted
  for the same purchase. Previously the events were mutex-gated, dropping the intro
  signal on combined offers. Pure-trial / pure-intro offers still fire only their
  respective event.
- Trial event ISO recovery now picks the base plan that actually has a trial phase,
  not the first registered spec for the product (was incorrect for multi-base-plan
  products with mixed trial / no-trial plans).

API:
- OfferSelector.pickOffer / findOfferWithIntroPhase added (package-private) for
  spec-anchored offer resolution.
- OfferSelector.hasIntroPhase elevated to public for advanced offer routing.
- isIntroEligible doc tightened: it reflects Play's offer-eligibility filter
  (first-time-redeemer, audience tags, promo codes), not "has redeemed once".

Tests: +5 cases in OfferSelectorTest covering combined trial+intro eligibility,
findOfferWithIntroPhase, multi-cycle intro, preferTrial-vs-intro precedence.

Docs: README intro section rewritten as a 5-step integration walkthrough; CHANGELOG
entries updated to reflect post-fix semantics; GUIDE.md scenario 4.6 added for
combined trial+intro offers.
- CHANGELOG: add release date to v0.3.0 heading; new "Changed" entry under
  Unreleased explaining getFormattedPrice(productId, basePlanId) returns the
  intro price (not recurring) when SubscriptionSpec.withIntro is registered, and
  pointing migrators to getRecurringPrice.
- BillingAnalytics: add onIntroStarted bullet to the class-level event list with
  the trial/intro independence note.
- docs/GUIDE.md: bump install coordinate from v0.2.1 to v0.3.0; drop unused
  introPeriod local from §4 paywall snippet (kept inline as a comment so readers
  still discover the API).
- README: same drop in the 5-step intro walkthrough for parity.
@code-execute-rishi code-execute-rishi merged commit 4aa835e into main May 9, 2026
2 checks passed
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