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
37 changes: 37 additions & 0 deletions MIGRATION-v6.md
Original file line number Diff line number Diff line change
Expand Up @@ -495,6 +495,43 @@ PLYPresentationView(request: request);

---

## Apple commitment plans (iOS 26.4+) — new in v6

v6 surfaces Apple's "monthly subscription with N-month commitment" (installment)
billing. **This is Apple-only**: on Android and other platforms the fields below
are always empty / null, so guard on them before use.

- `PLYPlan.commitmentInfo` — `List<PLYCommitmentInfo>` (empty when the plan has
no commitment). Populated wherever a plan is exposed: `allProducts`,
`planWithIdentifier`, the `purchase` interceptor payload, and the presentation
outcome plan. Each `PLYCommitmentInfo` carries:
`billingPlanType` (`PLYBillingPlanType`: `unspecified` / `upFront` / `monthly`),
`billingPrice` (`double?`), `billingPeriod` (ISO 8601 duration, e.g. `"P1M"`),
`totalPrice` (`double?`), `totalPeriod` (e.g. `"P1Y"`), `totalDuration` (`int?`,
number of billing cycles).
- `PLYSubscription.commitmentProgress` — `PLYCommitmentProgress?` on
`userSubscriptions` / `userSubscriptionsHistory` results:
`billingPeriodNumber` (`int?`), `totalBillingPeriods` (`int?`),
`commitmentExpiresDate` (ISO 8601 `String?`), `commitmentPrice` (`double?`).
- `PLYDynamicOffering` gains an optional `billingPlanType`
(`PLYBillingPlanType`, defaults to `unspecified`) to force a commitment plan
type when calling `setDynamicOffering`.

```dart
final plan = await Purchasely.planWithIdentifier('my_plan');
for (final c in plan?.commitmentInfo ?? const []) {
print('${c.billingPlanType}: ${c.billingPrice} every ${c.billingPeriod}, '
'total ${c.totalPrice} over ${c.totalDuration} cycles');
}

// Force the monthly-commitment variant of a plan in a placement:
await Purchasely.setDynamicOffering(
PLYDynamicOffering('ref', 'my_plan', null, PLYBillingPlanType.monthly),
);
```

---

## What's unchanged

Only the **paywall surface** (start, display / preload / close / back, and the
Expand Down
35 changes: 34 additions & 1 deletion purchasely/ios/purchasely_flutter/Classes/PLYPlan+ToMap.swift
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,40 @@ extension PLYPlan {
if let introPeriod = self.localizedIntroductoryPeriod(language: nil) {
result["introPeriod"] = introPeriod
}


// Apple commitment installment details (iOS 26.4+ "monthly subscription
// with N-month commitment"). Empty on non-Apple stores / older iOS.
if !self.commitmentInfo.isEmpty {
result["commitmentInfo"] = self.commitmentInfoMaps
}

return result
}

/// Serializes `commitmentInfo` into the same wire shape the Dart
/// `plyCommitmentInfoFromMap` parses. Reused by the interceptor payload.
var commitmentInfoMaps: [[String: Any]] {
self.commitmentInfo.map { info in
[
"billingPlanType": info.billingPlanType.wireValue,
"billingPrice": info.billingPrice.doubleValue,
"billingPeriod": info.billingPeriod,
"totalPrice": info.totalPrice.doubleValue,
"totalPeriod": info.totalPeriod,
"totalDuration": info.totalDuration,
]
}
}
}

