diff --git a/plugin/plugin.xml b/plugin/plugin.xml index 7889e2d..142f436 100644 --- a/plugin/plugin.xml +++ b/plugin/plugin.xml @@ -26,6 +26,13 @@ + + + + + + + @@ -38,6 +45,7 @@ + @@ -45,7 +53,7 @@ - + @@ -63,7 +71,7 @@ - + diff --git a/plugin/src/android/QonversionPlugin.java b/plugin/src/android/QonversionPlugin.java index 5846070..a44af90 100644 --- a/plugin/src/android/QonversionPlugin.java +++ b/plugin/src/android/QonversionPlugin.java @@ -82,53 +82,30 @@ public void initializeSdk( entitlementsUpdateCallbackContext.sendPluginResult(result); } - @PluginAction(thread = ExecutionThread.UI, actionName = "purchaseProduct", isAutofinish = false) - public void purchaseProduct(String productId, String offeringId, CallbackContext callbackContext) { - qonversionSandwich.purchaseProduct(productId, offeringId, getPurchaseResultListener(callbackContext)); - } - @PluginAction(thread = ExecutionThread.UI, actionName = "purchase", isAutofinish = false) - public void purchase(String productId, CallbackContext callbackContext) { - qonversionSandwich.purchase(productId, getPurchaseResultListener(callbackContext)); - } - - @PluginAction(thread = ExecutionThread.UI, actionName = "updateProductWithId", isAutofinish = false) - public void updateProductWithId( - final String productId, - @Nullable final String offeringId, - final String oldProductId, - CallbackContext callbackContext - ) { - updateProductWithIdAndProrationMode(productId, offeringId, oldProductId, null, callbackContext); + public void purchase(String productId, @Nullable String offerId, @Nullable Boolean applyOffer, CallbackContext callbackContext) { + qonversionSandwich.purchase(productId, offerId, applyOffer, getPurchaseResultListener(callbackContext)); } - @PluginAction(thread = ExecutionThread.UI, actionName = "updateProductWithIdAndProrationMode", isAutofinish = false) - public void updateProductWithIdAndProrationMode( - final String productId, - @Nullable final String offeringId, - final String oldProductId, - @Nullable final Integer prorationMode, + @PluginAction(thread = ExecutionThread.UI, actionName = "updatePurchase", isAutofinish = false) + public void updatePurchase( + String productId, + @Nullable String offerId, + @Nullable Boolean applyOffer, + String oldProductId, + @Nullable String updatePolicyKey, CallbackContext callbackContext ) { - qonversionSandwich.updatePurchaseWithProduct( + qonversionSandwich.updatePurchase( productId, - offeringId, + offerId, + applyOffer, oldProductId, - prorationMode, + updatePolicyKey, getPurchaseResultListener(callbackContext) ); } - @PluginAction(thread = ExecutionThread.UI, actionName = "updatePurchase", isAutofinish = false) - public void updatePurchase(String productId, String oldProductId, CallbackContext callbackContext) { - updatePurchaseWithProrationMode(productId, oldProductId, null, callbackContext); - } - - @PluginAction(thread = ExecutionThread.UI, actionName = "updatePurchaseWithProrationMode", isAutofinish = false) - public void updatePurchaseWithProrationMode(String productId, String oldProductId, Integer prorationMode, CallbackContext callbackContext) { - qonversionSandwich.updatePurchase(productId, oldProductId, prorationMode, getPurchaseResultListener(callbackContext)); - } - @PluginAction(thread = ExecutionThread.WORKER, actionName = "setDefinedProperty") public void setDefinedProperty(String key, String value, CallbackContext callbackContext) { qonversionSandwich.setDefinedProperty(key, value); diff --git a/plugin/src/ios/CDVQonversionPlugin.h b/plugin/src/ios/CDVQonversionPlugin.h index 90320e0..6092891 100644 --- a/plugin/src/ios/CDVQonversionPlugin.h +++ b/plugin/src/ios/CDVQonversionPlugin.h @@ -22,7 +22,6 @@ NS_ASSUME_NONNULL_BEGIN - (void)userProperties:(CDVInvokedUrlCommand *)command; - (void)attribution:(CDVInvokedUrlCommand *)command; - (void)checkEntitlements:(CDVInvokedUrlCommand *)command; -- (void)purchaseProduct:(CDVInvokedUrlCommand *)command; - (void)purchase:(CDVInvokedUrlCommand *)command; - (void)products:(CDVInvokedUrlCommand *)command; - (void)restore:(CDVInvokedUrlCommand *)command; diff --git a/plugin/src/ios/CDVQonversionPlugin.m b/plugin/src/ios/CDVQonversionPlugin.m index ea168c3..2ab37b6 100644 --- a/plugin/src/ios/CDVQonversionPlugin.m +++ b/plugin/src/ios/CDVQonversionPlugin.m @@ -111,15 +111,6 @@ - (void)checkEntitlements:(CDVInvokedUrlCommand *)command { }]; } -- (void)purchaseProduct:(CDVInvokedUrlCommand *)command { - NSString *productId = [command argumentAtIndex:0]; - NSString *offeringId = [command argumentAtIndex:1]; - __block __weak CDVQonversionPlugin *weakSelf = self; - [self.qonversionSandwich purchaseProduct:productId offeringId:offeringId completion:^(NSDictionary * _Nullable result, SandwichError * _Nullable error) { - [weakSelf returnCordovaResult:result error:error command:command]; - }]; -} - - (void)purchase:(CDVInvokedUrlCommand *)command { NSString *productId = [command argumentAtIndex:0]; __block __weak CDVQonversionPlugin *weakSelf = self; diff --git a/plugin/src/plugin/Mapper.ts b/plugin/src/plugin/Mapper.ts index 86a8963..0ad3db5 100644 --- a/plugin/src/plugin/Mapper.ts +++ b/plugin/src/plugin/Mapper.ts @@ -1,22 +1,20 @@ import { AutomationsEventType, + EntitlementRenewState, EntitlementSource, + ExperimentGroupType, IntroEligibilityStatus, OfferingTag, - ProductDuration, - ProductDurations, + PricingPhaseRecurrenceMode, + PricingPhaseType, + SubscriptionPeriodUnit, ProductType, - ProductTypes, - EntitlementRenewState, + RemoteConfigurationAssignmentType, + RemoteConfigurationSourceType, SKPeriodUnit, SKProductDiscountPaymentMode, SKProductDiscountType, - TrialDuration, - TrialDurations, - ExperimentGroupType, UserPropertyKey, - RemoteConfigurationAssignmentType, - RemoteConfigurationSourceType, TransactionType, TransactionOwnershipType, TransactionEnvironment, @@ -35,26 +33,96 @@ import {ActionResult} from "./ActionResult"; import {QonversionError} from "./QonversionError"; import {AutomationsEvent} from "./AutomationsEvent"; import {User} from './User'; -import {RemoteConfig} from "./RemoteConfig"; -import {RemoteConfigurationSource} from "./RemoteConfigurationSource"; -import {ExperimentGroup} from "./ExperimentGroup"; import {Experiment} from "./Experiment"; -import {UserProperty} from './UserProperty'; +import {ExperimentGroup} from "./ExperimentGroup"; +import {SubscriptionPeriod} from "./SubscriptionPeriod"; +import {RemoteConfig} from "./RemoteConfig"; import {UserProperties} from './UserProperties'; +import {UserProperty} from './UserProperty'; +import {RemoteConfigurationSource} from "./RemoteConfigurationSource"; import {Transaction} from "./Transaction"; +import {ProductStoreDetails} from "./ProductStoreDetails"; +import {ProductOfferDetails} from "./ProductOfferDetails"; +import {ProductInAppDetails} from "./ProductInAppDetails"; +import {ProductPrice} from "./ProductPrice"; +import {ProductPricingPhase} from "./ProductPricingPhase"; export 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; @@ -78,7 +146,7 @@ type QSkuDetails = { }; type QSKProduct = { - subscriptionPeriod: null | QSubscriptionPeriod; + subscriptionPeriod: null | QSKSubscriptionPeriod; introductoryPrice: QProductDiscount | null; discounts: Array | null; localizedDescription: string | undefined; @@ -94,13 +162,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; @@ -179,35 +247,35 @@ export type QRemoteConfig = { source: QRemoteConfigurationSource; }; -export type QRemoteConfigurationSource = { +type QRemoteConfigurationSource = { id: string; name: string; type: string; assignmentType: string; }; -export type QUserProperties = { - properties: QUserProperty[]; -}; - -export type QUserProperty = { - key: string; - value: string; -}; - -export type QExperiment = { +type QExperiment = { id: string; name: string; group: QExperimentGroup; } -export type QExperimentGroup = { +type QExperimentGroup = { id: string; name: string; type: string; } -const skuDetailsPriceRatio = 1000000; +type QUserProperty = { + key: string; + value: string; +}; + +export type QUserProperties = { + properties: QUserProperty[]; +}; + +const priceMicrosRatio = 1000000; class Mapper { static convertEntitlements( @@ -393,6 +461,15 @@ class Mapper { return UserPropertyKey.CUSTOM; } + static convertUserProperties(properties: QUserProperties): UserProperties { + const mappedProperties = properties.properties.map(propertyData => { + const definedKey = Mapper.convertDefinedUserPropertyKey(propertyData.key); + return new UserProperty(propertyData.key, propertyData.value, definedKey); + }); + + return new UserProperties(mappedProperties); + } + static convertProducts(products: Record | null | undefined): Map { let mappedProducts = new Map(); @@ -410,13 +487,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; @@ -433,34 +511,59 @@ 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; } // noinspection UnnecessaryLocalVariableJS 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; @@ -531,10 +634,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 ); } @@ -567,8 +871,8 @@ class Mapper { ); } - static convertSubscriptionPeriod( - subscriptionPeriod: QSubscriptionPeriod + static convertSKSubscriptionPeriod( + subscriptionPeriod: QSKSubscriptionPeriod ): SKSubscriptionPeriod { return new SKSubscriptionPeriod( subscriptionPeriod.numberOfUnits, @@ -579,7 +883,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 ); } @@ -668,7 +972,7 @@ class Mapper { ) } - static convertUserInfo(user: QUser) { + static convertUserInfo(user: QUser): User { return new User(user.qonversionId, user.identityId); } @@ -680,8 +984,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) @@ -712,15 +1016,6 @@ class Mapper { } } - static convertUserProperties(properties: QUserProperties): UserProperties { - const mappedProperties = properties.properties.map(propertyData => { - const definedKey = Mapper.convertDefinedUserPropertyKey(propertyData.key); - return new UserProperty(propertyData.key, propertyData.value, definedKey); - }); - - return new UserProperties(mappedProperties); - } - static convertGroupType(type: String): ExperimentGroupType { switch (type) { case "control": diff --git a/plugin/src/plugin/Product.ts b/plugin/src/plugin/Product.ts index 6cfaec7..3b31646 100644 --- a/plugin/src/plugin/Product.ts +++ b/plugin/src/plugin/Product.ts @@ -1,52 +1,157 @@ -import {ProductDurations, ProductTypes, TrialDurations} from "./enums"; +import {ProductType, PurchaseUpdatePolicy} from "./enums"; import {SKProduct} from "./SKProduct"; import {SkuDetails} from "./SkuDetails"; +import {ProductStoreDetails} from "./ProductStoreDetails"; +import {ProductOfferDetails} from './ProductOfferDetails'; +import {PurchaseModel} from './PurchaseModel'; +import {PurchaseUpdateModel} from './PurchaseUpdateModel'; +import {SubscriptionPeriod} from './SubscriptionPeriod'; export 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/plugin/src/plugin/ProductInAppDetails.ts b/plugin/src/plugin/ProductInAppDetails.ts new file mode 100644 index 0000000..974b601 --- /dev/null +++ b/plugin/src/plugin/ProductInAppDetails.ts @@ -0,0 +1,15 @@ +import {ProductPrice} from "./ProductPrice"; + +/** + * This class contains all the information about the Google in-app product details. + */ +export class ProductInAppDetails { + /** + * The price of the in-app product. + */ + price: ProductPrice; + + constructor(price: ProductPrice) { + this.price = price; + } +} diff --git a/plugin/src/plugin/ProductOfferDetails.ts b/plugin/src/plugin/ProductOfferDetails.ts new file mode 100644 index 0000000..ee94d7a --- /dev/null +++ b/plugin/src/plugin/ProductOfferDetails.ts @@ -0,0 +1,92 @@ +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. + */ +export 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; + } +} diff --git a/plugin/src/plugin/ProductPrice.ts b/plugin/src/plugin/ProductPrice.ts new file mode 100644 index 0000000..90c3b30 --- /dev/null +++ b/plugin/src/plugin/ProductPrice.ts @@ -0,0 +1,44 @@ +/** + * Information about the Google product's price. + */ +export 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; + } +} diff --git a/plugin/src/plugin/ProductPricingPhase.ts b/plugin/src/plugin/ProductPricingPhase.ts new file mode 100644 index 0000000..ba41ac9 --- /dev/null +++ b/plugin/src/plugin/ProductPricingPhase.ts @@ -0,0 +1,69 @@ +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. + */ +export 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; + } +} diff --git a/plugin/src/plugin/ProductStoreDetails.ts b/plugin/src/plugin/ProductStoreDetails.ts new file mode 100644 index 0000000..9fb9a6d --- /dev/null +++ b/plugin/src/plugin/ProductStoreDetails.ts @@ -0,0 +1,141 @@ +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. + */ +export 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; + } +} diff --git a/plugin/src/plugin/PurchaseModel.ts b/plugin/src/plugin/PurchaseModel.ts new file mode 100644 index 0000000..be2535d --- /dev/null +++ b/plugin/src/plugin/PurchaseModel.ts @@ -0,0 +1,27 @@ +/** + * 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. + */ +export 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; + } +} diff --git a/plugin/src/plugin/PurchaseUpdateModel.ts b/plugin/src/plugin/PurchaseUpdateModel.ts new file mode 100644 index 0000000..bf71874 --- /dev/null +++ b/plugin/src/plugin/PurchaseUpdateModel.ts @@ -0,0 +1,41 @@ +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. + */ +export 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; + } +} diff --git a/plugin/src/plugin/Qonversion.ts b/plugin/src/plugin/Qonversion.ts index 63aad5e..e296feb 100644 --- a/plugin/src/plugin/Qonversion.ts +++ b/plugin/src/plugin/Qonversion.ts @@ -21,16 +21,16 @@ import { IntroEligibilityStatus, LaunchMode, OfferingTag, - ProductDuration, + PricingPhaseRecurrenceMode, + PricingPhaseType, ProductType, - ProrationMode, + PurchaseUpdatePolicy, SKPeriodUnit, SKProductDiscountPaymentMode, SKProductDiscountType, TransactionEnvironment, TransactionOwnershipType, TransactionType, - TrialDuration, UserPropertyKey } from './enums'; import {IntroEligibility} from './IntroEligibility'; @@ -47,6 +47,14 @@ import {Transaction} from './Transaction'; import {RemoteConfig} from './RemoteConfig'; import {RemoteConfigurationSource} from "./RemoteConfigurationSource"; import {ExperimentGroup} from './ExperimentGroup'; +import {SubscriptionPeriod} from './SubscriptionPeriod'; +import {ProductInAppDetails} from './ProductInAppDetails'; +import {ProductOfferDetails} from './ProductOfferDetails'; +import {ProductPrice} from './ProductPrice'; +import {ProductPricingPhase} from './ProductPricingPhase'; +import {ProductStoreDetails} from './ProductStoreDetails'; +import {PurchaseModel} from './PurchaseModel'; +import {PurchaseUpdateModel} from './PurchaseUpdateModel'; export default class Qonversion { private constructor() {} @@ -97,8 +105,6 @@ export default class Qonversion { static LaunchMode = LaunchMode; static Environment = Environment; static ProductType = ProductType; - static ProductDuration = ProductDuration; - static TrialDuration = TrialDuration; static EntitlementRenewState = EntitlementRenewState; static EntitlementSource = EntitlementSource; static RemoteConfigurationSource = RemoteConfigurationSource; @@ -112,7 +118,6 @@ export default class Qonversion { static UserProperty = UserProperty; static UserProperties = UserProperties; static AttributionProvider = AttributionProvider; - static ProrationMode = ProrationMode; static EntitlementsCacheLifetime = EntitlementsCacheLifetime; static SKPeriodUnit = SKPeriodUnit; static SKProductDiscountType = SKProductDiscountType; @@ -125,12 +130,23 @@ export default class Qonversion { static Offering = Offering; static Offerings = Offerings; static Product = Product; + static PricingPhaseRecurrenceMode = PricingPhaseRecurrenceMode; + static PricingPhaseType = PricingPhaseType; + static PurchaseUpdatePolicy = PurchaseUpdatePolicy; + static ProductInAppDetails = ProductInAppDetails; + static ProductOfferDetails = ProductOfferDetails; + static ProductPrice = ProductPrice; + static ProductPricingPhase = ProductPricingPhase; + static ProductStoreDetails = ProductStoreDetails; + static PurchaseModel = PurchaseModel; + static PurchaseUpdateModel = PurchaseUpdateModel; static QonversionError = QonversionError; static User = User; static SKProduct = SKProduct; static SKProductDiscount = SKProductDiscount; static SKSubscriptionPeriod = SKSubscriptionPeriod; static SkuDetails = SkuDetails; + static SubscriptionPeriod = SubscriptionPeriod; // The rest static Config = QonversionConfig; diff --git a/plugin/src/plugin/QonversionApi.ts b/plugin/src/plugin/QonversionApi.ts index bda8ece..1f10825 100644 --- a/plugin/src/plugin/QonversionApi.ts +++ b/plugin/src/plugin/QonversionApi.ts @@ -1,6 +1,6 @@ import {Entitlement} from './Entitlement'; import {Product} from './Product'; -import {ProrationMode, AttributionProvider, UserPropertyKey} from './enums'; +import {AttributionProvider, UserPropertyKey} from './enums'; import {Offerings} from './Offerings'; import {IntroEligibility} from './IntroEligibility'; import {User} from './User'; @@ -8,6 +8,8 @@ import {EntitlementsUpdateListener} from './EntitlementsUpdateListener'; import {PromoPurchasesListener} from './PromoPurchasesListener'; import {RemoteConfig} from "./RemoteConfig"; import {UserProperties} from './UserProperties'; +import {PurchaseModel} from './PurchaseModel'; +import {PurchaseUpdateModel} from './PurchaseUpdateModel'; export interface QonversionApi { @@ -25,68 +27,30 @@ export 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 @@ export 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/plugin/src/plugin/QonversionInternal.ts b/plugin/src/plugin/QonversionInternal.ts index 4f918a3..26c39aa 100644 --- a/plugin/src/plugin/QonversionInternal.ts +++ b/plugin/src/plugin/QonversionInternal.ts @@ -1,4 +1,4 @@ -import {AttributionProvider, ProrationMode, UserPropertyKey} from "./enums"; +import {AttributionProvider, UserPropertyKey} from "./enums"; import {IntroEligibility} from "./IntroEligibility"; import Mapper, { QEntitlement, @@ -20,6 +20,8 @@ import {QonversionConfig} from './QonversionConfig'; import {EntitlementsUpdateListener} from './EntitlementsUpdateListener'; import {RemoteConfig} from "./RemoteConfig"; import {UserProperties} from './UserProperties'; +import {PurchaseModel} from './PurchaseModel'; +import {PurchaseUpdateModel} from './PurchaseUpdateModel'; const sdkVersion = "4.3.0"; @@ -57,22 +59,13 @@ 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> { + async purchase(purchaseModel: PurchaseModel): Promise> { try { - const purchasePromise = !!offeringId ? - callNative>('purchaseProduct', [productId, offeringId]) - : - callNative>('purchase', [productId]); - - const entitlements = await purchasePromise; + const entitlements = await callNative>('purchase', [ + purchaseModel.productId, + purchaseModel.offerId, + purchaseModel.applyOffer, + ]); // noinspection UnnecessaryLocalVariableJS const mappedEntitlement = Mapper.convertEntitlements(entitlements); @@ -88,28 +81,22 @@ export default class QonversionInternal implements QonversionApi { } } - async updatePurchase( - productId: string, - oldProductId: string, - prorationMode: ProrationMode | undefined - ): Promise | null> { + async updatePurchase(purchaseUpdateModel: PurchaseUpdateModel): Promise | null> { if (!isAndroid()) { return null; } try { - let entitlements; - if (!prorationMode) { - entitlements = await callNative>( - 'updatePurchase', - [productId, oldProductId] - ); - } else { - entitlements = await callNative>( - 'updatePurchaseWithProrationMode', - [productId, oldProductId, prorationMode] - ); - } + const entitlements = await callNative>( + 'updatePurchase', + [ + purchaseUpdateModel.productId, + purchaseUpdateModel.offerId, + purchaseUpdateModel.applyOffer, + purchaseUpdateModel.oldProductId, + purchaseUpdateModel.updatePolicy, + ] + ); // noinspection UnnecessaryLocalVariableJS const mappedEntitlement: Map = Mapper.convertEntitlements(entitlements); @@ -125,43 +112,6 @@ export default class QonversionInternal implements QonversionApi { } } - async updatePurchaseWithProduct( - product: Product, - oldProductId: String, - prorationMode: ProrationMode | undefined - ): Promise | null> { - if (!isAndroid()) { - return null; - } - - try { - let entitlements; - if (!prorationMode) { - entitlements = await callNative>( - 'updateProductWithId', - [product.qonversionID, product.offeringId, oldProductId] - ); - } else { - entitlements = await callNative>( - 'updateProductWithIdAndProrationMode', - [product.qonversionID, product.offeringId, oldProductId, prorationMode] - ); - } - - // noinspection UnnecessaryLocalVariableJS - const mappedEntitlement: Map = Mapper.convertEntitlements(entitlements); - - return mappedEntitlement; - } catch (e: any) { - if (e) { - e.userCanceled = e.code === DefinedNativeErrorCodes.PURCHASE_CANCELLED_BY_USER; - throw e; - } else { - throw 'Unknown error occurred while updating purchase'; - } - } - } - async products(): Promise> { let products = await callNative>('products'); // noinspection UnnecessaryLocalVariableJS diff --git a/plugin/src/plugin/SkuDetails.ts b/plugin/src/plugin/SkuDetails.ts index 06e5b0d..236c625 100644 --- a/plugin/src/plugin/SkuDetails.ts +++ b/plugin/src/plugin/SkuDetails.ts @@ -1,3 +1,6 @@ +/** + * @deprecated + */ export class SkuDetails { description: string; freeTrialPeriod: string; diff --git a/plugin/src/plugin/SubscriptionPeriod.ts b/plugin/src/plugin/SubscriptionPeriod.ts new file mode 100644 index 0000000..ade833b --- /dev/null +++ b/plugin/src/plugin/SubscriptionPeriod.ts @@ -0,0 +1,31 @@ +import {SubscriptionPeriodUnit} from "./enums"; + +/** + * A class describing a subscription period + */ +export 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; + } +} diff --git a/plugin/src/plugin/enums.ts b/plugin/src/plugin/enums.ts index a8cac40..20df961 100644 --- a/plugin/src/plugin/enums.ts +++ b/plugin/src/plugin/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 enum SubscriptionPeriodUnit { + DAY = "Day", + WEEK = "Week", + MONTH = "Month", + YEAR = "Year", + UNKNOWN = "Unknown", +} -export type ProductTypes = typeof ProductType[keyof typeof ProductType]; +/** + * Recurrence mode of the pricing phase. + */ +export enum PricingPhaseRecurrenceMode { + /** + * The billing plan payment recurs for infinite billing periods unless canceled. + */ + INFINITE_RECURRING = "InfiniteRecurring", -export const ProductDuration = { - 0: "WEEKLY", - 1: "MONTHLY", - 2: "3_MONTHS", - 3: "6_MONTHS", - 4: "ANNUAL", - 5: "LIFETIME", -} as const; + /** + * The billing plan payment recurs for a fixed number of billing periods + * set in {@link ProductPricingPhase.billingCycleCount}. + */ + FINITE_RECURRING = "FiniteRecurring", -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 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", -export type TrialDurations = typeof TrialDuration[keyof typeof TrialDuration]; + /** + * 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", + + /** + * 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 = { @@ -163,6 +235,25 @@ export enum IntroEligibilityStatus { INELIGIBLE = "intro_or_trial_ineligible", } +export enum ExperimentGroupType { + UNKNOWN = "unknown", + CONTROL = "control", + TREATMENT = "treatment", +} + +export enum RemoteConfigurationSourceType { + UNKNOWN = "unknown", + EXPERIMENT_CONTROL_GROUP = "experiment_control_group", + EXPERIMENT_TREATMENT_GROUP = "experiment_treatment_group", + REMOTE_CONFIGURATION = "remote_configuration", +} + +export enum RemoteConfigurationAssignmentType { + UNKNOWN = "unknown", + AUTO = "auto", + MANUAL = "manual", +} + export enum ActionResultType { UNKNOWN = "unknown", URL = "url", @@ -192,22 +283,3 @@ export enum AutomationsEventType { SUBSCRIPTION_DOWNGRADED = "subscription_downgraded", SUBSCRIPTION_PRODUCT_CHANGED = "subscription_product_changed", } - -export enum ExperimentGroupType { - UNKNOWN = "unknown", - CONTROL = "control", - TREATMENT = "treatment", -} - -export enum RemoteConfigurationSourceType { - UNKNOWN = "unknown", - EXPERIMENT_CONTROL_GROUP = "experiment_control_group", - EXPERIMENT_TREATMENT_GROUP = "experiment_treatment_group", - REMOTE_CONFIGURATION = "remote_configuration", -} - -export enum RemoteConfigurationAssignmentType { - UNKNOWN = "unknown", - AUTO = "auto", - MANUAL = "manual", -} diff --git a/sample/package.json b/sample/package.json index ead7ecf..1f5272d 100644 --- a/sample/package.json +++ b/sample/package.json @@ -8,7 +8,8 @@ "refresh": "cordova plugin rm cordova-plugin-qonversion && cordova plugin add ../plugin", "android": "cordova run android -- --gradleArg=-PcdvCompileSdkVersion=33", "ios": "cordova run ios", - "test": "echo \"Error: no test specified\" && exit 1" + "test": "echo \"Error: no test specified\" && exit 1", + "xcode": "open \"./platforms/ios/Qonversion Cordova.xcworkspace\"" }, "keywords": [ "ecosystem:cordova" @@ -31,4 +32,4 @@ "cordova-plugin-qonversion": {} } } -} \ No newline at end of file +} diff --git a/sample/www/index.html b/sample/www/index.html index c8748ca..7fc9897 100644 --- a/sample/www/index.html +++ b/sample/www/index.html @@ -47,11 +47,13 @@

Qonversion Cordova Plugin

+
- - + + +
diff --git a/sample/www/js/index.js b/sample/www/js/index.js index 0882449..b1c6b22 100644 --- a/sample/www/js/index.js +++ b/sample/www/js/index.js @@ -26,7 +26,7 @@ const app = { document.getElementById("initialize-sdk").addEventListener("click", this.initializeSdk); document.getElementById("purchase").addEventListener("click", this.purchase); - document.getElementById("purchase-product").addEventListener("click", this.purchaseProduct); + document.getElementById("update-purchase").addEventListener("click", this.updatePurchase); document.getElementById("get-products").addEventListener("click", this.getProducts); document.getElementById("get-remote-config").addEventListener("click", this.getRemoteConfig); document.getElementById("get-offerings").addEventListener("click", this.getOfferings); @@ -73,27 +73,33 @@ const app = { async purchase() { const productId = document.getElementById('product-id').value; + const offerId = document.getElementById('offer-id').value; + const products = await Qonversion.getSharedInstance().products(); + const product = products.get(productId); try { - const entitlements = await Qonversion.getSharedInstance().purchase(productId); + const purchaseModel = product.toPurchaseModel(offerId); + const entitlements = await Qonversion.getSharedInstance().purchase(purchaseModel); console.log('Qonversion purchase:', entitlements, productId); } catch (e) { console.log('Qonversion purchase failed', e); } }, - async purchaseProduct() { - const productId = document.getElementById('purchase-product-id').value; + async updatePurchase() { + const productId = document.getElementById('update-product-id').value; + const oldProductId = document.getElementById('update-product-old-id').value; const products = await Qonversion.getSharedInstance().products(); const product = products.get(productId); try { if (product) { - const entitlements = await Qonversion.getSharedInstance().purchaseProduct(product); - console.log('Qonversion purchaseProduct:', entitlements, product); + const purchaseUpdateModel = product.toPurchaseUpdateModel(oldProductId, Qonversion.PurchaseUpdatePolicy.CHARGE_FULL_PRICE); + const entitlements = await Qonversion.getSharedInstance().updatePurchase(purchaseUpdateModel); + console.log('Qonversion updatePurchase:', entitlements, product); } else { - console.log('Qonversion purchaseProduct:', 'product not found', productId); + console.log('Qonversion updatePurchase:', 'product not found', productId); } } catch (e) { - console.log('Qonversion purchaseProduct failed', e); + console.log('Qonversion updatePurchase failed', e); } }, diff --git a/sample/yarn.lock b/sample/yarn.lock index 975b197..b03d976 100644 --- a/sample/yarn.lock +++ b/sample/yarn.lock @@ -184,7 +184,7 @@ cordova-plugin-device@^2.1.0: integrity sha512-FU0Lw1jZpuKOgG4v80LrfMAOIMCGfAVPumn7AwaX9S1iU/X3OPZUyoKUgP09q4bxL35IeNPkqNWVKYduAXZ1sg== "cordova-plugin-qonversion@file:../plugin": - version "4.2.0" + version "4.3.0" resolved "file:../plugin" cross-spawn@^7.0.1, cross-spawn@^7.0.3: @@ -276,7 +276,17 @@ fs-extra@^10.1.0: jsonfile "^6.0.1" universalify "^2.0.0" -fs-extra@^9.0.0, fs-extra@^9.1.0: +fs-extra@^9.0.0: + version "9.1.0" + resolved "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz" + integrity sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ== + dependencies: + at-least-node "^1.0.0" + graceful-fs "^4.2.0" + jsonfile "^6.0.1" + universalify "^2.0.0" + +fs-extra@^9.1.0: version "9.1.0" resolved "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz" integrity sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ== @@ -365,10 +375,10 @@ ios-sim@^8.0.2: plist "^3.0.1" simctl "^2" -is-core-module@^2.11.0: - version "2.12.1" - resolved "https://registry.npmjs.org/is-core-module/-/is-core-module-2.12.1.tgz" - integrity sha512-Q4ZuBAe2FUsKtyQJoQHlvP8OvBERxO3jEmy1I7hcRXcJBGGHFh/aJBswbXuS9sgrDH2QUO8ilkwNPHvHMd8clg== +is-core-module@^2.13.0: + version "2.13.0" + resolved "https://registry.npmjs.org/is-core-module/-/is-core-module-2.13.0.tgz" + integrity sha512-Z7dk6Qo8pOCp3l4tsX2C5ZVas4V+UxwQodwZhLopL91TX8UyyHEXafPcyoeeWuLrwzHcr3igO78wNLwHJHsMCQ== dependencies: has "^1.0.3" @@ -571,11 +581,11 @@ rechoir@^0.6.2: resolve "^1.1.6" resolve@^1.1.6: - version "1.22.2" - resolved "https://registry.npmjs.org/resolve/-/resolve-1.22.2.tgz" - integrity sha512-Sb+mjNHOULsBv818T40qSPeRiuWLyaGMa5ewydRLFimneixmVy2zdivRl+AF6jaYPC8ERxGDmFSiqui6SfPd+g== + version "1.22.4" + resolved "https://registry.npmjs.org/resolve/-/resolve-1.22.4.tgz" + integrity sha512-PXNdCiPqDqeUou+w1C2eTQbNfxKSuMxqTCuvlmmMsk1NWHL5fRrhY6Pl0qEYYc6+QqGClco1Qj8XnjPego4wfg== dependencies: - is-core-module "^2.11.0" + is-core-module "^2.13.0" path-parse "^1.0.7" supports-preserve-symlinks-flag "^1.0.0"