The company has scheduled the charging function recently. Since the project is online education, the service provided is virtual goods. According to the official regulations of iOS, virtual goods transactions can only use iOS in-app payment, other official regulations like wechat and Alipay are not allowed to exist. (Note: virtual items only have this regulation, and iOS official tax 30%; In the case of physical transactions, other payment methods are allowed, as well as in-app payments.)

In combination with relevant platform regulations, we finally determine the payment method: wechat Pay for Android terminal and IAP in-app payment for iOS.

WeChat pay

I have to say that our generation of programmers are lucky. Thanks to the rapid development of mobile payment in China, the process loop of wechat payment is N times more perfect than that of iOS. At the same time, wechat official services, at least in the domestic network, can be regarded as 100 percent reliable.

  • The process of wechat Pay is relatively simple:
  1. The client initiates a purchase request to the business background
  2. The business background generates an order to the wechat server
  3. Integrate wechat order information and business data required by its own system and return it to the client
  4. After receiving the wechat payment information, the client invokes the payment through WeChatOpensdk
  5. Subscribe payment callback in the page, receive payment information and do business process (e.g., enter the payment result page, etc.)
  6. Finally, the background is requested to take the initiative to query the final payment status in the wechat system and return it to the front end to display the results. (PS: The back end actively queries the order status in the wechat system, which is synchronous and can get the payment result immediately)
  • Next, Flutter uses the FluwX plugin, which is easy to use. In the project, I encapsulated wechat Payment, and the code is as follows:
