From f7de5c3fb4f9e7e77c81c966bbf8ffedfd668385 Mon Sep 17 00:00:00 2001 From: Kamo Spertsyan Date: Fri, 22 Nov 2024 11:52:26 +0300 Subject: [PATCH 1/2] Promo offers implementation for iOS. --- QonversionCapacitorPlugin.podspec | 2 +- android/build.gradle | 2 +- example/ios/App/Podfile.lock | 20 +++++------ example/src/index.html | 5 +++ example/src/js/index.js | 28 +++++++++++++++ .../QonversionPlugin/QonversionPlugin.swift | 15 ++++++-- src/QonversionApi.ts | 15 ++++++++ src/QonversionNativePlugin.ts | 7 +++- src/dto/PromotionalOffer.ts | 15 ++++++++ src/dto/PurchaseOptions.ts | 6 +++- src/dto/PurchaseOptionsBuilder.ts | 23 ++++++++++++- src/dto/Transaction.ts | 3 ++ src/dto/storeProducts/SKPaymentDiscount.ts | 21 ++++++++++++ src/index.ts | 2 ++ src/internal/Mapper.ts | 34 +++++++++++++++++++ src/internal/QonversionInternal.ts | 27 ++++++++++++++- src/internal/web.ts | 6 +++- 17 files changed, 212 insertions(+), 19 deletions(-) create mode 100644 src/dto/PromotionalOffer.ts create mode 100644 src/dto/storeProducts/SKPaymentDiscount.ts diff --git a/QonversionCapacitorPlugin.podspec b/QonversionCapacitorPlugin.podspec index d6f4091..69c19a3 100644 --- a/QonversionCapacitorPlugin.podspec +++ b/QonversionCapacitorPlugin.podspec @@ -14,5 +14,5 @@ Pod::Spec.new do |s| s.ios.deployment_target = '13.0' s.dependency 'Capacitor' s.swift_version = '5.1' - s.dependency "QonversionSandwich", "5.1.6" + s.dependency "QonversionSandwich", "5.2.0" end diff --git a/android/build.gradle b/android/build.gradle index 3e16529..95a06cf 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -61,7 +61,7 @@ dependencies { implementation project(':capacitor-android') implementation "androidx.appcompat:appcompat:$androidxAppCompatVersion" implementation 'androidx.core:core-ktx:1.13.1' - implementation "io.qonversion.sandwich:sandwich:5.1.6" + implementation "io.qonversion.sandwich:sandwich:5.2.0" testImplementation "junit:junit:$junitVersion" androidTestImplementation "androidx.test.ext:junit:$androidxJunitVersion" androidTestImplementation "androidx.test.espresso:espresso-core:$androidxEspressoCoreVersion" diff --git a/example/ios/App/Podfile.lock b/example/ios/App/Podfile.lock index 364abc4..7d80951 100644 --- a/example/ios/App/Podfile.lock +++ b/example/ios/App/Podfile.lock @@ -6,14 +6,14 @@ PODS: - CapacitorCordova (6.1.2) - CapacitorSplashScreen (6.0.2): - Capacitor - - Qonversion (5.12.4): - - Qonversion/Main (= 5.12.4) - - Qonversion/Main (5.12.4) - - QonversionCapacitorPlugin (0.1.1): + - Qonversion (5.13.0): + - Qonversion/Main (= 5.13.0) + - Qonversion/Main (5.13.0) + - QonversionCapacitorPlugin (0.1.3): - Capacitor - - QonversionSandwich (= 5.1.6) - - QonversionSandwich (5.1.6): - - Qonversion (= 5.12.4) + - QonversionSandwich (= 5.2.0) + - QonversionSandwich (5.2.0): + - Qonversion (= 5.13.0) DEPENDENCIES: - "Capacitor (from `../../node_modules/@capacitor/ios`)" @@ -44,9 +44,9 @@ SPEC CHECKSUMS: CapacitorCamera: ed022171dbf3853e68eec877b4d78995378af6b7 CapacitorCordova: f48c89f96c319101cd2f0ce8a2b7449b5fb8b3dd CapacitorSplashScreen: 250df9ef8014fac5c7c1fd231f0f8b1d8f0b5624 - Qonversion: cca480020597aa8bb74d9c5d0bf1916e68b8440e - QonversionCapacitorPlugin: e1ef7bcf16b785b6edbe6c2f35a323fcb4bb07b4 - QonversionSandwich: 5ed2ecfc84e4af49e49ddb1250ccf00dc9a7c0a5 + Qonversion: 37addeba74c5b328de9e1173b580c971b6d764ec + QonversionCapacitorPlugin: 96d479bb23452fd216f8119d8375fc41e31822e2 + QonversionSandwich: 3ffa118b7214ebd2dcd3f3a1a0a33a39f9c48c8e PODFILE CHECKSUM: 238528980101a97d01a6a29581a5e0d56d489a11 diff --git a/example/src/index.html b/example/src/index.html index ced23e5..ad8e9bc 100644 --- a/example/src/index.html +++ b/example/src/index.html @@ -21,6 +21,11 @@

Qonversion Capacitor Sample Project

+
+ + + +
diff --git a/example/src/js/index.js b/example/src/js/index.js index e6041aa..308653f 100644 --- a/example/src/js/index.js +++ b/example/src/js/index.js @@ -44,6 +44,34 @@ window.getProducts = async () => { console.log('Qonversion products:', products); } +window.getPromoOffer = async () => { + const productId = document.getElementById('product-id-promo').value; + const discountId = document.getElementById('discount-id-promo').value; + const products = await Qonversion.getSharedInstance().products(); + + const product = products.get(productId); + if (!product) { + console.log('Qonversion product not found: ', productId); + return; + } + + const discount = product.skProduct.discounts.find(discount => discount.identifier === discountId); + if (!discount) { + console.log('Qonversion discount not found for requested product: ', discountId); + return; + } + + try { + const promoOffer = await Qonversion.getSharedInstance().getPromotionalOffer(product, discount); + console.log('Qonversion getPromotionalOffer:', promoOffer); + + const entitlements = await Qonversion.getSharedInstance().purchaseProduct(product, new PurchaseOptionsBuilder().setPromotionalOffer(promoOffer).build()); + console.log('Entitlements: ', entitlements); + } catch (e) { + console.log('Qonversion getPromotionalOffer failed', e); + } +} + window.getRemoteConfig = async () => { const contextKey = document.getElementById('context-key').value; const key = contextKey?.length > 0 ? contextKey : undefined; diff --git a/ios/Sources/QonversionPlugin/QonversionPlugin.swift b/ios/Sources/QonversionPlugin/QonversionPlugin.swift index 8bd49c6..6f19549 100644 --- a/ios/Sources/QonversionPlugin/QonversionPlugin.swift +++ b/ios/Sources/QonversionPlugin/QonversionPlugin.swift @@ -11,6 +11,7 @@ public class QonversionPlugin: CAPPlugin, CAPBridgedPlugin { CAPPluginMethod(name: "identify", returnType: CAPPluginReturnPromise), CAPPluginMethod(name: "logout", returnType: CAPPluginReturnPromise), CAPPluginMethod(name: "products", returnType: CAPPluginReturnPromise), + CAPPluginMethod(name: "getPromotionalOffer", returnType: CAPPluginReturnPromise), CAPPluginMethod(name: "purchase", returnType: CAPPluginReturnPromise), CAPPluginMethod(name: "promoPurchase", returnType: CAPPluginReturnPromise), CAPPluginMethod(name: "checkEntitlements", returnType: CAPPluginReturnPromise), @@ -80,14 +81,24 @@ public class QonversionPlugin: CAPPlugin, CAPBridgedPlugin { qonversionSandwich?.products(getDefaultCompletion(call)) } + @objc func getPromotionalOffer(_ call: CAPPluginCall) { + guard let productId = call.getString("productId"), + let discountId = call.getString("discountId") else { + return call.noNecessaryDataError() + } + + qonversionSandwich?.getPromotionalOffer(productId, productDiscountId:discountId, completion: getDefaultCompletion(call)) + } + @objc func purchase(_ call: CAPPluginCall) { guard let productId = call.getString("productId") else { return call.noNecessaryDataError() } let quantity = call.getInt("quantity") ?? 1 - let contextKeys = call.getArray("contextKeys")?.capacitor.replacingNullValues().compactMap({$0}) as? [String] ?? [] + let contextKeys = call.getArray("contextKeys")?.capacitor.replacingNullValues().compactMap({$0}) as? [String] ?? [] + let promoOffer = call.getObject("promoOffer") ?? [:] - qonversionSandwich?.purchase(productId, quantity:quantity, contextKeys:contextKeys, completion: getDefaultCompletion(call)) + qonversionSandwich?.purchase(productId, quantity:quantity, contextKeys:contextKeys, promoOffer:promoOffer, completion: getDefaultCompletion(call)) } @objc func promoPurchase(_ call: CAPPluginCall) { diff --git a/src/QonversionApi.ts b/src/QonversionApi.ts index 392ec70..7247320 100644 --- a/src/QonversionApi.ts +++ b/src/QonversionApi.ts @@ -10,6 +10,8 @@ import {AttributionProvider, UserPropertyKey} from './dto/enums'; import {UserProperties} from './dto/UserProperties'; import {EntitlementsUpdateListener} from './dto/EntitlementsUpdateListener'; import {PromoPurchasesListener} from './dto/PromoPurchasesListener'; +import {SKProductDiscount} from './dto/storeProducts/SKProductDiscount'; +import {PromotionalOffer} from './dto/PromotionalOffer'; export interface QonversionApi { /** @@ -24,6 +26,19 @@ export interface QonversionApi { */ syncStoreKit2Purchases(): void; + /** + * iOS only. + * Retrieve the promotional offer for the product if it exists. + * Make sure to call this function before displaying product details to the user. + * The generated signature for the promotional offer is valid for a single transaction. + * If the purchase fails, you need to call this function again to obtain a new promotional offer signature. + * Use this signature to complete the purchase through the purchase function, along with the purchase options object. + * @param product - product you want to purchase. + * @param discount - discount to create promotional offer signature. + * @returns the promise with the PromotionalOffer. + */ + getPromotionalOffer(product: Product, discount: SKProductDiscount): Promise; + /** * Make a purchase and validate it through server-to-server using Qonversion's Backend * @param product product to purchase diff --git a/src/QonversionNativePlugin.ts b/src/QonversionNativePlugin.ts index 9321a2e..d386959 100644 --- a/src/QonversionNativePlugin.ts +++ b/src/QonversionNativePlugin.ts @@ -2,10 +2,12 @@ import { QEntitlement, QOfferings, QProduct, + QPromotionalOffer, QRemoteConfig, QRemoteConfigList, QTrialIntroEligibility, - QUser, QUserProperties + QUser, + QUserProperties } from './internal/Mapper'; export interface QonversionNativePlugin { @@ -26,6 +28,8 @@ export interface QonversionNativePlugin { storeSdkInfo(params: {source: string, version: string}): void; + getPromotionalOffer(params: {productId: string, discountId: string | undefined}): Promise; + purchase(params: { productId: string, quantity?: number, @@ -34,6 +38,7 @@ export interface QonversionNativePlugin { applyOffer?: boolean | undefined, oldProductId?: string | undefined, updatePolicyKey?: string | null | undefined, + promoOffer?: Object | null, }): Promise | null | undefined>; products(): Promise | null | undefined>; diff --git a/src/dto/PromotionalOffer.ts b/src/dto/PromotionalOffer.ts new file mode 100644 index 0000000..57d33e7 --- /dev/null +++ b/src/dto/PromotionalOffer.ts @@ -0,0 +1,15 @@ +import {SKProductDiscount} from './storeProducts/SKProductDiscount'; +import {SKPaymentDiscount} from './storeProducts/SKPaymentDiscount'; + +export class PromotionalOffer { + public readonly productDiscount: SKProductDiscount; + public readonly paymentDiscount: SKPaymentDiscount; + + constructor ( + productDiscount: SKProductDiscount, + paymentDiscount: SKPaymentDiscount + ) { + this.productDiscount = productDiscount; + this.paymentDiscount = paymentDiscount; + } +} diff --git a/src/dto/PurchaseOptions.ts b/src/dto/PurchaseOptions.ts index f1f9bd8..e033a86 100644 --- a/src/dto/PurchaseOptions.ts +++ b/src/dto/PurchaseOptions.ts @@ -1,5 +1,6 @@ import {Product} from "./Product"; import {PurchaseUpdatePolicy} from "./enums"; +import {PromotionalOffer} from './PromotionalOffer'; export class PurchaseOptions { public readonly offerId: string | null; @@ -8,6 +9,7 @@ export class PurchaseOptions { public readonly updatePolicy: PurchaseUpdatePolicy | null; public readonly contextKeys: string[] | null; public readonly quantity: number; + public readonly promotionalOffer: PromotionalOffer | null; constructor ( offerId: string | null, @@ -15,7 +17,8 @@ export class PurchaseOptions { oldProduct: Product | null, updatePolicy: PurchaseUpdatePolicy | null, contextKeys: string[] | null, - quantity: number + quantity: number, + promotionalOffer: PromotionalOffer | null ) { this.offerId = offerId; this.applyOffer = applyOffer; @@ -23,5 +26,6 @@ export class PurchaseOptions { this.updatePolicy = updatePolicy; this.contextKeys = contextKeys; this.quantity = quantity; + this.promotionalOffer = promotionalOffer; } } diff --git a/src/dto/PurchaseOptionsBuilder.ts b/src/dto/PurchaseOptionsBuilder.ts index 54d4c94..b096405 100644 --- a/src/dto/PurchaseOptionsBuilder.ts +++ b/src/dto/PurchaseOptionsBuilder.ts @@ -2,6 +2,7 @@ import {Product} from './Product'; import {PurchaseUpdatePolicy} from './enums'; import {ProductOfferDetails} from './storeProducts/ProductOfferDetails'; import {PurchaseOptions} from './PurchaseOptions'; +import {PromotionalOffer} from './PromotionalOffer'; export class PurchaseOptionsBuilder { private offerId: string | null = null; @@ -10,6 +11,7 @@ export class PurchaseOptionsBuilder { private updatePolicy: PurchaseUpdatePolicy | null = null; private contextKeys: string[] | null = null; private quantity: number = 1; + private promoOffer: PromotionalOffer | null = null; /** * iOS only. @@ -95,11 +97,30 @@ export class PurchaseOptionsBuilder { return this; } + /** + * Set the promotional offer details. + * + * @param promoOffer promotional offer details. + * @return builder instance for chain calls. + */ + setPromotionalOffer(promoOffer: PromotionalOffer): PurchaseOptionsBuilder { + this.promoOffer = promoOffer; + return this; + } + /** * Generate {@link PurchaseOptions} instance with all the provided options. * @return the complete {@link PurchaseOptions} instance. */ build(): PurchaseOptions { - return new PurchaseOptions(this.offerId, this.applyOffer, this.oldProduct, this.updatePolicy, this.contextKeys, this.quantity); + return new PurchaseOptions( + this.offerId, + this.applyOffer, + this.oldProduct, + this.updatePolicy, + this.contextKeys, + this.quantity, + this.promoOffer + ); } } diff --git a/src/dto/Transaction.ts b/src/dto/Transaction.ts index f1352da..f83a8f0 100644 --- a/src/dto/Transaction.ts +++ b/src/dto/Transaction.ts @@ -10,6 +10,7 @@ export class Transaction { expirationDate?: Date; transactionRevocationDate?: Date; offerCode?: string; + promoOfferId?: string; constructor( originalTransactionId: string, @@ -21,6 +22,7 @@ export class Transaction { expirationTimestamp: number | undefined, transactionRevocationTimestamp: number | undefined, offerCode: string | undefined, + promoOfferId: string | undefined, ) { this.originalTransactionId = originalTransactionId; this.transactionId = transactionId; @@ -31,5 +33,6 @@ export class Transaction { this.expirationDate = expirationTimestamp ? new Date(expirationTimestamp) : undefined; this.transactionRevocationDate = transactionRevocationTimestamp ? new Date(transactionRevocationTimestamp) : undefined; this.offerCode = offerCode; + this.promoOfferId = promoOfferId; } } diff --git a/src/dto/storeProducts/SKPaymentDiscount.ts b/src/dto/storeProducts/SKPaymentDiscount.ts new file mode 100644 index 0000000..73f3238 --- /dev/null +++ b/src/dto/storeProducts/SKPaymentDiscount.ts @@ -0,0 +1,21 @@ +export class SKPaymentDiscount { + identifier: string; + keyIdentifier: string; + nonce: string; + signature: string; + timestamp: number; + + constructor ( + identifier: string, + keyIdentifier: string, + nonce: string, + signature: string, + timestamp: number, + ) { + this.identifier = identifier; + this.keyIdentifier = keyIdentifier; + this.nonce = nonce; + this.signature = signature; + this.timestamp = timestamp; + } +} diff --git a/src/index.ts b/src/index.ts index 817eee9..361d231 100644 --- a/src/index.ts +++ b/src/index.ts @@ -5,6 +5,7 @@ export * from './dto/IntroEligibility'; export * from './dto/Offering'; export * from './dto/Offerings'; export * from './dto/Product'; +export * from './dto/PromotionalOffer'; export * from './dto/QonversionError'; export * from './dto/User'; export * from './dto/UserProperty'; @@ -28,6 +29,7 @@ export * from './dto/PurchaseOptions'; export * from './dto/PurchaseOptionsBuilder'; export * from './dto/EntitlementsUpdateListener'; export * from './dto/PromoPurchasesListener'; +export * from './dto/storeProducts/SKPaymentDiscount'; export * from './dto/storeProducts/SKProduct'; export * from './dto/storeProducts/SKProductDiscount'; export * from './dto/storeProducts/SKSubscriptionPeriod'; diff --git a/src/internal/Mapper.ts b/src/internal/Mapper.ts index 94579dc..4b09c8f 100644 --- a/src/internal/Mapper.ts +++ b/src/internal/Mapper.ts @@ -47,6 +47,8 @@ import {ProductInAppDetails} from "../dto/storeProducts/ProductInAppDetails"; import {ProductPrice} from "../dto/storeProducts/ProductPrice"; import {ProductPricingPhase} from "../dto/storeProducts/ProductPricingPhase"; import {ProductInstallmentPlanDetails} from '../dto/storeProducts/ProductInstallmentPlanDetails'; +import {PromotionalOffer} from '../dto/PromotionalOffer'; +import {SKPaymentDiscount} from '../dto/storeProducts/SKPaymentDiscount'; export type QProduct = { id: string; @@ -104,6 +106,11 @@ type QProductInstallmentPlanDetails = { subsequentCommitmentPaymentsCount: number; } +export type QPromotionalOffer = { + productDiscount: QProductDiscount, + paymentDiscount: QPaymentDiscount, +} + type QProductOfferDetails = { basePlanId: string, offerId?: string | null, @@ -185,6 +192,14 @@ type QProductDiscount = { priceLocale: QLocale; }; +type QPaymentDiscount = { + identifier: string; + keyIdentifier: string; + nonce: string; + signature: string; + timestamp: number; +}; + type QLocale = { currencySymbol: string | null; currencyCode: string | null; @@ -226,6 +241,7 @@ type QTransaction = { environment: string; ownershipType: string; type: string; + promoOfferId: string; } export type QOfferings = { @@ -286,6 +302,19 @@ export type QUserProperties = { const priceMicrosRatio = 1000000; class Mapper { + static convertPromoOffer( + promoOffer: QPromotionalOffer | null | undefined + ): PromotionalOffer | null { + if (!promoOffer) { + return null; + } + + const productDiscount = this.convertProductDiscount(promoOffer.productDiscount); + const paymentDiscount = this.convertPaymentDiscount(promoOffer.paymentDiscount); + + return new PromotionalOffer(productDiscount, paymentDiscount); + } + static convertEntitlements( entitlements: Record | null | undefined ): Map { @@ -364,6 +393,7 @@ class Mapper { transaction.expirationTimestamp, transaction.transactionRevocationTimestamp, transaction.offerCode, + transaction.promoOfferId, ); } @@ -907,6 +937,10 @@ class Mapper { ); } + static convertPaymentDiscount(discount: QPaymentDiscount): SKPaymentDiscount { + return new SKPaymentDiscount(discount.identifier, discount.keyIdentifier, discount.nonce, discount.signature, discount.timestamp) + } + static convertProductDiscount(discount: QProductDiscount): SKProductDiscount { let subscriptionPeriod: SKSubscriptionPeriod | undefined = undefined; if (discount.subscriptionPeriod != null) { diff --git a/src/internal/QonversionInternal.ts b/src/internal/QonversionInternal.ts index 9483d48..4497205 100644 --- a/src/internal/QonversionInternal.ts +++ b/src/internal/QonversionInternal.ts @@ -17,6 +17,8 @@ import {RemoteConfigList} from '../dto/RemoteConfigList'; import {QonversionApi} from '../QonversionApi'; import {QonversionNativePlugin} from '../QonversionNativePlugin'; import {PurchaseOptionsBuilder} from '../dto/PurchaseOptionsBuilder'; +import {SKProductDiscount} from '../dto/storeProducts/SKProductDiscount'; +import {PromotionalOffer} from '../dto/PromotionalOffer'; const sdkVersion = "0.1.3"; @@ -55,6 +57,20 @@ export default class QonversionInternal implements QonversionApi { } } + async getPromotionalOffer(product: Product, discount: SKProductDiscount): Promise { + if (isAndroid()) { + return null; + } + + const promoOffer = await QonversionNative.getPromotionalOffer({ + productId: product.qonversionID, + discountId: discount.identifier, + }); + const mappedPromoOffer: PromotionalOffer | null = Mapper.convertPromoOffer(promoOffer); + + return mappedPromoOffer; + } + async purchaseProduct(product: Product, options: PurchaseOptions | undefined): Promise> { try { if (!options) { @@ -62,11 +78,20 @@ export default class QonversionInternal implements QonversionApi { } let purchasePromise: Promise | null | undefined>; + const promoOffer = { + productDiscountId: options.promotionalOffer?.productDiscount.identifier, + keyIdentifier: options.promotionalOffer?.paymentDiscount.keyIdentifier, + nonce: options.promotionalOffer?.paymentDiscount.nonce, + signature: options.promotionalOffer?.paymentDiscount.signature, + timestamp: options.promotionalOffer?.paymentDiscount.timestamp + }; + if (isIos()) { purchasePromise = QonversionNative.purchase({ productId: product.qonversionID, quantity: options.quantity, - contextKeys: options.contextKeys + contextKeys: options.contextKeys, + promoOffer: promoOffer }); } else { purchasePromise = QonversionNative.purchase({ diff --git a/src/internal/web.ts b/src/internal/web.ts index 84654e5..187f0c3 100644 --- a/src/internal/web.ts +++ b/src/internal/web.ts @@ -3,7 +3,7 @@ import {QonversionNativePlugin} from '../QonversionNativePlugin'; import { QEntitlement, QOfferings, - QProduct, + QProduct, QPromotionalOffer, QRemoteConfig, QRemoteConfigList, QTrialIntroEligibility, @@ -50,6 +50,10 @@ export class QonversionWeb extends WebPlugin implements QonversionNativePlugin { throw this.unimplemented("not implemented yet"); } + getPromotionalOffer(params: { productId: string; discountId: string | undefined }): Promise { + throw this.unimplemented("not implemented yet"); + } + identify(params: { userId: string }): Promise { throw this.unimplemented("not implemented yet"); } From 793b7473429224589642a530a85fcf6ec1d91d79 Mon Sep 17 00:00:00 2001 From: Kamo Spertsyan Date: Fri, 22 Nov 2024 11:55:37 +0300 Subject: [PATCH 2/2] Fix sample --- example/src/js/index.js | 3 --- 1 file changed, 3 deletions(-) diff --git a/example/src/js/index.js b/example/src/js/index.js index 308653f..17f5366 100644 --- a/example/src/js/index.js +++ b/example/src/js/index.js @@ -64,9 +64,6 @@ window.getPromoOffer = async () => { try { const promoOffer = await Qonversion.getSharedInstance().getPromotionalOffer(product, discount); console.log('Qonversion getPromotionalOffer:', promoOffer); - - const entitlements = await Qonversion.getSharedInstance().purchaseProduct(product, new PurchaseOptionsBuilder().setPromotionalOffer(promoOffer).build()); - console.log('Entitlements: ', entitlements); } catch (e) { console.log('Qonversion getPromotionalOffer failed', e); }