extension PLYBillingPlanType {
/// Wire value sent to the Dart bridge, matching the native SDK's own
/// string form (`"up_front"` / `"monthly"`).
var wireValue: String {
switch self {
case .upFront: return "up_front"
case .monthly: return "monthly"
default: return "unspecified"
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,17 @@ extension PLYSubscription {
if let date = cancelledDate {
result["cancelledDate"] = dateFormat.string(from:date)
}


// Apple monthly-commitment progress (iOS 26.4+). Nil on non-Apple / older iOS.
if let progress = commitmentProgress {
result["commitmentProgress"] = [
"billingPeriodNumber": progress.billingPeriodNumber,
"totalBillingPeriods": progress.totalBillingPeriods,
"commitmentExpiresDate": dateFormat.string(from: progress.commitmentExpiresDate),
"commitmentPrice": progress.commitmentPrice.doubleValue,
]
}

return result
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -746,10 +746,16 @@ public class SwiftPurchaselyFlutterPlugin: NSObject, FlutterPlugin {
if let url = params.url?.absoluteString { map["url"] = url }
if let title = params.title { map["title"] = title }
if let plan = params.plan {
map["plan"] = [
var planMap: [String: Any] = [
"vendorId": plan.vendorId as Any,
"productId": plan.appleProductId as Any?,
]
// Apple commitment installment details (iOS 26.4+), same wire shape
// as PLYPlan.toMap so the Dart payload parses a fully-typed plan.
if !plan.commitmentInfo.isEmpty {
planMap["commitmentInfo"] = plan.commitmentInfoMaps
}
map["plan"] = planMap
}
// Promotional offer attached to the tapped plan (`offer` on the wire,
// matching Android's shape). iOS has no `subscriptionOffer` equivalent —
Expand Down Expand Up @@ -1353,14 +1359,25 @@ public class SwiftPurchaselyFlutterPlugin: NSObject, FlutterPlugin {
}

let offerVendorId = arguments["offerVendorId"] as? String
let billingPlanType = Self.billingPlanType(fromWire: arguments["billingPlanType"] as? String)

DispatchQueue.main.async {
Purchasely.setDynamicOffering(reference: reference, planVendorId: planVendorId, offerVendorId: offerVendorId, completion: { success in
Purchasely.setDynamicOffering(reference: reference, planVendorId: planVendorId, offerVendorId: offerVendorId, billingPlanType: billingPlanType, completion: { success in
result(success)
})
}
}

/// Maps the Dart `billingPlanType` wire string to the native enum. Unknown
/// / nil (Apple-only feature) falls back to `.unspecified`.
private static func billingPlanType(fromWire wire: String?) -> PLYBillingPlanType {
switch wire {
case "up_front": return .upFront
case "monthly": return .monthly
default: return .unspecified
}
}

private func getDynamicOfferings(result: @escaping FlutterResult) {
DispatchQueue.main.async {
Purchasely.getDynamicOfferings { offerings in
Expand All @@ -1372,6 +1389,7 @@ public class SwiftPurchaselyFlutterPlugin: NSObject, FlutterPlugin {

map["reference"] = offering.reference
map["planVendorId"] = offering.planId
map["billingPlanType"] = offering.billingPlanType.wireValue

if let offerId = offering.offerId {
map["offerVendorId"] = offerId
Expand Down
29 changes: 23 additions & 6 deletions purchasely/lib/purchasely_flutter.dart
Original file line number Diff line number Diff line change
Expand Up @@ -373,7 +373,9 @@ class Purchasely {
null,
null,
null,
null));
null)
..commitmentProgress =
plyCommitmentProgressFromMap(element['commitmentProgress']));
});
return subscriptions;
}
Expand Down Expand Up @@ -413,7 +415,8 @@ class Purchasely {
element['subscriptionDurationInDays'],
element['subscriptionDurationInWeeks'],
element['subscriptionDurationInMonths'],
));
)..commitmentProgress =
plyCommitmentProgressFromMap(element['commitmentProgress']));
});
return subscriptions;
}
Expand Down Expand Up @@ -712,7 +715,8 @@ class Purchasely {
return await _channel.invokeMethod('setDynamicOffering', <String, dynamic>{
'reference': offering.reference,
'planVendorId': offering.planVendorId,
'offerVendorId': offering.offerVendorId
'offerVendorId': offering.offerVendorId,
'billingPlanType': offering.billingPlanType.wire
});
}

Expand Down Expand Up @@ -775,7 +779,10 @@ class Purchasely {
}

