diff --git a/CHANGELOG.md b/CHANGELOG.md index 8f54d39..544dd5f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,52 @@ # 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 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. 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`. 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, + **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. + +### 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 87516a3..74dd14f 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`. | @@ -803,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" + +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"); +} +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 = @@ -1004,6 +1080,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, 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()`. | | `List getActiveEntitlements()` | Product ids the user currently holds entitlement for. | @@ -1017,12 +1095,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 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`. 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 | 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 +1137,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 +1200,11 @@ 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); +// 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); @@ -1126,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 61cde3c..adeccf0 100644 --- a/docs/GUIDE.md +++ b/docs/GUIDE.md @@ -10,13 +10,14 @@ 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) > **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. @@ -250,45 +251,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" + +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"); } -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. @@ -319,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 2228644..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); } /** @@ -83,10 +97,55 @@ public static boolean isTrialEligible(@NonNull ProductDetails details, @NonNull return false; } + /** + * 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"). + *

+ * 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 null; + for (ProductDetails.SubscriptionOfferDetails o : all) { + if (!basePlanId.equals(o.getBasePlanId())) continue; + if (o.getOfferId() == null) continue; + if (hasIntroPhase(o)) return o; + } + return null; + } + private static boolean hasFreeTrialPhase(@NonNull ProductDetails.SubscriptionOfferDetails offer) { for (ProductDetails.PricingPhase p : offer.getPricingPhases().getPricingPhaseList()) { if (p.getPriceAmountMicros() == 0L) return true; } return false; } + + 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) { + 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..edc9ec4 100644 --- a/library/src/main/java/com/playbillingwrapper/PlayBillingWrapper.java +++ b/library/src/main/java/com/playbillingwrapper/PlayBillingWrapper.java @@ -574,6 +574,160 @@ 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 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); + 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 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()}. + */ + @Nullable + public com.playbillingwrapper.model.SubscriptionOfferDetails.PricingPhases getIntroPhase( + @NonNull String productId, @NonNull String basePlanId) { + 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; + } + + /** + * 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. + *

+ * 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. + */ + 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(); + + 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; + } + + /** + * 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 +751,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 +767,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 @@ -889,17 +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)); } + + // 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 0c6d0ad..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.
  • @@ -57,11 +60,30 @@ 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, @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). + *

    + * 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, + 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..0e4e04d 100644 --- a/library/src/test/java/com/playbillingwrapper/OfferSelectorTest.java +++ b/library/src/test/java/com/playbillingwrapper/OfferSelectorTest.java @@ -117,6 +117,107 @@ 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)); + } + + @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) { @@ -152,6 +253,27 @@ 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; + } + + 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; } } 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()