Browse Source

Improve in-app purchase for MacOS (#12464)

* Add methods to finish transactions

* Add a method to get the product descriptions from the App Store

* Improve the documentation of a transaction structure

* Add a tutorial for In App Purchase

* Fix typo in In-App Purchase tutorial

* Fix style of In-App Purchase files

* Fix In-App-Purchase product structure conversion in amr64

* Fix code style in In-App Purchase tutorial documentation

* Fix typos in In-App Purchase documentation

* Fix typo in In-App Purchase spec

* Slight style fixes
Adrien Fery 7 years ago
parent
commit
5486a65702

+ 29 - 1
atom/browser/api/atom_api_in_app_purchase.cc

@@ -45,6 +45,29 @@ struct Converter<in_app_purchase::Transaction> {
   }
 };
 
+template <>
+struct Converter<in_app_purchase::Product> {
+  static v8::Local<v8::Value> ToV8(v8::Isolate* isolate,
+                                   const in_app_purchase::Product& val) {
+    mate::Dictionary dict = mate::Dictionary::CreateEmpty(isolate);
+    dict.SetHidden("simple", true);
+    dict.Set("productIdentifier", val.productIdentifier);
+    dict.Set("localizedDescription", val.localizedDescription);
+    dict.Set("localizedTitle", val.localizedTitle);
+    dict.Set("contentVersion", val.localizedTitle);
+    dict.Set("contentLengths", val.contentLengths);
+
+    // Pricing Information
+    dict.Set("price", val.price);
+    dict.Set("formattedPrice", val.formattedPrice);
+
+    // Downloadable Content Information
+    dict.Set("isDownloadable", val.downloadable);
+
+    return dict.GetHandle();
+  }
+};
+
 }  // namespace mate
 
 namespace atom {
@@ -64,7 +87,12 @@ void InAppPurchase::BuildPrototype(v8::Isolate* isolate,
   mate::ObjectTemplateBuilder(isolate, prototype->PrototypeTemplate())
       .SetMethod("canMakePayments", &in_app_purchase::CanMakePayments)
       .SetMethod("getReceiptURL", &in_app_purchase::GetReceiptURL)
-      .SetMethod("purchaseProduct", &InAppPurchase::PurchaseProduct);
+      .SetMethod("purchaseProduct", &InAppPurchase::PurchaseProduct)
+      .SetMethod("finishAllTransactions",
+                 &in_app_purchase::FinishAllTransactions)
+      .SetMethod("finishTransactionByDate",
+                 &in_app_purchase::FinishTransactionByDate)
+      .SetMethod("getProducts", &in_app_purchase::GetProducts);
 }
 
 InAppPurchase::InAppPurchase(v8::Isolate* isolate) {

+ 3 - 2
atom/browser/api/atom_api_in_app_purchase.h

@@ -11,14 +11,15 @@
 #include "atom/browser/api/event_emitter.h"
 #include "atom/browser/mac/in_app_purchase.h"
 #include "atom/browser/mac/in_app_purchase_observer.h"
+#include "atom/browser/mac/in_app_purchase_product.h"
 #include "native_mate/handle.h"
 
 namespace atom {
 
 namespace api {
 
-class InAppPurchase: public mate::EventEmitter<InAppPurchase>,
-                     public in_app_purchase::TransactionObserver {
+class InAppPurchase : public mate::EventEmitter<InAppPurchase>,
+                      public in_app_purchase::TransactionObserver {
  public:
   static mate::Handle<InAppPurchase> Create(v8::Isolate* isolate);
 

+ 4 - 0
atom/browser/mac/in_app_purchase.h

@@ -19,6 +19,10 @@ typedef base::Callback<void(bool isProductValid)> InAppPurchaseCallback;
 
 bool CanMakePayments(void);
 
+void FinishAllTransactions(void);
+
+void FinishTransactionByDate(const std::string& date);
+
 std::string GetReceiptURL(void);
 
 void PurchaseProduct(const std::string& productID,

+ 28 - 0
atom/browser/mac/in_app_purchase.mm

@@ -136,6 +136,34 @@ bool CanMakePayments() {
   return [SKPaymentQueue canMakePayments];
 }
 
+void FinishAllTransactions() {
+  for (SKPaymentTransaction* transaction in SKPaymentQueue.defaultQueue
+           .transactions) {
+    [[SKPaymentQueue defaultQueue] finishTransaction:transaction];
+  }
+}
+
+void FinishTransactionByDate(const std::string& date) {
+  // Create the date formatter.
+  NSDateFormatter* dateFormatter = [[NSDateFormatter alloc] init];
+  NSLocale* enUSPOSIXLocale =
+      [NSLocale localeWithLocaleIdentifier:@"en_US_POSIX"];
+  [dateFormatter setLocale:enUSPOSIXLocale];
+  [dateFormatter setDateFormat:@"yyyy-MM-dd'T'HH:mm:ssZZZZZ"];
+
+  // Remove the transaction.
+  NSString* transactionDate = base::SysUTF8ToNSString(date);
+
+  for (SKPaymentTransaction* transaction in SKPaymentQueue.defaultQueue
+           .transactions) {
+    if ([transactionDate
+            isEqualToString:[dateFormatter
+                                stringFromDate:transaction.transactionDate]]) {
+      [[SKPaymentQueue defaultQueue] finishTransaction:transaction];
+    }
+  }
+}
+
 std::string GetReceiptURL() {
   NSURL* receiptURL = [[NSBundle mainBundle] appStoreReceiptURL];
   if (receiptURL != nil) {

+ 9 - 10
atom/browser/mac/in_app_purchase_observer.mm

@@ -17,9 +17,8 @@
 
 namespace {
 
-using InAppTransactionCallback =
-    base::RepeatingCallback<
-        void(const std::vector<in_app_purchase::Transaction>&)>;
+using InAppTransactionCallback = base::RepeatingCallback<void(
+    const std::vector<in_app_purchase::Transaction>&)>;
 
 }  // namespace
 
@@ -72,8 +71,8 @@ using InAppTransactionCallback =
   }
 
   // Send the callback to the browser thread.
-  content::BrowserThread::PostTask(
-      content::BrowserThread::UI, FROM_HERE, base::Bind(callback_, converted));
+  content::BrowserThread::PostTask(content::BrowserThread::UI, FROM_HERE,
+                                   base::Bind(callback_, converted));
 }
 
 /**
@@ -141,9 +140,9 @@ using InAppTransactionCallback =
   }
 
   if (transaction.transactionState < 5) {
-    transactionStruct.transactionState = [[@[
-        @"purchasing", @"purchased", @"failed", @"restored", @"deferred"
-    ] objectAtIndex:transaction.transactionState] UTF8String];
+    transactionStruct.transactionState =
+        [[@[ @"purchasing", @"purchased", @"failed", @"restored", @"deferred" ]
+            objectAtIndex:transaction.transactionState] UTF8String];
   }
 
   if (transaction.payment != nil) {
@@ -177,8 +176,8 @@ namespace in_app_purchase {
 
 TransactionObserver::TransactionObserver() : weak_ptr_factory_(this) {
   obeserver_ = [[InAppTransactionObserver alloc]
-     initWithCallback:base::Bind(&TransactionObserver::OnTransactionsUpdated,
-                                 weak_ptr_factory_.GetWeakPtr())];
+      initWithCallback:base::Bind(&TransactionObserver::OnTransactionsUpdated,
+                                  weak_ptr_factory_.GetWeakPtr())];
 }
 
 TransactionObserver::~TransactionObserver() {

+ 47 - 0
atom/browser/mac/in_app_purchase_product.h

@@ -0,0 +1,47 @@
+// Copyright (c) 2018 Amaplex Software, Inc.
+// Use of this source code is governed by the MIT license that can be
+// found in the LICENSE file.
+
+#ifndef ATOM_BROWSER_MAC_IN_APP_PURCHASE_PRODUCT_H_
+#define ATOM_BROWSER_MAC_IN_APP_PURCHASE_PRODUCT_H_
+
+#include <string>
+#include <vector>
+
+#include "base/callback.h"
+
+namespace in_app_purchase {
+
+// --------------------------- Structures ---------------------------
+
+struct Product {
+  // Product Identifier
+  std::string productIdentifier;
+
+  // Product Attributes
+  std::string localizedDescription;
+  std::string localizedTitle;
+  std::string contentVersion;
+  std::vector<uint32_t> contentLengths;
+
+  // Pricing Information
+  double price = 0.0;
+  std::string formattedPrice;
+
+  // Downloadable Content Information
+  bool downloadable = false;
+};
+
+// --------------------------- Typedefs ---------------------------
+
+typedef base::Callback<void(const std::vector<in_app_purchase::Product>&)>
+    InAppPurchaseProductsCallback;
+
+// --------------------------- Functions ---------------------------
+
+void GetProducts(const std::vector<std::string>& productIDs,
+                 const InAppPurchaseProductsCallback& callback);
+
+}  // namespace in_app_purchase
+
+#endif  // ATOM_BROWSER_MAC_IN_APP_PURCHASE_PRODUCT_H_

+ 179 - 0
atom/browser/mac/in_app_purchase_product.mm

@@ -0,0 +1,179 @@
+// Copyright (c) 2017 Amaplex Software, Inc.
+// Use of this source code is governed by the MIT license that can be
+// found in the LICENSE file.
+
+#include "atom/browser/mac/in_app_purchase_product.h"
+
+#include "base/bind.h"
+#include "base/strings/sys_string_conversions.h"
+#include "content/public/browser/browser_thread.h"
+
+#import <StoreKit/StoreKit.h>
+
+// ============================================================================
+//                             InAppPurchaseProduct
+// ============================================================================
+
+// --------------------------------- Interface --------------------------------
+
+@interface InAppPurchaseProduct : NSObject<SKProductsRequestDelegate> {
+ @private
+  in_app_purchase::InAppPurchaseProductsCallback callback_;
+}
+
+- (id)initWithCallback:
+    (const in_app_purchase::InAppPurchaseProductsCallback&)callback;
+
+@end
+
+// ------------------------------- Implementation -----------------------------
+
+@implementation InAppPurchaseProduct
+
+/**
+ * Init with a callback.
+ *
+ * @param callback - The callback that will be called to return the products.
+ */
+- (id)initWithCallback:
+    (const in_app_purchase::InAppPurchaseProductsCallback&)callback {
+  if ((self = [super init])) {
+    callback_ = callback;
+  }
+
+  return self;
+}
+
+/**
+ * Return products.
+ *
+ * @param productIDs - The products' id to fetch.
+ */
+- (void)getProducts:(NSSet*)productIDs {
+  SKProductsRequest* productsRequest;
+  productsRequest =
+      [[SKProductsRequest alloc] initWithProductIdentifiers:productIDs];
+
+  productsRequest.delegate = self;
+  [productsRequest start];
+}
+
+/**
+ * @see SKProductsRequestDelegate
+ */
+- (void)productsRequest:(SKProductsRequest*)request
+     didReceiveResponse:(SKProductsResponse*)response {
+  // Release request object.
+  [request release];
+
+  // Get the products.
+  NSArray* products = response.products;
+
+  // Convert the products.
+  std::vector<in_app_purchase::Product> converted;
+  converted.reserve([products count]);
+
+  for (SKProduct* product in products) {
+    converted.push_back([self skProductToStruct:product]);
+  }
+
+  // Send the callback to the browser thread.
+  content::BrowserThread::PostTask(content::BrowserThread::UI, FROM_HERE,
+                                   base::Bind(callback_, converted));
+
+  [self release];
+}
+
+/**
+ * Format local price.
+ *
+ * @param price - The price to format.
+ * @param priceLocal - The local format.
+ */
+- (NSString*)formatPrice:(NSDecimalNumber*)price
+               withLocal:(NSLocale*)priceLocal {
+  NSNumberFormatter* numberFormatter = [[NSNumberFormatter alloc] init];
+
+  [numberFormatter setFormatterBehavior:NSNumberFormatterBehavior10_4];
+  [numberFormatter setNumberStyle:NSNumberFormatterCurrencyStyle];
+  [numberFormatter setLocale:priceLocal];
+
+  return [numberFormatter stringFromNumber:price];
+}
+
+/**
+ * Convert a skProduct object to Product structure.
+ *
+ * @param product - The SKProduct object to convert.
+ */
+- (in_app_purchase::Product)skProductToStruct:(SKProduct*)product {
+  in_app_purchase::Product productStruct;
+
+  // Product Identifier
+  if (product.productIdentifier != nil) {
+    productStruct.productIdentifier = [product.productIdentifier UTF8String];
+  }
+
+  // Product Attributes
+  if (product.localizedDescription != nil) {
+    productStruct.localizedDescription =
+        [product.localizedDescription UTF8String];
+  }
+  if (product.localizedTitle != nil) {
+    productStruct.localizedTitle = [product.localizedTitle UTF8String];
+  }
+  if (product.contentVersion != nil) {
+    productStruct.contentVersion = [product.contentVersion UTF8String];
+  }
+  if (product.contentLengths != nil) {
+    productStruct.contentLengths.reserve([product.contentLengths count]);
+
+    for (NSNumber* contentLength in product.contentLengths) {
+      productStruct.contentLengths.push_back([contentLength longLongValue]);
+    }
+  }
+
+  // Pricing Information
+  if (product.price != nil) {
+    productStruct.price = [product.price doubleValue];
+
+    if (product.priceLocale != nil) {
+      productStruct.formattedPrice =
+          [[self formatPrice:product.price withLocal:product.priceLocale]
+              UTF8String];
+    }
+  }
+
+  // Downloadable Content Information
+  if (product.downloadable == true) {
+    productStruct.downloadable = true;
+  }
+
+  return productStruct;
+}
+
+@end
+
+// ============================================================================
+//                             C++ in_app_purchase
+// ============================================================================
+
+namespace in_app_purchase {
+
+void GetProducts(const std::vector<std::string>& productIDs,
+                 const InAppPurchaseProductsCallback& callback) {
+  auto* iapProduct = [[InAppPurchaseProduct alloc] initWithCallback:callback];
+
+  // Convert the products' id to NSSet.
+  NSMutableSet* productsIDSet =
+      [NSMutableSet setWithCapacity:productIDs.size()];
+
+  for (auto& productID : productIDs) {
+    [productsIDSet addObject:base::SysUTF8ToNSString(productID)];
+  }
+
+  // Fetch the products.
+  [iapProduct getProducts:productsIDSet];
+}
+
+}  // namespace in_app_purchase

+ 23 - 2
docs/api/in-app-purchase.md

@@ -21,15 +21,24 @@ Returns:
 
 The `inAppPurchase` module has the following methods:
 
+
 ### `inAppPurchase.purchaseProduct(productID, quantity, callback)`
 
-* `productID` String - The id of the product to purchase. (the id of `com.example.app.product1` is `product1`).
+* `productID` String - The identifiers of the product to purchase. (The identifier of `com.example.app.product1` is `product1`).
 * `quantity` Integer (optional) - The number of items the user wants to purchase.
 * `callback` Function (optional) - The callback called when the payment is added to the PaymentQueue.
-* `isProductValid` Boolean - Determine if the product is valid and added to the payment queue.
+    * `isProductValid` Boolean - Determine if the product is valid and added to the payment queue.
 
 You should listen for the `transactions-updated` event as soon as possible and certainly before you call `purchaseProduct`.
 
+### `inAppPurchase.getProducts(productIDs, callback)`
+
+* `productIDs` String[] - The identifiers of the products to get.
+* `callback` Function - The callback called with the products or an empty array if the products don't exist.
+    * `products` Product[] - Array of [`Product`](structures/product) objects
+
+Retrieves the product descriptions.
+
 ### `inAppPurchase.canMakePayments()`
 
 Returns `true` if the user can make a payment and `false` otherwise.
@@ -37,3 +46,15 @@ Returns `true` if the user can make a payment and `false` otherwise.
 ### `inAppPurchase.getReceiptURL()`
 
 Returns `String`, the path to the receipt.
+
+
+### `inAppPurchase.finishAllTransactions()`
+
+Completes all pending transactions.
+
+
+### `inAppPurchase.finishTransactionByDate(date)`
+
+* `date` String - The ISO formatted date of the transaction to finish.
+
+Completes the pending transactions corresponding to the date.

+ 10 - 0
docs/api/structures/product.md

@@ -0,0 +1,10 @@
+# Product Object
+
+* `productIdentifier` String - The string that identifies the product to the Apple App Store.
+* `localizedDescription` String - A description of the product.
+* `localizedTitle` String - The name of the product.
+* `contentVersion` String - A string that identifies the version of the content.
+* `contentLengths` Number[] - The total size of the content, in bytes.
+* `price` Number - The cost of the product in the local currency.
+* `formattedPrice` String - The locale formatted price of the product.
+* `downloadable` Boolean - A Boolean value that indicates whether the App Store has downloadable content for this product.

+ 7 - 7
docs/api/structures/transaction.md

@@ -1,11 +1,11 @@
 # Transaction Object
 
-* `transactionIdentifier` String
-* `transactionDate` String
-* `originalTransactionIdentifier` String
+* `transactionIdentifier` String - A string that uniquely identifies a successful payment transaction.
+* `transactionDate` String - The date the transaction was added to the App Store’s payment queue.
+* `originalTransactionIdentifier` String - The identifier of the restored transaction by the App Store.
 * `transactionState` String - The transaction sate (`"purchasing"`, `"purchased"`, `"failed"`, `"restored"`, or `"deferred"`)
-* `errorCode` Integer
-* `errorMessage` String
+* `errorCode` Integer - The error code if an error occurred while processing the transaction.
+* `errorMessage` String - The error message if an error occurred while processing the transaction.
 * `payment` Object
-  * `productIdentifier` String
-  * `quantity` Integer
+  * `productIdentifier` String - The identifier of the purchased product.
+  * `quantity` Integer  - The quantity purchased.

+ 125 - 0
docs/tutorial/in-app-purchases.md

@@ -0,0 +1,125 @@
+# In-App Purchase (macOS)
+
+## Preparing
+
+### Paid Applications Agreement
+If you haven't already, you’ll need to sign the Paid Applications Agreement and set up your banking and tax information in iTunes Connect. 
+
+[iTunes Connect Developer Help: Agreements, tax, and banking overview](https://help.apple.com/itunes-connect/developer/#/devb6df5ee51)
+
+### Create Your In-App Purchases
+Then, you'll need to configure your in-app purchases in iTunes Connect, and include details such as name, pricing, and description that highlights the features and functionality of your in-app purchase.
+
+[iTunes Connect Developer Help: Create an in-app purchase](https://help.apple.com/itunes-connect/developer/#/devae49fb316)
+
+
+### Change the CFBundleIdentifier
+
+To test In-App Purchase in development with Electron you'll have to change the `CFBundleIdentifier` in `node_modules/electron/dist/Electron.app/Contents/Info.plist`. You have to replace `com.github.electron` by the bundle identifier of the application you created with iTunes Connect.
+
+```xml
+<key>CFBundleIdentifier</key>
+<string>com.example.app</string>
+```
+
+
+## Code example
+
+Here is an example that shows how to use In-App Purchases in Electron. You'll have to replace the product ids by the identifiers of the products created with iTunes Connect (the identifier of `com.example.app.product1` is `product1`). Note that you have to listen to the `transactions-updated` event as soon as possible in your app.
+
+
+```javascript
+const { inAppPurchase } = require('electron').remote
+const PRODUCT_IDS = ['id1', 'id2']
+
+// Listen for transactions as soon as possible.
+inAppPurchase.on('transactions-updated', (event, transactions) => {
+  if (!Array.isArray(transactions)) {
+    return
+  }
+
+  // Check each transaction.
+  transactions.forEach(function (transaction) {
+    var payment = transaction.payment
+
+    switch (transaction.transactionState) {
+      case 'purchasing':
+        console.log(`Purchasing ${payment.productIdentifier}...`)
+        break
+      case 'purchased':
+
+        console.log(`${payment.productIdentifier} purchased.`)
+
+        // Get the receipt url.
+        let receiptURL = inAppPurchase.getReceiptURL()
+
+        console.log(`Receipt URL: ${receiptURL}`)
+
+        // Submit the receipt file to the server and check if it is valid.
+        // @see https://developer.apple.com/library/content/releasenotes/General/ValidateAppStoreReceipt/Chapters/ValidateRemotely.html
+        // ...
+        // If the receipt is valid, the product is purchased
+        // ...
+
+        // Finish the transaction.
+        inAppPurchase.finishTransactionByDate(transaction.transactionDate)
+
+        break
+      case 'failed':
+
+        console.log(`Failed to purchase ${payment.productIdentifier}.`)
+
+        // Finish the transaction.
+        inAppPurchase.finishTransactionByDate(transaction.transactionDate)
+
+        break
+      case 'restored':
+
+        console.log(`The purchase of ${payment.productIdentifier} has been restored.`)
+
+        break
+      case 'deferred':
+
+        console.log(`The purchase of ${payment.productIdentifier} has been deferred.`)
+
+        break
+      default:
+        break
+    }
+  })
+})
+
+// Check if the user is allowed to make in-app purchase.
+if (!inAppPurchase.canMakePayments()) {
+  console.log('The user is not allowed to make in-app purchase.')
+}
+
+// Retrieve and display the product descriptions.
+inAppPurchase.getProducts(PRODUCT_IDS, (products) => {
+  // Check the parameters.
+  if (!Array.isArray(products) || products.length <= 0) {
+    console.log('Unable to retrieve the product informations.')
+    return
+  }
+
+  // Display the name and price of each product.
+  products.forEach((product) => {
+    console.log(`The price of ${product.localizedTitle} is ${product.formattedPrice}.`)
+  })
+
+  // Ask the user which product he/she wants to purchase.
+  // ...
+  let selectedProduct = products[0]
+  let selectedQuantity = 1
+
+  // Purchase the selected product.
+  inAppPurchase.purchaseProduct(selectedProduct.productIdentifier, selectedQuantity, (isProductValid) => {
+    if (!isProductValid) {
+      console.log('The product is not valid.')
+      return
+    }
+
+    console.log('The payment has been added to the payment queue.')
+  })
+})
+```

+ 2 - 0
filenames.gypi

@@ -240,6 +240,8 @@
       'atom/browser/mac/in_app_purchase.mm',
       'atom/browser/mac/in_app_purchase_observer.h',
       'atom/browser/mac/in_app_purchase_observer.mm',
+      'atom/browser/mac/in_app_purchase_product.h',
+      'atom/browser/mac/in_app_purchase_product.mm',
       'atom/browser/native_browser_view.cc',
       'atom/browser/native_browser_view.h',
       'atom/browser/native_browser_view_mac.h',

+ 15 - 0
spec/api-in-app-purchase-spec.js

@@ -15,6 +15,14 @@ describe('inAppPurchase module', function () {
     inAppPurchase.canMakePayments()
   })
 
+  it('finishAllTransactions() does not throw', () => {
+    inAppPurchase.finishAllTransactions()
+  })
+
+  it('finishTransactionByDate() does not throw', () => {
+    inAppPurchase.finishTransactionByDate(new Date().toISOString())
+  })
+
   it('getReceiptURL() returns receipt URL', () => {
     assert.ok(inAppPurchase.getReceiptURL().endsWith('_MASReceipt/receipt'))
   })
@@ -32,4 +40,11 @@ describe('inAppPurchase module', function () {
       done()
     })
   })
+
+  it('getProducts() returns an empty list when getting invalid product', (done) => {
+    inAppPurchase.getProducts(['non-exist'], (products) => {
+      assert.ok(products.length === 0)
+      done()
+    })
+  })
 })