From 5fb133d3faf51f4e73fec148418d6089d003d3e2 Mon Sep 17 00:00:00 2001 From: code-execute-rishi <69856912+code-execute-rishi@users.noreply.github.com> Date: Fri, 24 Apr 2026 19:27:44 +0530 Subject: [PATCH 1/3] feat: first-class intro pricing ($X first period, then base) APIs 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 --- CHANGELOG.md | 27 ++- README.md | 18 +- docs/GUIDE.md | 52 +++--- .../com/playbillingwrapper/OfferSelector.java | 30 ++++ .../PlayBillingWrapper.java | 166 ++++++++++++++++++ .../listener/BillingAnalytics.java | 12 ++ .../model/SubscriptionSpec.java | 20 +++ .../playbillingwrapper/OfferSelectorTest.java | 65 +++++++ .../SubscriptionSpecTest.java | 14 ++ 9 files changed, 378 insertions(+), 26 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8f54d39..79db045 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,31 @@ # Changelog -## Unreleased (post-review hardening) +## Unreleased + +### Added -- first-class intro pricing + +Making "cheap first period then normal price" offers (e.g. `$1 first week, then $19/year`) +a one-liner, symmetric with the existing free-trial API. + +- **`SubscriptionSpec.withIntro(productId, basePlanId, introOfferId)`** -- sugar that sets + `preferredOfferId` to the intro offer. +- **`PlayBillingWrapper.isIntroEligible(productId [, basePlanId])`** -- true when Play still + exposes an offer with a non-zero `FINITE_RECURRING` phase on the base plan. Mirrors + `isTrialEligible`. +- **`getIntroPhase(id, basePlan)` / `getIntroPeriodIso(id, basePlan)`** -- typed accessor + + ISO-8601 period for the intro phase. +- **`getIntroEndMillis(purchase [, basePlan])`** -- deterministic wall-clock estimate of + `purchaseTime + introPeriod * billingCycleCount`. Mirrors `getTrialEndMillis`. +- **`getIntroPrice(id, basePlan)` / `getRecurringPrice(id, basePlan)`** -- formatted price + strings for paywall CTAs, disambiguating the existing `getFormattedPrice` which returns + the first non-trial phase (i.e. the intro price when an intro offer is selected). +- **`BillingAnalytics.onIntroStarted(productId, periodIso, billingCycleCount, purchase)`** + -- default no-op hook. Fires once per `purchaseToken` on first-time delivery, mutually + exclusive with `onTrialStarted`. +- **`OfferSelector.isIntroEligible(details, basePlanId)`** -- static helper used by the + wrapper and exposed for advanced offer routing. + +## v0.3.0 (post-review hardening) Fixes surfaced by a manual correctness review of 0.1.1. diff --git a/README.md b/README.md index 87516a3..d90c095 100644 --- a/README.md +++ b/README.md @@ -215,6 +215,7 @@ A non-exhaustive list of real-app patterns the library now handles: | Downgrade yearly → monthly at next renewal | `changeSubscription(..., ChangeMode.DOWNGRADE_DEFERRED)`. | | Swap preserving free trial | `ChangeMode.SWAP_WITHOUT_PRORATION`. | | Explicit winback / promo offer id | `SubscriptionSpec.builder().preferredOfferId("winback_25").build()`. | +| Intro pricing offer ("$1 first week, then $19/year") | `addSubscription(SubscriptionSpec.withIntro(id, basePlanId, "intro_1w_1usd"))`; query with `isIntroEligible`, `getIntroPhase`, `getIntroPeriodIso`, `getIntroEndMillis`, `getIntroPrice`, `getRecurringPrice`; analytics fires `onIntroStarted`. | | User paused subscription from Play | `subscriptionState(id) == PAUSED`, `purchase.isPaused() == true`. | | Paywall price labels | `getFormattedPrice(id)` for one-time, `getFormattedPrice(id, basePlanId)` for subs. | | Intro pricing UI ("Free for 3 days, then $3.99/mo") | `getOfferPhases(id, basePlanId)` returns typed phases with `isFree / isIntro / isRecurring`. | @@ -1004,6 +1005,8 @@ Optionally pass `ProcessLifecycleOwner.get().getLifecycle()` so `release()` is c | `SubscriptionState monthlyState()` / `yearlyState()` | Default-spec sugar. | | `boolean isTrialEligible(String productId, String basePlanId)` | Any free-trial offer on the base plan? | | `boolean isTrialEligibleForYearly()` | Sugar for default yearly spec. | +| `boolean isIntroEligible(String productId, String basePlanId)` | Any intro-pricing offer (non-zero price + `FINITE_RECURRING`) on the base plan? | +| `boolean isIntroEligible(String productId)` | Any registered base plan has an eligible intro offer. | | `boolean isSubscribed()` | Any registered subscription is entitling. | | `boolean isPremium()` | Any lifetime product owned OR `isSubscribed()`. | | `List getActiveEntitlements()` | Product ids the user currently holds entitlement for. | @@ -1017,12 +1020,23 @@ Optionally pass `ProcessLifecycleOwner.get().getLifecycle()` so `release()` is c | `long getTrialEndMillis(PurchaseInfo, String basePlanId)` | Deterministic wall-clock estimate of `purchaseTime + trialDuration`. | | `long getTrialEndMillis(PurchaseInfo)` | Convenience: scans every registered spec for the productId; ambiguous for multi-plan products. | +#### Intro pricing introspection + +| Method | Returns | +|--------|---------| +| `PricingPhases getIntroPhase(String productId, String basePlanId)` | Typed intro pricing phase of the best offer on the base plan, or `null` if no intro offer is eligible. | +| `String getIntroPeriodIso(String productId, String basePlanId)` | ISO 8601 billing period of the intro phase, e.g. `"P1W"`, `"P1M"`. | +| `long getIntroEndMillis(PurchaseInfo, String basePlanId)` | Estimated wall-clock end of the intro phase, computed as `purchaseTime + introPeriod * billingCycleCount`. | +| `long getIntroEndMillis(PurchaseInfo)` | Convenience scan across registered specs for this product id. | + #### Paywall price helpers | Method | Returns | |--------|---------| | `String getFormattedPrice(String productId)` | Play-formatted price string for a one-time product (lifetime or consumable). | -| `String getFormattedPrice(String productId, String basePlanId)` | Formatted price of the first non-trial pricing phase of the best offer on a base plan. | +| `String getFormattedPrice(String productId, String basePlanId)` | Formatted price of the first non-trial pricing phase — intro price if intro offer selected, base price otherwise. | +| `String getIntroPrice(String productId, String basePlanId)` | Formatted price of the intro phase (e.g. `"$1.00"`), or `null` if no intro offer. | +| `String getRecurringPrice(String productId, String basePlanId)` | Formatted price of the recurring (INFINITE_RECURRING) phase — the renewal price after any trial or intro ends. | | `List getOfferPhases(String productId, String basePlanId)` | **Typed** phase list (library wrapper, not Play SDK) with `isFree / isIntro / isRecurring / getPeriodIso / getPeriodDurationMillis` helpers. | | `List getPricingPhases(String productId, String basePlanId)` | **Deprecated** — returns the raw Play SDK type; prefer `getOfferPhases`. | @@ -1048,6 +1062,7 @@ Declares one (`productId`, `basePlanId`) pair with optional trial preference and ```java SubscriptionSpec.of("com.app.premium", "monthly"); SubscriptionSpec.withTrial("com.app.premium", "monthly"); // 3-day trial monthly +SubscriptionSpec.withIntro("com.app.premium", "yearly", "intro_1w_1usd"); // $1 first week, then base SubscriptionSpec.builder() .productId("com.app.premium") .basePlanId("monthly") @@ -1110,6 +1125,7 @@ void onBeginCheckout(String productId, @Nullable String basePlanId, @Nullable St void onPurchaseCompleted(String productId, PurchaseInfo); void onSubscriptionActivated(String productId, SubscriptionState state, PurchaseInfo); void onTrialStarted(String productId, @Nullable String periodIso, PurchaseInfo); +void onIntroStarted(String productId, @Nullable String periodIso, int billingCycleCount, PurchaseInfo); void onSubscriptionCancelled(String productId, PurchaseInfo); void onConsumablePurchased(String productId, int quantity, PurchaseInfo); void onUserCancelled(String productId); diff --git a/docs/GUIDE.md b/docs/GUIDE.md index 61cde3c..405f907 100644 --- a/docs/GUIDE.md +++ b/docs/GUIDE.md @@ -250,45 +250,49 @@ just less than the regular price. ```java BillingConfig cfg = BillingConfig.builder() - .addSubscription(SubscriptionSpec.builder() - .productId("com.yourapp.premium") - .basePlanId("monthly") - .preferredOfferId("intro_99c_1mo") // pick the intro offer explicitly - .tag("monthly_intro") - .build()) + // Sugar: equivalent to builder().preferredOfferId("intro_99c_1mo"). + .addSubscription(SubscriptionSpec.withIntro( + "com.yourapp.premium", "monthly", "intro_99c_1mo")) .userId(sha256(currentUserId())) .build(); -// Render the full phase sequence on the paywall. -List phases = - billing.getPricingPhases("com.yourapp.premium", "monthly"); - -// Typical intro result: one finite-recurring phase + one infinite-recurring phase. -StringBuilder label = new StringBuilder(); -for (ProductDetails.PricingPhase p : phases) { - if (p.getRecurrenceMode() == ProductDetails.RecurrenceMode.FINITE_RECURRING) { - label.append(p.getFormattedPrice()) - .append(" for ").append(p.getBillingCycleCount()).append(" month(s), then "); - } else { - label.append(p.getFormattedPrice()).append(" / month"); - } +// One-liner CTA labels using the typed intro helpers. +String introPrice = billing.getIntroPrice("com.yourapp.premium", "monthly"); // "$0.99" +String recurringPrice = billing.getRecurringPrice("com.yourapp.premium", "monthly"); // "$4.99" +String introPeriod = billing.getIntroPeriodIso("com.yourapp.premium", "monthly"); // "P1M" + +if (introPrice != null) { + ctaIntro.setText(introPrice + " for 1 month, then " + recurringPrice + " / month"); +} else { + // Repeat user -- Play omits the intro offer, library falls back to the base plan. + ctaIntro.setText(recurringPrice + " / month"); } -ctaIntro.setText(label.toString()); // "$0.99 for 1 month(s), then $4.99 / month" ctaIntro.setOnClickListener(v -> billing.subscribe(activity, "com.yourapp.premium", "monthly")); ``` +If you need every phase (intro + recurring) structurally, walk the typed phases: + +```java +List phases = billing.getOfferPhases("com.yourapp.premium", "monthly"); +for (PricingPhases p : phases) { + if (p.isIntro()) Log.d("paywall", "intro: " + p.getFormattedPrice() + " × " + p.getBillingCycleCount()); + if (p.isRecurring()) Log.d("paywall", "renews: " + p.getFormattedPrice() + " / " + p.getPeriodIso()); +} +``` + ### 4.3 Detecting which phase the user is currently in Play does not expose "current pricing phase" in the client `Purchase`. Options: -1. **Time-based estimate** (client-only, good for UI): +1. **Time-based estimate** (client-only, good for UI) -- one-liner via the library: ```java - long purchaseAt = purchase.getPurchaseTime(); - long introEnd = purchaseAt + PlayBillingWrapper.parseIso8601DurationMillis("P1M"); - boolean inIntroPhase = System.currentTimeMillis() < introEnd; + long introEnd = billing.getIntroEndMillis(purchase, "monthly"); + boolean inIntroPhase = introEnd > 0 && System.currentTimeMillis() < introEnd; ``` + Uses `purchaseTime + introPeriod * billingCycleCount`. Returns `-1` if the purchase has + no intro offer on the given base plan (e.g. repeat user who paid the full base price). 2. **Authoritative** (server-side): query [`purchases.subscriptionsv2.get`](https://developers.google.com/android-publisher/api-ref/rest/v3/purchases.subscriptionsv2/get) — the response contains the `currentPeriod` object with the phase id in effect. diff --git a/library/src/main/java/com/playbillingwrapper/OfferSelector.java b/library/src/main/java/com/playbillingwrapper/OfferSelector.java index 2228644..5230c13 100644 --- a/library/src/main/java/com/playbillingwrapper/OfferSelector.java +++ b/library/src/main/java/com/playbillingwrapper/OfferSelector.java @@ -83,10 +83,40 @@ public static boolean isTrialEligible(@NonNull ProductDetails details, @NonNull return false; } + /** + * True if the given product has at least one eligible offer on {@code basePlanId} + * that contains an intro pricing phase (non-zero price with + * {@code RecurrenceMode.FINITE_RECURRING}, e.g. "$1 for the first week"). + *

