Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
4 changes: 4 additions & 0 deletions purchasely/references/concepts/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Comment thread
kherembourg marked this conversation as resolved.
| [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 |

Expand All @@ -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) |
Expand Down
83 changes: 83 additions & 0 deletions purchasely/references/concepts/dynamic-offerings.md
Original file line number Diff line number Diff line change
@@ -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(...)`.
Comment thread
kherembourg marked this conversation as resolved.

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
50 changes: 50 additions & 0 deletions purchasely/references/concepts/monthly-commitment.md
Original file line number Diff line number Diff line change
@@ -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.
Comment thread
Copilot marked this conversation as resolved.

## 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)
16 changes: 16 additions & 0 deletions purchasely/references/troubleshooting/common-issues.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<flow_id>` 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

Expand Down Expand Up @@ -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).
10 changes: 10 additions & 0 deletions purchasely/skills/purchasely-sdk-expert/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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-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

Use this checklist when another Purchasely workflow asks for expert validation and no Claude Code subagent is available:
Expand Down
Loading