No Description
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

SubscriptionManager.cs 49KB

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