+ * Like {@link #isTrialEligible}, Google Play silently omits ineligible offers from + * {@code getSubscriptionOfferDetails()}, so this doubles as the eligibility signal for + * "has this account ever redeemed this intro offer". + */ + public static boolean isIntroEligible(@NonNull ProductDetails details, @NonNull String basePlanId) { + List all = details.getSubscriptionOfferDetails(); + if (all == null) return false; + for (ProductDetails.SubscriptionOfferDetails o : all) { + if (!basePlanId.equals(o.getBasePlanId())) continue; + if (o.getOfferId() == null) continue; + if (hasIntroPhase(o)) return true; + } + return false; + } + private static boolean hasFreeTrialPhase(@NonNull ProductDetails.SubscriptionOfferDetails offer) { for (ProductDetails.PricingPhase p : offer.getPricingPhases().getPricingPhaseList()) { if (p.getPriceAmountMicros() == 0L) return true; } return false; } + + static boolean hasIntroPhase(@NonNull ProductDetails.SubscriptionOfferDetails offer) { + for (ProductDetails.PricingPhase p : offer.getPricingPhases().getPricingPhaseList()) { + if (p.getPriceAmountMicros() > 0L + && p.getRecurrenceMode() == ProductDetails.RecurrenceMode.FINITE_RECURRING) { + return true; + } + } + return false; + } } diff --git a/library/src/main/java/com/playbillingwrapper/PlayBillingWrapper.java b/library/src/main/java/com/playbillingwrapper/PlayBillingWrapper.java index d56cd7b..11c18d0 100644 --- a/library/src/main/java/com/playbillingwrapper/PlayBillingWrapper.java +++ b/library/src/main/java/com/playbillingwrapper/PlayBillingWrapper.java @@ -574,6 +574,116 @@ public long getTrialEndMillis(@NonNull PurchaseInfo purchase) { return -1; } + // --------------------------------------------------------------------- + // Intro-pricing queries (e.g. "$1 first week, then $19/yr") + // --------------------------------------------------------------------- + + /** + * True if the given subscription base plan has at least one eligible offer with an + * intro pricing phase (non-zero price, {@code FINITE_RECURRING}). Play silently omits + * ineligible offers, so this is also the correct "has this account redeemed the intro" + * eligibility check. + */ + public boolean isIntroEligible(@NonNull String productId, @NonNull String basePlanId) { + ProductDetails details = findProductDetails(productId); + if (details == null) return false; + return OfferSelector.isIntroEligible(details, basePlanId); + } + + /** + * Convenience: true if ANY registered {@link SubscriptionSpec} for {@code productId} + * has an eligible intro-pricing offer. + */ + public boolean isIntroEligible(@NonNull String productId) { + for (SubscriptionSpec spec : config.subscriptions) { + if (!spec.productId.equals(productId)) continue; + if (isIntroEligible(spec.productId, spec.basePlanId)) return true; + } + return false; + } + + /** + * Returns the first intro pricing phase on {@code (productId, basePlanId)} as the + * library's typed wrapper, or {@code null} if no intro offer is eligible. Walks the + * offer chosen by the registered {@link SubscriptionSpec} (preferredOfferId > trial > + * base plan), so callers that registered their intro offer via + * {@link SubscriptionSpec#withIntro} get the exact phase they configured. + *

+ * Useful for paywall labels like {@code phase.getFormattedPrice() + " for " + + * phase.getBillingCycleCount() + " " + phase.getPeriodIso()}. + */ + @Nullable + public com.playbillingwrapper.model.SubscriptionOfferDetails.PricingPhases getIntroPhase( + @NonNull String productId, @NonNull String basePlanId) { + List phases = + getOfferPhases(productId, basePlanId); + if (phases == null) return null; + for (com.playbillingwrapper.model.SubscriptionOfferDetails.PricingPhases p : phases) { + if (p.isIntro()) return p; + } + return null; + } + + /** + * ISO 8601 billing period of the first intro pricing phase, e.g. {@code "P1W"} for + * "$1 first week". Returns {@code null} if no intro offer is eligible. + */ + @Nullable + public String getIntroPeriodIso(@NonNull String productId, @NonNull String basePlanId) { + com.playbillingwrapper.model.SubscriptionOfferDetails.PricingPhases phase = + getIntroPhase(productId, basePlanId); + return phase == null ? null : phase.getPeriodIso(); + } + + /** + * Estimated wall-clock end of the intro pricing phase for a subscription purchase on + * the given {@code basePlanId}, computed as + * {@code purchaseTime + (introPeriodMs * billingCycleCount)}. Pass the base plan the + * user actually bought. Returns {@code -1} if the purchase is not a subscription, is + * PENDING, lacks a ProductInfo, or has no intro offer on the given base plan. + *

+ * Client-side estimate; authoritative phase transitions are only available via the + * Play Developer API server-side. Months approximated as 30 days, years as 365 days. + */ + public long getIntroEndMillis(@NonNull PurchaseInfo purchase, @NonNull String basePlanId) { + if (purchase.getSkuProductType() != SkuProductType.SUBSCRIPTION) return -1; + if (!purchase.isPurchased()) return -1; + ProductInfo info = purchase.getProductInfo(); + if (info == null) return -1; + ProductDetails details = info.getProductDetails(); + List offers = details.getSubscriptionOfferDetails(); + if (offers == null) return -1; + for (ProductDetails.SubscriptionOfferDetails offer : offers) { + if (!basePlanId.equals(offer.getBasePlanId())) continue; + if (offer.getOfferId() == null) continue; + for (ProductDetails.PricingPhase phase : offer.getPricingPhases().getPricingPhaseList()) { + if (phase.getPriceAmountMicros() > 0L + && phase.getRecurrenceMode() == ProductDetails.RecurrenceMode.FINITE_RECURRING) { + long periodMs = parseIso8601DurationMillis(phase.getBillingPeriod()); + if (periodMs <= 0) return -1; + int cycles = Math.max(1, phase.getBillingCycleCount()); + return purchase.getPurchaseTime() + periodMs * cycles; + } + } + } + return -1; + } + + /** + * Convenience overload that scans every registered {@link SubscriptionSpec} for + * {@code purchase.getProduct()} and returns the first intro-end estimate it can + * compute. Ambiguous when a product has multiple base plans -- prefer + * {@link #getIntroEndMillis(PurchaseInfo, String)} in that case. + */ + public long getIntroEndMillis(@NonNull PurchaseInfo purchase) { + for (SubscriptionSpec spec : config.subscriptions) { + if (!spec.productId.equals(purchase.getProduct())) continue; + long est = getIntroEndMillis(purchase, spec.basePlanId); + if (est > 0) return est; + } + return -1; + } + // --------------------------------------------------------------------- // Price + offer display helpers // --------------------------------------------------------------------- @@ -597,6 +707,11 @@ public String getFormattedPrice(@NonNull String productId) { * {@code (productId, basePlanId)}. Returns {@code null} if no offer is available yet. * Use {@link #getPricingPhases(String, String)} if you need to render every phase * (intro + recurring) separately. + *

+ * Note: for intro-pricing offers the first non-trial phase is the intro price (e.g. + * $1), not the recurring price. Prefer {@link #getRecurringPrice(String, String)} + * when you want the renewal price and {@link #getIntroPrice(String, String)} when + * you want the intro price. */ @Nullable public String getFormattedPrice(@NonNull String productId, @NonNull String basePlanId) { @@ -608,6 +723,34 @@ public String getFormattedPrice(@NonNull String productId, @NonNull String baseP return phases.get(phases.size() - 1).getFormattedPrice(); } + /** + * Formatted price of the intro pricing phase on {@code (productId, basePlanId)} + * (e.g. {@code "$1.00"} for a "first week at $1" offer). Returns {@code null} if + * no intro offer is eligible / configured. + */ + @Nullable + public String getIntroPrice(@NonNull String productId, @NonNull String basePlanId) { + com.playbillingwrapper.model.SubscriptionOfferDetails.PricingPhases phase = + getIntroPhase(productId, basePlanId); + return phase == null ? null : phase.getFormattedPrice(); + } + + /** + * Formatted price of the recurring (INFINITE_RECURRING) pricing phase on + * {@code (productId, basePlanId)} -- i.e. the renewal price the user pays after any + * trial or intro phase ends. Returns {@code null} if no offer is available yet. + */ + @Nullable + public String getRecurringPrice(@NonNull String productId, @NonNull String basePlanId) { + List phases = + getOfferPhases(productId, basePlanId); + if (phases == null || phases.isEmpty()) return null; + for (com.playbillingwrapper.model.SubscriptionOfferDetails.PricingPhases p : phases) { + if (p.isRecurring()) return p.getFormattedPrice(); + } + return phases.get(phases.size() - 1).getFormattedPrice(); + } + /** * Every pricing phase of the best offer on {@code (productId, basePlanId)} (trial auto- * preferred when eligible, otherwise the base plan offer). Returns {@code null} if @@ -899,6 +1042,29 @@ private void dispatchPurchases(List purchases) { } final String isoFinal = iso; analytics(a -> a.onTrialStarted(p.getProduct(), isoFinal, p)); + } else { + // Intro-started event: only fires when the purchase has no trial phase + // but does have an intro phase. Trial and intro are mutually exclusive + // from the "which event should I log" perspective -- a trial-then-intro + // offer still logs as onTrialStarted, which matches funnel conventions. + long introEndMs = getIntroEndMillis(p); + if (introEndMs > 0L) { + String iso = null; + int cycles = 1; + for (SubscriptionSpec s : config.subscriptions) { + if (!s.productId.equals(p.getProduct())) continue; + com.playbillingwrapper.model.SubscriptionOfferDetails.PricingPhases phase = + getIntroPhase(s.productId, s.basePlanId); + if (phase != null) { + iso = phase.getPeriodIso(); + cycles = Math.max(1, phase.getBillingCycleCount()); + break; + } + } + final String isoFinal = iso; + final int cyclesFinal = cycles; + analytics(a -> a.onIntroStarted(p.getProduct(), isoFinal, cyclesFinal, p)); + } } } else { l.onLifetimePurchased(p); diff --git a/library/src/main/java/com/playbillingwrapper/listener/BillingAnalytics.java b/library/src/main/java/com/playbillingwrapper/listener/BillingAnalytics.java index 0c6d0ad..472cf50 100644 --- a/library/src/main/java/com/playbillingwrapper/listener/BillingAnalytics.java +++ b/library/src/main/java/com/playbillingwrapper/listener/BillingAnalytics.java @@ -62,6 +62,18 @@ default void onTrialStarted(@NonNull String productId, @Nullable String periodIso, @NonNull PurchaseInfo purchase) { } + /** + * A subscription was activated with an intro-pricing offer (e.g. "$1 first week, + * then $19/year"). Fires once per {@code purchaseToken}. {@code periodIso} is the + * intro phase billing period as ISO 8601 (e.g. {@code "P1W"}, {@code "P1M"}). + * {@code billingCycleCount} is how many times the intro phase repeats before the + * recurring phase kicks in (often 1 but can be N). + */ + default void onIntroStarted(@NonNull String productId, + @Nullable String periodIso, + int billingCycleCount, + @NonNull PurchaseInfo purchase) { } + /** Auto-renewing flipped from true to false (fires once per transition). */ default void onSubscriptionCancelled(@NonNull String productId, @NonNull PurchaseInfo purchase) { } diff --git a/library/src/main/java/com/playbillingwrapper/model/SubscriptionSpec.java b/library/src/main/java/com/playbillingwrapper/model/SubscriptionSpec.java index 7e523e6..9d7c9e6 100644 --- a/library/src/main/java/com/playbillingwrapper/model/SubscriptionSpec.java +++ b/library/src/main/java/com/playbillingwrapper/model/SubscriptionSpec.java @@ -68,6 +68,26 @@ public static SubscriptionSpec withTrial(@NonNull String productId, @NonNull Str return builder().productId(productId).basePlanId(basePlanId).preferTrial(true).build(); } + /** + * Convenience for a base plan routed to a specific intro-pricing offer (e.g. "$1 first + * week, then $19/year"). {@code introOfferId} must match an offer configured on the + * base plan in Play Console. Equivalent to + * {@code builder().productId(...).basePlanId(...).preferredOfferId(introOfferId).build()}. + *

+ * Play silently omits this offer from {@code ProductDetails} for repeat users, so the + * library falls back to the base plan offer automatically -- no client-side eligibility + * check needed. Use {@link com.playbillingwrapper.PlayBillingWrapper#isIntroEligible} + * if you need to branch paywall copy. + */ + @NonNull + public static SubscriptionSpec withIntro(@NonNull String productId, + @NonNull String basePlanId, + @NonNull String introOfferId) { + return builder().productId(productId).basePlanId(basePlanId) + .preferredOfferId(Objects.requireNonNull(introOfferId, "introOfferId")) + .build(); + } + public static Builder builder() { return new Builder(); } public static final class Builder { diff --git a/library/src/test/java/com/playbillingwrapper/OfferSelectorTest.java b/library/src/test/java/com/playbillingwrapper/OfferSelectorTest.java index d2ddffd..6c8cba3 100644 --- a/library/src/test/java/com/playbillingwrapper/OfferSelectorTest.java +++ b/library/src/test/java/com/playbillingwrapper/OfferSelectorTest.java @@ -117,6 +117,60 @@ public void isTrialEligible_false_for_different_base_plan() { assertFalse(OfferSelector.isTrialEligible(details, BASE_PLAN)); } + @Test + public void isIntroEligible_true_when_offer_has_finite_recurring_paid_phase() { + ProductDetails details = withOffers( + offer(BASE_PLAN, null, "tok-base", paidPhase()), + offer(BASE_PLAN, "intro_1w_1usd", "tok-intro", introPhase(), paidPhase()) + ); + assertTrue(OfferSelector.isIntroEligible(details, BASE_PLAN)); + } + + @Test + public void isIntroEligible_false_when_only_base_plan_offer_exists() { + ProductDetails details = withOffers( + offer(BASE_PLAN, null, "tok-base", paidPhase()) + ); + assertFalse(OfferSelector.isIntroEligible(details, BASE_PLAN)); + } + + @Test + public void isIntroEligible_false_when_only_free_trial_offer() { + ProductDetails details = withOffers( + offer(BASE_PLAN, null, "tok-base", paidPhase()), + offer(BASE_PLAN, "freetrial", "tok-trial", freePhase(), paidPhase()) + ); + assertFalse(OfferSelector.isIntroEligible(details, BASE_PLAN)); + } + + @Test + public void isIntroEligible_false_for_different_base_plan() { + ProductDetails details = withOffers( + offer("monthly", "intro_1w_1usd", "tok-intro", introPhase(), paidPhase()) + ); + assertFalse(OfferSelector.isIntroEligible(details, BASE_PLAN)); + } + + @Test + public void preferredOfferId_picks_intro_offer() { + ProductDetails details = withOffers( + offer(BASE_PLAN, null, "tok-base", paidPhase()), + offer(BASE_PLAN, "intro_1w_1usd", "tok-intro", introPhase(), paidPhase()) + ); + assertEquals("tok-intro", + OfferSelector.pick(details, BASE_PLAN, "intro_1w_1usd", false)); + } + + @Test + public void falls_back_to_base_plan_when_intro_offer_omitted_by_play() { + // Play hides the intro offer from repeat redeemers -- wrapper must still resolve a token. + ProductDetails details = withOffers( + offer(BASE_PLAN, null, "tok-base", paidPhase()) + ); + assertEquals("tok-base", + OfferSelector.pick(details, BASE_PLAN, "intro_1w_1usd", false)); + } + // ---- helpers ---- private static ProductDetails withOffers(ProductDetails.SubscriptionOfferDetails... offers) { @@ -152,6 +206,17 @@ private static ProductDetails.PricingPhase paidPhase() { when(p.getPriceAmountMicros()).thenReturn(12_99_000_000L); when(p.getFormattedPrice()).thenReturn("₹1299.00"); when(p.getBillingPeriod()).thenReturn("P1Y"); + when(p.getRecurrenceMode()).thenReturn(ProductDetails.RecurrenceMode.INFINITE_RECURRING); + return p; + } + + private static ProductDetails.PricingPhase introPhase() { + ProductDetails.PricingPhase p = mock(ProductDetails.PricingPhase.class); + when(p.getPriceAmountMicros()).thenReturn(1_000_000L); + when(p.getFormattedPrice()).thenReturn("$1.00"); + when(p.getBillingPeriod()).thenReturn("P1W"); + when(p.getBillingCycleCount()).thenReturn(1); + when(p.getRecurrenceMode()).thenReturn(ProductDetails.RecurrenceMode.FINITE_RECURRING); return p; } } diff --git a/library/src/test/java/com/playbillingwrapper/SubscriptionSpecTest.java b/library/src/test/java/com/playbillingwrapper/SubscriptionSpecTest.java index 76baab5..e034e57 100644 --- a/library/src/test/java/com/playbillingwrapper/SubscriptionSpecTest.java +++ b/library/src/test/java/com/playbillingwrapper/SubscriptionSpecTest.java @@ -29,6 +29,20 @@ public void withTrial_sets_preferTrial_true() { assertNull(s.preferredOfferId); } + @Test + public void withIntro_sets_preferredOfferId() { + SubscriptionSpec s = SubscriptionSpec.withIntro("prod", "yearly", "intro_1w_1usd"); + assertEquals("prod", s.productId); + assertEquals("yearly", s.basePlanId); + assertFalse(s.preferTrial); + assertEquals("intro_1w_1usd", s.preferredOfferId); + } + + @Test(expected = NullPointerException.class) + public void withIntro_requires_offerId() { + SubscriptionSpec.withIntro("prod", "yearly", null); + } + @Test public void builder_populates_every_field() { SubscriptionSpec s = SubscriptionSpec.builder() From 0725d69ce0973bb023eccc0dfbb577db38948350 Mon Sep 17 00:00:00 2001 From: code-execute-rishi <69856912+code-execute-rishi@users.noreply.github.com> Date: Sat, 9 May 2026 14:54:58 +0530 Subject: [PATCH 2/3] fix(intro): spec-anchored offer resolution + independent intro/trial 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.md | 28 ++-- README.md | 93 ++++++++++- docs/GUIDE.md | 33 +++- .../com/playbillingwrapper/OfferSelector.java | 57 +++++-- .../PlayBillingWrapper.java | 149 +++++++++++------- .../listener/BillingAnalytics.java | 7 + .../playbillingwrapper/OfferSelectorTest.java | 57 +++++++ 7 files changed, 340 insertions(+), 84 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 79db045..c772829 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,21 +9,31 @@ a one-liner, symmetric with the existing free-trial API. - **`SubscriptionSpec.withIntro(productId, basePlanId, introOfferId)`** -- sugar that sets `preferredOfferId` to the intro offer. -- **`PlayBillingWrapper.isIntroEligible(productId [, basePlanId])`** -- true when Play still - exposes an offer with a non-zero `FINITE_RECURRING` phase on the base plan. Mirrors - `isTrialEligible`. +- **`PlayBillingWrapper.isIntroEligible(productId [, basePlanId])`** -- true when an offer + with a non-zero `FINITE_RECURRING` phase passes Play's offer-eligibility filter on this + account. Play omits offers the account fails the filter for (first-time-redeemer offer + for a repeat buyer, missing audience tag, expired promo), so this is the correct + "should I show the intro CTA?" gate. Mirrors `isTrialEligible`. - **`getIntroPhase(id, basePlan)` / `getIntroPeriodIso(id, basePlan)`** -- typed accessor + - ISO-8601 period for the intro phase. + ISO-8601 period for the intro phase. Spec-aware: prefers the registered + `SubscriptionSpec`'s offer (covers combined trial+intro shapes), falls back to the first + eligible offer on the base plan with an intro phase. - **`getIntroEndMillis(purchase [, basePlan])`** -- deterministic wall-clock estimate of - `purchaseTime + introPeriod * billingCycleCount`. Mirrors `getTrialEndMillis`. + `purchaseTime + introPeriod * billingCycleCount`. Mirrors `getTrialEndMillis`. The + registered `SubscriptionSpec` is the source of truth for which offer the user purchased + -- a `preferTrial=true` spec that resolved to a trial-only offer returns `-1`, **not** + the end of an unrelated intro offer on the same base plan. - **`getIntroPrice(id, basePlan)` / `getRecurringPrice(id, basePlan)`** -- formatted price strings for paywall CTAs, disambiguating the existing `getFormattedPrice` which returns the first non-trial phase (i.e. the intro price when an intro offer is selected). - **`BillingAnalytics.onIntroStarted(productId, periodIso, billingCycleCount, purchase)`** - -- default no-op hook. Fires once per `purchaseToken` on first-time delivery, mutually - exclusive with `onTrialStarted`. -- **`OfferSelector.isIntroEligible(details, basePlanId)`** -- static helper used by the - wrapper and exposed for advanced offer routing. + -- default no-op hook. Fires once per `purchaseToken` on first-time delivery, + **independent of `onTrialStarted`**: a combined offer (free trial -> intro week -> + recurring) fires both events for the same purchase. Pure-trial / pure-intro offers fire + only their respective event. Dedupe in your analytics pipeline if you need a single + funnel signal per checkout. +- **`OfferSelector.isIntroEligible(details, basePlanId)`** + **`hasIntroPhase(offer)`** -- + static helpers used by the wrapper and exposed for advanced offer routing. ## v0.3.0 (post-review hardening) diff --git a/README.md b/README.md index d90c095..eddcd82 100644 --- a/README.md +++ b/README.md @@ -804,10 +804,85 @@ BillingConfig cfg = BillingConfig.builder() Every event has a `default` no-op implementation, so implementers override only the ones they emit. +### Intro pricing in 5 steps + +End-to-end integration for an offer like "$0.99 first month, then $4.99/mo". + +**1. Play Console.** Create a subscription, add a base plan (e.g. `monthly`), then add an +**offer** on that base plan with a single intro phase: price `$0.99`, period `1 month`, +billing cycle count `1`, then the recurring base price. Note the offer id (e.g. +`intro_99c_1mo`). + +**2. Register the spec.** Tell the library which offer to route checkouts through: + +```java +BillingConfig cfg = BillingConfig.builder() + .addSubscription(SubscriptionSpec.withIntro( + "com.app.premium", "monthly", "intro_99c_1mo")) + .userId(sha256(uid)) + .build(); +PlayBillingWrapper billing = new PlayBillingWrapper(app, cfg, listener); +billing.connect(); +``` + +`withIntro(...)` is sugar for `builder().productId(...).basePlanId(...).preferredOfferId(...)`. + +**3. Build the paywall CTA.** The intro and recurring prices are separate accessors: + +```java +String introPrice = billing.getIntroPrice("com.app.premium", "monthly"); // "$0.99" +String recurringPrice = billing.getRecurringPrice("com.app.premium", "monthly"); // "$4.99" +String introPeriod = billing.getIntroPeriodIso("com.app.premium", "monthly"); // "P1M" + +if (billing.isIntroEligible("com.app.premium", "monthly")) { + ctaButton.setText(introPrice + " for 1 month, then " + recurringPrice + " / month"); +} else { + // Repeat user -- Play omits the intro offer; library falls back to base plan auto. + ctaButton.setText(recurringPrice + " / month"); +} +ctaButton.setOnClickListener(v -> + billing.subscribe(activity, "com.app.premium", "monthly")); +``` + +`getIntroPrice` returns `null` for users Play has filtered out of the offer; gate on +`isIntroEligible` first. + +**4. Listen for activation.** `onSubscriptionActivated` fires for every successful +purchase. `onIntroStarted` fires for purchases that include an intro phase: + +```java +config.analyticsListener(new BillingAnalytics() { + @Override public void onIntroStarted(String productId, String periodIso, + int billingCycleCount, PurchaseInfo p) { + analytics.track("intro_started", Map.of( + "product_id", productId, + "period_iso", periodIso, // "P1M" + "cycles", billingCycleCount // 1 + )); + } +}); +``` + +For a *combined* trial+intro offer (free trial -> intro phase -> recurring), both +`onTrialStarted` and `onIntroStarted` fire for the same purchase. Pure-trial and +pure-intro offers fire only their respective event. + +**5. Track when the intro ends.** Client-side estimate, useful for in-app banners +("Intro ends in 3 days"): + +```java +long introEndMs = billing.getIntroEndMillis(purchase, "monthly"); +boolean inIntroPhase = introEndMs > 0 && System.currentTimeMillis() < introEndMs; +``` + +Returns `-1` for purchases without an intro phase. The registered `SubscriptionSpec` is +the source of truth: a `preferTrial=true` spec that resolved to a trial-only offer +returns `-1` rather than the end of an unrelated intro offer on the same base plan. For +authoritative phase transitions, use `purchases.subscriptionsv2.get` server-side. + ### Intro pricing with typed phases -`getOfferPhases(id, basePlanId)` returns the library's typed `PricingPhases` wrapper -- no -Play SDK types leak into the paywall. +If your paywall renders every phase verbatim, walk the typed phase list: ```java List phases = @@ -1005,7 +1080,7 @@ Optionally pass `ProcessLifecycleOwner.get().getLifecycle()` so `release()` is c | `SubscriptionState monthlyState()` / `yearlyState()` | Default-spec sugar. | | `boolean isTrialEligible(String productId, String basePlanId)` | Any free-trial offer on the base plan? | | `boolean isTrialEligibleForYearly()` | Sugar for default yearly spec. | -| `boolean isIntroEligible(String productId, String basePlanId)` | Any intro-pricing offer (non-zero price + `FINITE_RECURRING`) on the base plan? | +| `boolean isIntroEligible(String productId, String basePlanId)` | Any intro-pricing offer (non-zero price + `FINITE_RECURRING`) on the base plan, after Play's offer-eligibility filter. `false` when the current Play account fails the filter (e.g. repeat redeemer of a first-time-only offer, missing audience tag, expired promo) — Play silently omits those offers from `ProductDetails`. | | `boolean isIntroEligible(String productId)` | Any registered base plan has an eligible intro offer. | | `boolean isSubscribed()` | Any registered subscription is entitling. | | `boolean isPremium()` | Any lifetime product owned OR `isSubscribed()`. | @@ -1024,10 +1099,10 @@ Optionally pass `ProcessLifecycleOwner.get().getLifecycle()` so `release()` is c | Method | Returns | |--------|---------| -| `PricingPhases getIntroPhase(String productId, String basePlanId)` | Typed intro pricing phase of the best offer on the base plan, or `null` if no intro offer is eligible. | +| `PricingPhases getIntroPhase(String productId, String basePlanId)` | Typed intro pricing phase on the base plan, or `null` if no offer carries an intro phase. Resolution order: registered `SubscriptionSpec`'s preferred offer (when it carries an intro phase — covers combined trial+intro offers) → first eligible offer on the base plan with an intro phase. | | `String getIntroPeriodIso(String productId, String basePlanId)` | ISO 8601 billing period of the intro phase, e.g. `"P1W"`, `"P1M"`. | -| `long getIntroEndMillis(PurchaseInfo, String basePlanId)` | Estimated wall-clock end of the intro phase, computed as `purchaseTime + introPeriod * billingCycleCount`. | -| `long getIntroEndMillis(PurchaseInfo)` | Convenience scan across registered specs for this product id. | +| `long getIntroEndMillis(PurchaseInfo, String basePlanId)` | Estimated wall-clock end of the intro phase, computed as `purchaseTime + introPeriod * billingCycleCount`. The registered `SubscriptionSpec` is the source of truth for which offer the user purchased — a `preferTrial=true` spec that resolved to a trial-only offer returns `-1`, **not** the end of an unrelated intro offer on the same base plan. With no spec registered for `(productId, basePlanId)`, falls back to the first offer on the base plan with an intro phase. | +| `long getIntroEndMillis(PurchaseInfo)` | Convenience scan across registered specs for this product id; returns the first non-`-1` estimate. | #### Paywall price helpers @@ -1126,6 +1201,10 @@ void onPurchaseCompleted(String productId, PurchaseInfo); void onSubscriptionActivated(String productId, SubscriptionState state, PurchaseInfo); void onTrialStarted(String productId, @Nullable String periodIso, PurchaseInfo); void onIntroStarted(String productId, @Nullable String periodIso, int billingCycleCount, PurchaseInfo); +// Trial and intro events are independent. A combined offer (free trial -> intro week -> +// recurring) fires BOTH events for the same purchase; dedupe in your analytics pipeline +// if you need a single funnel signal per checkout. Pure-trial / pure-intro offers fire +// only their respective event. void onSubscriptionCancelled(String productId, PurchaseInfo); void onConsumablePurchased(String productId, int quantity, PurchaseInfo); void onUserCancelled(String productId); @@ -1142,6 +1221,8 @@ static String pick(ProductDetails details, @Nullable String preferredOfferId, boolean preferTrial); static boolean isTrialEligible(ProductDetails details, String basePlanId); +static boolean isIntroEligible(ProductDetails details, String basePlanId); +static boolean hasIntroPhase(ProductDetails.SubscriptionOfferDetails offer); ``` ### `IdempotencyStore` diff --git a/docs/GUIDE.md b/docs/GUIDE.md index 405f907..5023ed3 100644 --- a/docs/GUIDE.md +++ b/docs/GUIDE.md @@ -10,6 +10,7 @@ Jump to: 2. [Monthly subscription (no trial)](#2-monthly-subscription-no-trial) 3. [Lifetime one-time product](#3-lifetime-one-time-product) 4. [Subscription with intro pricing (cheap first period, then normal price)](#4-subscription-with-intro-pricing-cheap-first-period-then-normal-price) + - 4.6 Combined trial + intro offers 5. [Wiring all four in a single paywall](#5-wiring-all-four-in-a-single-paywall) 6. [Shared Play Console prep](#shared-play-console-prep) 7. [Shared testing setup](#shared-testing-setup) @@ -323,11 +324,41 @@ rolls it out. |---|------|----------| | 1 | Register the `intro_99c_1mo` offer in Play Console. Wait 10 minutes for propagation. | `getPricingPhases("com.yourapp.premium", "monthly")` returns 2 phases: `[FINITE_RECURRING $0.99 × 1 month, INFINITE_RECURRING $4.99]`. | | 2 | License tester who never subscribed before → tap CTA → Play dialog shows `$0.99 for 1 month, then $4.99`. | Play dialog wording matches your `getPricingPhases()` output. | -| 3 | Complete purchase. | `onSubscriptionActivated(productId, ACTIVE, purchase)` fires. Play reports `isAutoRenewing() == true`. Your server-side verification should see `currentPeriod.phaseId == "intro_99c_1mo"`. | +| 3 | Complete purchase. | `onSubscriptionActivated(productId, ACTIVE, purchase)` and `onIntroStarted(productId, "P1M", 1, purchase)` fire. Play reports `isAutoRenewing() == true`. Your server-side verification should see `currentPeriod.phaseId == "intro_99c_1mo"`. | | 4 | Wait for the accelerated 1-month intro phase to roll over to the base plan. | No client callback fires (Play does not emit an event for phase transitions). `getFormattedPrice(productId, basePlanId)` still returns the same string because it's the regular-phase price. | | 5 | License tester who already consumed the intro → tap CTA. | `Play` dialog charges the full `$4.99`; the library auto-falls back to the base plan offer because the intro offer is omitted from `ProductDetails` for ineligible users. | | 6 | Roll out a price change to `$5.99`. After Play propagation, a fresh paywall load shows `$0.99 for 1 month, then $5.99 / month`. | `getPricingPhases()` reflects the new regular phase. Existing subscribers see the old price until they consent / auto-accept. | +### 4.6 Combined trial + intro offers + +Play allows up to three pricing phases on a single offer: a free trial, then a paid intro +phase, then the recurring base price. Configure it in Play Console as one offer with +two non-recurring phases (`Free for 3 days`, `$0.99 for 1 month`) plus the base plan's +recurring phase. + +The library treats these as one offer driven by a single spec: + +```java +SubscriptionSpec.builder() + .productId("com.yourapp.premium") + .basePlanId("monthly") + .preferredOfferId("trial3d_intro1mo") + .build(); +``` + +Both events fire on activation -- they describe orthogonal facts about the purchase, not +funnel steps: + +```java +onTrialStarted("com.yourapp.premium", "P3D", purchase); +onIntroStarted("com.yourapp.premium", "P1M", 1, purchase); +``` + +If your funnel needs a single "started" event per checkout, dedupe by `purchaseToken` +in your analytics pipeline. `getTrialEndMillis(purchase, "monthly")` returns the end of +the free phase; `getIntroEndMillis(purchase, "monthly")` returns the end of the paid +intro phase (i.e. when the recurring price kicks in). + --- ## 5. Wiring all four in a single paywall diff --git a/library/src/main/java/com/playbillingwrapper/OfferSelector.java b/library/src/main/java/com/playbillingwrapper/OfferSelector.java index 5230c13..9951ce7 100644 --- a/library/src/main/java/com/playbillingwrapper/OfferSelector.java +++ b/library/src/main/java/com/playbillingwrapper/OfferSelector.java @@ -36,6 +36,20 @@ public static String pick(@NonNull ProductDetails details, @NonNull String basePlanId, @Nullable String preferredOfferId, boolean preferTrial) { + ProductDetails.SubscriptionOfferDetails chosen = pickOffer(details, basePlanId, preferredOfferId, preferTrial); + return chosen == null ? null : chosen.getOfferToken(); + } + + /** + * Same selection logic as {@link #pick} but returns the chosen offer details object + * instead of just its token. Used by post-purchase code paths that need to inspect + * the offer's pricing phases (e.g. resolving the intro phase attached to a purchase). + */ + @Nullable + static ProductDetails.SubscriptionOfferDetails pickOffer(@NonNull ProductDetails details, + @NonNull String basePlanId, + @Nullable String preferredOfferId, + boolean preferTrial) { List all = details.getSubscriptionOfferDetails(); if (all == null || all.isEmpty()) return null; @@ -47,22 +61,22 @@ public static String pick(@NonNull ProductDetails details, if (preferredOfferId != null) { for (ProductDetails.SubscriptionOfferDetails o : onBasePlan) { - if (preferredOfferId.equals(o.getOfferId())) return o.getOfferToken(); + if (preferredOfferId.equals(o.getOfferId())) return o; } } if (preferTrial) { for (ProductDetails.SubscriptionOfferDetails o : onBasePlan) { if (o.getOfferId() == null) continue; // base plan itself, not a promo - if (hasFreeTrialPhase(o)) return o.getOfferToken(); + if (hasFreeTrialPhase(o)) return o; } } for (ProductDetails.SubscriptionOfferDetails o : onBasePlan) { - if (o.getOfferId() == null) return o.getOfferToken(); + if (o.getOfferId() == null) return o; } - return onBasePlan.get(0).getOfferToken(); + return onBasePlan.get(0); } /** @@ -84,23 +98,38 @@ public static boolean isTrialEligible(@NonNull ProductDetails details, @NonNull } /** - * True if the given product has at least one eligible offer on {@code basePlanId} - * that contains an intro pricing phase (non-zero price with - * {@code RecurrenceMode.FINITE_RECURRING}, e.g. "$1 for the first week"). + * True if the given product has at least one offer on {@code basePlanId} that contains + * an intro pricing phase (non-zero price with {@code RecurrenceMode.FINITE_RECURRING}, + * e.g. "$1 for the first week"). *

- * Like {@link #isTrialEligible}, Google Play silently omits ineligible offers from - * {@code getSubscriptionOfferDetails()}, so this doubles as the eligibility signal for - * "has this account ever redeemed this intro offer". + * Google Play omits offers the current Play account fails the eligibility filter for + * (e.g. first-time-redeemer offers for repeat buyers, audience-tag-gated offers, promo + * codes), so this is a reliable signal for "the current account can be sold this intro + * offer right now". It does not distinguish between offer-eligibility-filter + * variants (first-time vs audience-tag vs promo); callers that need to know why + * an offer is/isn't visible must inspect {@link ProductDetails.SubscriptionOfferDetails#getOfferTags()} + * or query the Play Developer API. */ public static boolean isIntroEligible(@NonNull ProductDetails details, @NonNull String basePlanId) { + return findOfferWithIntroPhase(details, basePlanId) != null; + } + + /** + * Returns the first offer on {@code basePlanId} that contains an intro pricing phase, + * or {@code null} if none exists. Skips the base-plan offer (offerId == null), which + * by definition has only the recurring phase. + */ + @Nullable + static ProductDetails.SubscriptionOfferDetails findOfferWithIntroPhase( + @NonNull ProductDetails details, @NonNull String basePlanId) { List all = details.getSubscriptionOfferDetails(); - if (all == null) return false; + if (all == null) return null; for (ProductDetails.SubscriptionOfferDetails o : all) { if (!basePlanId.equals(o.getBasePlanId())) continue; if (o.getOfferId() == null) continue; - if (hasIntroPhase(o)) return true; + if (hasIntroPhase(o)) return o; } - return false; + return null; } private static boolean hasFreeTrialPhase(@NonNull ProductDetails.SubscriptionOfferDetails offer) { @@ -110,7 +139,7 @@ private static boolean hasFreeTrialPhase(@NonNull ProductDetails.SubscriptionOff return false; } - static boolean hasIntroPhase(@NonNull ProductDetails.SubscriptionOfferDetails offer) { + public static boolean hasIntroPhase(@NonNull ProductDetails.SubscriptionOfferDetails offer) { for (ProductDetails.PricingPhase p : offer.getPricingPhases().getPricingPhaseList()) { if (p.getPriceAmountMicros() > 0L && p.getRecurrenceMode() == ProductDetails.RecurrenceMode.FINITE_RECURRING) { diff --git a/library/src/main/java/com/playbillingwrapper/PlayBillingWrapper.java b/library/src/main/java/com/playbillingwrapper/PlayBillingWrapper.java index 11c18d0..edc9ec4 100644 --- a/library/src/main/java/com/playbillingwrapper/PlayBillingWrapper.java +++ b/library/src/main/java/com/playbillingwrapper/PlayBillingWrapper.java @@ -579,10 +579,11 @@ public long getTrialEndMillis(@NonNull PurchaseInfo purchase) { // --------------------------------------------------------------------- /** - * True if the given subscription base plan has at least one eligible offer with an - * intro pricing phase (non-zero price, {@code FINITE_RECURRING}). Play silently omits - * ineligible offers, so this is also the correct "has this account redeemed the intro" - * eligibility check. + * True if the given subscription base plan has at least one offer with an intro + * pricing phase (non-zero price, {@code FINITE_RECURRING}). Play omits offers the + * current Play account fails eligibility on, so a {@code true} here means the offer + * is sellable right now to this account. Doesn't distinguish among offer-eligibility + * variants (first-time-redeemer, audience-tag, promo); for that, inspect offer tags. */ public boolean isIntroEligible(@NonNull String productId, @NonNull String basePlanId) { ProductDetails details = findProductDetails(productId); @@ -603,11 +604,16 @@ public boolean isIntroEligible(@NonNull String productId) { } /** - * Returns the first intro pricing phase on {@code (productId, basePlanId)} as the - * library's typed wrapper, or {@code null} if no intro offer is eligible. Walks the - * offer chosen by the registered {@link SubscriptionSpec} (preferredOfferId > trial > - * base plan), so callers that registered their intro offer via - * {@link SubscriptionSpec#withIntro} get the exact phase they configured. + * Returns the intro pricing phase on {@code (productId, basePlanId)} as the library's + * typed wrapper, or {@code null} if no offer on the base plan has an intro phase. + *

+ * Resolution order: if a {@link SubscriptionSpec} is registered for this base plan + * AND its preferred offer has an intro phase (covers combined trial+intro offers + * routed via {@code preferTrial}/{@code preferredOfferId}), that offer's intro phase + * is returned; otherwise the first offer on the base plan containing an intro phase + * is returned. The latter matches the semantics of + * {@link #isIntroEligible(String, String)}, so a paywall that branches on + * {@code isIntroEligible} can always read a non-null phase here. *

* Useful for paywall labels like {@code phase.getFormattedPrice() + " for " + * phase.getBillingCycleCount() + " " + phase.getPeriodIso()}. @@ -615,11 +621,32 @@ public boolean isIntroEligible(@NonNull String productId) { @Nullable public com.playbillingwrapper.model.SubscriptionOfferDetails.PricingPhases getIntroPhase( @NonNull String productId, @NonNull String basePlanId) { - List phases = - getOfferPhases(productId, basePlanId); - if (phases == null) return null; - for (com.playbillingwrapper.model.SubscriptionOfferDetails.PricingPhases p : phases) { - if (p.isIntro()) return p; + ProductDetails details = findProductDetails(productId); + if (details == null) return null; + + ProductDetails.SubscriptionOfferDetails offer = null; + SubscriptionSpec spec = findSpecOrNull(productId, basePlanId); + if (spec != null) { + ProductDetails.SubscriptionOfferDetails picked = + OfferSelector.pickOffer(details, basePlanId, spec.preferredOfferId, spec.preferTrial); + if (picked != null && OfferSelector.hasIntroPhase(picked)) offer = picked; + } + if (offer == null) { + offer = OfferSelector.findOfferWithIntroPhase(details, basePlanId); + } + if (offer == null) return null; + + for (ProductDetails.PricingPhase p : offer.getPricingPhases().getPricingPhaseList()) { + if (p.getPriceAmountMicros() > 0L + && p.getRecurrenceMode() == ProductDetails.RecurrenceMode.FINITE_RECURRING) { + return new com.playbillingwrapper.model.SubscriptionOfferDetails.PricingPhases( + p.getFormattedPrice(), + p.getPriceAmountMicros(), + p.getPriceCurrencyCode(), + p.getBillingPeriod(), + p.getBillingCycleCount(), + p.getRecurrenceMode()); + } } return null; } @@ -642,6 +669,18 @@ public String getIntroPeriodIso(@NonNull String productId, @NonNull String baseP * user actually bought. Returns {@code -1} if the purchase is not a subscription, is * PENDING, lacks a ProductInfo, or has no intro offer on the given base plan. *

+ * Resolves the offer the user purchased the same way {@link OfferSelector#pick} did at + * checkout: registered spec's {@code preferredOfferId} > trial preference > base plan. + * The spec is treated as the source of truth for purchase intent -- if a registered + * spec resolves to an offer that has no intro phase (e.g. {@code preferTrial=true} + * picked a free-trial-only offer), this returns {@code -1} rather than falling back + * to an unrelated intro offer the user did not buy. + *

+ * If no spec is registered for {@code (productId, basePlanId)} -- e.g. caller passed a + * base plan they never registered -- it falls back to the first offer on that base + * plan that contains an intro phase. With multiple intro offers on the same base plan, + * register a spec to disambiguate. + *

* Client-side estimate; authoritative phase transitions are only available via the * Play Developer API server-side. Months approximated as 30 days, years as 365 days. */ @@ -651,19 +690,24 @@ public long getIntroEndMillis(@NonNull PurchaseInfo purchase, @NonNull String ba ProductInfo info = purchase.getProductInfo(); if (info == null) return -1; ProductDetails details = info.getProductDetails(); - List offers = details.getSubscriptionOfferDetails(); - if (offers == null) return -1; - for (ProductDetails.SubscriptionOfferDetails offer : offers) { - if (!basePlanId.equals(offer.getBasePlanId())) continue; - if (offer.getOfferId() == null) continue; - for (ProductDetails.PricingPhase phase : offer.getPricingPhases().getPricingPhaseList()) { - if (phase.getPriceAmountMicros() > 0L - && phase.getRecurrenceMode() == ProductDetails.RecurrenceMode.FINITE_RECURRING) { - long periodMs = parseIso8601DurationMillis(phase.getBillingPeriod()); - if (periodMs <= 0) return -1; - int cycles = Math.max(1, phase.getBillingCycleCount()); - return purchase.getPurchaseTime() + periodMs * cycles; - } + + ProductDetails.SubscriptionOfferDetails offer; + SubscriptionSpec spec = findSpecOrNull(purchase.getProduct(), basePlanId); + if (spec != null) { + offer = OfferSelector.pickOffer(details, basePlanId, spec.preferredOfferId, spec.preferTrial); + if (offer == null || !OfferSelector.hasIntroPhase(offer)) return -1; + } else { + offer = OfferSelector.findOfferWithIntroPhase(details, basePlanId); + } + if (offer == null) return -1; + + for (ProductDetails.PricingPhase phase : offer.getPricingPhases().getPricingPhaseList()) { + if (phase.getPriceAmountMicros() > 0L + && phase.getRecurrenceMode() == ProductDetails.RecurrenceMode.FINITE_RECURRING) { + long periodMs = parseIso8601DurationMillis(phase.getBillingPeriod()); + if (periodMs <= 0) return -1; + int cycles = Math.max(1, phase.getBillingCycleCount()); + return purchase.getPurchaseTime() + periodMs * cycles; } } return -1; @@ -1032,40 +1076,37 @@ private void dispatchPurchases(List purchases) { // and the purchase was just minted (first-time delivery path). long trialEndMs = getTrialEndMillis(p); if (trialEndMs > 0L) { - // Recover ISO period so callers get the raw string too. + // Recover ISO period from the base plan that actually has a trial. String iso = null; for (SubscriptionSpec s : config.subscriptions) { - if (s.productId.equals(p.getProduct())) { - iso = getTrialPeriodIso(s.productId, s.basePlanId); - if (iso != null) break; - } + if (!s.productId.equals(p.getProduct())) continue; + if (getTrialEndMillis(p, s.basePlanId) <= 0L) continue; + iso = getTrialPeriodIso(s.productId, s.basePlanId); + if (iso != null) break; } final String isoFinal = iso; analytics(a -> a.onTrialStarted(p.getProduct(), isoFinal, p)); - } else { - // Intro-started event: only fires when the purchase has no trial phase - // but does have an intro phase. Trial and intro are mutually exclusive - // from the "which event should I log" perspective -- a trial-then-intro - // offer still logs as onTrialStarted, which matches funnel conventions. - long introEndMs = getIntroEndMillis(p); - if (introEndMs > 0L) { - String iso = null; - int cycles = 1; - for (SubscriptionSpec s : config.subscriptions) { - if (!s.productId.equals(p.getProduct())) continue; - com.playbillingwrapper.model.SubscriptionOfferDetails.PricingPhases phase = - getIntroPhase(s.productId, s.basePlanId); - if (phase != null) { - iso = phase.getPeriodIso(); - cycles = Math.max(1, phase.getBillingCycleCount()); - break; - } - } - final String isoFinal = iso; - final int cyclesFinal = cycles; - analytics(a -> a.onIntroStarted(p.getProduct(), isoFinal, cyclesFinal, p)); + } + + // Intro-started event fires independently of the trial event. A combined + // offer (free trial -> $1 intro week -> $X/yr) emits both events; pure-intro + // offers emit only this one. Resolves the basePlan from the spec that + // actually has an intro offer attached. + String introBasePlan = null; + for (SubscriptionSpec s : config.subscriptions) { + if (!s.productId.equals(p.getProduct())) continue; + if (getIntroEndMillis(p, s.basePlanId) > 0L) { + introBasePlan = s.basePlanId; + break; } } + if (introBasePlan != null) { + com.playbillingwrapper.model.SubscriptionOfferDetails.PricingPhases phase = + getIntroPhase(p.getProduct(), introBasePlan); + final String isoFinal = phase == null ? null : phase.getPeriodIso(); + final int cyclesFinal = phase == null ? 1 : Math.max(1, phase.getBillingCycleCount()); + analytics(a -> a.onIntroStarted(p.getProduct(), isoFinal, cyclesFinal, p)); + } } else { l.onLifetimePurchased(p); analytics(a -> a.onPurchaseCompleted(p.getProduct(), p)); diff --git a/library/src/main/java/com/playbillingwrapper/listener/BillingAnalytics.java b/library/src/main/java/com/playbillingwrapper/listener/BillingAnalytics.java index 472cf50..eb66190 100644 --- a/library/src/main/java/com/playbillingwrapper/listener/BillingAnalytics.java +++ b/library/src/main/java/com/playbillingwrapper/listener/BillingAnalytics.java @@ -57,6 +57,9 @@ default void onSubscriptionActivated(@NonNull String productId, * A subscription was activated with a free-trial offer. Fires once per * {@code purchaseToken}. {@code periodIso} is the trial length as ISO 8601 * (e.g. {@code "P3D"}, {@code "P7D"}). Useful for funnel dashboards. + *

+ * Independent of {@link #onIntroStarted}: a combined offer (free trial -> intro week + * -> recurring) fires both events for the same purchase. */ default void onTrialStarted(@NonNull String productId, @Nullable String periodIso, @@ -68,6 +71,10 @@ default void onTrialStarted(@NonNull String productId, * intro phase billing period as ISO 8601 (e.g. {@code "P1W"}, {@code "P1M"}). * {@code billingCycleCount} is how many times the intro phase repeats before the * recurring phase kicks in (often 1 but can be N). + *

+ * Independent of {@link #onTrialStarted}: a combined offer (free trial -> intro week + * -> recurring) fires both events for the same purchase. Dedupe in your analytics + * pipeline if you want a single funnel event per checkout. */ default void onIntroStarted(@NonNull String productId, @Nullable String periodIso, diff --git a/library/src/test/java/com/playbillingwrapper/OfferSelectorTest.java b/library/src/test/java/com/playbillingwrapper/OfferSelectorTest.java index 6c8cba3..0e4e04d 100644 --- a/library/src/test/java/com/playbillingwrapper/OfferSelectorTest.java +++ b/library/src/test/java/com/playbillingwrapper/OfferSelectorTest.java @@ -171,6 +171,53 @@ public void falls_back_to_base_plan_when_intro_offer_omitted_by_play() { OfferSelector.pick(details, BASE_PLAN, "intro_1w_1usd", false)); } + @Test + public void isIntroEligible_true_for_combined_trial_intro_offer() { + // Combined offer: free week -> $1 intro month -> recurring $19/yr. + ProductDetails details = withOffers( + offer(BASE_PLAN, "trial_intro_combo", "tok-combo", freePhase(), introPhase(), paidPhase()) + ); + assertTrue(OfferSelector.isIntroEligible(details, BASE_PLAN)); + assertTrue(OfferSelector.isTrialEligible(details, BASE_PLAN)); + } + + @Test + public void findOfferWithIntroPhase_returns_offer_when_present() { + ProductDetails details = withOffers( + offer(BASE_PLAN, null, "tok-base", paidPhase()), + offer(BASE_PLAN, "intro_1w_1usd", "tok-intro", introPhase(), paidPhase()) + ); + ProductDetails.SubscriptionOfferDetails got = + OfferSelector.findOfferWithIntroPhase(details, BASE_PLAN); + assertEquals("tok-intro", got.getOfferToken()); + } + + @Test + public void findOfferWithIntroPhase_null_when_only_base_plan() { + ProductDetails details = withOffers( + offer(BASE_PLAN, null, "tok-base", paidPhase()) + ); + assertNull(OfferSelector.findOfferWithIntroPhase(details, BASE_PLAN)); + } + + @Test + public void hasIntroPhase_true_with_multi_cycle_intro() { + ProductDetails.SubscriptionOfferDetails o = offer( + BASE_PLAN, "intro_3m", "tok-3m", multiCycleIntroPhase(3), paidPhase()); + assertTrue(OfferSelector.hasIntroPhase(o)); + } + + @Test + public void pick_with_preferTrial_true_picks_trial_even_when_intro_present() { + // preferTrial wins over intro offer when both eligible. + ProductDetails details = withOffers( + offer(BASE_PLAN, null, "tok-base", paidPhase()), + offer(BASE_PLAN, "intro_1w_1usd", "tok-intro", introPhase(), paidPhase()), + offer(BASE_PLAN, "freetrial", "tok-trial", freePhase(), paidPhase()) + ); + assertEquals("tok-trial", OfferSelector.pick(details, BASE_PLAN, null, true)); + } + // ---- helpers ---- private static ProductDetails withOffers(ProductDetails.SubscriptionOfferDetails... offers) { @@ -219,4 +266,14 @@ private static ProductDetails.PricingPhase introPhase() { when(p.getRecurrenceMode()).thenReturn(ProductDetails.RecurrenceMode.FINITE_RECURRING); return p; } + + private static ProductDetails.PricingPhase multiCycleIntroPhase(int cycles) { + ProductDetails.PricingPhase p = mock(ProductDetails.PricingPhase.class); + when(p.getPriceAmountMicros()).thenReturn(2_990_000L); + when(p.getFormattedPrice()).thenReturn("$2.99"); + when(p.getBillingPeriod()).thenReturn("P1M"); + when(p.getBillingCycleCount()).thenReturn(cycles); + when(p.getRecurrenceMode()).thenReturn(ProductDetails.RecurrenceMode.FINITE_RECURRING); + return p; + } } From a654e4c304657f289ed5a19c03fcf322532751c4 Mon Sep 17 00:00:00 2001 From: code-execute-rishi <69856912+code-execute-rishi@users.noreply.github.com> Date: Sat, 9 May 2026 14:59:22 +0530 Subject: [PATCH 3/3] docs: address remaining CodeRabbit review notes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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. --- CHANGELOG.md | 13 ++++++++++++- README.md | 2 +- docs/GUIDE.md | 4 ++-- .../listener/BillingAnalytics.java | 3 +++ 4 files changed, 18 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c772829..544dd5f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -35,7 +35,18 @@ a one-liner, symmetric with the existing free-trial API. - **`OfferSelector.isIntroEligible(details, basePlanId)`** + **`hasIntroPhase(offer)`** -- static helpers used by the wrapper and exposed for advanced offer routing. -## v0.3.0 (post-review hardening) +### Changed + +- **`getFormattedPrice(productId, basePlanId)` semantics clarified.** The method always + returned the first non-trial pricing phase. With the new intro-pricing surface, that + first non-trial phase is the *intro* price (e.g. `"$1.00"`) when an intro offer is + selected via `SubscriptionSpec.withIntro(...)` -- not the recurring price. Paywall + surfaces that always want the renewal price should migrate to the new + `getRecurringPrice(productId, basePlanId)`. No code change; documenting so integrators + upgrading from v0.3.0 who start using `withIntro` aren't surprised by labels flipping + from recurring → intro. + +## v0.3.0 (post-review hardening) — 2026-04-21 Fixes surfaced by a manual correctness review of 0.1.1. diff --git a/README.md b/README.md index eddcd82..74dd14f 100644 --- a/README.md +++ b/README.md @@ -832,10 +832,10 @@ billing.connect(); ```java String introPrice = billing.getIntroPrice("com.app.premium", "monthly"); // "$0.99" String recurringPrice = billing.getRecurringPrice("com.app.premium", "monthly"); // "$4.99" -String introPeriod = billing.getIntroPeriodIso("com.app.premium", "monthly"); // "P1M" if (billing.isIntroEligible("com.app.premium", "monthly")) { ctaButton.setText(introPrice + " for 1 month, then " + recurringPrice + " / month"); + // Need the period dynamically? billing.getIntroPeriodIso(...) returns "P1M" / "P1W". } else { // Repeat user -- Play omits the intro offer; library falls back to base plan auto. ctaButton.setText(recurringPrice + " / month"); diff --git a/docs/GUIDE.md b/docs/GUIDE.md index 5023ed3..adeccf0 100644 --- a/docs/GUIDE.md +++ b/docs/GUIDE.md @@ -17,7 +17,7 @@ Jump to: > **Install:** > ```gradle -> implementation 'com.github.code-execute-rishi:PlayBillingWrapper:v0.2.1' +> implementation 'com.github.code-execute-rishi:PlayBillingWrapper:v0.3.0' > ``` > Plus `` in the manifest. @@ -260,10 +260,10 @@ BillingConfig cfg = BillingConfig.builder() // One-liner CTA labels using the typed intro helpers. String introPrice = billing.getIntroPrice("com.yourapp.premium", "monthly"); // "$0.99" String recurringPrice = billing.getRecurringPrice("com.yourapp.premium", "monthly"); // "$4.99" -String introPeriod = billing.getIntroPeriodIso("com.yourapp.premium", "monthly"); // "P1M" if (introPrice != null) { ctaIntro.setText(introPrice + " for 1 month, then " + recurringPrice + " / month"); + // Need the period dynamically? billing.getIntroPeriodIso(...) returns "P1M" / "P1W". } else { // Repeat user -- Play omits the intro offer, library falls back to the base plan. ctaIntro.setText(recurringPrice + " / month"); diff --git a/library/src/main/java/com/playbillingwrapper/listener/BillingAnalytics.java b/library/src/main/java/com/playbillingwrapper/listener/BillingAnalytics.java index eb66190..f33655e 100644 --- a/library/src/main/java/com/playbillingwrapper/listener/BillingAnalytics.java +++ b/library/src/main/java/com/playbillingwrapper/listener/BillingAnalytics.java @@ -22,6 +22,9 @@ * {@code onConsumablePurchased}). *

  • {@link #onTrialStarted} — a subscription with a free-trial offer was just * activated.
  • + *
  • {@link #onIntroStarted} — a subscription with an intro-pricing offer was just + * activated. Independent of {@link #onTrialStarted}: a combined offer (free + * trial → intro phase → recurring) fires both events for the same purchase.
  • *
  • {@link #onSubscriptionCancelled} — Play reports an auto-renew true→false * transition.
  • *
  • {@link #onUserCancelled} — user dismissed the Play dialog before paying.