SubscriptionManager.cs 50 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075
  1. using System;
  2. using System.Xml;
  3. using System.Reflection;
  4. using System.Collections.Generic;
  5. using UnityEngine.Purchasing;
  6. using UnityEngine.Purchasing.Security;
  7. using UnityEngine;
  8. namespace UnityEngine.Purchasing
  9. {
  10. /// <summary>
  11. /// A period of time expressed in either days, months, or years. Conveys a subscription's duration definition.
  12. /// Note this reflects the types of subscription durations settable on a subscription on supported app stores.
  13. /// </summary>
  14. public class TimeSpanUnits
  15. {
  16. /// <summary>
  17. /// Discrete duration in days, if less than a month, otherwise zero.
  18. /// </summary>
  19. public double days;
  20. /// <summary>
  21. /// Discrete duration in months, if less than a year, otherwise zero.
  22. /// </summary>
  23. public int months;
  24. /// <summary>
  25. /// Discrete duration in years, otherwise zero.
  26. /// </summary>
  27. public int years;
  28. /// <summary>
  29. /// Construct a subscription duration.
  30. /// </summary>
  31. /// <param name="d">Discrete duration in days, if less than a month, otherwise zero.</param>
  32. /// <param name="m">Discrete duration in months, if less than a year, otherwise zero.</param>
  33. /// <param name="y">Discrete duration in years, otherwise zero.</param>
  34. public TimeSpanUnits(double d, int m, int y)
  35. {
  36. this.days = d;
  37. this.months = m;
  38. this.years = y;
  39. }
  40. }
  41. /// <summary>
  42. /// Use to query in-app purchasing subscription product information, and upgrade subscription products.
  43. /// Supports the Apple App Store, Google Play store, and Amazon AppStore.
  44. /// Note Amazon support offers no subscription duration information.
  45. /// Note expiration dates may become invalid after updating subscriptions between two types of duration.
  46. /// </summary>
  47. /// <seealso cref="IAppleExtensions.GetIntroductoryPriceDictionary"/>
  48. /// <seealso cref="UpdateSubscription"/>
  49. public class SubscriptionManager
  50. {
  51. private string receipt;
  52. private string productId;
  53. private string intro_json;
  54. /// <summary>
  55. /// Performs subscription updating, migrating a subscription into another as long as they are both members
  56. /// of the same subscription group on the App Store.
  57. /// </summary>
  58. /// <param name="newProduct">Destination subscription product, belonging to the same subscription group as <paramref name="oldProduct"/></param>
  59. /// <param name="oldProduct">Source subscription product, belonging to the same subscription group as <paramref name="newProduct"/></param>
  60. /// <param name="developerPayload">Carried-over metadata from prior call to <typeparamref name="SubscriptionManager.UpdateSubscription"/> </param>
  61. /// <param name="appleStore">Triggered upon completion of the subscription update.</param>
  62. /// <param name="googleStore">Triggered upon completion of the subscription update.</param>
  63. public static void UpdateSubscription(Product newProduct, Product oldProduct, string developerPayload, Action<Product, string> appleStore, Action<string, string> googleStore)
  64. {
  65. if (oldProduct.receipt == null)
  66. {
  67. Debug.LogError("The product has not been purchased, a subscription can only be upgrade/downgrade when has already been purchased");
  68. return;
  69. }
  70. var receipt_wrapper = (Dictionary<string, object>)MiniJson.JsonDecode(oldProduct.receipt);
  71. if (receipt_wrapper == null || !receipt_wrapper.ContainsKey("Store") || !receipt_wrapper.ContainsKey("Payload"))
  72. {
  73. Debug.LogWarning("The product receipt does not contain enough information");
  74. return;
  75. }
  76. var store = (string)receipt_wrapper["Store"];
  77. var payload = (string)receipt_wrapper["Payload"];
  78. if (payload != null)
  79. {
  80. switch (store)
  81. {
  82. case "GooglePlay":
  83. {
  84. SubscriptionManager oldSubscriptionManager = new SubscriptionManager(oldProduct, null);
  85. SubscriptionInfo oldSubscriptionInfo = null;
  86. try
  87. {
  88. oldSubscriptionInfo = oldSubscriptionManager.getSubscriptionInfo();
  89. }
  90. catch (Exception e)
  91. {
  92. Debug.unityLogger.LogError("Error: the product that will be updated does not have a valid receipt", e);
  93. return;
  94. }
  95. string newSubscriptionId = newProduct.definition.storeSpecificId;
  96. googleStore(oldSubscriptionInfo.getSubscriptionInfoJsonString(), newSubscriptionId);
  97. return;
  98. }
  99. case "AppleAppStore":
  100. case "MacAppStore":
  101. {
  102. appleStore(newProduct, developerPayload);
  103. return;
  104. }
  105. default:
  106. {
  107. Debug.LogWarning("This store does not support update subscriptions");
  108. return;
  109. }
  110. }
  111. }
  112. }
  113. /// <summary>
  114. /// Performs subscription updating, migrating a subscription into another as long as they are both members
  115. /// of the same subscription group on the App Store.
  116. /// </summary>
  117. /// <param name="oldProduct">Source subscription product, belonging to the same subscription group as <paramref name="newProduct"/></param>
  118. /// <param name="newProduct">Destination subscription product, belonging to the same subscription group as <paramref name="oldProduct"/></param>
  119. /// <param name="googlePlayUpdateCallback">Triggered upon completion of the subscription update.</param>
  120. public static void UpdateSubscriptionInGooglePlayStore(Product oldProduct, Product newProduct, Action<string, string> googlePlayUpdateCallback)
  121. {
  122. SubscriptionManager oldSubscriptionManager = new SubscriptionManager(oldProduct, null);
  123. SubscriptionInfo oldSubscriptionInfo = null;
  124. try
  125. {
  126. oldSubscriptionInfo = oldSubscriptionManager.getSubscriptionInfo();
  127. }
  128. catch (Exception e)
  129. {
  130. Debug.unityLogger.LogError("Error: the product that will be updated does not have a valid receipt", e);
  131. return;
  132. }
  133. string newSubscriptionId = newProduct.definition.storeSpecificId;
  134. googlePlayUpdateCallback(oldSubscriptionInfo.getSubscriptionInfoJsonString(), newSubscriptionId);
  135. }
  136. /// <summary>
  137. /// Performs subscription updating, migrating a subscription into another as long as they are both members
  138. /// of the same subscription group on the App Store.
  139. /// </summary>
  140. /// <param name="newProduct">Destination subscription product, belonging to the same subscription group as <paramref name="oldProduct"/></param>
  141. /// <param name="developerPayload">Carried-over metadata from prior call to <typeparamref name="SubscriptionManager.UpdateSubscription"/> </param>
  142. /// <param name="appleStoreUpdateCallback">Triggered upon completion of the subscription update.</param>
  143. public static void UpdateSubscriptionInAppleStore(Product newProduct, string developerPayload, Action<Product, string> appleStoreUpdateCallback)
  144. {
  145. appleStoreUpdateCallback(newProduct, developerPayload);
  146. }
  147. /// <summary>
  148. /// Construct an object that allows inspection of a subscription product.
  149. /// </summary>
  150. /// <param name="product">Subscription to be inspected</param>
  151. /// <param name="intro_json">From <typeparamref name="IAppleExtensions.GetIntroductoryPriceDictionary"/></param>
  152. public SubscriptionManager(Product product, string intro_json)
  153. {
  154. this.receipt = product.receipt;
  155. this.productId = product.definition.storeSpecificId;
  156. this.intro_json = intro_json;
  157. }
  158. /// <summary>
  159. /// Construct an object that allows inspection of a subscription product.
  160. /// </summary>
  161. /// <param name="receipt">A Unity IAP unified receipt from <typeparamref name="Product.receipt"/></param>
  162. /// <param name="id">A product identifier.</param>
  163. /// <param name="intro_json">From <typeparamref name="IAppleExtensions.GetIntroductoryPriceDictionary"/></param>
  164. public SubscriptionManager(string receipt, string id, string intro_json)
  165. {
  166. this.receipt = receipt;
  167. this.productId = id;
  168. this.intro_json = intro_json;
  169. }
  170. /// <summary>
  171. /// Convert my Product into a <typeparamref name="SubscriptionInfo"/>.
  172. /// My Product.receipt must have a "Payload" JSON key containing supported native app store
  173. /// information, which will be converted here.
  174. /// </summary>
  175. /// <returns></returns>
  176. /// <exception cref="NullProductIdException">My Product must have a non-null product identifier</exception>
  177. /// <exception cref="StoreSubscriptionInfoNotSupportedException">A supported app store must be used as my product</exception>
  178. /// <exception cref="NullReceiptException">My product must have</exception>
  179. public SubscriptionInfo getSubscriptionInfo()
  180. {
  181. if (this.receipt != null)
  182. {
  183. var receipt_wrapper = (Dictionary<string, object>)MiniJson.JsonDecode(receipt);
  184. var validPayload = receipt_wrapper.TryGetValue("Payload", out var payloadAsObject);
  185. var validStore = receipt_wrapper.TryGetValue("Store", out var storeAsObject);
  186. if (validPayload && validStore)
  187. {
  188. var payload = payloadAsObject as string;
  189. var store = storeAsObject as string;
  190. switch (store)
  191. {
  192. case GooglePlay.Name:
  193. {
  194. return getGooglePlayStoreSubInfo(payload);
  195. }
  196. case AppleAppStore.Name:
  197. case MacAppStore.Name:
  198. {
  199. if (this.productId == null)
  200. {
  201. throw new NullProductIdException();
  202. }
  203. return getAppleAppStoreSubInfo(payload, this.productId);
  204. }
  205. case AmazonApps.Name:
  206. {
  207. return getAmazonAppStoreSubInfo(this.productId);
  208. }
  209. default:
  210. {
  211. throw new StoreSubscriptionInfoNotSupportedException("Store not supported: " + store);
  212. }
  213. }
  214. }
  215. }
  216. throw new NullReceiptException();
  217. }
  218. private SubscriptionInfo getAmazonAppStoreSubInfo(string productId)
  219. {
  220. return new SubscriptionInfo(productId);
  221. }
  222. private SubscriptionInfo getAppleAppStoreSubInfo(string payload, string productId)
  223. {
  224. AppleReceipt receipt = null;
  225. var logger = UnityEngine.Debug.unityLogger;
  226. try
  227. {
  228. receipt = new AppleReceiptParser().Parse(Convert.FromBase64String(payload));
  229. }
  230. catch (ArgumentException e)
  231. {
  232. logger.Log("Unable to parse Apple receipt", e);
  233. }
  234. catch (Security.IAPSecurityException e)
  235. {
  236. logger.Log("Unable to parse Apple receipt", e);
  237. }
  238. catch (NullReferenceException e)
  239. {
  240. logger.Log("Unable to parse Apple receipt", e);
  241. }
  242. List<AppleInAppPurchaseReceipt> inAppPurchaseReceipts = new List<AppleInAppPurchaseReceipt>();
  243. if (receipt != null && receipt.inAppPurchaseReceipts != null && receipt.inAppPurchaseReceipts.Length > 0)
  244. {
  245. foreach (AppleInAppPurchaseReceipt r in receipt.inAppPurchaseReceipts)
  246. {
  247. if (r.productID.Equals(productId))
  248. {
  249. inAppPurchaseReceipts.Add(r);
  250. }
  251. }
  252. }
  253. return inAppPurchaseReceipts.Count == 0 ? null : new SubscriptionInfo(findMostRecentReceipt(inAppPurchaseReceipts), this.intro_json);
  254. }
  255. private AppleInAppPurchaseReceipt findMostRecentReceipt(List<AppleInAppPurchaseReceipt> receipts)
  256. {
  257. receipts.Sort((b, a) => (a.purchaseDate.CompareTo(b.purchaseDate)));
  258. return receipts[0];
  259. }
  260. private SubscriptionInfo getGooglePlayStoreSubInfo(string payload)
  261. {
  262. var payload_wrapper = (Dictionary<string, object>)MiniJson.JsonDecode(payload);
  263. var validSkuDetailsKey = payload_wrapper.TryGetValue("skuDetails", out var skuDetailsObject);
  264. string skuDetails = null;
  265. if (validSkuDetailsKey) skuDetails = skuDetailsObject as string;
  266. var purchaseHistorySupported = false;
  267. var original_json_payload_wrapper =
  268. (Dictionary<string, object>)MiniJson.JsonDecode((string)payload_wrapper["json"]);
  269. var validIsAutoRenewingKey =
  270. original_json_payload_wrapper.TryGetValue("autoRenewing", out var autoRenewingObject);
  271. var isAutoRenewing = false;
  272. if (validIsAutoRenewingKey) isAutoRenewing = (bool)autoRenewingObject;
  273. // Google specifies times in milliseconds since 1970.
  274. DateTime epoch = new DateTime(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc);
  275. var validPurchaseTimeKey =
  276. original_json_payload_wrapper.TryGetValue("purchaseTime", out var purchaseTimeObject);
  277. long purchaseTime = 0;
  278. if (validPurchaseTimeKey) purchaseTime = (long)purchaseTimeObject;
  279. var purchaseDate = epoch.AddMilliseconds(purchaseTime);
  280. var validDeveloperPayloadKey =
  281. original_json_payload_wrapper.TryGetValue("developerPayload", out var developerPayloadObject);
  282. var isFreeTrial = false;
  283. var hasIntroductoryPrice = false;
  284. string updateMetadata = null;
  285. if (validDeveloperPayloadKey)
  286. {
  287. var developerPayloadJSON = (string)developerPayloadObject;
  288. var developerPayload_wrapper = (Dictionary<string, object>)MiniJson.JsonDecode(developerPayloadJSON);
  289. var validIsFreeTrialKey =
  290. developerPayload_wrapper.TryGetValue("is_free_trial", out var isFreeTrialObject);
  291. if (validIsFreeTrialKey) isFreeTrial = (bool)isFreeTrialObject;
  292. var validHasIntroductoryPriceKey =
  293. developerPayload_wrapper.TryGetValue("has_introductory_price_trial",
  294. out var hasIntroductoryPriceObject);
  295. if (validHasIntroductoryPriceKey) hasIntroductoryPrice = (bool)hasIntroductoryPriceObject;
  296. var validIsUpdatedKey = developerPayload_wrapper.TryGetValue("is_updated", out var isUpdatedObject);
  297. var isUpdated = false;
  298. if (validIsUpdatedKey) isUpdated = (bool)isUpdatedObject;
  299. if (isUpdated)
  300. {
  301. var isValidUpdateMetaKey = developerPayload_wrapper.TryGetValue("update_subscription_metadata",
  302. out var updateMetadataObject);
  303. if (isValidUpdateMetaKey) updateMetadata = (string)updateMetadataObject;
  304. }
  305. }
  306. return new SubscriptionInfo(skuDetails, isAutoRenewing, purchaseDate, isFreeTrial, hasIntroductoryPrice,
  307. purchaseHistorySupported, updateMetadata);
  308. }
  309. }
  310. /// <summary>
  311. /// A container for a Product’s subscription-related information.
  312. /// </summary>
  313. /// <seealso cref="SubscriptionManager.getSubscriptionInfo"/>
  314. public class SubscriptionInfo
  315. {
  316. private Result is_subscribed;
  317. private Result is_expired;
  318. private Result is_cancelled;
  319. private Result is_free_trial;
  320. private Result is_auto_renewing;
  321. private Result is_introductory_price_period;
  322. private string productId;
  323. private DateTime purchaseDate;
  324. private DateTime subscriptionExpireDate;
  325. private DateTime subscriptionCancelDate;
  326. private TimeSpan remainedTime;
  327. private string introductory_price;
  328. private TimeSpan introductory_price_period;
  329. private long introductory_price_cycles;
  330. private TimeSpan freeTrialPeriod;
  331. private TimeSpan subscriptionPeriod;
  332. // for test
  333. private string free_trial_period_string;
  334. private string sku_details;
  335. /// <summary>
  336. /// Unpack Apple receipt subscription data.
  337. /// </summary>
  338. /// <param name="r">The Apple receipt from <typeparamref name="CrossPlatformValidator"/></param>
  339. /// <param name="intro_json">From <typeparamref name="IAppleExtensions.GetIntroductoryPriceDictionary"/>. Keys:
  340. /// <c>introductoryPriceLocale</c>, <c>introductoryPrice</c>, <c>introductoryPriceNumberOfPeriods</c>, <c>numberOfUnits</c>,
  341. /// <c>unit</c>, which can be fetched from Apple's remote service.</param>
  342. /// <exception cref="InvalidProductTypeException">Error found involving an invalid product type.</exception>
  343. /// <see cref="UnityEngine.Purchasing.Security.CrossPlatformValidator"/>
  344. public SubscriptionInfo(AppleInAppPurchaseReceipt r, string intro_json)
  345. {
  346. var productType = (AppleStoreProductType)Enum.Parse(typeof(AppleStoreProductType), r.productType.ToString());
  347. if (productType == AppleStoreProductType.Consumable || productType == AppleStoreProductType.NonConsumable)
  348. {
  349. throw new InvalidProductTypeException();
  350. }
  351. if (!string.IsNullOrEmpty(intro_json))
  352. {
  353. var intro_wrapper = (Dictionary<string, object>)MiniJson.JsonDecode(intro_json);
  354. var nunit = -1;
  355. var unit = SubscriptionPeriodUnit.NotAvailable;
  356. this.introductory_price = intro_wrapper.TryGetString("introductoryPrice") + intro_wrapper.TryGetString("introductoryPriceLocale");
  357. if (string.IsNullOrEmpty(this.introductory_price))
  358. {
  359. this.introductory_price = "not available";
  360. }
  361. else
  362. {
  363. try
  364. {
  365. this.introductory_price_cycles = Convert.ToInt64(intro_wrapper.TryGetString("introductoryPriceNumberOfPeriods"));
  366. nunit = Convert.ToInt32(intro_wrapper.TryGetString("numberOfUnits"));
  367. unit = (SubscriptionPeriodUnit)Convert.ToInt32(intro_wrapper.TryGetString("unit"));
  368. }
  369. catch (Exception e)
  370. {
  371. Debug.unityLogger.Log("Unable to parse introductory period cycles and duration, this product does not have configuration of introductory price period", e);
  372. unit = SubscriptionPeriodUnit.NotAvailable;
  373. }
  374. }
  375. DateTime now = DateTime.Now;
  376. switch (unit)
  377. {
  378. case SubscriptionPeriodUnit.Day:
  379. this.introductory_price_period = TimeSpan.FromTicks(TimeSpan.FromDays(1).Ticks * nunit);
  380. break;
  381. case SubscriptionPeriodUnit.Month:
  382. TimeSpan month_span = now.AddMonths(1) - now;
  383. this.introductory_price_period = TimeSpan.FromTicks(month_span.Ticks * nunit);
  384. break;
  385. case SubscriptionPeriodUnit.Week:
  386. this.introductory_price_period = TimeSpan.FromTicks(TimeSpan.FromDays(7).Ticks * nunit);
  387. break;
  388. case SubscriptionPeriodUnit.Year:
  389. TimeSpan year_span = now.AddYears(1) - now;
  390. this.introductory_price_period = TimeSpan.FromTicks(year_span.Ticks * nunit);
  391. break;
  392. case SubscriptionPeriodUnit.NotAvailable:
  393. this.introductory_price_period = TimeSpan.Zero;
  394. this.introductory_price_cycles = 0;
  395. break;
  396. }
  397. }
  398. else
  399. {
  400. this.introductory_price = "not available";
  401. this.introductory_price_period = TimeSpan.Zero;
  402. this.introductory_price_cycles = 0;
  403. }
  404. DateTime current_date = DateTime.UtcNow;
  405. this.purchaseDate = r.purchaseDate;
  406. this.productId = r.productID;
  407. this.subscriptionExpireDate = r.subscriptionExpirationDate;
  408. this.subscriptionCancelDate = r.cancellationDate;
  409. // if the product is non-renewing subscription, apple store will not return expiration date for this product
  410. if (productType == AppleStoreProductType.NonRenewingSubscription)
  411. {
  412. this.is_subscribed = Result.Unsupported;
  413. this.is_expired = Result.Unsupported;
  414. this.is_cancelled = Result.Unsupported;
  415. this.is_free_trial = Result.Unsupported;
  416. this.is_auto_renewing = Result.Unsupported;
  417. this.is_introductory_price_period = Result.Unsupported;
  418. }
  419. else
  420. {
  421. this.is_cancelled = (r.cancellationDate.Ticks > 0) && (r.cancellationDate.Ticks < current_date.Ticks) ? Result.True : Result.False;
  422. this.is_subscribed = r.subscriptionExpirationDate.Ticks >= current_date.Ticks ? Result.True : Result.False;
  423. this.is_expired = (r.subscriptionExpirationDate.Ticks > 0 && r.subscriptionExpirationDate.Ticks < current_date.Ticks) ? Result.True : Result.False;
  424. this.is_free_trial = (r.isFreeTrial == 1) ? Result.True : Result.False;
  425. this.is_auto_renewing = ((productType == AppleStoreProductType.AutoRenewingSubscription) && this.is_cancelled == Result.False
  426. && this.is_expired == Result.False) ? Result.True : Result.False;
  427. this.is_introductory_price_period = r.isIntroductoryPricePeriod == 1 ? Result.True : Result.False;
  428. }
  429. if (this.is_subscribed == Result.True)
  430. {
  431. this.remainedTime = r.subscriptionExpirationDate.Subtract(current_date);
  432. }
  433. else
  434. {
  435. this.remainedTime = TimeSpan.Zero;
  436. }
  437. }
  438. /// <summary>
  439. /// Especially crucial values relating to Google subscription products.
  440. /// Note this is intended to be called internally.
  441. /// </summary>
  442. /// <param name="skuDetails">The raw JSON from <c>SkuDetail.getOriginalJson</c></param>
  443. /// <param name="isAutoRenewing">Whether this subscription is expected to auto-renew</param>
  444. /// <param name="purchaseDate">A date this subscription was billed</param>
  445. /// <param name="isFreeTrial">Indicates whether this Product is a free trial</param>
  446. /// <param name="hasIntroductoryPriceTrial">Indicates whether this Product may be owned with an introductory price period.</param>
  447. /// <param name="purchaseHistorySupported">Unsupported</param>
  448. /// <param name="updateMetadata">Unsupported. Mechanism previously propagated subscription upgrade information to new subscription. </param>
  449. /// <exception cref="InvalidProductTypeException">For non-subscription product types. </exception>
  450. public SubscriptionInfo(string skuDetails, bool isAutoRenewing, DateTime purchaseDate, bool isFreeTrial,
  451. bool hasIntroductoryPriceTrial, bool purchaseHistorySupported, string updateMetadata)
  452. {
  453. var skuDetails_wrapper = (Dictionary<string, object>)MiniJson.JsonDecode(skuDetails);
  454. var validTypeKey = skuDetails_wrapper.TryGetValue("type", out var typeObject);
  455. if (!validTypeKey || (string)typeObject == "inapp")
  456. {
  457. throw new InvalidProductTypeException();
  458. }
  459. var validProductIdKey = skuDetails_wrapper.TryGetValue("productId", out var productIdObject);
  460. productId = null;
  461. if (validProductIdKey) productId = productIdObject as string;
  462. this.purchaseDate = purchaseDate;
  463. this.is_subscribed = Result.True;
  464. this.is_auto_renewing = isAutoRenewing ? Result.True : Result.False;
  465. this.is_expired = Result.False;
  466. this.is_cancelled = isAutoRenewing ? Result.False : Result.True;
  467. this.is_free_trial = Result.False;
  468. string sub_period = null;
  469. if (skuDetails_wrapper.ContainsKey("subscriptionPeriod"))
  470. {
  471. sub_period = (string)skuDetails_wrapper["subscriptionPeriod"];
  472. }
  473. string free_trial_period = null;
  474. if (skuDetails_wrapper.ContainsKey("freeTrialPeriod"))
  475. {
  476. free_trial_period = (string)skuDetails_wrapper["freeTrialPeriod"];
  477. }
  478. string introductory_price = null;
  479. if (skuDetails_wrapper.ContainsKey("introductoryPrice"))
  480. {
  481. introductory_price = (string)skuDetails_wrapper["introductoryPrice"];
  482. }
  483. string introductory_price_period_string = null;
  484. if (skuDetails_wrapper.ContainsKey("introductoryPricePeriod"))
  485. {
  486. introductory_price_period_string = (string)skuDetails_wrapper["introductoryPricePeriod"];
  487. }
  488. long introductory_price_cycles = 0;
  489. if (skuDetails_wrapper.ContainsKey("introductoryPriceCycles"))
  490. {
  491. introductory_price_cycles = (long)skuDetails_wrapper["introductoryPriceCycles"];
  492. }
  493. // for test
  494. free_trial_period_string = free_trial_period;
  495. this.subscriptionPeriod = computePeriodTimeSpan(parsePeriodTimeSpanUnits(sub_period));
  496. this.freeTrialPeriod = TimeSpan.Zero;
  497. if (isFreeTrial)
  498. {
  499. this.freeTrialPeriod = parseTimeSpan(free_trial_period);
  500. }
  501. this.introductory_price = introductory_price;
  502. this.introductory_price_cycles = introductory_price_cycles;
  503. this.introductory_price_period = TimeSpan.Zero;
  504. this.is_introductory_price_period = Result.False;
  505. TimeSpan total_introductory_duration = TimeSpan.Zero;
  506. if (hasIntroductoryPriceTrial)
  507. {
  508. if (introductory_price_period_string != null && introductory_price_period_string.Equals(sub_period))
  509. {
  510. this.introductory_price_period = this.subscriptionPeriod;
  511. }
  512. else
  513. {
  514. this.introductory_price_period = parseTimeSpan(introductory_price_period_string);
  515. }
  516. // compute the total introductory duration according to the introductory price period and period cycles
  517. total_introductory_duration = accumulateIntroductoryDuration(parsePeriodTimeSpanUnits(introductory_price_period_string), this.introductory_price_cycles);
  518. }
  519. // if this subscription is updated from other subscription, the remaining time will be applied to this subscription
  520. TimeSpan extra_time = TimeSpan.FromSeconds(updateMetadata == null ? 0.0 : computeExtraTime(updateMetadata, this.subscriptionPeriod.TotalSeconds));
  521. TimeSpan time_since_purchased = DateTime.UtcNow.Subtract(purchaseDate);
  522. // this subscription is still in the extra time (the time left by the previous subscription when updated to the current one)
  523. if (time_since_purchased <= extra_time)
  524. {
  525. // this subscription is in the remaining credits from the previous updated one
  526. this.subscriptionExpireDate = purchaseDate.Add(extra_time);
  527. }
  528. else if (time_since_purchased <= this.freeTrialPeriod.Add(extra_time))
  529. {
  530. // this subscription is in the free trial period
  531. // this product will be valid until free trial ends, the beginning of next billing date
  532. this.is_free_trial = Result.True;
  533. this.subscriptionExpireDate = purchaseDate.Add(this.freeTrialPeriod.Add(extra_time));
  534. }
  535. else if (time_since_purchased < this.freeTrialPeriod.Add(extra_time).Add(total_introductory_duration))
  536. {
  537. // this subscription is in the introductory price period
  538. this.is_introductory_price_period = Result.True;
  539. DateTime introductory_price_begin_date = this.purchaseDate.Add(this.freeTrialPeriod.Add(extra_time));
  540. this.subscriptionExpireDate = nextBillingDate(introductory_price_begin_date, parsePeriodTimeSpanUnits(introductory_price_period_string));
  541. }
  542. else
  543. {
  544. // no matter sub is cancelled or not, the expire date will be next billing date
  545. DateTime billing_begin_date = this.purchaseDate.Add(this.freeTrialPeriod.Add(extra_time).Add(total_introductory_duration));
  546. this.subscriptionExpireDate = nextBillingDate(billing_begin_date, parsePeriodTimeSpanUnits(sub_period));
  547. }
  548. this.remainedTime = this.subscriptionExpireDate.Subtract(DateTime.UtcNow);
  549. this.sku_details = skuDetails;
  550. }
  551. /// <summary>
  552. /// Especially crucial values relating to subscription products.
  553. /// Note this is intended to be called internally.
  554. /// </summary>
  555. /// <param name="productId">This subscription's product identifier</param>
  556. public SubscriptionInfo(string productId)
  557. {
  558. this.productId = productId;
  559. this.is_subscribed = Result.True;
  560. this.is_expired = Result.False;
  561. this.is_cancelled = Result.Unsupported;
  562. this.is_free_trial = Result.Unsupported;
  563. this.is_auto_renewing = Result.Unsupported;
  564. this.remainedTime = TimeSpan.MaxValue;
  565. this.is_introductory_price_period = Result.Unsupported;
  566. this.introductory_price_period = TimeSpan.MaxValue;
  567. this.introductory_price = null;
  568. this.introductory_price_cycles = 0;
  569. }
  570. /// <summary>
  571. /// Store specific product identifier.
  572. /// </summary>
  573. /// <returns>The product identifier from the store receipt.</returns>
  574. public string getProductId() { return this.productId; }
  575. /// <summary>
  576. /// A date this subscription was billed.
  577. /// Note the store-specific behavior.
  578. /// </summary>
  579. /// <returns>
  580. /// For Apple, the purchase date is the date when the subscription was either purchased or renewed.
  581. /// For Google, the purchase date is the date when the subscription was originally purchased.
  582. /// </returns>
  583. public DateTime getPurchaseDate() { return this.purchaseDate; }
  584. /// <summary>
  585. /// Indicates whether this auto-renewable subscription Product is currently subscribed or not.
  586. /// Note the store-specific behavior.
  587. /// Note also that the receipt may update and change this subscription expiration status if the user sends
  588. /// their iOS app to the background and then returns it to the foreground. It is therefore recommended to remember
  589. /// subscription expiration state at app-launch, and ignore the fact that a subscription may expire later during
  590. /// this app launch runtime session.
  591. /// </summary>
  592. /// <returns>
  593. /// <typeparamref name="Result.True"/> Subscription status if the store receipt's expiration date is
  594. /// after the device's current time.
  595. /// <typeparamref name="Result.False"/> otherwise.
  596. /// Non-renewable subscriptions in the Apple store return a <typeparamref name="Result.Unsupported"/> value.
  597. /// </returns>
  598. /// <seealso cref="isExpired"/>
  599. /// <seealso cref="DateTime.UtcNow"/>
  600. public Result isSubscribed() { return this.is_subscribed; }
  601. /// <summary>
  602. /// Indicates whether this auto-renewable subscription Product is currently unsubscribed or not.
  603. /// Note the store-specific behavior.
  604. /// Note also that the receipt may update and change this subscription expiration status if the user sends
  605. /// their iOS app to the background and then returns it to the foreground. It is therefore recommended to remember
  606. /// subscription expiration state at app-launch, and ignore the fact that a subscription may expire later during
  607. /// this app launch runtime session.
  608. /// </summary>
  609. /// <returns>
  610. /// <typeparamref name="Result.True"/> Subscription status if the store receipt's expiration date is
  611. /// before the device's current time.
  612. /// <typeparamref name="Result.False"/> otherwise.
  613. /// Non-renewable subscriptions in the Apple store return a <typeparamref name="Result.Unsupported"/> value.
  614. /// </returns>
  615. /// <seealso cref="isSubscribed"/>
  616. /// <seealso cref="DateTime.UtcNow"/>
  617. public Result isExpired() { return this.is_expired; }
  618. /// <summary>
  619. /// Indicates whether this Product has been cancelled.
  620. /// A cancelled subscription means the Product is currently subscribed, and will not renew on the next billing date.
  621. /// </summary>
  622. /// <returns>
  623. /// <typeparamref name="Result.True"/> Cancellation status if the store receipt's indicates this subscription is cancelled.
  624. /// <typeparamref name="Result.False"/> otherwise.
  625. /// Non-renewable subscriptions in the Apple store return a <typeparamref name="Result.Unsupported"/> value.
  626. /// </returns>
  627. public Result isCancelled() { return this.is_cancelled; }
  628. /// <summary>
  629. /// Indicates whether this Product is a free trial.
  630. /// Note the store-specific behavior.
  631. /// </summary>
  632. /// <returns>
  633. /// <typeparamref name="Result.True"/> This subscription is a free trial according to the store receipt.
  634. /// <typeparamref name="Result.False"/> This subscription is not a free trial according to the store receipt.
  635. /// Non-renewable subscriptions in the Apple store
  636. /// and Google subscriptions queried on devices with version lower than 6 of the Android in-app billing API return a <typeparamref name="Result.Unsupported"/> value.
  637. /// </returns>
  638. public Result isFreeTrial() { return this.is_free_trial; }
  639. /// <summary>
  640. /// Indicates whether this Product is expected to auto-renew. The product must be auto-renewable, not canceled, and not expired.
  641. /// </summary>
  642. /// <returns>
  643. /// <typeparamref name="Result.True"/> The store receipt's indicates this subscription is auto-renewing.
  644. /// <typeparamref name="Result.False"/> The store receipt's indicates this subscription is not auto-renewing.
  645. /// Non-renewable subscriptions in the Apple store return a <typeparamref name="Result.Unsupported"/> value.
  646. /// </returns>
  647. public Result isAutoRenewing() { return this.is_auto_renewing; }
  648. /// <summary>
  649. /// Indicates how much time remains until the next billing date.
  650. /// Note the store-specific behavior.
  651. /// Note also that the receipt may update and change this subscription expiration status if the user sends
  652. /// their iOS app to the background and then returns it to the foreground.
  653. /// </summary>
  654. /// <returns>
  655. /// A time duration from now until subscription billing occurs.
  656. /// Google subscriptions queried on devices with version lower than 6 of the Android in-app billing API return <typeparamref name="TimeSpan.MaxValue"/>.
  657. /// </returns>
  658. /// <seealso cref="DateTime.UtcNow"/>
  659. public TimeSpan getRemainingTime() { return this.remainedTime; }
  660. /// <summary>
  661. /// Indicates whether this Product is currently owned within an introductory price period.
  662. /// Note the store-specific behavior.
  663. /// </summary>
  664. /// <returns>
  665. /// <typeparamref name="Result.True"/> The store receipt's indicates this subscription is within its introductory price period.
  666. /// <typeparamref name="Result.False"/> The store receipt's indicates this subscription is not within its introductory price period.
  667. /// <typeparamref name="Result.False"/> If the product is not configured to have an introductory period.
  668. /// Non-renewable subscriptions in the Apple store return a <typeparamref name="Result.Unsupported"/> value.
  669. /// Google subscriptions queried on devices with version lower than 6 of the Android in-app billing API return a <typeparamref name="Result.Unsupported"/> value.
  670. /// </returns>
  671. public Result isIntroductoryPricePeriod() { return this.is_introductory_price_period; }
  672. /// <summary>
  673. /// Indicates how much time remains for the introductory price period.
  674. /// Note the store-specific behavior.
  675. /// </summary>
  676. /// <returns>
  677. /// Duration remaining in this product's introductory price period.
  678. /// Subscription products with no introductory price period return <typeparamref name="TimeSpan.Zero"/>.
  679. /// Products in the Apple store return <typeparamref name="TimeSpan.Zero"/> if the application does
  680. /// not support iOS version 11.2+, macOS 10.13.2+, or tvOS 11.2+.
  681. /// <typeparamref name="TimeSpan.Zero"/> returned also for products which do not have an introductory period configured.
  682. /// </returns>
  683. public TimeSpan getIntroductoryPricePeriod() { return this.introductory_price_period; }
  684. /// <summary>
  685. /// For subscriptions with an introductory price, get this price.
  686. /// Note the store-specific behavior.
  687. /// </summary>
  688. /// <returns>
  689. /// For subscriptions with a introductory price, a localized price string.
  690. /// For Google store the price may not include the currency symbol (e.g. $) and the currency code is available in <typeparamref name="ProductMetadata.isoCurrencyCode"/>.
  691. /// For all other product configurations, the string <c>"not available"</c>.
  692. /// </returns>
  693. /// <seealso cref="ProductMetadata.isoCurrencyCode"/>
  694. public string getIntroductoryPrice() { return string.IsNullOrEmpty(this.introductory_price) ? "not available" : this.introductory_price; }
  695. /// <summary>
  696. /// Indicates the number of introductory price billing periods that can be applied to this subscription Product.
  697. /// Note the store-specific behavior.
  698. /// </summary>
  699. /// <returns>
  700. /// Products in the Apple store return <c>0</c> if the application does not support iOS version 11.2+, macOS 10.13.2+, or tvOS 11.2+.
  701. /// <c>0</c> returned also for products which do not have an introductory period configured.
  702. /// </returns>
  703. /// <seealso cref="intro"/>
  704. public long getIntroductoryPricePeriodCycles() { return this.introductory_price_cycles; }
  705. /// <summary>
  706. /// When this auto-renewable receipt expires.
  707. /// </summary>
  708. /// <returns>
  709. /// An absolute date when this receipt will expire.
  710. /// </returns>
  711. public DateTime getExpireDate() { return this.subscriptionExpireDate; }
  712. /// <summary>
  713. /// When this auto-renewable receipt was canceled.
  714. /// Note the store-specific behavior.
  715. /// </summary>
  716. /// <returns>
  717. /// For Apple store, the date when this receipt was canceled.
  718. /// For other stores this will be <c>null</c>.
  719. /// </returns>
  720. public DateTime getCancelDate() { return this.subscriptionCancelDate; }
  721. /// <summary>
  722. /// The period duration of the free trial for this subscription, if enabled.
  723. /// Note the store-specific behavior.
  724. /// </summary>
  725. /// <returns>
  726. /// For Google Play store if the product is configured with a free trial, this will be the period duration.
  727. /// For Apple store this will be <c> null </c>.
  728. /// </returns>
  729. public TimeSpan getFreeTrialPeriod() { return this.freeTrialPeriod; }
  730. /// <summary>
  731. /// The duration of this subscription.
  732. /// Note the store-specific behavior.
  733. /// </summary>
  734. /// <returns>
  735. /// A duration this subscription is valid for.
  736. /// <typeparamref name="TimeSpan.Zero"/> returned for Apple products.
  737. /// </returns>
  738. public TimeSpan getSubscriptionPeriod() { return this.subscriptionPeriod; }
  739. /// <summary>
  740. /// The string representation of the period in ISO8601 format this subscription is free for.
  741. /// Note the store-specific behavior.
  742. /// </summary>
  743. /// <returns>
  744. /// For Google Play store on configured subscription this will be the period which the can own this product for free, unless
  745. /// the user is ineligible for this free trial.
  746. /// For Apple store this will be <c> null </c>.
  747. /// </returns>
  748. public string getFreeTrialPeriodString() { return this.free_trial_period_string; }
  749. /// <summary>
  750. /// The raw JSON SkuDetails from the underlying Google API.
  751. /// Note the store-specific behavior.
  752. /// Note this is not supported.
  753. /// </summary>
  754. /// <returns>
  755. /// For Google store the <c> SkuDetails#getOriginalJson </c> results.
  756. /// For Apple this returns <c>null</c>.
  757. /// </returns>
  758. public string getSkuDetails() { return this.sku_details; }
  759. /// <summary>
  760. /// A JSON including a collection of data involving free-trial and introductory prices.
  761. /// Note the store-specific behavior.
  762. /// Used internally for subscription updating on Google store.
  763. /// </summary>
  764. /// <returns>
  765. /// A JSON with keys: <c>productId</c>, <c>is_free_trial</c>, <c>is_introductory_price_period</c>, <c>remaining_time_in_seconds</c>.
  766. /// </returns>
  767. /// <seealso cref="SubscriptionManager.UpdateSubscription"/>
  768. public string getSubscriptionInfoJsonString()
  769. {
  770. Dictionary<string, object> dict = new Dictionary<string, object>();
  771. dict.Add("productId", this.productId);
  772. dict.Add("is_free_trial", this.is_free_trial);
  773. dict.Add("is_introductory_price_period", this.is_introductory_price_period == Result.True);
  774. dict.Add("remaining_time_in_seconds", this.remainedTime.TotalSeconds);
  775. return MiniJson.JsonEncode(dict);
  776. }
  777. private DateTime nextBillingDate(DateTime billing_begin_date, TimeSpanUnits units)
  778. {
  779. if (units.days == 0.0 && units.months == 0 && units.years == 0) return new DateTime(1970, 1, 1, 0, 0, 0, 0, DateTimeKind.Utc);
  780. DateTime next_billing_date = billing_begin_date;
  781. // find the next billing date that after the current date
  782. while (DateTime.Compare(next_billing_date, DateTime.UtcNow) <= 0)
  783. {
  784. next_billing_date = next_billing_date.AddDays(units.days).AddMonths(units.months).AddYears(units.years);
  785. }
  786. return next_billing_date;
  787. }
  788. private TimeSpan accumulateIntroductoryDuration(TimeSpanUnits units, long cycles)
  789. {
  790. TimeSpan result = TimeSpan.Zero;
  791. for (long i = 0; i < cycles; i++)
  792. {
  793. result = result.Add(computePeriodTimeSpan(units));
  794. }
  795. return result;
  796. }
  797. private TimeSpan computePeriodTimeSpan(TimeSpanUnits units)
  798. {
  799. DateTime now = DateTime.Now;
  800. return now.AddDays(units.days).AddMonths(units.months).AddYears(units.years).Subtract(now);
  801. }
  802. private double computeExtraTime(string metadata, double new_sku_period_in_seconds)
  803. {
  804. var wrapper = (Dictionary<string, object>)MiniJson.JsonDecode(metadata);
  805. long old_sku_remaining_seconds = (long)wrapper["old_sku_remaining_seconds"];
  806. long old_sku_price_in_micros = (long)wrapper["old_sku_price_in_micros"];
  807. double old_sku_period_in_seconds = (parseTimeSpan((string)wrapper["old_sku_period_string"])).TotalSeconds;
  808. long new_sku_price_in_micros = (long)wrapper["new_sku_price_in_micros"];
  809. double result = ((((double)old_sku_remaining_seconds / (double)old_sku_period_in_seconds) * (double)old_sku_price_in_micros) / (double)new_sku_price_in_micros) * new_sku_period_in_seconds;
  810. return result;
  811. }
  812. private TimeSpan parseTimeSpan(string period_string)
  813. {
  814. TimeSpan result = TimeSpan.Zero;
  815. try
  816. {
  817. result = XmlConvert.ToTimeSpan(period_string);
  818. }
  819. catch (Exception)
  820. {
  821. if (period_string == null || period_string.Length == 0)
  822. {
  823. result = TimeSpan.Zero;
  824. }
  825. else
  826. {
  827. // .Net "P1W" is not supported and throws a FormatException
  828. // not sure if only weekly billing contains "W"
  829. // need more testing
  830. result = new TimeSpan(7, 0, 0, 0);
  831. }
  832. }
  833. return result;
  834. }
  835. private TimeSpanUnits parsePeriodTimeSpanUnits(string time_span)
  836. {
  837. switch (time_span)
  838. {
  839. case "P1W":
  840. // weekly subscription
  841. return new TimeSpanUnits(7.0, 0, 0);
  842. case "P1M":
  843. // monthly subscription
  844. return new TimeSpanUnits(0.0, 1, 0);
  845. case "P3M":
  846. // 3 months subscription
  847. return new TimeSpanUnits(0.0, 3, 0);
  848. case "P6M":
  849. // 6 months subscription
  850. return new TimeSpanUnits(0.0, 6, 0);
  851. case "P1Y":
  852. // yearly subscription
  853. return new TimeSpanUnits(0.0, 0, 1);
  854. default:
  855. // seasonal subscription or duration in days
  856. return new TimeSpanUnits((double)parseTimeSpan(time_span).Days, 0, 0);
  857. }
  858. }
  859. }
  860. /// <summary>
  861. /// For representing boolean values which may also be not available.
  862. /// </summary>
  863. public enum Result
  864. {
  865. /// <summary>
  866. /// Corresponds to boolean <c> true </c>.
  867. /// </summary>
  868. True,
  869. /// <summary>
  870. /// Corresponds to boolean <c> false </c>.
  871. /// </summary>
  872. False,
  873. /// <summary>
  874. /// Corresponds to no value, such as for situations where no result is available.
  875. /// </summary>
  876. Unsupported,
  877. };
  878. /// <summary>
  879. /// Used internally to parse Apple receipts. Corresponds to Apple SKProductPeriodUnit.
  880. /// </summary>
  881. /// <see cref="https://developer.apple.com/documentation/storekit/skproductperiodunit?language=objc"/>
  882. public enum SubscriptionPeriodUnit
  883. {
  884. /// <summary>
  885. /// An interval lasting one day.
  886. /// </summary>
  887. Day = 0,
  888. /// <summary>
  889. /// An interval lasting one month.
  890. /// </summary>
  891. Month = 1,
  892. /// <summary>
  893. /// An interval lasting one week.
  894. /// </summary>
  895. Week = 2,
  896. /// <summary>
  897. /// An interval lasting one year.
  898. /// </summary>
  899. Year = 3,
  900. /// <summary>
  901. /// Default value when no value is available.
  902. /// </summary>
  903. NotAvailable = 4,
  904. };
  905. enum AppleStoreProductType
  906. {
  907. NonConsumable = 0,
  908. Consumable = 1,
  909. NonRenewingSubscription = 2,
  910. AutoRenewingSubscription = 3,
  911. };
  912. /// <summary>
  913. /// Error found during receipt parsing.
  914. /// </summary>
  915. public class ReceiptParserException : System.Exception
  916. {
  917. /// <summary>
  918. /// Construct an error object for receipt parsing.
  919. /// </summary>
  920. public ReceiptParserException() { }
  921. /// <summary>
  922. /// Construct an error object for receipt parsing.
  923. /// </summary>
  924. /// <param name="message">Description of error</param>
  925. public ReceiptParserException(string message) : base(message) { }
  926. }
  927. /// <summary>
  928. /// An error was found when an invalid <typeparamref name="Product.definition.type"/> is provided.
  929. /// </summary>
  930. public class InvalidProductTypeException : ReceiptParserException { }
  931. /// <summary>
  932. /// An error was found when an unexpectedly null <typeparamref name="Product.definition.id"/> is provided.
  933. /// </summary>
  934. public class NullProductIdException : ReceiptParserException { }
  935. /// <summary>
  936. /// An error was found when an unexpectedly null <typeparamref name="Product.receipt"/> is provided.
  937. /// </summary>
  938. public class NullReceiptException : ReceiptParserException { }
  939. /// <summary>
  940. /// An error was found when an unsupported app store <typeparamref name="Product.receipt"/> is provided.
  941. /// </summary>
  942. public class StoreSubscriptionInfoNotSupportedException : ReceiptParserException
  943. {
  944. /// <summary>
  945. /// An error was found when an unsupported app store <typeparamref name="Product.receipt"/> is provided.
  946. /// </summary>
  947. /// <param name="message">Human readable explanation of this error</param>
  948. public StoreSubscriptionInfoNotSupportedException(string message) : base(message)
  949. {
  950. }
  951. }
  952. }