diff --git a/android/build.gradle b/android/build.gradle index 43cb51f..5b7c0bc 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -65,7 +65,7 @@ repositories { dependencies { //noinspection GradleDynamicVersion implementation 'com.facebook.react:react-native:+' // From node_modules - implementation "io.qonversion.sandwich:sandwich:3.3.3" + implementation "io.qonversion.sandwich:sandwich:4.0.0" } afterEvaluate { project -> diff --git a/android/src/main/java/com/reactlibrary/QonversionModule.java b/android/src/main/java/com/reactlibrary/QonversionModule.java index 173a5d9..9d8a81d 100644 --- a/android/src/main/java/com/reactlibrary/QonversionModule.java +++ b/android/src/main/java/com/reactlibrary/QonversionModule.java @@ -84,52 +84,29 @@ public void syncHistoricalData() { } @ReactMethod - public void purchaseProduct(String productId, String offeringId, final Promise promise) { - qonversionSandwich.purchaseProduct(productId, offeringId, getPurchaseResultListener(promise)); + public void purchase(String productId, @Nullable String offerId, @Nullable Boolean applyOffer, final Promise promise) { + qonversionSandwich.purchase(productId, offerId, applyOffer, getPurchaseResultListener(promise)); } @ReactMethod - public void purchase(String productId, final Promise promise) { - qonversionSandwich.purchase(productId, getPurchaseResultListener(promise)); - } - - @ReactMethod - public void updateProductWithId( - final String productId, - @Nullable final String offeringId, - final String oldProductId, - final Promise promise - ) { - updateProductWithIdAndProrationMode(productId, offeringId, oldProductId, null, promise); - } - - @ReactMethod - public void updateProductWithIdAndProrationMode( - final String productId, - @Nullable final String offeringId, - final String oldProductId, - @Nullable final Integer prorationMode, + public void updatePurchase( + String productId, + @Nullable String offerId, + @Nullable Boolean applyOffer, + String oldProductId, + @Nullable String updatePolicyKey, final Promise promise ) { - qonversionSandwich.updatePurchaseWithProduct( + qonversionSandwich.updatePurchase( productId, - offeringId, + offerId, + applyOffer, oldProductId, - prorationMode, + updatePolicyKey, getPurchaseResultListener(promise) ); } - @ReactMethod - public void updatePurchase(String productId, String oldProductId, final Promise promise) { - updatePurchaseWithProrationMode(productId, oldProductId, null, promise); - } - - @ReactMethod - public void updatePurchaseWithProrationMode(String productId, String oldProductId, Integer prorationMode, final Promise promise) { - qonversionSandwich.updatePurchase(productId, oldProductId, prorationMode, getPurchaseResultListener(promise)); - } - @ReactMethod public void setDefinedProperty(String key, String value) { qonversionSandwich.setDefinedProperty(key, value); diff --git a/example/App.js b/example/App.js index 5eb0bba..927953d 100644 --- a/example/App.js +++ b/example/App.js @@ -16,6 +16,7 @@ import Qonversion, { Environment, Entitlement, EntitlementsCacheLifetime, + PurchaseModel, } from 'react-native-qonversion'; import NotificationsManager from './notificationsManager'; @@ -28,14 +29,8 @@ type StateType = { checkEntitlementsHidden: boolean; }; -const prettyDuration = { - 'WEEKLY': 'weekly', - 'MONTHLY': 'monthly', - '3_MONTHS': '3 months', - '6_MONTHS': '6 months', - 'ANNUAL': 'annual', - 'LIFETIME': 'lifetime', -}; +const InAppProductId = 'in_app'; +const SubscriptionProductId = 'weekly'; export class QonversionSample extends React.PureComponent<{}, StateType> { constructor(props) { @@ -90,18 +85,25 @@ export class QonversionSample extends React.PureComponent<{}, StateType> { let inAppTitle = this.state.inAppButtonTitle; let subscriptionButtonTitle = this.state.subscriptionButtonTitle; - const inApp: Product = products.get('in_app'); + const inApp: Product = products.get(InAppProductId); if (inApp) { inAppTitle = 'Buy for ' + inApp.prettyPrice; + const entitlement = entitlements.get('Test Entitlement'); if (entitlement) { inAppTitle = entitlement.isActive ? 'Purchased' : inAppTitle; } } - const main: Product = products.get('weekly'); - if (main) { - subscriptionButtonTitle = 'Subscribe for ' + main.prettyPrice + ' / ' + prettyDuration[main.duration]; + const subscription: Product = products.get(SubscriptionProductId); + if (subscription) { + subscriptionButtonTitle = 'Subscribe for ' + + subscription.prettyPrice + + ' / ' + + subscription.subscriptionPeriod.unitCount + + ' ' + + subscription.subscriptionPeriod.unit; + const entitlement = entitlements.get('plus'); if (entitlement) { subscriptionButtonTitle = entitlement.isActive ? 'Purchased' : subscriptionButtonTitle; @@ -146,7 +148,7 @@ export class QonversionSample extends React.PureComponent<{}, StateType> { style={styles.subscriptionButton} onPress={() => { this.setState({loading: true}); - Qonversion.getSharedInstance().purchase('weekly').then(() => { + Qonversion.getSharedInstance().purchase(new PurchaseModel(SubscriptionProductId)).then(() => { this.setState({loading: false, subscriptionButtonTitle: 'Purchased'}); }).catch(error => { this.setState({loading: false}); @@ -170,7 +172,7 @@ export class QonversionSample extends React.PureComponent<{}, StateType> { style={styles.inAppButton} onPress={() => { this.setState({loading: true}); - Qonversion.getSharedInstance().purchase('in_app').then(() => { + Qonversion.getSharedInstance().purchase(new PurchaseModel(InAppProductId)).then(() => { this.setState({loading: false, inAppButtonTitle: 'Purchased'}); }).catch(error => { this.setState({loading: false}); diff --git a/example/ios/Podfile.lock b/example/ios/Podfile.lock index 7c55d66..6f2233b 100644 --- a/example/ios/Podfile.lock +++ b/example/ios/Podfile.lock @@ -78,7 +78,7 @@ PODS: - Qonversion (5.5.2): - Qonversion/Main (= 5.5.2) - Qonversion/Main (5.5.2) - - QonversionSandwich (3.3.1): + - QonversionSandwich (4.0.0): - Qonversion (= 5.5.2) - RCT-Folly (2021.07.22.00): - boost @@ -289,8 +289,8 @@ PODS: - React-jsinspector (0.70.5) - React-logger (0.70.5): - glog - - react-native-qonversion (6.2.0): - - QonversionSandwich (= 3.3.1) + - react-native-qonversion (6.3.1): + - QonversionSandwich (= 4.0.0) - React - React-perflogger (0.70.5) - React-RCTActionSheet (0.70.5): @@ -535,7 +535,7 @@ SPEC CHECKSUMS: libevent: 4049cae6c81cdb3654a443be001fb9bdceff7913 OpenSSL-Universal: ebc357f1e6bc71fa463ccb2fe676756aff50e88c Qonversion: 290d69d209f83a1defa914912a83fc8897a4af34 - QonversionSandwich: a8339feabb3302d6b2dfe9a21372906cfccd507a + QonversionSandwich: 6c377e9f302673eeb22813c5bcc3be70ead9a491 RCT-Folly: 0080d0a6ebf2577475bda044aa59e2ca1f909cda RCTRequired: 21229f84411088e5d8538f21212de49e46cc83e2 RCTTypeSafety: 62eed57a32924b09edaaf170a548d1fc96223086 @@ -550,7 +550,7 @@ SPEC CHECKSUMS: React-jsiexecutor: 31564fa6912459921568e8b0e49024285a4d584b React-jsinspector: badd81696361249893a80477983e697aab3c1a34 React-logger: fdda34dd285bdb0232e059b19d9606fa0ec3bb9c - react-native-qonversion: 83b17ba32811c66dc8bf1182b3fe782f4ab22fa6 + react-native-qonversion: a070dfd191b0c52b459970e1c7b6e14d4fe7e03c React-perflogger: e68d3795cf5d247a0379735cbac7309adf2fb931 React-RCTActionSheet: 05452c3b281edb27850253db13ecd4c5a65bc247 React-RCTAnimation: 578eebac706428e68466118e84aeacf3a282b4da @@ -570,4 +570,4 @@ SPEC CHECKSUMS: PODFILE CHECKSUM: 0dce4b7944944a0de16fd6cacc29aa20d6858b4f -COCOAPODS: 1.11.3 +COCOAPODS: 1.13.0 diff --git a/ios/RNQonversion.m b/ios/RNQonversion.m index 65aeca1..cbbf7a1 100644 --- a/ios/RNQonversion.m +++ b/ios/RNQonversion.m @@ -52,12 +52,6 @@ + (BOOL)requiresMainQueueSetup }]; } -RCT_EXPORT_METHOD(purchaseProduct:(NSString *)productId offeringId:(NSString *)offeringId completion:(RCTResponseSenderBlock)completion rejecter:(RCTPromiseRejectBlock)reject) { - [_qonversionSandwich purchaseProduct:productId offeringId:offeringId completion:^(NSDictionary * _Nullable result, SandwichError * _Nullable error) { - [self handlePurchaseResult:result error:error completion:completion rejecter:reject]; - }]; -} - RCT_EXPORT_METHOD(setDefinedProperty:(NSString *)property value:(NSString *)value) { [_qonversionSandwich setDefinedProperty:property value:value]; } diff --git a/react-native-qonversion.podspec b/react-native-qonversion.podspec index edf8e18..e9c191e 100644 --- a/react-native-qonversion.podspec +++ b/react-native-qonversion.podspec @@ -22,5 +22,5 @@ Pod::Spec.new do |s| s.requires_arc = true s.dependency "React" - s.dependency "QonversionSandwich", "3.3.3" + s.dependency "QonversionSandwich", "4.0.0" end diff --git a/src/QonversionApi.ts b/src/QonversionApi.ts index 5db0a49..84fbb79 100644 --- a/src/QonversionApi.ts +++ b/src/QonversionApi.ts @@ -1,6 +1,6 @@ import Entitlement from './dto/Entitlement'; import Product from './dto/Product'; -import {UserPropertyKey, ProrationMode, AttributionProvider} from './dto/enums'; +import {UserPropertyKey, AttributionProvider} from './dto/enums'; import Offerings from './dto/Offerings'; import IntroEligibility from './dto/IntroEligibility'; import User from './dto/User'; @@ -8,6 +8,8 @@ import {EntitlementsUpdateListener} from './dto/EntitlementsUpdateListener'; import {PromoPurchasesListener} from './dto/PromoPurchasesListener'; import RemoteConfig from "./dto/RemoteConfig"; import UserProperties from './dto/UserProperties'; +import PurchaseModel from './dto/PurchaseModel'; +import PurchaseUpdateModel from './dto/PurchaseUpdateModel'; interface QonversionApi { @@ -25,68 +27,30 @@ interface QonversionApi { /** * Make a purchase and validate it through server-to-server using Qonversion's Backend - * - * @param productId Qonversion product identifier for purchase + * @param purchaseModel necessary information for purchase * @returns the promise with the user entitlements including the ones obtained by the purchase - */ - purchase(productId: string): Promise>; - - /** - * Make a purchase and validate it through server-to-server using Qonversion's Backend * - * @param product - Qonversion's {@link Product} object - * @returns the promise with the user entitlements including the ones obtained by the purchase + * @see [Making Purchases](https://documentation.qonversion.io/docs/making-purchases) */ - purchaseProduct(product: Product): Promise>; + purchase(purchaseModel: PurchaseModel): Promise>; /** * Android only. Returns `null` if called on iOS. * * Update (upgrade/downgrade) subscription on Google Play Store and validate it through server-to-server using Qonversion's Backend * - * @param productId Qonversion product identifier for purchase - * @param oldProductId Qonversion product identifier from which the upgrade/downgrade will be initialized - * @param prorationMode proration mode + * @param purchaseUpdateModel necessary information for purchase update * @returns the promise with the user entitlements including updated ones. * - * @see [Google Play Documentation](https://developer.android.com/google/play/billing/subscriptions#upgrade-downgrade) - * for more details. - * @see [Proration mode](https://developer.android.com/google/play/billing/subscriptions#proration) - * @see [Product Center](https://qonversion.io/docs/product-center) + * @see [Update policy](https://developer.android.com/google/play/billing/subscriptions#replacement-modes) + * @see [Making Purchases](https://documentation.qonversion.io/docs/making-purchases) */ - updatePurchase( - productId: string, - oldProductId: string, - prorationMode: ProrationMode | undefined - ): Promise | null>; - - /** - * Android only. Returns `null` if called on iOS. - * - * Update (upgrade/downgrade) subscription on Google Play Store and validate it through server-to-server using Qonversion's Backend - * - * @param product Qonversion product for purchase - * @param oldProductId Qonversion product identifier from which the upgrade/downgrade will be initialized - * @param prorationMode proration mode - * @returns the promise with the user entitlements including updated ones - * - * @see [Google Play Documentation](https://developer.android.com/google/play/billing/subscriptions#upgrade-downgrade) - * for more details. - * @see [Proration mode](https://developer.android.com/google/play/billing/subscriptions#proration) - * @see [Product Center](https://qonversion.io/docs/product-center) - */ - updatePurchaseWithProduct( - product: Product, - oldProductId: String, - prorationMode: ProrationMode | undefined - ): Promise | null>; + updatePurchase(purchaseUpdateModel: PurchaseUpdateModel): Promise | null>; /** * Returns Qonversion products in association with Apple and Google Play Store Products. * * @returns the promise with Qonversion products - * - * @see [Product Center](https://qonversion.io/docs/product-center) */ products(): Promise>; @@ -101,7 +65,6 @@ interface QonversionApi { * @returns the promise with Qonversion offerings * * @see [Offerings](https://qonversion.io/docs/offerings) for more details - * @see [Product Center](https://qonversion.io/docs/product-center) for more details */ offerings(): Promise; diff --git a/src/dto/Product.ts b/src/dto/Product.ts index 31ebe92..d8bcfae 100644 --- a/src/dto/Product.ts +++ b/src/dto/Product.ts @@ -1,53 +1,158 @@ -import { ProductDurations, ProductTypes, TrialDurations } from "./enums"; +import {ProductType, PurchaseUpdatePolicy} from "./enums"; import SKProduct from "./storeProducts/SKProduct"; import SkuDetails from "./storeProducts/SkuDetails"; +import ProductStoreDetails from "./storeProducts/ProductStoreDetails"; +import ProductOfferDetails from './storeProducts/ProductOfferDetails'; +import PurchaseModel from './PurchaseModel'; +import PurchaseUpdateModel from './PurchaseUpdateModel'; +import SubscriptionPeriod from './SubscriptionPeriod'; class Product { qonversionID: string; - storeID: string; - type: ProductTypes; - duration: ProductDurations; + storeID: string | null; + + /** + * Identifier of the base plan for Google product. + */ + basePlanID: string | null; + + /** + * Google Play Store details of this product. + * Android only. Null for iOS, or if the product was not found. + * Doesn't take into account {@link basePlanID}. + * @deprecated Consider using {@link storeDetails} instead. + */ skuDetails: SkuDetails | null; + + /** + * Google Play Store details of this product. + * Android only. Null for iOS, or if the product was not found. + */ + storeDetails: ProductStoreDetails | null; + + /** + * App store details of this product. + * iOS only. Null for Android, or if the product was not found. + */ skProduct: SKProduct | null; - prettyPrice?: string; - trialDuration?: TrialDurations; + + offeringId?: string | null; + + /** + * For Android - the subscription base plan duration. If the {@link basePlanID} is not specified, + * the duration is calculated using the deprecated {@link skuDetails}. + * For iOS - the duration of the {@link skProduct}. + * Null, if it's not a subscription product or the product was not found in the store. + */ + subscriptionPeriod: SubscriptionPeriod | null; + + /** + * The subscription trial duration of the default offer for Android or of the product for iOS. + * See {@link ProductStoreDetails.defaultSubscriptionOfferDetails} for the information on how we + * choose the default offer for Android. + * Null, if it's not a subscription product or the product was not found the store. + */ + trialPeriod: SubscriptionPeriod | null; + + /** + * The calculated type of this product based on the store information. + * On Android uses deprecated {@link skuDetails} for the old subscription products + * where {@link basePlanID} is not specified, and {@link storeDetails} for all the other products. + * On iOS uses {@link skProduct} information. + */ + type: ProductType; + + /** + * Formatted price of for this product, including the currency sign. + */ + prettyPrice: string | null; + price?: number; currencyCode?: string; storeTitle?: string; storeDescription?: string; prettyIntroductoryPrice?: string; - offeringId?: string | null; constructor( qonversionID: string, storeID: string, - type: ProductTypes, - duration: ProductDurations, + basePlanID: string | null, skuDetails: SkuDetails | null, + storeDetails: ProductStoreDetails | null, skProduct: SKProduct | null, - prettyPrice: string | undefined, - trialDuration: TrialDurations | undefined, + offeringId: string | null, + subscriptionPeriod: SubscriptionPeriod | null, + trialPeriod: SubscriptionPeriod | null, + type: ProductType, + prettyPrice: string | null, price: number | undefined, currencyCode: string | undefined, storeTitle: string | undefined, storeDescription: string | undefined, prettyIntroductoryPrice: string | undefined, - offeringId: string | null, ) { this.qonversionID = qonversionID; this.storeID = storeID; - this.type = type; - this.duration = duration; + this.basePlanID = basePlanID; this.skuDetails = skuDetails; + this.storeDetails = storeDetails; this.skProduct = skProduct; + this.offeringId = offeringId; + this.subscriptionPeriod = subscriptionPeriod; + this.trialPeriod = trialPeriod; + this.type = type; this.prettyPrice = prettyPrice; - this.trialDuration = trialDuration; this.price = price; this.currencyCode = currencyCode; this.storeTitle = storeTitle; this.storeDescription = storeDescription; this.prettyIntroductoryPrice = prettyIntroductoryPrice; - this.offeringId = offeringId; + } + + /** + * Converts this product to purchase model to pass to {@link Qonversion.purchase}. + * @param offerId concrete Android offer identifier if necessary. + * If the products' base plan id is specified, but offer id is not provided for + * purchase, then default offer will be used. + * Ignored if base plan id is not specified. + * Ignored for iOS. + * To know how we choose the default offer, see {@link ProductStoreDetails.defaultSubscriptionOfferDetails}. + * @returns purchase model to pass to the purchase method. + */ + toPurchaseModel(offerId: string | null = null): PurchaseModel { + return new PurchaseModel(this.qonversionID, offerId); + } + + /** + * Converts this product to purchase model to pass to {@link Qonversion.purchase}. + * @param offer concrete Android offer which you'd like to purchase. + * @return purchase model to pass to the purchase method. + */ + toPurchaseModelWithOffer(offer: ProductOfferDetails): PurchaseModel { + const model = this.toPurchaseModel(offer.offerId); + // Remove offer for the case when provided offer details are for bare base plan. + if (offer.offerId == null) { + model.removeOffer(); + } + + return model; + } + + /** + * Android only. + * + * Converts this product to purchase update (upgrade/downgrade) model + * to pass to {@link Qonversion.updatePurchase}. + * @param oldProductId Qonversion product identifier from which the upgrade/downgrade + * will be initialized. + * @param updatePolicy purchase update policy. + * @return purchase model to pass to the update purchase method. + */ + toPurchaseUpdateModel( + oldProductId: string, + updatePolicy: PurchaseUpdatePolicy | null = null + ): PurchaseUpdateModel { + return new PurchaseUpdateModel(this.qonversionID, oldProductId, updatePolicy); } } diff --git a/src/dto/PurchaseModel.ts b/src/dto/PurchaseModel.ts new file mode 100644 index 0000000..dc3cbe0 --- /dev/null +++ b/src/dto/PurchaseModel.ts @@ -0,0 +1,29 @@ +/** + * Used to provide all the necessary purchase data to the {@link Qonversion.purchase} method. + * Can be created manually or using the {@link Product.toPurchaseModel} method. + * + * If {@link offerId} is not specified for Android, then the default offer will be applied. + * To know how we choose the default offer, see {@link ProductStoreDetails.defaultSubscriptionOfferDetails}. + * + * If you want to remove any intro/trial offer from the purchase on Android (use only a bare base plan), + * call the {@link removeOffer} method. + */ +class PurchaseModel { + + public readonly productId: string; + public offerId: string | null = null; + + public applyOffer: boolean = true; + + constructor(productId: string, offerId: string | null = null) { + this.productId = productId; + this.offerId = offerId; + } + + removeOffer(): PurchaseModel { + this.applyOffer = false; + return this; + } +} + +export default PurchaseModel; diff --git a/src/dto/PurchaseUpdateModel.ts b/src/dto/PurchaseUpdateModel.ts new file mode 100644 index 0000000..47a1f97 --- /dev/null +++ b/src/dto/PurchaseUpdateModel.ts @@ -0,0 +1,43 @@ +import {PurchaseUpdatePolicy} from './enums'; + +/** + * Used to provide all the necessary purchase data to the {@link Qonversion.updatePurchase} method. + * Can be created manually or using the {@link Product.toPurchaseUpdateModel} method. + * + * Requires Qonversion product identifiers - {@link productId} for the purchasing one and + * {@link oldProductId} for the purchased one. + * + * If {@link offerId} is not specified for Android, then the default offer will be applied. + * To know how we choose the default offer, see {@link ProductStoreDetails.defaultSubscriptionOfferDetails}. + * + * If you want to remove any intro/trial offer from the purchase on Android (use only a bare base plan), + * call the {@link removeOffer} method. + */ +class PurchaseUpdateModel { + + public readonly productId: string; + public readonly oldProductId: string; + public updatePolicy: PurchaseUpdatePolicy | null = null; + public offerId: string | null = null; + + public applyOffer: boolean = true; + + constructor( + productId: string, + oldProductId: string, + updatePolicy: PurchaseUpdatePolicy | null = null, + offerId: string | null = null, + ) { + this.productId = productId; + this.oldProductId = oldProductId; + this.updatePolicy = updatePolicy; + this.offerId = offerId; + } + + removeOffer(): PurchaseUpdateModel { + this.applyOffer = false; + return this; + } +} + +export default PurchaseUpdateModel; diff --git a/src/dto/SubscriptionPeriod.ts b/src/dto/SubscriptionPeriod.ts new file mode 100644 index 0000000..dcf50be --- /dev/null +++ b/src/dto/SubscriptionPeriod.ts @@ -0,0 +1,33 @@ +import {SubscriptionPeriodUnit} from "./enums"; + +/** + * A class describing a subscription period + */ +class SubscriptionPeriod { + /** + * A count of subsequent intervals. + */ + unitCount: number; + + /** + * Interval unit. + */ + unit: SubscriptionPeriodUnit; + + /** + * ISO 8601 representation of the period, e.g. "P7D", meaning 7 days period. + */ + iso: string; + + constructor( + unitCount: number, + unit: SubscriptionPeriodUnit, + iso: string, + ) { + this.unitCount = unitCount; + this.unit = unit; + this.iso = iso; + } +} + +export default SubscriptionPeriod; diff --git a/src/dto/enums.ts b/src/dto/enums.ts index 309df83..81dae5b 100644 --- a/src/dto/enums.ts +++ b/src/dto/enums.ts @@ -1,47 +1,84 @@ export enum LaunchMode { ANALYTICS = 'Analytics', - SUBSCRIPTION_MANAGEMENT = 'SubscriptionManagement' + SUBSCRIPTION_MANAGEMENT = 'SubscriptionManagement', } export enum Environment { SANDBOX = "Sandbox", - PRODUCTION = "Production" + PRODUCTION = "Production", } -export const ProductType = { - "0": "TRIAL", - "1": "DIRECT_SUBSCRIPTION", - "2": "ONE_TIME", -} as const; +export enum ProductType { + TRIAL = "Trial", + INTRO = "Intro", /** Currently works for Android only. iOS support will be added soon. */ + SUBSCRIPTION = "Subscription", + IN_APP = "InApp", + UNKNOWN = "Unknown", +} -export type ProductTypes = typeof ProductType[keyof typeof ProductType]; +export enum SubscriptionPeriodUnit { + DAY = "Day", + WEEK = "Week", + MONTH = "Month", + YEAR = "Year", + UNKNOWN = "Unknown", +} -export const ProductDuration = { - 0: "WEEKLY", - 1: "MONTHLY", - 2: "3_MONTHS", - 3: "6_MONTHS", - 4: "ANNUAL", - 5: "LIFETIME", -} as const; +/** + * Recurrence mode of the pricing phase. + */ +export enum PricingPhaseRecurrenceMode { + /** + * The billing plan payment recurs for infinite billing periods unless canceled. + */ + INFINITE_RECURRING = "InfiniteRecurring", -export type ProductDurations = typeof ProductDuration[keyof typeof ProductDuration]; - -export const TrialDuration = { - "-1": "NOT_AVAILABLE", - "0": "UNKNOWN", - "1": "THREE_DAYS", - "2": "WEEK", - "3": "TWO_WEEKS", - "4": "MONTH", - "5": "TWO_MONTHS", - "6": "THREE_MONTHS", - "7": "SIX_MONTHS", - "8": "YEAR", - "9": "OTHER", -} as const; + /** + * The billing plan payment recurs for a fixed number of billing periods + * set in {@link ProductPricingPhase.billingCycleCount}. + */ + FINITE_RECURRING = "FiniteRecurring", + + /** + * The billing plan payment is a one-time charge that does not repeat. + */ + NON_RECURRING = "NonRecurring", + + /** + * Unknown recurrence mode. + */ + UNKNOWN = "Unknown", +} + +/** + * Type of the pricing phase. + */ +export enum PricingPhaseType { + /** + * Regular subscription without any discounts like trial or intro offers. + */ + REGULAR = "Regular", + + /** + * A free phase. + */ + FREE_TRIAL = "FreeTrial", + + /** + * A phase with a discounted payment for a single period. + */ + DISCOUNTED_SINGLE_PAYMENT = "DiscountedSinglePayment", + + /** + * A phase with a discounted payment for several periods, described in {@link ProductPricingPhase.billingCycleCount}. + */ + DISCOUNTED_RECURRING_PAYMENT = "DiscountedRecurringPayment", -export type TrialDurations = typeof TrialDuration[keyof typeof TrialDuration]; + /** + * Unknown pricing phase type. + */ + UNKNOWN = "Unknown", +} export enum EntitlementRenewState { NON_RENEWABLE = 'non_renewable', @@ -108,12 +145,47 @@ export enum AttributionProvider { APPLE_AD_SERVICES = "AppleAdServices", // ios only } -export enum ProrationMode { - UNKNOWN_SUBSCRIPTION_UPGRADE_DOWNGRADE_POLICY = 0, - IMMEDIATE_WITH_TIME_PRORATION = 1, - IMMEDIATE_AND_CHARGE_PRORATED_PRICE = 2, - IMMEDIATE_WITHOUT_PRORATION = 3, - DEFERRED = 4, +/** + * A policy used for purchase updates on Android, which describes + * how to migrate from purchased plan to a new one. + * + * Used in {@link PurchaseUpdateModel} class for purchase updates. + */ +export enum PurchaseUpdatePolicy { + /** + * The new plan takes effect immediately, and the user is charged full price of new plan + * and is given a full billing cycle of subscription, plus remaining prorated time + * from the old plan. + */ + CHARGE_FULL_PRICE = 'ChargeFullPrice', + + /** + * The new plan takes effect immediately, and the billing cycle remains the same. + */ + CHARGE_PRORATED_PRICE = 'ChargeProratedPrice', + + /** + * The new plan takes effect immediately, and the remaining time will be prorated + * and credited to the user. + */ + WITH_TIME_PRORATION = 'WithTimeProration', + + /** + * The new purchase takes effect immediately, the new plan will take effect + * when the old item expires. + */ + DEFERRED = 'Deferred', + + /** + * The new plan takes effect immediately, and the new price will be charged + * on next recurrence time. + */ + WITHOUT_PRORATION = 'WithoutProration', + + /** + * Unknown police. + */ + UNKNOWN = 'Unknown', } export enum EntitlementsCacheLifetime { @@ -124,7 +196,7 @@ export enum EntitlementsCacheLifetime { THREE_MONTHS = "ThreeMonths", SIX_MONTHS = "SixMonths", YEAR = "Year", - UNLIMITED = "Unlimited" + UNLIMITED = "Unlimited", } export const SKPeriodUnit = { @@ -232,5 +304,5 @@ export enum ScreenPresentationStyle { * Android only - screen will appear/disappear without any animation. * For iOS consider providing the {@link ScreenPresentationConfig.animated} flag. */ - NO_ANIMATION = 'NoAnimation' + NO_ANIMATION = 'NoAnimation', } diff --git a/src/dto/storeProducts/ProductInAppDetails.ts b/src/dto/storeProducts/ProductInAppDetails.ts new file mode 100644 index 0000000..711b62b --- /dev/null +++ b/src/dto/storeProducts/ProductInAppDetails.ts @@ -0,0 +1,17 @@ +import ProductPrice from "./ProductPrice"; + +/** + * This class contains all the information about the Google in-app product details. + */ +class ProductInAppDetails { + /** + * The price of the in-app product. + */ + price: ProductPrice; + + constructor(price: ProductPrice) { + this.price = price; + } +} + +export default ProductInAppDetails; diff --git a/src/dto/storeProducts/ProductOfferDetails.ts b/src/dto/storeProducts/ProductOfferDetails.ts new file mode 100644 index 0000000..d78b671 --- /dev/null +++ b/src/dto/storeProducts/ProductOfferDetails.ts @@ -0,0 +1,94 @@ +import ProductPricingPhase from "./ProductPricingPhase"; + +/** + * This class contains all the information about the Google subscription offer details. + * It might be either a plain base plan details or a base plan with the concrete offer details. + */ +class ProductOfferDetails { + /** + * The identifier of the current base plan. + */ + basePlanId: string; + + /** + * The identifier of the concrete offer, to which these details belong. + * Null, if these are plain base plan details. + */ + offerId: string | null; + + /** + * A token to purchase the current offer. + */ + offerToken: string; + + /** + * List of tags set for the current offer. + */ + tags: string[]; + + /** + * A time-ordered list of pricing phases for the current offer. + */ + pricingPhases: ProductPricingPhase[]; + + /** + * A base plan phase details. + */ + basePlan: ProductPricingPhase | null; + + /** + * A trial phase details, if exists. + */ + introPhase: ProductPricingPhase | null; + + /** + * An intro phase details, if exists. + * The intro phase is one of single or recurrent discounted payments. + */ + trialPhase: ProductPricingPhase | null; + + /** + * True, if there is a trial phase in the current offer. False otherwise. + */ + hasTrial: boolean; + + /** + * True, if there is any intro phase in the current offer. False otherwise. + * The intro phase is one of single or recurrent discounted payments. + */ + hasIntro: boolean; + + /** + * True, if there is any trial or intro phase in the current offer. False otherwise. + * The intro phase is one of single or recurrent discounted payments. + */ + hasTrialOrIntro: boolean; + + constructor( + basePlanId: string, + offerId: string | null, + offerToken: string, + tags: string[], + pricingPhases: ProductPricingPhase[], + basePlan: ProductPricingPhase | null, + introPhase: ProductPricingPhase | null, + trialPhase: ProductPricingPhase | null, + hasTrial: boolean, + hasIntro: boolean, + hasTrialOrIntro: boolean, + ) { + this.basePlanId = basePlanId; + this.offerId = offerId; + this.offerToken = offerToken; + this.tags = tags; + this.pricingPhases = pricingPhases; + this.basePlan = basePlan; + this.introPhase = introPhase; + this.trialPhase = trialPhase; + this.hasTrial = hasTrial; + this.hasIntro = hasIntro; + this.hasTrialOrIntro = hasTrialOrIntro; + } +} + +export default ProductOfferDetails; diff --git a/src/dto/storeProducts/ProductPrice.ts b/src/dto/storeProducts/ProductPrice.ts new file mode 100644 index 0000000..cedd4f3 --- /dev/null +++ b/src/dto/storeProducts/ProductPrice.ts @@ -0,0 +1,46 @@ +/** + * Information about the Google product's price. + */ +class ProductPrice { + /** + * Total amount of money in micro-units, + * where 1,000,000 micro-units equal one unit of the currency. + */ + priceAmountMicros: number; + + /** + * ISO 4217 currency code for price. + */ + priceCurrencyCode: string; + + /** + * Formatted price for the payment, including its currency sign. + */ + formattedPrice: string; + + /** + * True, if the price is zero. False otherwise. + */ + isFree: boolean; + + /** + * Price currency symbol. Null if failed to parse. + */ + currencySymbol: string | null; + + constructor( + priceAmountMicros: number, + priceCurrencyCode: string, + formattedPrice: string, + isFree: boolean, + currencySymbol: string | null = null + ) { + this.priceAmountMicros = priceAmountMicros; + this.priceCurrencyCode = priceCurrencyCode; + this.formattedPrice = formattedPrice; + this.isFree = isFree; + this.currencySymbol = currencySymbol; + } +} + +export default ProductPrice; diff --git a/src/dto/storeProducts/ProductPricingPhase.ts b/src/dto/storeProducts/ProductPricingPhase.ts new file mode 100644 index 0000000..d5d3930 --- /dev/null +++ b/src/dto/storeProducts/ProductPricingPhase.ts @@ -0,0 +1,71 @@ +import SubscriptionPeriod from "../SubscriptionPeriod"; +import ProductPrice from "./ProductPrice"; +import {PricingPhaseRecurrenceMode, PricingPhaseType} from "../enums"; + +/** + * This class represents a pricing phase, describing how a user pays at a point in time. + */ +class ProductPricingPhase { + /** + * Price for the current phase. + */ + price: ProductPrice; + + /** + * The billing period for which the given price applies. + */ + billingPeriod: SubscriptionPeriod; + + /** + * Number of cycles for which the billing period is applied. + */ + billingCycleCount: number; + + /** + * Recurrence mode for the pricing phase. + */ + recurrenceMode: PricingPhaseRecurrenceMode; + + /** + * Type of the pricing phase. + */ + type: PricingPhaseType; + + /** + * True, if the current phase is a trial period. False otherwise. + */ + isTrial: boolean; + + /** + * True, if the current phase is an intro period. False otherwise. + * The intro phase is one of single or recurrent discounted payments. + */ + isIntro: boolean; + + /** + * True, if the current phase represents the base plan. False otherwise. + */ + isBasePlan: boolean; + + constructor( + price: ProductPrice, + billingPeriod: SubscriptionPeriod, + billingCycleCount: number, + recurrenceMode: PricingPhaseRecurrenceMode, + type: PricingPhaseType, + isTrial: boolean, + isIntro: boolean, + isBasePlan: boolean, + ) { + this.price = price; + this.billingPeriod = billingPeriod; + this.billingCycleCount = billingCycleCount; + this.recurrenceMode = recurrenceMode; + this.type = type; + this.isTrial = isTrial; + this.isIntro = isIntro; + this.isBasePlan = isBasePlan; + } +} + +export default ProductPricingPhase; diff --git a/src/dto/storeProducts/ProductStoreDetails.ts b/src/dto/storeProducts/ProductStoreDetails.ts new file mode 100644 index 0000000..504501a --- /dev/null +++ b/src/dto/storeProducts/ProductStoreDetails.ts @@ -0,0 +1,143 @@ +import {ProductType} from "../enums"; +import ProductOfferDetails from "./ProductOfferDetails"; +import ProductInAppDetails from "./ProductInAppDetails"; + +/** + * This class contains all the information about the concrete Google product, + * either subscription or in-app. In case of a subscription also determines concrete base plan. + */ +class ProductStoreDetails { + /** + * Identifier of the base plan to which these details relate. + * Null for in-app products. + */ + basePlanId: string | null; + + /** + * Identifier of the subscription or the in-app product. + */ + productId: string; + + /** + * Name of the subscription or the in-app product. + */ + name: string; + + /** + * Title of the subscription or the in-app product. + * The title includes the name of the app. + */ + title: string; + + /** + * Description of the subscription or the in-app product. + */ + description: string; + + /** + * Offer details for the subscription. + * Offer details contain all the available variations of purchase offers, + * including both base plan and eligible base plan + offer combinations + * from Google Play Console for current {@link basePlanId}. + * Null for in-app products. + */ + subscriptionOfferDetails: ProductOfferDetails[] | null; + + /** + * The most profitable subscription offer for the client in our opinion from all the available offers. + * We calculate the cheapest price for the client by comparing all the trial or intro phases + * and the base plan. + */ + defaultSubscriptionOfferDetails: ProductOfferDetails | null; + + /** + * Subscription offer details containing only the base plan without any offer. + */ + basePlanSubscriptionOfferDetails: ProductOfferDetails | null; + + /** + * Offer details for the in-app product. + * Null for subscriptions. + */ + inAppOfferDetails: ProductInAppDetails | null; + + /** + * True, if there is any eligible offer with a trial + * for this subscription and base plan combination. + * False otherwise or for an in-app product. + */ + hasTrialOffer: boolean; + + /** + * True, if there is any eligible offer with an intro price + * for this subscription and base plan combination. + * False otherwise or for an in-app product. + */ + hasIntroOffer: boolean; + + /** + * True, if there is any eligible offer with a trial or an intro price + * for this subscription and base plan combination. + * False otherwise or for an in-app product. + */ + hasTrialOrIntroOffer: boolean; + + /** + * The calculated type of the current product. + */ + productType: ProductType; + + /** + * True, if the product type is InApp. + */ + isInApp: boolean; + + /** + * True, if the product type is Subscription. + */ + isSubscription: boolean; + + /** + * True, if the subscription product is prepaid, which means that users pay in advance - + * they will need to make a new payment to extend their plan. + */ + isPrepaid: boolean; + + constructor( + basePlanId: string | null, + productId: string, + name: string, + title: string, + description: string, + subscriptionOfferDetails: ProductOfferDetails[] | null, + defaultSubscriptionOfferDetails: ProductOfferDetails | null, + basePlanSubscriptionOfferDetails: ProductOfferDetails | null, + inAppOfferDetails: ProductInAppDetails | null, + hasTrialOffer: boolean, + hasIntroOffer: boolean, + hasTrialOrIntroOffer: boolean, + productType: ProductType, + isInApp: boolean, + isSubscription: boolean, + isPrepaid: boolean, + ) { + this.basePlanId = basePlanId; + this.productId = productId; + this.name = name; + this.title = title; + this.description = description; + this.subscriptionOfferDetails = subscriptionOfferDetails; + this.defaultSubscriptionOfferDetails = defaultSubscriptionOfferDetails; + this.basePlanSubscriptionOfferDetails = basePlanSubscriptionOfferDetails; + this.inAppOfferDetails = inAppOfferDetails; + this.hasTrialOffer = hasTrialOffer; + this.hasIntroOffer = hasIntroOffer; + this.hasTrialOrIntroOffer = hasTrialOrIntroOffer; + this.productType = productType; + this.isInApp = isInApp; + this.isSubscription = isSubscription; + this.isPrepaid = isPrepaid; + } +} + +export default ProductStoreDetails; diff --git a/src/dto/storeProducts/SkuDetails.ts b/src/dto/storeProducts/SkuDetails.ts index 2529bda..97329ce 100644 --- a/src/dto/storeProducts/SkuDetails.ts +++ b/src/dto/storeProducts/SkuDetails.ts @@ -1,3 +1,6 @@ +/** + * @deprecated + */ class SkuDetails { description: string; freeTrialPeriod: string; diff --git a/src/index.ts b/src/index.ts index 0dd0100..9f47353 100644 --- a/src/index.ts +++ b/src/index.ts @@ -15,13 +15,21 @@ export { default as Offering } from './dto/Offering'; export { default as Offerings } from './dto/Offerings'; export { default as Entitlement } from './dto/Entitlement'; export { default as Product } from './dto/Product'; +export { default as PurchaseModel } from './dto/PurchaseModel'; +export { default as PurchaseUpdateModel } from './dto/PurchaseUpdateModel'; export { PromoPurchasesListener } from './dto/PromoPurchasesListener'; export { ScreenPresentationConfig } from './dto/ScreenPresentationConfig'; +export { default as SubscriptionPeriod } from './dto/SubscriptionPeriod'; export { default as QonversionError } from './dto/QonversionError'; export { default as User } from './dto/User'; export { default as UserProperty } from './dto/UserProperty'; export { default as UserProperties } from './dto/UserProperties'; -export { default as SkuDetails } from './dto/storeProducts/SkuDetails'; +export { default as ProductInAppDetails } from './dto/storeProducts/ProductInAppDetails'; +export { default as ProductOfferDetails } from './dto/storeProducts/ProductOfferDetails'; +export { default as ProductPrice } from './dto/storeProducts/ProductPrice'; +export { default as ProductPricingPhase } from './dto/storeProducts/ProductPricingPhase'; +export { default as ProductStoreDetails } from './dto/storeProducts/ProductStoreDetails'; export { default as SKProduct } from './dto/storeProducts/SKProduct'; -export { default as SKSubscriptionPeriod } from './dto/storeProducts/SKSubscriptionPeriod'; export { default as SKProductDiscount } from './dto/storeProducts/SKProductDiscount'; +export { default as SKSubscriptionPeriod } from './dto/storeProducts/SKSubscriptionPeriod'; +export { default as SkuDetails } from './dto/storeProducts/SkuDetails'; diff --git a/src/internal/Mapper.ts b/src/internal/Mapper.ts index 4384b4f..1eb4563 100644 --- a/src/internal/Mapper.ts +++ b/src/internal/Mapper.ts @@ -6,10 +6,10 @@ import { ExperimentGroupType, IntroEligibilityStatus, OfferingTag, - ProductDuration, - ProductDurations, + PricingPhaseRecurrenceMode, + PricingPhaseType, + SubscriptionPeriodUnit, ProductType, - ProductTypes, RemoteConfigurationAssignmentType, RemoteConfigurationSourceType, SKPeriodUnit, @@ -18,8 +18,6 @@ import { TransactionEnvironment, TransactionOwnershipType, TransactionType, - TrialDuration, - TrialDurations, UserPropertyKey, } from "../dto/enums"; import IntroEligibility from "../dto/IntroEligibility"; @@ -38,24 +36,94 @@ import User from '../dto/User'; import {ScreenPresentationConfig} from '../dto/ScreenPresentationConfig'; import Experiment from "../dto/Experiment"; import ExperimentGroup from "../dto/ExperimentGroup"; +import SubscriptionPeriod from "../dto/SubscriptionPeriod"; import RemoteConfig from "../dto/RemoteConfig"; import UserProperties from '../dto/UserProperties'; import UserProperty from '../dto/UserProperty'; import RemoteConfigurationSource from "../dto/RemoteConfigurationSource"; import Transaction from "../dto/Transaction"; +import ProductStoreDetails from "../dto/storeProducts/ProductStoreDetails"; +import ProductOfferDetails from "../dto/storeProducts/ProductOfferDetails"; +import ProductInAppDetails from "../dto/storeProducts/ProductInAppDetails"; +import ProductPrice from "../dto/storeProducts/ProductPrice"; +import ProductPricingPhase from "../dto/storeProducts/ProductPricingPhase"; type QProduct = { id: string; storeId: string; - type: keyof typeof ProductType; - duration: keyof typeof ProductDuration; + basePlanId?: string | null; + type: string; + subscriptionPeriod?: QSubscriptionPeriod | null; + trialPeriod?: QSubscriptionPeriod | null; skuDetails?: QSkuDetails | null; // android + storeDetails?: QProductStoreDetails // android skProduct?: QSKProduct | null // iOS - prettyPrice?: string; - trialDuration: keyof typeof TrialDuration | null; - offeringId: string | null; + prettyPrice?: string | null; + offeringId?: string | null; }; +type QProductStoreDetails = { + basePlanId?: string | null, + productId: string, + name: string, + title: string + description: string, + subscriptionOfferDetails?: QProductOfferDetails[] | null, + defaultSubscriptionOfferDetails?: QProductOfferDetails | null, + basePlanSubscriptionOfferDetails?: QProductOfferDetails | null, + inAppOfferDetails?: QProductInAppDetails | null, + hasTrialOffer: boolean, + hasIntroOffer: boolean, + hasTrialOrIntroOffer: boolean, + productType: string, + isInApp: boolean, + isSubscription: boolean, + isPrepaid: boolean, +} + +type QSubscriptionPeriod = { + unitCount: number, + unit: string, + iso: string, +} + +type QProductPricingPhase = { + price: QProductPrice, + billingPeriod: QSubscriptionPeriod, + billingCycleCount: number, + recurrenceMode: string, + type: string + isTrial: boolean, + isIntro: boolean, + isBasePlan: boolean, +} + +type QProductOfferDetails = { + basePlanId: string, + offerId?: string | null, + offerToken: string, + tags: string[], + pricingPhases: QProductPricingPhase[], + basePlan?: QProductPricingPhase | null, + trialPhase?: QProductPricingPhase | null, + introPhase: QProductPricingPhase | null, + hasTrial: boolean, + hasIntro: boolean, + hasTrialOrIntro: boolean, +} + +type QProductPrice = { + priceAmountMicros: number, + priceCurrencyCode: string, + formattedPrice: string, + isFree: boolean, + currencySymbol: string, +} + +type QProductInAppDetails = { + price: QProductPrice, +} + type QSkuDetails = { description: string; freeTrialPeriod: string; @@ -79,7 +147,7 @@ type QSkuDetails = { }; type QSKProduct = { - subscriptionPeriod: null | QSubscriptionPeriod; + subscriptionPeriod: null | QSKSubscriptionPeriod; introductoryPrice: QProductDiscount | null; discounts: Array | null; localizedDescription: string | undefined; @@ -95,13 +163,13 @@ type QSKProduct = { isFamilyShareable: boolean | undefined; }; -type QSubscriptionPeriod = { +type QSKSubscriptionPeriod = { numberOfUnits: number; unit: keyof typeof SKPeriodUnit; }; type QProductDiscount = { - subscriptionPeriod: null | QSubscriptionPeriod; + subscriptionPeriod: null | QSKSubscriptionPeriod; price: string; numberOfPeriods: number; paymentMode: keyof typeof SKProductDiscountPaymentMode; @@ -116,7 +184,7 @@ type QLocale = { localeIdentifier: string; }; -type QEntitlement = { +export type QEntitlement = { id: string; productId: string; active: boolean; @@ -201,7 +269,7 @@ type QUserProperties = { properties: QUserProperty[]; }; -const skuDetailsPriceRatio = 1000000; +const priceMicrosRatio = 1000000; class Mapper { static convertEntitlements( @@ -408,13 +476,14 @@ class Mapper { } static convertProduct(product: QProduct): Product { - const productType: ProductTypes = ProductType[product.type]; - const productDuration: ProductDurations = ProductDuration[product.duration]; - const trialDuration: TrialDurations | undefined = product.trialDuration == null ? undefined : TrialDuration[product.trialDuration]; - const offeringId: string | null = product.offeringId; + const productType = Mapper.convertProductType(product.type); + const subscriptionPeriod: SubscriptionPeriod | null = Mapper.convertSubscriptionPeriod(product.subscriptionPeriod); + const trialPeriod: SubscriptionPeriod | null = Mapper.convertSubscriptionPeriod(product.trialPeriod); + const offeringId: string | null = product.offeringId ?? null; let skProduct: SKProduct | null = null; let skuDetails: SkuDetails | null = null; + let storeDetails: ProductStoreDetails | null = null; let price: number | undefined; let currencyCode: string | undefined; let storeTitle: string | undefined; @@ -431,33 +500,58 @@ class Mapper { if (skProduct.productDiscount) { prettyIntroductoryPrice = skProduct.productDiscount.currencySymbol + skProduct.productDiscount.price; } - } else if (!!product.skuDetails) { - skuDetails = Mapper.convertSkuDetails(product.skuDetails as QSkuDetails); - price = skuDetails.priceAmountMicros / skuDetailsPriceRatio; - currencyCode = skuDetails.priceCurrencyCode; - storeTitle = skuDetails.title; - storeDescription = skuDetails.description; - - if (skuDetails.introductoryPrice.length > 0) { - prettyIntroductoryPrice = skuDetails.introductoryPrice; + } else { + let priceMicros = null + if (!!product.skuDetails) { + skuDetails = Mapper.convertSkuDetails(product.skuDetails as QSkuDetails); + storeTitle = skuDetails.title; + storeDescription = skuDetails.description; + + priceMicros = skuDetails.priceAmountMicros; + currencyCode = skuDetails.priceCurrencyCode; + if (skuDetails.introductoryPrice.length > 0) { + prettyIntroductoryPrice = skuDetails.introductoryPrice; + } } + + if (!!product.storeDetails) { + storeDetails = Mapper.convertProductStoreDetails(product.storeDetails); + storeTitle = storeDetails.title; + storeDescription = storeDetails.description; + + const defaultOffer = storeDetails.defaultSubscriptionOfferDetails; + const inAppOffer = storeDetails.inAppOfferDetails; + if (defaultOffer) { + priceMicros = defaultOffer.basePlan?.price?.priceAmountMicros; + currencyCode = defaultOffer.basePlan?.price?.priceCurrencyCode; + prettyIntroductoryPrice = defaultOffer.introPhase?.price?.formattedPrice; + } else if (inAppOffer) { + priceMicros = inAppOffer.price.priceAmountMicros; + currencyCode = inAppOffer.price.priceCurrencyCode; + prettyIntroductoryPrice = undefined; + } + } + + price = priceMicros ? priceMicros / priceMicrosRatio : undefined; } const mappedProduct = new Product( product.id, product.storeId, - productType, - productDuration, + product.basePlanId ?? null, skuDetails, + storeDetails, skProduct, - product.prettyPrice, - trialDuration, + offeringId, + subscriptionPeriod, + trialPeriod, + productType, + product.prettyPrice ?? null, price, currencyCode, storeTitle, storeDescription, prettyIntroductoryPrice, - offeringId ); return mappedProduct; @@ -528,10 +622,211 @@ class Mapper { ); } + static convertProductType(productType: string): ProductType { + let type = ProductType.UNKNOWN + switch (productType) { + case ProductType.TRIAL: + type = ProductType.TRIAL; + break; + case ProductType.INTRO: + type = ProductType.INTRO; + break; + case ProductType.SUBSCRIPTION: + type = ProductType.SUBSCRIPTION; + break; + case ProductType.IN_APP: + type = ProductType.IN_APP; + break; + } + + return type; + } + + static convertSubscriptionPeriod(productPeriod: QSubscriptionPeriod | null | undefined): SubscriptionPeriod | null { + if (!productPeriod) { + return null; + } + + const unit = Mapper.convertSubscriptionPeriodUnit(productPeriod.unit); + + return new SubscriptionPeriod( + productPeriod.unitCount, + unit, + productPeriod.iso, + ) + } + + static convertSubscriptionPeriodUnit(unit: string): SubscriptionPeriodUnit { + let result: SubscriptionPeriodUnit = SubscriptionPeriodUnit.UNKNOWN; + switch (unit) { + case SubscriptionPeriodUnit.DAY: + result = SubscriptionPeriodUnit.DAY; + break; + case SubscriptionPeriodUnit.WEEK: + result = SubscriptionPeriodUnit.WEEK; + break; + case SubscriptionPeriodUnit.MONTH: + result = SubscriptionPeriodUnit.MONTH; + break; + case SubscriptionPeriodUnit.YEAR: + result = SubscriptionPeriodUnit.YEAR; + break; + } + + return result; + } + + static convertProductPricingPhase(pricingPhase: QProductPricingPhase | null | undefined): ProductPricingPhase | null { + if (!pricingPhase) { + return null; + } + + const price: ProductPrice = Mapper.convertProductPrice(pricingPhase.price); + const billingPeriod = Mapper.convertSubscriptionPeriod(pricingPhase.billingPeriod)!!; + const recurrenceMode = Mapper.convertPrisingPhaseRecurrenceMode(pricingPhase.recurrenceMode); + const type = Mapper.convertPrisingPhaseType(pricingPhase.type); + + return new ProductPricingPhase( + price, + billingPeriod, + pricingPhase.billingCycleCount, + recurrenceMode, + type, + pricingPhase.isTrial, + pricingPhase.isIntro, + pricingPhase.isBasePlan, + ); + } + + static convertPrisingPhaseRecurrenceMode(recurrenceMode: string): PricingPhaseRecurrenceMode { + let mode: PricingPhaseRecurrenceMode = PricingPhaseRecurrenceMode.UNKNOWN; + switch (recurrenceMode) { + case PricingPhaseRecurrenceMode.INFINITE_RECURRING: + mode = PricingPhaseRecurrenceMode.INFINITE_RECURRING; + break; + case PricingPhaseRecurrenceMode.FINITE_RECURRING: + mode = PricingPhaseRecurrenceMode.FINITE_RECURRING; + break; + case PricingPhaseRecurrenceMode.NON_RECURRING: + mode = PricingPhaseRecurrenceMode.NON_RECURRING; + break; + } + + return mode; + } + + static convertPrisingPhaseType(type: string): PricingPhaseType { + let result: PricingPhaseType = PricingPhaseType.UNKNOWN + switch (type) { + case PricingPhaseType.REGULAR: + result = PricingPhaseType.REGULAR; + break; + case PricingPhaseType.FREE_TRIAL: + result = PricingPhaseType.FREE_TRIAL; + break; + case PricingPhaseType.DISCOUNTED_SINGLE_PAYMENT: + result = PricingPhaseType.DISCOUNTED_SINGLE_PAYMENT; + break; + case PricingPhaseType.DISCOUNTED_RECURRING_PAYMENT: + result = PricingPhaseType.DISCOUNTED_RECURRING_PAYMENT; + break; + } + + return result; + } + + static convertProductOfferDetails(defaultOfferDetail: QProductOfferDetails): ProductOfferDetails { + let basePlan = Mapper.convertProductPricingPhase(defaultOfferDetail.basePlan); + let trialPhase = Mapper.convertProductPricingPhase(defaultOfferDetail.trialPhase); + let introPhase = Mapper.convertProductPricingPhase(defaultOfferDetail.introPhase); + + let pricingPhases = defaultOfferDetail.pricingPhases.map( + pricingPhase => Mapper.convertProductPricingPhase(pricingPhase) + ).filter(Boolean) as ProductPricingPhase[]; + + return new ProductOfferDetails( + defaultOfferDetail.basePlanId, + defaultOfferDetail.offerId ?? null, + defaultOfferDetail.offerToken, + defaultOfferDetail.tags, + pricingPhases, + basePlan, + introPhase, + trialPhase, + defaultOfferDetail.hasTrial, + defaultOfferDetail.hasIntro, + defaultOfferDetail.hasTrialOrIntro, + ); + } + + static convertInAppOfferDetails(inAppOfferDetails: QProductInAppDetails): ProductInAppDetails { + let productPrice: ProductPrice = this.convertProductPrice(inAppOfferDetails.price); + + return new ProductInAppDetails(productPrice); + } + + static convertProductPrice(productPrice: QProductPrice): ProductPrice { + return new ProductPrice( + productPrice.priceAmountMicros, + productPrice.priceCurrencyCode, + productPrice.formattedPrice, + productPrice.isFree, + productPrice.currencySymbol, + ) + } + + static convertProductStoreDetails(productStoreDetails: QProductStoreDetails): ProductStoreDetails { + let defaultSubscriptionOfferDetails: ProductOfferDetails | null = null; + if (productStoreDetails.defaultSubscriptionOfferDetails != null) { + defaultSubscriptionOfferDetails = this.convertProductOfferDetails( + productStoreDetails.defaultSubscriptionOfferDetails + ); + } + + let basePlanSubscriptionOfferDetails: ProductOfferDetails | null = null; + if (productStoreDetails.basePlanSubscriptionOfferDetails != null) { + basePlanSubscriptionOfferDetails = this.convertProductOfferDetails( + productStoreDetails.basePlanSubscriptionOfferDetails + ); + } + + let inAppOfferDetails: ProductInAppDetails | null = null; + if (productStoreDetails.inAppOfferDetails != null) { + inAppOfferDetails = this.convertInAppOfferDetails(productStoreDetails.inAppOfferDetails); + } + + let subscriptionOfferDetails: ProductOfferDetails[] | null = null; + if (productStoreDetails.subscriptionOfferDetails != null) { + subscriptionOfferDetails = productStoreDetails.subscriptionOfferDetails.map( + defaultOfferDetail => this.convertProductOfferDetails(defaultOfferDetail)); + } + + const productType: ProductType = Mapper.convertProductType(productStoreDetails.productType); + + return new ProductStoreDetails( + productStoreDetails.basePlanId ?? null, + productStoreDetails.productId, + productStoreDetails.name, + productStoreDetails.title, + productStoreDetails.description, + subscriptionOfferDetails, + defaultSubscriptionOfferDetails, + basePlanSubscriptionOfferDetails, + inAppOfferDetails, + productStoreDetails.hasTrialOffer, + productStoreDetails.hasIntroOffer, + productStoreDetails.hasTrialOrIntroOffer, + productType, + productStoreDetails.isInApp, + productStoreDetails.isSubscription, + productStoreDetails.isPrepaid, + ); + } + static convertSKProduct(skProduct: QSKProduct): SKProduct { let subscriptionPeriod: SKSubscriptionPeriod | undefined; if (skProduct.subscriptionPeriod != null) { - subscriptionPeriod = this.convertSubscriptionPeriod( + subscriptionPeriod = this.convertSKSubscriptionPeriod( skProduct.subscriptionPeriod ); } @@ -564,8 +859,8 @@ class Mapper { ); } - static convertSubscriptionPeriod( - subscriptionPeriod: QSubscriptionPeriod + static convertSKSubscriptionPeriod( + subscriptionPeriod: QSKSubscriptionPeriod ): SKSubscriptionPeriod { return new SKSubscriptionPeriod( subscriptionPeriod.numberOfUnits, @@ -576,7 +871,7 @@ class Mapper { static convertProductDiscount(discount: QProductDiscount): SKProductDiscount { let subscriptionPeriod: SKSubscriptionPeriod | undefined = undefined; if (discount.subscriptionPeriod != null) { - subscriptionPeriod = this.convertSubscriptionPeriod( + subscriptionPeriod = this.convertSKSubscriptionPeriod( discount.subscriptionPeriod ); } @@ -680,8 +975,8 @@ class Mapper { experiment = new Experiment(remoteConfig.experiment.id, remoteConfig.experiment.name, group); } - const sourceType = this.convertRemoteConfigurationSourceType (remoteConfig.source.type); - const assignmentType = this.convertRemoteConfigurationAssignmentType (remoteConfig.source.assignmentType); + const sourceType = this.convertRemoteConfigurationSourceType(remoteConfig.source.type); + const assignmentType = this.convertRemoteConfigurationAssignmentType(remoteConfig.source.assignmentType); const source = new RemoteConfigurationSource(remoteConfig.source.id, remoteConfig.source.name, sourceType, assignmentType) diff --git a/src/internal/QonversionInternal.ts b/src/internal/QonversionInternal.ts index 10abc08..1f55396 100644 --- a/src/internal/QonversionInternal.ts +++ b/src/internal/QonversionInternal.ts @@ -1,7 +1,7 @@ import {NativeEventEmitter, NativeModules} from "react-native"; -import {AttributionProvider, ProrationMode, UserPropertyKey} from "../dto/enums"; +import {AttributionProvider, UserPropertyKey} from "../dto/enums"; import IntroEligibility from "../dto/IntroEligibility"; -import Mapper from "./Mapper"; +import Mapper, {QEntitlement} from "./Mapper"; import Offerings from "../dto/Offerings"; import Entitlement from "../dto/Entitlement"; import Product from "../dto/Product"; @@ -13,6 +13,8 @@ import QonversionApi from '../QonversionApi'; import QonversionConfig from '../QonversionConfig'; import RemoteConfig from "../dto/RemoteConfig"; import UserProperties from '../dto/UserProperties'; +import PurchaseModel from '../dto/PurchaseModel'; +import PurchaseUpdateModel from '../dto/PurchaseUpdateModel'; const {RNQonversion} = NativeModules; @@ -49,56 +51,22 @@ export default class QonversionInternal implements QonversionApi { } } - async purchase(productId: string): Promise> { - return QonversionInternal.purchaseProxy(productId); - } - - async purchaseProduct(product: Product): Promise> { - return QonversionInternal.purchaseProxy(product.qonversionID, product.offeringId); - } - - private static async purchaseProxy(productId: string, offeringId: string | null = null): Promise> { - try { - const purchasePromise = !!offeringId ? - RNQonversion.purchaseProduct(productId, offeringId) - : - RNQonversion.purchase(productId); - - const entitlements = await purchasePromise; - - // noinspection UnnecessaryLocalVariableJS - const mappedPermissions = Mapper.convertEntitlements(entitlements); - - return mappedPermissions; - } catch (e) { - e.userCanceled = e.code === DefinedNativeErrorCodes.PURCHASE_CANCELLED_BY_USER; - throw e; - } - } - - async updatePurchase( - productId: string, - oldProductId: string, - prorationMode: ProrationMode | undefined - ): Promise | null> { - if (!isAndroid()) { - return null; - } - + async purchase(purchaseModel: PurchaseModel): Promise> { try { - let entitlements; - if (!prorationMode) { - entitlements = await RNQonversion.updatePurchase(productId, oldProductId); + let purchasePromise: Promise | null | undefined>; + if (isIos()) { + purchasePromise = RNQonversion.purchase(purchaseModel.productId); } else { - entitlements = await RNQonversion.updatePurchaseWithProrationMode( - productId, - oldProductId, - prorationMode + purchasePromise = RNQonversion.purchase( + purchaseModel.productId, + purchaseModel.offerId, + purchaseModel.applyOffer, ); } + const entitlements = await purchasePromise; // noinspection UnnecessaryLocalVariableJS - const mappedPermissions: Map = Mapper.convertEntitlements(entitlements); + const mappedPermissions = Mapper.convertEntitlements(entitlements); return mappedPermissions; } catch (e) { @@ -107,27 +75,19 @@ export default class QonversionInternal implements QonversionApi { } } - async updatePurchaseWithProduct( - product: Product, - oldProductId: String, - prorationMode: ProrationMode | undefined - ): Promise | null> { + async updatePurchase(purchaseUpdateModel: PurchaseUpdateModel): Promise | null> { if (!isAndroid()) { return null; } try { - let entitlements; - if (!prorationMode) { - entitlements = await RNQonversion.updateProductWithId(product.qonversionID, product.offeringId, oldProductId); - } else { - entitlements = await RNQonversion.updateProductWithIdAndProrationMode( - product.qonversionID, - product.offeringId, - oldProductId, - prorationMode - ); - } + const entitlements = await RNQonversion.updatePurchase( + purchaseUpdateModel.productId, + purchaseUpdateModel.offerId, + purchaseUpdateModel.applyOffer, + purchaseUpdateModel.oldProductId, + purchaseUpdateModel.updatePolicy, + ); // noinspection UnnecessaryLocalVariableJS const mappedPermissions: Map = Mapper.convertEntitlements(entitlements);