From 26b6fe576319eb6e78d8a01a9f02b575fa0fc45b Mon Sep 17 00:00:00 2001 From: Kevin Date: Fri, 10 Jul 2026 12:42:18 +0200 Subject: [PATCH 1/2] docs(concepts): add dynamic offerings + monthly commitment references MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - New concepts/dynamic-offerings.md: setDynamicOffering runtime plan/offer overrides, applied server-side at placement fetch (register-before-fetch), cross-platform API, and the same-plan/conflicting-billing-type pitfall. - New concepts/monthly-commitment.md: Apple advance commitment (12-month billed monthly), PLYBillingPlanType, eligibility (iOS 26.4+, SDK v6+, excl. US/SG), setup, storefront fallback. - troubleshooting/common-issues.md: symptom→cause row + §12 diagnostic for billingPlanType resolving to .unspecified. - Index (concepts/README.md) + purchasely-sdk-expert SKILL.md reference list and routing. Co-Authored-By: Claude Opus 4.8 --- purchasely/references/concepts/README.md | 4 + .../references/concepts/dynamic-offerings.md | 83 +++++++++++++++++++ .../references/concepts/monthly-commitment.md | 50 +++++++++++ .../troubleshooting/common-issues.md | 16 ++++ .../skills/purchasely-sdk-expert/SKILL.md | 10 +++ 5 files changed, 163 insertions(+) create mode 100644 purchasely/references/concepts/dynamic-offerings.md create mode 100644 purchasely/references/concepts/monthly-commitment.md diff --git a/purchasely/references/concepts/README.md b/purchasely/references/concepts/README.md index 45c571b..903c6d1 100644 --- a/purchasely/references/concepts/README.md +++ b/purchasely/references/concepts/README.md @@ -24,6 +24,8 @@ When a topic also has a deeper platform-specific take (e.g. SwiftUI lifecycle, J | [subscription-checks.md](subscription-checks.md) | Gating content via `userSubscriptions`, restoring purchases (with Purchasely-paywall caveat) | | [subscription-management.md](subscription-management.md) | Opening the native Manage Subscription page (App Store / Play Store) | | [promotional-offers.md](promotional-offers.md) | Offer types, Apple promo offers, Google developer-determined offers, offer codes, win-back | +| [dynamic-offerings.md](dynamic-offerings.md) | `setDynamicOffering` runtime plan/offer overrides — applied server-side at fetch, register-before-fetch rule, same-plan billing-type pitfall | +| [monthly-commitment.md](monthly-commitment.md) | Apple advance-commitment (12-month billed monthly) `PLYBillingPlanType` — iOS 26.4+, eligibility (excl. US/SG), setup | | [campaigns.md](campaigns.md) | No-code Console automations (trigger / placement-based), `allowCampaigns` + `allowDeeplink` (native iOS/Android, React Native, Flutter v6, and Cordova v6), SDK ≥ 5.1.0 | | [analytics-integration.md](analytics-integration.md) | Forwarding UI events to Firebase / Amplitude / AppsFlyer + analytics wrapper pattern | @@ -37,6 +39,8 @@ When a topic also has a deeper platform-specific take (e.g. SwiftUI lifecycle, J | Adding app-side purchase buttons | `programmatic-purchases.md`, `subscription-checks.md` | | Adding subscription gating | `subscription-checks.md`, `subscription-management.md` | | Adding retention / win-back paywalls | `promotional-offers.md`, `campaigns.md` | +| Overriding a paywall's plan/offer at runtime (remote config, cohorts, experiments) | `dynamic-offerings.md` | +| Setting up 12-month commitment billed monthly (iOS) | `monthly-commitment.md`, `dynamic-offerings.md` | | Adding scheduled or event-driven paywalls | `campaigns.md` | | Wiring analytics / tracking | `analytics-integration.md`, `user-identity.md` | | Improving paywall perceived performance | `presentation-cache.md` (preload pattern) | diff --git a/purchasely/references/concepts/dynamic-offerings.md b/purchasely/references/concepts/dynamic-offerings.md new file mode 100644 index 0000000..9b313df --- /dev/null +++ b/purchasely/references/concepts/dynamic-offerings.md @@ -0,0 +1,83 @@ +# Dynamic Offerings — Runtime Plan / Offer Overrides + +Applies to: **iOS, Android, React Native, Flutter, Cordova**. + +A **dynamic offering** lets your app decide, **at runtime**, which plan (and optionally which promotional offer) a paywall slot resolves to — without republishing the Screen. In the Screen Composer, a plan-picker option (or a CTA) is bound to an **offering reference** (a string key). At runtime you map that reference to a concrete plan with `setDynamicOffering(...)`. + +Typical uses: contextual pricing (win-back vs onboarding), per-cohort plans, remote-config-driven experiments — all pointing the *same* screen at *different* plans. + +## The one rule that trips people up: offerings are applied **server-side, at fetch time** + +The SDK does **not** rewrite the paywall locally. When it fetches a presentation (the placement call), it sends the currently-registered offerings to the Purchasely backend, and the backend substitutes the mapped plan/offer into the returned paywall JSON. + +Consequences: + +- **Register offerings BEFORE you fetch / display the placement.** An offering set *after* the paywall is fetched has no effect until the next fetch. +- Offerings are **persisted** across app launches until you remove them — a stale offering from a previous session still applies. Call `removeDynamicOffering` / `clearDynamicOfferings` when a context ends. +- Because substitution is server-side, the *reference* must exactly match what the Screen Composer expects, and the *plan vendor id* must exist in your catalog. + +## API by platform + +`reference` + `planVendorId` are required; `offerVendorId` is optional (highlight a specific promotional offer). iOS additionally accepts `billingPlanType` — see [Monthly commitment billing](monthly-commitment.md). + +```swift +// iOS (Swift) — call before fetchPresentation / display +Purchasely.setDynamicOffering(reference: "onboarding_offer", + planVendorId: "PURCHASELY_PLUS_YEARLY", + offerVendorId: nil) { success in + // fetch / display the placement only after this returns true +} +Purchasely.getDynamicOfferings() // inspect +Purchasely.removeDynamicOffering(reference: "onboarding_offer") +Purchasely.clearDynamicOfferings() +``` + +```kotlin +// Android (Kotlin) +Purchasely.setDynamicOffering( + reference = "onboarding_offer", + planVendorId = "PURCHASELY_PLUS_YEARLY", + offerVendorId = null, +) { success -> /* then fetch / display */ } +``` + +```ts +// React Native +const ok = await Purchasely.setDynamicOffering({ + reference: 'onboarding_offer', + planVendorId: 'PURCHASELY_PLUS_YEARLY', + offerVendorId: undefined, +}) +``` + +```dart +// Flutter +final ok = await Purchasely.setDynamicOffering( + DynamicOffering(reference: 'onboarding_offer', planVendorId: 'PURCHASELY_PLUS_YEARLY'), +); +``` + +`getDynamicOfferings`, `removeDynamicOffering`, and `clearDynamicOfferings` exist on every platform. + +> **Note:** the `billingPlanType` argument is **iOS-only** at this time. Android, React Native, Flutter, and Cordova take `reference` / `planVendorId` / `offerVendorId` only. + +## ⚠️ Pitfall: one plan → one billing type per presentation + +Do **not** register several offering references that all resolve to the **same plan** within a single presentation while carrying **different billing plan types** (for example one `.monthly` and one `.upFront` on the same plan). + +When you do, the returned paywall ends up describing that same plan **more than once with conflicting billing types**. The SDK matches a plan by its vendor id, so it can no longer tell which billing type applies to it, and may report **`.unspecified`** to the purchase interceptor (and use it for the purchase) even though one of the offerings requested `.monthly`. Symptom: `parameters.billingPlanType == .unspecified` in the interceptor instead of the `.monthly` you set on the offering. + +Guidance: + +- Map a given plan to **a single billing plan type per presentation**. +- If you need both an up-front and a monthly-commitment variant on screen, back them with **two distinct plans / products**, not two offering references on the same plan. +- When re-configuring, `clearDynamicOfferings()` first so a leftover offering from a previous screen/session doesn't add a second mapping for the same plan. + +See [Monthly commitment billing](monthly-commitment.md) for the billing-type feature itself and [common issues](../troubleshooting/common-issues.md) for the diagnostic entry. + +## Related + +- [Monthly commitment billing (iOS)](monthly-commitment.md) — the `billingPlanType` feature +- [Promotional offers](promotional-offers.md) — what `offerVendorId` points at +- [Paywall actions](paywall-actions.md) — how a picker/CTA turns into a purchase action +- [Presentation cache](presentation-cache.md) — why "register before fetch" matters with preloading diff --git a/purchasely/references/concepts/monthly-commitment.md b/purchasely/references/concepts/monthly-commitment.md new file mode 100644 index 0000000..30d61ee --- /dev/null +++ b/purchasely/references/concepts/monthly-commitment.md @@ -0,0 +1,50 @@ +# Monthly Commitment Billing (Apple Advance Commitment) — iOS + +Applies to: **iOS only** (native iOS SDK). Requires **iOS 26.4+** and **SDK v6+**. + +Apple's **advance commitment** subscriptions let a long commitment period (e.g. a **12-month** commitment) be **billed monthly** instead of charged once up front. StoreKit models this as a *billing plan type* on the subscription: **up-front** vs **monthly**. The user commits to 12 months but is charged month by month. + +Purchasely surfaces this as the public enum **`PLYBillingPlanType`**: + +| Case | Meaning | +|------|---------| +| `.monthly` | 12-month commitment **billed monthly** (installment-style) | +| `.upFront` | Commitment **charged once up front** | +| `.unspecified` | No commitment billing info for this plan (plain subscription, or not configured) | + +`.unspecified` is a deliberate third state — it is **not** the same as `.upFront`. Only an explicitly configured up-front commitment resolves to `.upFront`. + +## Eligibility — all of these must hold + +- **iOS 26.4 or later** on the device. The StoreKit billing-plan-type API and the monthly-commitment purchase option exist only from 26.4; on older iOS the feature is unavailable. +- **Purchasely iOS SDK v6+**. +- **Storefront**: monthly commitment is offered in most App Store countries but **not** in the **United States** and **Singapore** (as of Apple's current rollout). On a US or Singapore storefront the SDK automatically **falls back from `.monthly` to `.upFront`** — a US/SG tester seeing `.upFront` is expected behavior, not a bug. +- **App Store Connect**: the product must be configured with advance-commitment (monthly) pricing. +- **Purchasely configuration**: the plan must carry the monthly billing plan type — set on the plan in the Screen Composer, or supplied at runtime via a [dynamic offering](dynamic-offerings.md) (`billingPlanType: .monthly`, iOS-only). + +## Setup checklist + +1. **App Store Connect** — enable advance commitment / monthly billing on the (yearly) product. +2. **Purchasely Console** — set the plan's commitment billing type to monthly on the paywall, or pass it via `setDynamicOffering(..., billingPlanType: .monthly)`. +3. **App** — Purchasely iOS SDK v6+, test on a real device or simulator running **iOS 26.4+**, signed into a **non-US / non-Singapore** App Store account (sandbox or TestFlight). + +## How it surfaces at runtime + +- In the **purchase action interceptor**, `parameters.billingPlanType` reflects the resolved type for the tapped plan. +- In **Full** running mode, the SDK passes the correct StoreKit purchase option automatically when the resolved type is `.monthly`. +- In **Observer** mode, your own purchase code reads `parameters.billingPlanType` to decide how to purchase — so an incorrect value here changes what your app buys. + +## When you expect `.monthly` but get `.unspecified` + +Check, in order: + +1. **Storefront** is not US / Singapore (those fall back to `.upFront`, and if the plan isn't set up as up-front either, you'll see `.unspecified`). +2. **iOS version** is 26.4+. +3. You did **not** map the **same plan to multiple offering references with different billing types** in one presentation — that ambiguity resolves to `.unspecified`. See the pitfall in [Dynamic offerings](dynamic-offerings.md#️-pitfall-one-plan--one-billing-type-per-presentation). +4. The plan actually carries the monthly commitment type in the Console / dynamic offering. + +## Related + +- [Dynamic offerings](dynamic-offerings.md) — how the `billingPlanType` is supplied at runtime, and the same-plan pitfall +- [Common issues](../troubleshooting/common-issues.md) — diagnostic entry for wrong / missing billing type +- [Programmatic purchases](programmatic-purchases.md) — app-side purchase APIs (Observer mode) diff --git a/purchasely/references/troubleshooting/common-issues.md b/purchasely/references/troubleshooting/common-issues.md index 6af9c8c..6416733 100644 --- a/purchasely/references/troubleshooting/common-issues.md +++ b/purchasely/references/troubleshooting/common-issues.md @@ -83,6 +83,7 @@ If any of those three is missing, you have a defined symptom — see the table b | Follow-up placement returns a presentation, but renders "the previous paywall again" | The Flow hosting the original placement chains a post-purchase step that points to the wrong paywall | The event's `flow_id` and `displayed_presentation` reveal the chained step. Dashboard → Flows → inspect `` post-purchase branches | | `IN_APP_RESTORED` but premium UI doesn't update | `userSubscriptions(...)` not called after the purchase completes, or callback not wired to your premium state | Check your post-purchase refresh path | | `is_fallback_presentation: true` on `PRESENTATION_LOADED` | Audience targeting failed, SDK served the default — usually a stale presentation cache | Trigger an attribute change → invalidate cache. Or call `PresentationCache.shared.invalidateAll()` explicitly (iOS) | +| Interceptor `billingPlanType` is `.unspecified` when you expected `.monthly` (iOS commitment) | US/Singapore storefront (auto-fallback), iOS < 26.4, plan not configured monthly, **or** several dynamic offerings mapping the same plan with different billing types | See §12. Check storefront + iOS version first; then whether the same plan is mapped by multiple offering references | ### Reading event property bags @@ -507,3 +508,18 @@ PLYPresentationAction.CLOSE -> { **Why clients don't hit this in prod:** most customer paywalls created via the Screen Composer default to `.closeAll` on their dismiss button, because that matches the "exit paywall" user intent. The bug surfaces on legacy or hand-configured paywalls that use `.close` on a single-step flow. **Related defensive work:** see Purchasely-iOS-Sources PR #563 which adds SDK-level safeguards (`closeFlow()` called when no visible content remains) so misconfigured paywalls degrade gracefully instead of freezing. + +## 12. Dynamic Offering Billing Type Resolves to `.unspecified` (iOS Commitment) + +**Symptoms:** On iOS, a 12-month **monthly-commitment** plan is expected but the purchase interceptor reports `parameters.billingPlanType == .unspecified` (or the purchase runs up-front). Often reported as "I set `billingPlanType: .monthly` on the dynamic offering but the interceptor says `unspecified`." + +**Check in order:** + +1. **Storefront** — monthly commitment is not offered in the **US** or **Singapore** App Stores; the SDK falls back to up-front there. Test on another storefront (sandbox / TestFlight account). +2. **iOS version** — the feature requires **iOS 26.4+** and **SDK v6+**. +3. **Plan configuration** — the plan must carry the monthly commitment billing type (Screen Composer, or the dynamic offering's `billingPlanType`). +4. **Same plan mapped by multiple offering references (most common when it "randomly" fails).** If you register more than one `setDynamicOffering` reference that resolves to the **same plan** in the same presentation, with **different** billing types (e.g. one `.monthly` and one `.upFront`), the plan appears more than once with conflicting billing types and the SDK can no longer pick the right one — it resolves to `.unspecified`. + +**Fix:** map a given plan to a **single** billing plan type per presentation. If you need both up-front and monthly-commitment variants on screen, back them with **two distinct plans/products**. Call `Purchasely.clearDynamicOfferings()` before re-registering so a leftover offering from a previous screen/session doesn't add a second mapping for the same plan. Register offerings **before** fetching/displaying the placement (they are applied server-side at fetch). + +See [dynamic-offerings.md](../concepts/dynamic-offerings.md) and [monthly-commitment.md](../concepts/monthly-commitment.md). diff --git a/purchasely/skills/purchasely-sdk-expert/SKILL.md b/purchasely/skills/purchasely-sdk-expert/SKILL.md index b1fa3a2..587954a 100644 --- a/purchasely/skills/purchasely-sdk-expert/SKILL.md +++ b/purchasely/skills/purchasely-sdk-expert/SKILL.md @@ -73,6 +73,8 @@ Load as needed: - `../../references/concepts/subscription-checks.md` — premium gating / restore - `../../references/concepts/subscription-management.md` — native subscription management pages - `../../references/concepts/promotional-offers.md` — Apple promos, Google offers, offer codes +- `../../references/concepts/dynamic-offerings.md` — `setDynamicOffering` runtime plan/offer overrides (server-side at fetch); same-plan billing-type pitfall +- `../../references/concepts/monthly-commitment.md` — Apple advance commitment (12-month billed monthly), `PLYBillingPlanType`, iOS 26.4+ eligibility (excl. US/SG) - `../../references/concepts/campaigns.md` — trigger / placement campaigns - `../../references/concepts/byos.md` — Bring Your Own Screen, iOS/Android only - `../../references/concepts/lottie-animations.md` — Lottie weak dependency bridge @@ -157,6 +159,14 @@ For Lottie / animation questions, load `../../references/concepts/lottie-animati - Android requires `PLYLottieInterface` and `Purchasely.lottieView`. - Cross-platform apps configure their underlying native host projects. +### Dynamic offerings & commitment billing + +For `setDynamicOffering` / runtime plan overrides, load `../../references/concepts/dynamic-offerings.md` first; for 12-month commitment billed monthly, also load `../../references/concepts/monthly-commitment.md`. + +- Dynamic offerings are applied **server-side at fetch** — register them **before** fetching/displaying the placement; they persist until removed. +- `billingPlanType` on `setDynamicOffering` is **iOS-only**; monthly commitment needs iOS 26.4+, SDK v6+, and a non-US/non-Singapore storefront (US/SG auto-fall back to up-front). +- Pitfall: mapping the **same plan** to multiple offering references with **different** billing types in one presentation makes the billing type ambiguous → resolves to `.unspecified`. One plan → one billing type per presentation. + ## Inline expert checkpoint Use this checklist when another Purchasely workflow asks for expert validation and no Claude Code subagent is available: From 7c4772f194aa8f444eb04745c03ad341595849b4 Mon Sep 17 00:00:00 2001 From: Kevin Date: Thu, 23 Jul 2026 11:03:30 +0200 Subject: [PATCH 2/2] docs: address review feedback (anchor slug, typo, changelog) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Removed the emoji/variation-selector from the "Pitfall" heading in dynamic-offerings.md and updated the cross-file anchor link in monthly-commitment.md (old slug #️-pitfall-... was broken by the leading invisible character; new slug is #pitfall-one-plan--one-billing-type-per-presentation). - Fixed grammar typo in purchasely-sdk-expert/SKILL.md: "auto-fall back" -> "auto-falls back". - Added the two new concept references (dynamic-offerings.md, monthly-commitment.md) to CHANGELOG.md under [Unreleased] > Added. Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 5 +++++ purchasely/references/concepts/dynamic-offerings.md | 2 +- purchasely/references/concepts/monthly-commitment.md | 2 +- purchasely/skills/purchasely-sdk-expert/SKILL.md | 2 +- 4 files changed, 8 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 155957c..b731d4c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,11 @@ All notable changes to this project are documented here. The format is based on ## [Unreleased] +### Added + +- `references/concepts/dynamic-offerings.md` — covers `setDynamicOffering` across platforms, server-side application at fetch time, one-plan-one-billing-type pitfall. +- `references/concepts/monthly-commitment.md` — covers Apple 12-month advance commitment (iOS 26.4+), `PLYBillingPlanType`, eligibility rules. + ## [2.0.0-rc.6] — 2026-07-07 Cordova joins the **v6 line**. The plugin now treats native iOS, native Android, Flutter, React Native, and Cordova as SDK v6 platforms. Cordova guidance targets **`@purchasely/cordova-plugin-purchasely` `6.0.0-rc.1`**, which pulls the **`6.0.0-rc.2`** native SDKs. diff --git a/purchasely/references/concepts/dynamic-offerings.md b/purchasely/references/concepts/dynamic-offerings.md index 9b313df..11137c1 100644 --- a/purchasely/references/concepts/dynamic-offerings.md +++ b/purchasely/references/concepts/dynamic-offerings.md @@ -61,7 +61,7 @@ final ok = await Purchasely.setDynamicOffering( > **Note:** the `billingPlanType` argument is **iOS-only** at this time. Android, React Native, Flutter, and Cordova take `reference` / `planVendorId` / `offerVendorId` only. -## ⚠️ Pitfall: one plan → one billing type per presentation +## Pitfall: one plan → one billing type per presentation Do **not** register several offering references that all resolve to the **same plan** within a single presentation while carrying **different billing plan types** (for example one `.monthly` and one `.upFront` on the same plan). diff --git a/purchasely/references/concepts/monthly-commitment.md b/purchasely/references/concepts/monthly-commitment.md index 30d61ee..5da8600 100644 --- a/purchasely/references/concepts/monthly-commitment.md +++ b/purchasely/references/concepts/monthly-commitment.md @@ -40,7 +40,7 @@ Check, in order: 1. **Storefront** is not US / Singapore (those fall back to `.upFront`, and if the plan isn't set up as up-front either, you'll see `.unspecified`). 2. **iOS version** is 26.4+. -3. You did **not** map the **same plan to multiple offering references with different billing types** in one presentation — that ambiguity resolves to `.unspecified`. See the pitfall in [Dynamic offerings](dynamic-offerings.md#️-pitfall-one-plan--one-billing-type-per-presentation). +3. You did **not** map the **same plan to multiple offering references with different billing types** in one presentation — that ambiguity resolves to `.unspecified`. See the pitfall in [Dynamic offerings](dynamic-offerings.md#pitfall-one-plan--one-billing-type-per-presentation). 4. The plan actually carries the monthly commitment type in the Console / dynamic offering. ## Related diff --git a/purchasely/skills/purchasely-sdk-expert/SKILL.md b/purchasely/skills/purchasely-sdk-expert/SKILL.md index 587954a..f4737b1 100644 --- a/purchasely/skills/purchasely-sdk-expert/SKILL.md +++ b/purchasely/skills/purchasely-sdk-expert/SKILL.md @@ -164,7 +164,7 @@ For Lottie / animation questions, load `../../references/concepts/lottie-animati For `setDynamicOffering` / runtime plan overrides, load `../../references/concepts/dynamic-offerings.md` first; for 12-month commitment billed monthly, also load `../../references/concepts/monthly-commitment.md`. - Dynamic offerings are applied **server-side at fetch** — register them **before** fetching/displaying the placement; they persist until removed. -- `billingPlanType` on `setDynamicOffering` is **iOS-only**; monthly commitment needs iOS 26.4+, SDK v6+, and a non-US/non-Singapore storefront (US/SG auto-fall back to up-front). +- `billingPlanType` on `setDynamicOffering` is **iOS-only**; monthly commitment needs iOS 26.4+, SDK v6+, and a non-US/non-Singapore storefront (US/SG auto-falls back to up-front). - Pitfall: mapping the **same plan** to multiple offering references with **different** billing types in one presentation makes the billing type ambiguous → resolves to `.unspecified`. One plan → one billing type per presentation. ## Inline expert checkpoint