dynamicOfferings.add(PLYDynamicOffering(
reference, planVendorId, offering['offerVendorId']));
reference,
planVendorId,
offering['offerVendorId'],
plyBillingPlanTypeFromWire(offering['billingPlanType'])));
});
return dynamicOfferings;
}
Expand Down Expand Up @@ -1081,6 +1088,10 @@ class PLYSubscription {
int? subscriptionDurationInWeeks = null;
int? subscriptionDurationInMonths = null;

/// Apple monthly-commitment progress (iOS 26.4+). Null on Android and other
/// platforms — Apple-only.
PLYCommitmentProgress? commitmentProgress;

PLYSubscription(
this.purchaseToken,
this.subscriptionSource,
Expand Down Expand Up @@ -1238,16 +1249,22 @@ class PLYDynamicOffering {
String planVendorId;
String? offerVendorId;

PLYDynamicOffering(this.reference, this.planVendorId, this.offerVendorId);
/// Apple billing plan type to force for this offering (iOS 26.4+). Ignored on
/// Android and other platforms. Defaults to [PLYBillingPlanType.unspecified].
PLYBillingPlanType billingPlanType;

PLYDynamicOffering(this.reference, this.planVendorId, this.offerVendorId,
[this.billingPlanType = PLYBillingPlanType.unspecified]);

Map<String, dynamic> toJson() => {
'reference': reference,
'planVendorId': planVendorId,
'offerVendorId': offerVendorId,
'billingPlanType': billingPlanType.wire,
};

@override
String toString() {
return 'PLYDynamicOffering(reference: $reference, planVendorId: $planVendorId, offerVendorId: $offerVendorId)';
return 'PLYDynamicOffering(reference: $reference, planVendorId: $planVendorId, offerVendorId: $offerVendorId, billingPlanType: $billingPlanType)';
}
}
92 changes: 92 additions & 0 deletions purchasely/lib/src/ply_models.dart
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,10 @@ class PLYPlan {
String? offerDuration;
String? offerPeriod;

/// Apple commitment installment details (iOS 26.4+ "monthly subscription with
/// N-month commitment"). Empty on Android and other platforms — Apple-only.
List<PLYCommitmentInfo> commitmentInfo = [];

PLYPlan(
this.vendorId,
this.productId,
Expand Down Expand Up @@ -108,3 +112,91 @@ class PLYSubscriptionOffer {
PLYSubscriptionOffer(
this.subscriptionId, this.basePlanId, this.offerToken, this.offerId);
}

/// Apple billing plan type for a commitment (iOS 26.4+). Apple-only; other
/// platforms always report [PLYBillingPlanType.unspecified].
enum PLYBillingPlanType { unspecified, upFront, monthly }

extension PLYBillingPlanTypeWire on PLYBillingPlanType {
Comment thread
kherembourg marked this conversation as resolved.
/// Wire value sent to the native bridge, matching the iOS SDK's wire form.
String get wire {
switch (this) {
case PLYBillingPlanType.upFront:
return 'up_front';
case PLYBillingPlanType.monthly:
return 'monthly';
case PLYBillingPlanType.unspecified:
return 'unspecified';
}
}
}

/// Commitment installment details for an Apple "monthly subscription with
/// N-month commitment" plan (iOS 26.4+). Apple-only.
class PLYCommitmentInfo {
final PLYBillingPlanType billingPlanType;

/// Per-billing-cycle price (e.g. 9.99 for a monthly-billed plan).
final double? billingPrice;

/// ISO 8601 duration of each billing cycle, e.g. "P1M".
final String? billingPeriod;

/// Total price over the full commitment (e.g. 119.88 for 12 × 9.99).
final double? totalPrice;

/// ISO 8601 duration of the full commitment, e.g. "P1Y".
final String? totalPeriod;

/// Number of billing cycles in the commitment (1 for up-front, 12 for a
/// 12-month monthly commitment).
final int? totalDuration;

PLYCommitmentInfo({
required this.billingPlanType,
this.billingPrice,
this.billingPeriod,
this.totalPrice,
this.totalPeriod,
this.totalDuration,
});

@override
String toString() => 'PLYCommitmentInfo('
'billingPlanType: $billingPlanType, '
'billingPrice: $billingPrice, '
'billingPeriod: $billingPeriod, '
'totalPrice: $totalPrice, '
'totalPeriod: $totalPeriod, '
'totalDuration: $totalDuration)';
}

/// A subscriber's progress through an Apple monthly-commitment plan
/// (iOS 26.4+). Null on Android and other platforms — Apple-only.
class PLYCommitmentProgress {
/// The current billing period number within the commitment (1-based).
final int? billingPeriodNumber;

/// The total number of billing periods in the commitment.
final int? totalBillingPeriods;

/// ISO 8601 date at which the commitment expires.
final String? commitmentExpiresDate;

/// The price charged for this billing period.
final double? commitmentPrice;

PLYCommitmentProgress({
this.billingPeriodNumber,
this.totalBillingPeriods,
this.commitmentExpiresDate,
this.commitmentPrice,
});

@override
String toString() => 'PLYCommitmentProgress('
'billingPeriodNumber: $billingPeriodNumber, '
'totalBillingPeriods: $totalBillingPeriods, '
'commitmentExpiresDate: $commitmentExpiresDate, '
'commitmentPrice: $commitmentPrice)';
}
Loading