import 'dart:async'; import 'package:flutter/foundation.dart'; import 'package:fluwx/fluwx.dart' as fluwx; class WechatPayment { StreamSubscription _wxPay; Void wxSubscriptionClose() => _wxPay? .cancel(); // WxPayModel is the data of the business layer, Void wxPay(WxPayModel WxPayModel, {VoidCallback onWxPaying, VoidCallback onSuccess, Function(String data) onError}) async {// the page layer can close the loading box, etc. .call(); If (! await fluwx.isWeChatInstalled) return onError? .call(' Please install wechat to complete payment or use iPhone to pay '); if (wxPayModel.appId ! = Config.WX_APP_ID) return onError? .call('AppID inconsistent, please contact administrator '); // This method is not singleton, so try to cancel the listener before payment, to avoid repeated callback _wxPay? .cancel(); / / pay for callback _wxPay = fluwx weChatResponseEventHandler. Listen ((event) {_wxPay? .cancel(); if (event is fluwx.WeChatPaymentResponse) { if (event.isSuccessful) { return onSuccess? .call(); } else { return onError? .call(event.errCode == -1 ? 'System error, please contact administrator' : 'you cancelled payment '); }}}); // Initiate payment fluwx.payWithWeChat(appId: wxPayModel.appId, partnerId: wxPayModel.partnerId, prepayId: wxPayModel.prepayId, packageValue: wxPayModel.packageValue, nonceStr: wxPayModel.nonceStr, timeStamp: wxPayModel.timeStamp, sign: wxPayModel.sign, signType: wxPayModel.signType, extData: wxPayModel.extData, ); }}Copy the code

The page side is called like this

WechatPayment paymentUtils = new WechatPayment(); paymentUtils.wxPay( state.model.wxPayModel, onError: (String err) { if (! mounted) return; _isPaying = false; SchedulerBinding.instance.addPostFrameCallback((_) { CommonUtils.showToast(err, backgroundColor: Theme.of(context).errorColor); }); }, onSuccess:(){ _isPaying = true; }, onWxPaying: () {// start wechat pay, set the payment status to true, close the loading box _isPaying = true; SchedulerBinding.instance.addPostFrameCallback((_) { Navigator.pop(context); }); });Copy the code

However, it should be noted that wechat callback is asynchronous, and there are many cases in which the callback cannot be received. The following are certain cases in which the callback cannot be received.

When wechat calls up the payment page, it actually jumps to a new application, which triggers the life cycle of switching between front and background for our application. Therefore, when the application is returned to the foreground and the payment status is still in progress, it can be proved that the payment status callback of wechat cannot be received, requiring special treatment. // ① Use the system back key to close the payment box after it pops up; // ② After entering the wechat payment password box, do not enter the system navigation to switch back to app or system back key to return; // ③ After entering wechat, go back to the desktop and go back to the application; // ④ After the payment box pops up, lock the screen and open it again; ⑤ Drop taskbar after popup payment; / / 6. Enter the password after the success, direct return to the desktop or use the navigation system or use the return key to return to the app / / 7 exit WeChat login, the login WeChat directly, after payment in the process of login back in app / / today after double open WeChat in management system application, the pay, then don't click any WeChat side, but click cancelCopy the code

At present, the mainstream approach is to monitor the life cycle of the app on the repayment page, that is, check the status when cutting back to the foreground from the background. If the payment is still in progress, directly enter the query result page, and the background checks the order to get the result and display it. (In theory, active background query still has the problem of wechat server delay, so when background query, it is recommended to adopt polling mechanism. If the payment is not successful, it is safer to confirm after 5 seconds delay)

class _XXXPageState extends State<XXXPage> with WidgetsBindingObserver { @override void initState() { super.initState();  WidgetsBinding.instance.addObserver(this); / / add a observer} @ override void the dispose () {WidgetsBinding. Instance. RemoveObserver (this); // Dispose of observer super.dispose(); } / / / application state monitoring @ override void didChangeAppLifecycleState (AppLifecycleState state) {switch (state) {case AppLifecycleState.resumed: { if (Platform.isAndroid && _isPaying) { _isPaying = false; // When the android device is listening and the payment is still in progress, the programmer needs to do something according to the business break; } default: break; } super.didChangeAppLifecycleState(state); }}Copy the code

At this point, wechat pay is very happy to solve the above code is abstract out of the tool class, can be used directly; But it does not involve the development of any business processes, which need to be supplemented by the users themselves. To sum up, the main line of wechat payment process can be summarized as follows: the server generates an order → the client initiates payment → the client notifies the server to verify the order → the client gets the final result → the client makes final payment. The whole process forms a closed loop, rational, data are safe and reasonable to operate by the back end. (The most important thing is that the front-end workload is simply not too little).

However, iOS is not the same, it is not too disgusting!

IOS IN-app purchases

  • IAP (In-app Purchase) is a form of payment based on an AppStore account. Since the entire iOS system is based on its own system (unlike wechat Pay, which is a third-party payment platform), we need to go to Apple developer Center to complete the following steps before development:

1. Signing agreements and banking services 2. Creating in-app purchase projects in the background. All the prices are stipulated by Apple. After creation, each project will have skU and productId 3. Add Gaza box tester Apple above steps reference content from the station big god: Geniune

  • Payment process: The application obtains the list of goods from the server through SKU → it takes out the corresponding products from the list and requests payment → it enters appStore for payment → the page listens to the payment callback to get the verification bill → the business background goes to Apple’s official website to verify the bill received by the application.

The process was simple, so simple that there was little to say to the business back end, but then came the pitfalls:

(1) Payment data is completely dependent on the front-end application, which is difficult to correspond with the order system of the business background; (2) IAP payment supports passing an skPayment object, in which applicationUsername is often used to save the OrderId of the system. However, in the callback received after the application payment is successful, applicationUsername will occasionally be null. Without the corresponding relationship, it is impossible to write off the order in the business system and recharge the user's value. (3) iOS payment callback is very unstable and sometimes delayed severely; And there is no method destined to query; ④ iOS in-app payment has a lot of exceptions to deal with, the most common is not logged in, did not agree with the latest iOS payment protocol, etc., will be sent to the app payment failure callback; However, when the user logs in or agrees, the iOS system will trigger a new payment, resulting in the invalid payment with the old business order number, and a new payment without the order number inexplicably. (5) There is an extreme lack of online information about Flutter in China, almost all of which are 19 years old. There are even fewer articles about Flutter and their reference is not strong. ⑥ Test document for interruption of the purchase of the test process has a huge hole, the back menu must not miss ~Copy the code

Through reviewing the documentation and debugging, we found that:

(1) Pay the wrong callback, basically can receive immediately; ② The above process says that IAP payment needs to be completed manually. At the same time, iOS rules cannot initiate multiple payments to the same skuId, as long as the current skuId has no final payment, the re-launch will fail; (2) Regardless of the success or failure of payment, as long as the App does not take the initiative to make the current payment final, the APP will receive the notification of the payment information after starting the app; (3) About applicationUsername, the callback message will only have this information if the callback is received immediately after the payment is completed. In the case of ②, applicationUsername is definitely not returned; (4) No applicationUsername means that the order is not matched, so we need to implement the order matching mechanism.

To sum up, we have a definite plan for exception handling:

(1) After app initiates payment, the business OrderId and skuId need to be stored persistently (that is, the data will not be deleted even after app uninstalls); (2) As long as the persistent storage is not empty, the app needs to start monitoring immediately to receive the iOS system’s order push; ③ If the payment fails, the current payment can be final, but the payment can only be final after iOS push is clearly received and background verification is successful, and the persistent storage can be deleted.


Finally, combined with the business system and the handling of special cases, the payment process should be as follows:

  1. When the service background returns the commodity list, the corresponding skuId must be returned
  2. App requests App Store to request product information through skuId
  3. The app initiates payment for the goods and stores the business order number in applicationUsername. The successful initiation is written to the persistent store with the state as Pending
  4. After receiving the iOS system callback, the final payment will be made immediately, and the corresponding persistent storage state will be changed to CANCle; Successfully get the ticket and business OrderId and send it to the background
  5. Background call up the Apple server interface, and pass in the ticket (the ticket actually stores the latest time, appStore user information, etc.)
  6. The background obtained the top 100 payment records of all current appStore users returned by Apple, got productId into the database to match whether the user has uncanceled orders, and modified the status of business orders accordingly
  7. App confirms successful verification, final payment, and deletes persistent storage

Also need to do some special treatment:

  1. When the app is just started, if the persistent store is not empty, iOS payment subscription listener needs to be started immediately to receive iOS push for unfinished orders.
  2. Since iOS restricts the same skuId from repeatedly initiating payments, a skuId will always have only one record in the persistent store. Therefore, when the payment push applicationUsername received by app is null, the order collection mechanism is adopted. The principle is: find the stored record through skuId, get its corresponding OrderId, and send it to the background for verification.
  • Next, Futter uses the in_app_purchase plugin, which is officially available and supports Both Google and IAP payments. Persistent storage uses the flutter_secure_storage plug-in.

Following the above flow, I also encapsulate the utility classes. And since the listener may be invoked in multiple places, all must be in singleton mode, with the following code:

import 'dart:async'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:in_app_purchase/in_app_purchase.dart'; // iOS payment single instance final iOSPayment = iOSPayment (); Class IOSPayment {// static final IOSPayment _iosPayment = iospayment.init (); factory IOSPayment() { return _iosPayment; } IOSPayment.init(); / / application pay inside instance InAppPurchaseConnection purchaseConnection = InAppPurchaseConnection. Instance; FlutterSecureStorage storage = new FlutterSecureStorage(); // iOS subscription <List<PurchaseDetails>> subscription; / / / determine whether can use to pay the Future < bool > isAvailable () is async = > await purchaseConnection. IsAvailable (); // startSubscription void startSubscription() async {if (subscription! = null) return; print('>>> start subscription'); / / payment message subscription Stream purchaseUpdates = purchaseConnection. PurchaseUpdatedStream; subscription = purchaseUpdates.listen( (purchaseDetailsList) { purchaseDetailsList.forEach((PurchaseDetails purchaseDetails) async { if (purchaseDetails.status == PurchaseStatus.pending) { print('>>> pending'); // The business code is short: the order starts to pay, notifies the external, and records it in the cache; } else { if (purchaseDetails.status == PurchaseStatus.error) { print('>>> error'); / / business code slightly: is there a payment error, notice to external / / here is to delete the String value = await storage. Read (key: purchaseDetails, productID); String orderId = value.split('¥')[0]; writeStorage(purchaseDetails.productID, orderId, 'cancel'); finalTransaction(purchaseDetails); } else if (purchaseDetails.status == PurchaseStatus.purchased) { print('>>> purchased'); String orderId = purchaseDetails.skPaymentTransaction.payment.applicationUsername; If (orderId = = null | | orderId. IsEmpty) {/ / if applicationUsername is empty, Execute a pool orderId = await foundRecentOrder (purchaseDetails, productID); } if (orderId.isempty) {// PurchalTransaction (details) failed; BlocProvider.of<PaymentUtilsBloc>(Application.navigatorState.currentContext).add(IosPayFailureEvent(errorMessage: 'Payment error, please try again later ')); return; } // business code omitted: the payment is successful, the external notification is sent // business code omitted: the order verification starts, the verification result is monitored by the external); }}}); }, onDone: () { stopListen(); }, onError: (error) { stopListen(); }); Future<bool> checkProductBySku(String sku, {Function(String err) onError}) async {if (! await isAvailable()) { onError? .call(' Unable to connect to AppStore, please try again later '); return false; } ProductDetailsResponse appStoreProducts = await purchaseConnection.queryProductDetails([sku].toSet()); if (appStoreProducts.productDetails.length == 0) { onError? .call(' No product found, please contact administrator '); return false; } return true; Void iosPay(String sku, String orderId, {Function(String err) onError}) async {// Get ProductDetailsResponse appStoreProducts = await purchaseConnection.queryProductDetails([sku].toSet()); // Initiate payment purchaseconnection.buynonconsumable (purchaseParam: purchaseParam (productDetails: appStoreProducts.productDetails.first, applicationUserName: OrderId,),). Then ((value) {if (value) {// Write writeStorage(sku, orderId, 'pending'); } }).catchError((err) { onError? .call(' You have an unfinished transaction for the current product, please wait for the iOS system to check and initiate the purchase again. '); print(err); }); } writeStorage(String key, String value, String status) {storage.write(key: key, value: '$value¥$status'); } // close the transaction void finalTransaction PurchaseDetails PurchaseDetails async {await purchaseConnection.completePurchase(purchaseDetails); // Clear the cache after each order is completed. await checkStorage()) { stopListen(); Future<String> foundRecentOrder(String sku) async {String orderId = "; String values = await storage.read(key: sku); if (values ! = null) {orderId = values.split('¥')[0]; } return orderId; Future<bool> checkStorage() async {Map<String, String> remainingValues = await storage.readall (); return remainingValues.isNotEmpty; } // Close stopListen() async {subscription? .cancel(); subscription = null; }}Copy the code

When the page is called, it is recommended to enable the timer, because the iOS callback is unstable, so the 30-second timer starts when the application is back to the foreground. If no payment callback is received within 30 seconds, corresponding prompt needs to be made. This part is also stored in the business process, and I will not show the code here. The following code calls the above utility class:

iOSPayment.startSubscription(); iOSPayment.iosPay( state.skuId, state.model.orderId, onError: (String err) { if (! mounted) return; // If the payment encounters an error, stop the timer immediately and close the popup box},);Copy the code
If (platform.isios && await iospayment.checkStorage ()) {// Start subscription: Pay the cache is not cleared, models can be used within the application after iOSPayment. StartSubscription (needDelayed: true); }Copy the code

Testing IAP interrupts testing of purchases

  • This test simulates the user’s operation of clicking the purchase agreement. When the system agreement pop-up box pops up, iOS will send a payment error message. At this point our code will final the payment and change the information status of the corresponding skuId in the persistence to cancel;
  • Then after the user agrees, iOS will initiate the same one without OdrerId(yes, lost…). After the user pays successfully, our code will receive the push of payment without OdrerId. After the collection mechanism is implemented in persistent storage, it will be sent to the background for verification.

How do you simulate this process? Take a look at the official document description. Here’s a translation:

# # # # set test by login [is] the App Store Connect (https://help.apple.com/app-store-connect/#/devcd5016d31) to enable the Sandbox interrupt to buy Apple ID, then:  1. In Users and Access, click Testers under the sandbox in the sidebar. On the right, you can view your Sandbox Apple ID. 2. Select the Sandbox Apple ID for which you want to enable interrupt purchases. If enabled, you see a check mark under the Interrupt Purchase column. 3. In the dialog box that is displayed, select Interrupt Purchase of this tester. #### Start test 1. Log in to the test device using the Apple ID of the sandbox that has been interrupted. 2. In your application, select Buy or Subscribe to make an in-app purchase. 3. The system displays the payment order. 4. In your code, verify that the payment queue has received a new transaction while in state. 5. Verify the payment slip on the device. 6. A payment failure was observed in your code. The payment queue receives updated transactions in the state. 7. Check if your code call removes it from the queue. 8. On the device, you observe that "Terms and conditions" are displayed, interrupting the purchase (because you have configured the sandbox environment). 9. On the device, click to agree to the terms and conditions. In your code, verify that new transactions received by the payment queue are in the same state and have the same number of failed transactions. In your code, validate the receipt. Check if your app offers a service or product, then call. 12. On the device, the user should observe the successful purchase.Copy the code

This means that the Sandbox test account can be set to interrupt in the Apple background. But no matter how much I agree, I still receive subscriptions that fail to pay. In fact, the document is missing. After the interruption, the app pops up the agreement dialog box, which is the eighth step above. At this time, the interruption test must be closed in the background, and then the ninth step is performed. There is no official documentation, there is no information on the Internet, and finally I got the inspiration from a comment from QA on the official forum. Here also thanks the company boss spent a long time to find this information. ## Write at the end thank you for your diligence to see the end, this long article hope to help the development of payment partners less step on some pits. Payment in Iaps is a mess, but from an iOS developer’s point of view. In fact, it is understandable: they are the mobile system, they can guarantee all the payment process inside the system, do not care about the developer’s business logic.


However, this approach is extremely unfriendly to developers; In addition, there is a kind of process, the app launched after the payment, as long as there is a callback as soon as final, success is sent to the background, performed by the backstage to gather together a single mechanism, this for the front end is more reasonable, after all, the data is the client is always not enough security, but this app is possible for the same skuId crazy to buy, the background when we gather together, You can’t do one to one. Both advantages and disadvantages ~~~

I hope we can learn and progress together!!