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
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,14 @@
# Changelog
All notable changes to this package will be documented in this file.

## [0.5.0]

### Enhancements
* Custom products now work with a purchase controller on iOS and Android. A custom product is one whose store is set to Custom on the Superwall dashboard, sold through your own payment system instead of the App Store or Google Play. They now reach a new `IPurchaseController.PurchaseCustomProduct(CustomProductPurchase purchase, completion)` method with the product's ID, the `CustomTransactionId` Superwall generated for the purchase (send it back from your server as `custom_transaction_id` to attribute the revenue), and on Android any subscription it replaces. The method has a default implementation that fails the purchase, so existing controllers compile unchanged.

### Fixes
* iOS sent custom products to `PurchaseFromAppStore` as if they were App Store products, so a controller tried to buy them from StoreKit. Android failed them without reaching C#. Both now call `PurchaseCustomProduct`.

## [0.4.1]

### Enhancements
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1235,31 +1235,27 @@ class SuperwallUnityBridge {
offerId: String?,
replacement: ProductReplacement?
): PurchaseResult {
// Custom (non-Play) products have no ProductDetails. The C# controller only
// purchases Play products, so fail as the SDK's default implementation does.
val productDetails = product.rawStoreProduct?.underlyingProductDetails
?: return PurchaseResult.Failed(
"The Unity purchase controller does not handle custom products " +
"(${product.fullIdentifier})."
val callbackId = java.util.UUID.randomUUID().toString()
// Custom products (store "Custom" on the dashboard) have no ProductDetails: C# buys
// them through its own payment system with PurchaseCustomProduct.
val (method, request) = if (product.isCustomProduct) {
"purchaseCustomProduct" to customProductPurchaseRequest(
callbackId, product.fullIdentifier, product.customTransactionId, replacement
)
} else {
val productDetails = product.rawStoreProduct?.underlyingProductDetails
?: return PurchaseResult.Failed(
"No Google Play product details for ${product.fullIdentifier}."
)
"purchaseFromGooglePlay" to googlePlayPurchaseRequest(
callbackId, productDetails.productId, basePlanId, offerId, replacement
)
}

val callbackId = java.util.UUID.randomUUID().toString()
val deferred = CompletableDeferred<PurchaseResult>()
pendingPurchases[callbackId] = deferred

val sent = sendToUnity("purchaseFromGooglePlay", JSONObject().apply {
put("productId", productDetails.productId)
put("basePlanId", basePlanId ?: "")
put("offerId", offerId ?: "")
put("callbackId", callbackId)
if (replacement != null) {
put("replacement", JSONObject().apply {
put("mode", replacement.mode.rawName)
put("oldProductId", replacement.oldProductId ?: JSONObject.NULL)
put("oldPurchaseToken", replacement.oldPurchaseToken ?: JSONObject.NULL)
})
}
})
val sent = sendToUnity(method, request)
if (!sent) {
// C# never got the request, so no reply will ever come.
pendingPurchases.remove(callbackId)
Expand Down Expand Up @@ -1340,3 +1336,40 @@ class SuperwallUnityBridge {
}
}
}

/** The `purchaseFromGooglePlay` request BridgeCallbackHandler.HandlePurchaseFromGooglePlay reads. */
internal fun googlePlayPurchaseRequest(
callbackId: String,
productId: String,
basePlanId: String?,
offerId: String?,
replacement: ProductReplacement?
): JSONObject = JSONObject().apply {
put("productId", productId)
put("basePlanId", basePlanId ?: "")
put("offerId", offerId ?: "")
put("callbackId", callbackId)
putReplacement(replacement)
}

/** The `purchaseCustomProduct` request BridgeCallbackHandler.HandlePurchaseCustomProduct reads. */
internal fun customProductPurchaseRequest(
callbackId: String,
productId: String,
customTransactionId: String?,
replacement: ProductReplacement?
): JSONObject = JSONObject().apply {
put("productId", productId)
put("customTransactionId", customTransactionId ?: JSONObject.NULL)
put("callbackId", callbackId)
putReplacement(replacement)
}

private fun JSONObject.putReplacement(replacement: ProductReplacement?) {
if (replacement == null) return
put("replacement", JSONObject().apply {
put("mode", replacement.mode.rawName)
put("oldProductId", replacement.oldProductId ?: JSONObject.NULL)
put("oldPurchaseToken", replacement.oldPurchaseToken ?: JSONObject.NULL)
})
}
32 changes: 28 additions & 4 deletions Plugins/iOS/SuperwallUnityBridge.swift
Original file line number Diff line number Diff line change
Expand Up @@ -564,6 +564,28 @@ private class SuperwallUnityDelegate: SuperwallDelegate {

// MARK: - Unity Purchase Controller

/// The C# purchase controller method a purchase goes to, and the request BridgeCallbackHandler
/// reads. `StoreProduct.isCustomProduct` isn't public, but SuperwallKit sets `customTransactionId`
/// before it calls the purchase controller for a custom product (store "Custom" on the
/// dashboard), and only for those, so it's what tells the two apart.
func purchaseRequest(
productIdentifier: String,
customTransactionId: String?,
callbackId: String
) -> (method: String, data: [String: Any]) {
if let customTransactionId {
return ("purchaseCustomProduct", [
"productId": productIdentifier,
"customTransactionId": customTransactionId,
"callbackId": callbackId
])
}
return ("purchaseFromAppStore", [
"productId": productIdentifier,
"callbackId": callbackId
])
}

private class UnityPurchaseController: PurchaseController {
private var pendingPurchaseContinuations: [String: CheckedContinuation<PurchaseResult, Never>] = [:]
private var pendingRestoreContinuations: [String: CheckedContinuation<RestorationResult, Never>] = [:]
Expand All @@ -577,10 +599,12 @@ private class UnityPurchaseController: PurchaseController {
pendingPurchaseContinuations[callbackId] = continuation
lock.unlock()

sendToUnity(method: "purchaseFromAppStore", data: [
"productId": product.productIdentifier,
"callbackId": callbackId
])
let request = purchaseRequest(
productIdentifier: product.productIdentifier,
customTransactionId: product.customTransactionId,
callbackId: callbackId
)
sendToUnity(method: request.method, data: request.data)
}
}

Expand Down
34 changes: 34 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -236,6 +236,40 @@ public void PurchaseFromGooglePlay(string productId, string basePlanId, string o
`replacement` is null for normal purchases. Implementing the overload is optional: by default it
calls the four-argument `PurchaseFromGooglePlay` and ignores the replacement.

### Custom products

A custom product is one whose store is set to **Custom** on the Superwall dashboard: you sell it
through your own payment system (Stripe, Paddle, a web checkout or another store) rather than the
App Store or Google Play. On both platforms Superwall sends these to `PurchaseCustomProduct`
instead of `PurchaseFromAppStore` or `PurchaseFromGooglePlay`:

```csharp
public void PurchaseCustomProduct(CustomProductPurchase purchase, Action<PurchaseResult> completion)
{
// Start your checkout for purchase.ProductId. Pass purchase.CustomTransactionId along and have
// your server report it to Superwall as custom_transaction_id, so the revenue is attributed
// to the paywall that sold it.
MyCheckout.Buy(purchase.ProductId, purchase.CustomTransactionId, succeeded =>
{
if (succeeded)
{
// Superwall doesn't grant entitlements for custom products: set them yourself.
Superwall.Shared.SubscriptionStatus = SubscriptionStatus.CreateActive(myEntitlements);
completion(PurchaseResult.Purchased());
}
else
{
completion(PurchaseResult.Cancelled());
}
});
}
```

On Android, `purchase.Replacement` is set when the purchase replaces a subscription the user has.
For custom products its `OldProductId` comes from the active entitlements you reported, and
`OldPurchaseToken` is null. Implementing `PurchaseCustomProduct` is optional: by default it fails
the purchase.

## User Identity

```csharp
Expand Down
13 changes: 13 additions & 0 deletions Runtime/IPurchaseController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,19 @@ void PurchaseFromGooglePlay(string productId, string basePlanId, string offerId,
ProductReplacement replacement, Action<PurchaseResult> completion)
=> PurchaseFromGooglePlay(productId, basePlanId, offerId, completion);

/// <summary>
/// Called on iOS and Android when the user buys a custom product: one whose store is set to
/// Custom on the Superwall dashboard. Buy it through your own payment system, then complete
/// with the result. Superwall doesn't grant entitlements for custom products, so after a
/// successful purchase set <c>Superwall.Shared.SubscriptionStatus</c> yourself.
/// The default implementation fails the purchase, so controllers that don't sell custom
/// products need no changes.
/// </summary>
void PurchaseCustomProduct(CustomProductPurchase purchase, Action<PurchaseResult> completion)
=> completion(PurchaseResult.Failed(
"This purchase controller does not handle custom products. Implement " +
$"IPurchaseController.PurchaseCustomProduct to purchase {purchase?.ProductId}."));

void RestorePurchases(Action<RestorationResult> completion);
}
}
46 changes: 37 additions & 9 deletions Runtime/Internal/BridgeCallbackHandler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,9 @@ private void RouteCallback(string method, Dictionary<string, object> data)
case "purchaseFromGooglePlay":
HandlePurchaseFromGooglePlay(data);
break;
case "purchaseCustomProduct":
HandlePurchaseCustomProduct(data);
break;
case "restorePurchases":
HandleRestorePurchases(data);
break;
Expand Down Expand Up @@ -1002,15 +1005,7 @@ private void HandlePurchaseFromGooglePlay(Dictionary<string, object> data)
var callbackId = GetString(data, "callbackId");
if (!IsPurchaseRequestPending(callbackId)) return;

ProductReplacement replacement = null;
var replacementData = GetDict(data, "replacement");
if (replacementData != null)
{
replacement = new ProductReplacement(
ProductReplacement.ParseMode(GetString(replacementData, "mode")),
GetString(replacementData, "oldProductId"),
GetString(replacementData, "oldPurchaseToken"));
}
var replacement = DeserializeReplacement(GetDict(data, "replacement"));

var reply = CreatePurchaseReply(callbackId);
try
Expand All @@ -1024,6 +1019,39 @@ private void HandlePurchaseFromGooglePlay(Dictionary<string, object> data)
}
}

private void HandlePurchaseCustomProduct(Dictionary<string, object> data)
{
if (PurchaseController == null) return;

var callbackId = GetString(data, "callbackId");
if (!IsPurchaseRequestPending(callbackId)) return;

var purchase = new CustomProductPurchase(
GetString(data, "productId"),
GetString(data, "customTransactionId"),
DeserializeReplacement(GetDict(data, "replacement")));

var reply = CreatePurchaseReply(callbackId);
try
{
PurchaseController.PurchaseCustomProduct(purchase, reply);
}
catch (Exception e)
{
Debug.LogError($"[Superwall] PurchaseCustomProduct threw: {e.Message}\n{e.StackTrace}");
reply(PurchaseResult.Failed(e.Message));
}
}

private static ProductReplacement DeserializeReplacement(Dictionary<string, object> data)
{
if (data == null) return null;
return new ProductReplacement(
ProductReplacement.ParseMode(GetString(data, "mode")),
GetString(data, "oldProductId"),
GetString(data, "oldPurchaseToken"));
}

private void HandleRestorePurchases(Dictionary<string, object> data)
{
if (PurchaseController == null) return;
Expand Down
40 changes: 40 additions & 0 deletions Runtime/Models/CustomProductPurchase.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
using System;

namespace Superwall
{
/// <summary>
/// A purchase of a custom product: a product whose store is set to Custom on the Superwall
/// dashboard, sold through your own payment system (Stripe, Paddle, a web checkout or another
/// store) instead of the App Store or Google Play.
/// Passed to <see cref="IPurchaseController.PurchaseCustomProduct"/>.
/// </summary>
[Serializable]
public class CustomProductPurchase
{
/// <summary>The product's identifier on the Superwall dashboard.</summary>
public string ProductId;

/// <summary>
/// The transaction ID Superwall generated for this purchase. Pass it to your checkout, for
/// example as metadata, and have your server send it back as <c>custom_transaction_id</c>
/// when it reports the purchase to Superwall, so the revenue is attributed to the paywall
/// that sold it. It is also the purchase's original transaction ID in Superwall's events.
/// </summary>
public string CustomTransactionId;

/// <summary>
/// Android only: set when the paywall's Purchase action replaces an existing subscription
/// that shares an entitlement with this product. For custom products,
/// <see cref="ProductReplacement.OldProductId"/> comes from the active entitlements you
/// reported and <see cref="ProductReplacement.OldPurchaseToken"/> is null. Always null on iOS.
/// </summary>
public ProductReplacement Replacement;

public CustomProductPurchase(string productId, string customTransactionId, ProductReplacement replacement = null)
{
ProductId = productId;
CustomTransactionId = customTransactionId;
Replacement = replacement;
}
}
}
2 changes: 2 additions & 0 deletions Runtime/Models/CustomProductPurchase.cs.meta

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading