diff --git a/CHANGELOG.md b/CHANGELOG.md index 9beea8e..e3e7481 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/Plugins/Android/SuperwallSDK.androidlib/src/main/kotlin/com/superwall/sdk/unity/SuperwallUnityBridge.kt b/Plugins/Android/SuperwallSDK.androidlib/src/main/kotlin/com/superwall/sdk/unity/SuperwallUnityBridge.kt index 5b438ed..9cb19bd 100644 --- a/Plugins/Android/SuperwallSDK.androidlib/src/main/kotlin/com/superwall/sdk/unity/SuperwallUnityBridge.kt +++ b/Plugins/Android/SuperwallSDK.androidlib/src/main/kotlin/com/superwall/sdk/unity/SuperwallUnityBridge.kt @@ -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() 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) @@ -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) + }) +} diff --git a/Plugins/iOS/SuperwallUnityBridge.swift b/Plugins/iOS/SuperwallUnityBridge.swift index 16889d6..37565e8 100644 --- a/Plugins/iOS/SuperwallUnityBridge.swift +++ b/Plugins/iOS/SuperwallUnityBridge.swift @@ -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] = [:] private var pendingRestoreContinuations: [String: CheckedContinuation] = [:] @@ -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) } } diff --git a/README.md b/README.md index d1e09cd..49c4e98 100644 --- a/README.md +++ b/README.md @@ -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 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 diff --git a/Runtime/IPurchaseController.cs b/Runtime/IPurchaseController.cs index 356e5cb..129a002 100644 --- a/Runtime/IPurchaseController.cs +++ b/Runtime/IPurchaseController.cs @@ -20,6 +20,19 @@ void PurchaseFromGooglePlay(string productId, string basePlanId, string offerId, ProductReplacement replacement, Action completion) => PurchaseFromGooglePlay(productId, basePlanId, offerId, completion); + /// + /// 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 Superwall.Shared.SubscriptionStatus yourself. + /// The default implementation fails the purchase, so controllers that don't sell custom + /// products need no changes. + /// + void PurchaseCustomProduct(CustomProductPurchase purchase, Action completion) + => completion(PurchaseResult.Failed( + "This purchase controller does not handle custom products. Implement " + + $"IPurchaseController.PurchaseCustomProduct to purchase {purchase?.ProductId}.")); + void RestorePurchases(Action completion); } } diff --git a/Runtime/Internal/BridgeCallbackHandler.cs b/Runtime/Internal/BridgeCallbackHandler.cs index 107cb01..8c6ee6f 100644 --- a/Runtime/Internal/BridgeCallbackHandler.cs +++ b/Runtime/Internal/BridgeCallbackHandler.cs @@ -146,6 +146,9 @@ private void RouteCallback(string method, Dictionary data) case "purchaseFromGooglePlay": HandlePurchaseFromGooglePlay(data); break; + case "purchaseCustomProduct": + HandlePurchaseCustomProduct(data); + break; case "restorePurchases": HandleRestorePurchases(data); break; @@ -1002,15 +1005,7 @@ private void HandlePurchaseFromGooglePlay(Dictionary 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 @@ -1024,6 +1019,39 @@ private void HandlePurchaseFromGooglePlay(Dictionary data) } } + private void HandlePurchaseCustomProduct(Dictionary 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 data) + { + if (data == null) return null; + return new ProductReplacement( + ProductReplacement.ParseMode(GetString(data, "mode")), + GetString(data, "oldProductId"), + GetString(data, "oldPurchaseToken")); + } + private void HandleRestorePurchases(Dictionary data) { if (PurchaseController == null) return; diff --git a/Runtime/Models/CustomProductPurchase.cs b/Runtime/Models/CustomProductPurchase.cs new file mode 100644 index 0000000..a1099e9 --- /dev/null +++ b/Runtime/Models/CustomProductPurchase.cs @@ -0,0 +1,40 @@ +using System; + +namespace Superwall +{ + /// + /// 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 . + /// + [Serializable] + public class CustomProductPurchase + { + /// The product's identifier on the Superwall dashboard. + public string ProductId; + + /// + /// 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 custom_transaction_id + /// 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. + /// + public string CustomTransactionId; + + /// + /// Android only: set when the paywall's Purchase action replaces an existing subscription + /// that shares an entitlement with this product. For custom products, + /// comes from the active entitlements you + /// reported and is null. Always null on iOS. + /// + public ProductReplacement Replacement; + + public CustomProductPurchase(string productId, string customTransactionId, ProductReplacement replacement = null) + { + ProductId = productId; + CustomTransactionId = customTransactionId; + Replacement = replacement; + } + } +} diff --git a/Runtime/Models/CustomProductPurchase.cs.meta b/Runtime/Models/CustomProductPurchase.cs.meta new file mode 100644 index 0000000..1d22a23 --- /dev/null +++ b/Runtime/Models/CustomProductPurchase.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 9fb1f1bc2131444e8b220599a58b69ef diff --git a/Tests/Runtime/BridgeContractTests.cs b/Tests/Runtime/BridgeContractTests.cs index f3ee1fb..3498ef0 100644 --- a/Tests/Runtime/BridgeContractTests.cs +++ b/Tests/Runtime/BridgeContractTests.cs @@ -538,6 +538,110 @@ public IEnumerator PurchaseController_ReplacementOverload_ReceivesReplacement() BridgeCallbackHandler.Instance.PurchaseController = null; } + private static string CustomProductPurchaseRequest(string callbackId, bool withReplacement) + { + var data = new Dictionary + { + { "productId", "web_annual" }, + { "customTransactionId", "txn-abc" }, + { "callbackId", callbackId } + }; + if (withReplacement) + { + // Android only. Custom subscriptions have no Play purchase token. + data["replacement"] = new Dictionary + { + { "mode", "default" }, + { "oldProductId", "web_monthly" }, + { "oldPurchaseToken", null } + }; + } + return Json.Serialize(new Dictionary + { + { "method", "purchaseCustomProduct" }, + { "data", data } + }); + } + + [UnityTest] + public IEnumerator PurchaseController_CustomProduct_ReachesPurchaseCustomProduct() + { + BridgeCallbackHandler.Initialize(); + yield return null; + var controller = new MockCustomProductPurchaseController(); + BridgeCallbackHandler.Instance.PurchaseController = controller; + + BridgeCallbackHandler.Instance.OnCallback(CustomProductPurchaseRequest("cb-custom", withReplacement: false)); + + Assert.IsNotNull(controller.Purchase, "Custom product purchase must reach PurchaseCustomProduct"); + Assert.AreEqual("web_annual", controller.Purchase.ProductId); + Assert.AreEqual("txn-abc", controller.Purchase.CustomTransactionId); + Assert.IsNull(controller.Purchase.Replacement); + + LogAssert.Expect(LogType.Log, + new Regex(@"RespondToPurchaseController\(callbackId=cb-custom,.*""type"":""purchased""")); + controller.PendingPurchase(PurchaseResult.Purchased()); + yield return null; + + BridgeCallbackHandler.Instance.PurchaseController = null; + } + + [UnityTest] + public IEnumerator PurchaseController_CustomProduct_ReceivesReplacement() + { + BridgeCallbackHandler.Initialize(); + yield return null; + var controller = new MockCustomProductPurchaseController(); + BridgeCallbackHandler.Instance.PurchaseController = controller; + + BridgeCallbackHandler.Instance.OnCallback(CustomProductPurchaseRequest("cb-custom-replace", withReplacement: true)); + + Assert.IsNotNull(controller.Purchase.Replacement); + Assert.AreEqual(ProductReplacement.ReplacementMode.Default, controller.Purchase.Replacement.Mode); + Assert.AreEqual("web_monthly", controller.Purchase.Replacement.OldProductId); + Assert.IsNull(controller.Purchase.Replacement.OldPurchaseToken); + + controller.PendingPurchase(PurchaseResult.Cancelled()); + yield return null; + BridgeCallbackHandler.Instance.PurchaseController = null; + } + + [UnityTest] + public IEnumerator PurchaseController_WithoutCustomProductSupport_FailsCustomProduct() + { + // Controllers that don't implement PurchaseCustomProduct must answer, not hang the paywall, + // and must not be sent the custom product as a store purchase. + BridgeCallbackHandler.Initialize(); + yield return null; + var controller = new MockPurchaseControllerContract(); + BridgeCallbackHandler.Instance.PurchaseController = controller; + + LogAssert.Expect(LogType.Log, + new Regex(@"RespondToPurchaseController\(callbackId=cb-custom-legacy,.*""type"":""failed"".*PurchaseCustomProduct")); + BridgeCallbackHandler.Instance.OnCallback(CustomProductPurchaseRequest("cb-custom-legacy", withReplacement: false)); + yield return null; + + Assert.IsNull(controller.PendingPurchase, "A custom product must not reach the store purchase methods"); + BridgeCallbackHandler.Instance.PurchaseController = null; + } + + [UnityTest] + public IEnumerator PurchaseController_CustomProductThatThrows_RepliesFailed() + { + BridgeCallbackHandler.Initialize(); + yield return null; + var controller = new MockCustomProductPurchaseController { Throw = true }; + BridgeCallbackHandler.Instance.PurchaseController = controller; + + LogAssert.Expect(LogType.Error, new Regex("PurchaseCustomProduct threw: boom")); + LogAssert.Expect(LogType.Log, + new Regex(@"RespondToPurchaseController\(callbackId=cb-custom-throw,.*""type"":""failed""")); + BridgeCallbackHandler.Instance.OnCallback(CustomProductPurchaseRequest("cb-custom-throw", withReplacement: false)); + yield return null; + + BridgeCallbackHandler.Instance.PurchaseController = null; + } + [UnityTest] public IEnumerator PurchaseController_WithoutReplacementOverload_StillReceivesPurchase() { @@ -702,6 +806,21 @@ public void PurchaseFromGooglePlay(string productId, string basePlanId, string o } } + /// + /// Purchase controller that sells custom products and records what it received. + /// + class MockCustomProductPurchaseController : MockPurchaseControllerContract, IPurchaseController + { + public CustomProductPurchase Purchase; + + public void PurchaseCustomProduct(CustomProductPurchase purchase, System.Action completion) + { + if (Throw) throw new System.InvalidOperationException("boom"); + Purchase = purchase; + PendingPurchase = completion; + } + } + /// /// Local mock that exposes the few hooks our contract tests need without bringing in the /// full surface MockSuperwallDelegate uses elsewhere. diff --git a/ci~/android-harness/bridge-tests/src/test/kotlin/com/superwall/sdk/unity/PurchaseRequestTest.kt b/ci~/android-harness/bridge-tests/src/test/kotlin/com/superwall/sdk/unity/PurchaseRequestTest.kt new file mode 100644 index 0000000..7e9425e --- /dev/null +++ b/ci~/android-harness/bridge-tests/src/test/kotlin/com/superwall/sdk/unity/PurchaseRequestTest.kt @@ -0,0 +1,64 @@ +package com.superwall.sdk.unity + +import com.superwall.sdk.store.ProductReplacement +import org.json.JSONObject +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +// The keys must match what BridgeCallbackHandler reads (Tests/Runtime/BridgeContractTests.cs +// sends the same shapes to the C# side). +class PurchaseRequestTest { + private val upgrade = ProductReplacement( + mode = ProductReplacement.Mode.CHARGE_PRORATED, + oldProductId = "com.app.monthly", + oldPurchaseToken = "token-123", + ) + + @Test + fun `custom product request carries the product and its transaction id`() { + val request = customProductPurchaseRequest("cb-1", "web_annual", "txn-abc", replacement = null) + + assertEquals("web_annual", request.getString("productId")) + assertEquals("txn-abc", request.getString("customTransactionId")) + assertEquals("cb-1", request.getString("callbackId")) + assertFalse(request.has("replacement")) + // Play-only fields don't belong in a custom product request. + assertFalse(request.has("basePlanId")) + assertFalse(request.has("offerId")) + } + + @Test + fun `custom product request sends a missing transaction id as null`() { + val request = customProductPurchaseRequest("cb-1", "web_annual", null, replacement = null) + + assertTrue(request.isNull("customTransactionId")) + } + + @Test + fun `custom product request carries a replacement without a purchase token`() { + val replacement = ProductReplacement(ProductReplacement.Mode.DEFAULT, "web_monthly", null) + val request = customProductPurchaseRequest("cb-1", "web_annual", "txn-abc", replacement) + + val sent = request.getJSONObject("replacement") + assertEquals("default", sent.getString("mode")) + assertEquals("web_monthly", sent.getString("oldProductId")) + assertTrue(sent.isNull("oldPurchaseToken")) + } + + @Test + fun `google play request keeps its shape`() { + val request = googlePlayPurchaseRequest("cb-2", "com.app.annual", "annual", null, upgrade) + + assertEquals("com.app.annual", request.getString("productId")) + assertEquals("annual", request.getString("basePlanId")) + assertEquals("", request.getString("offerId")) + assertEquals("cb-2", request.getString("callbackId")) + assertFalse(request.has("customTransactionId")) + val sent: JSONObject = request.getJSONObject("replacement") + assertEquals("charge_prorated", sent.getString("mode")) + assertEquals("com.app.monthly", sent.getString("oldProductId")) + assertEquals("token-123", sent.getString("oldPurchaseToken")) + } +} diff --git a/ci~/ios-harness/Tests/SuperwallBridgeTests/PurchaseRequestTests.swift b/ci~/ios-harness/Tests/SuperwallBridgeTests/PurchaseRequestTests.swift new file mode 100644 index 0000000..468c40c --- /dev/null +++ b/ci~/ios-harness/Tests/SuperwallBridgeTests/PurchaseRequestTests.swift @@ -0,0 +1,32 @@ +import XCTest +@testable import SuperwallBridge + +// The keys must match what BridgeCallbackHandler reads (Tests/Runtime/BridgeContractTests.cs +// sends the same shapes to the C# side). +final class PurchaseRequestTests: XCTestCase { + func testCustomProductGoesToPurchaseCustomProduct() { + let request = purchaseRequest( + productIdentifier: "web_annual", + customTransactionId: "txn-abc", + callbackId: "cb-1" + ) + + XCTAssertEqual(request.method, "purchaseCustomProduct") + XCTAssertEqual(request.data["productId"] as? String, "web_annual") + XCTAssertEqual(request.data["customTransactionId"] as? String, "txn-abc") + XCTAssertEqual(request.data["callbackId"] as? String, "cb-1") + } + + func testAppStoreProductGoesToPurchaseFromAppStore() { + let request = purchaseRequest( + productIdentifier: "com.app.monthly", + customTransactionId: nil, + callbackId: "cb-2" + ) + + XCTAssertEqual(request.method, "purchaseFromAppStore") + XCTAssertEqual(request.data["productId"] as? String, "com.app.monthly") + XCTAssertEqual(request.data["callbackId"] as? String, "cb-2") + XCTAssertNil(request.data["customTransactionId"]) + } +} diff --git a/package.json b/package.json index 26611b4..0c6ce62 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "com.superwall.sdk", "displayName": "Superwall SDK", - "version": "0.4.1", + "version": "0.5.0", "unity": "6000.3", "description": "Superwall SDK for Unity. Remotely configure paywalls, run paywall A/B tests, and more — all without shipping an app update.", "dependencies": {},