salut! Aujourd'hui, je vais vous dire comment déployer un serveur pour vérifier les achats intégrés et les abonnements intégrés pour iOS et Android (validation serveur-serveur).
Sur Habré il y a un article de 2013 sur la vérification des achats par le serveur. L'article indique que la validation est principalement nécessaire pour empêcher l'accès au contenu payant à l'aide de jailbreak et d'autres logiciels. À mon avis, en 2020, ce problème n'est pas si urgent, et tout d'abord, un serveur avec vérification des achats est nécessaire pour synchroniser les achats au sein d'un même compte sur plusieurs appareils.
Il n'y a pas de difficulté technique à vérifier les reçus d'achat, en fait, le serveur «procède» simplement la demande et stocke les données d'achat.
Autrement dit, la tâche d'un tel serveur peut être divisée en 4 étapes:
- Recevoir une demande avec un reçu envoyé par l'application après l'achat
- Demande à Apple / Google pour vérification de chèque
- Sauvegarde des données de transaction
- RĂ©ponse de l'application
Dans le cadre de l'article, nous omettons le point 3, car il est purement individuel.
Node.js, .
«, App Store (App Store receipt)», . , (receipt) .
, , https://github.com/denjoygroup/inapppurchase. , , .
iOS
Apple Shared Secret
– , iTunnes Connect, .
:
apple: any = {
password: process.env.APPLE_SHARED_SECRET, // ,
host: 'buy.itunes.apple.com',
sandbox: 'sandbox.itunes.apple.com',
path: '/verifyReceipt',
apiHost: 'api.appstoreconnect.apple.com',
pathToCheckSales: '/v1/salesReports'
}
. , , sandbox.itunes.apple.com
, buy.itunes.apple.com
/**
* receiptValue - ,
* sandBox -
**/
async _verifyReceipt(receiptValue: string, sandBox: boolean) {
let options = {
host: sandBox ? this._constants.apple.sandbox : this._constants.apple.host,
path: this._constants.apple.path,
method: 'POST'
};
let body = {
'receipt-data': receiptValue,
'password': this._constants.apple.password
};
let result = null;
let stringResult = await this._handlerService.sendHttp(options, body, 'https');
result = JSON.parse(stringResult);
return result;
}
, Apple status
.
,
21000
– – POST
21002
– ,
21003
– ,
21004
– Shared Secret
21005
– ,
21006
–
21007
– SandBox ( ), prod
21008
– ,
21009
– ,
21010
–
0
–
iTunnes Connect
{
"environment":"Production",
"receipt":{
"receipt_type":"Production",
"adam_id":1527458047,
"app_item_id":1527458047,
"bundle_id":"BUNDLE_ID",
"application_version":"0",
"download_id":34089715299389,
"version_external_identifier":838212484,
"receipt_creation_date":"2020-11-03 20:47:54 Etc/GMT",
"receipt_creation_date_ms":"1604436474000",
"receipt_creation_date_pst":"2020-11-03 12:47:54 America/Los_Angeles",
"request_date":"2020-11-03 20:48:01 Etc/GMT",
"request_date_ms":"1604436481804",
"request_date_pst":"2020-11-03 12:48:01 America/Los_Angeles",
"original_purchase_date":"2020-10-26 19:24:19 Etc/GMT",
"original_purchase_date_ms":"1603740259000",
"original_purchase_date_pst":"2020-10-26 12:24:19 America/Los_Angeles",
"original_application_version":"0",
"in_app":[
{
"quantity":"1",
"product_id":"PRODUCT_ID",
"transaction_id":"140000855642848",
"original_transaction_id":"140000855642848",
"purchase_date":"2020-11-03 20:47:53 Etc/GMT",
"purchase_date_ms":"1604436473000",
"purchase_date_pst":"2020-11-03 12:47:53 America/Los_Angeles",
"original_purchase_date":"2020-11-03 20:47:54 Etc/GMT",
"original_purchase_date_ms":"1604436474000",
"original_purchase_date_pst":"2020-11-03 12:47:54 America/Los_Angeles",
"expires_date":"2020-12-03 20:47:53 Etc/GMT",
"expires_date_ms":"1607028473000",
"expires_date_pst":"2020-12-03 12:47:53 America/Los_Angeles",
"web_order_line_item_id":"140000337829668",
"is_trial_period":"false",
"is_in_intro_offer_period":"false"
}
]
},
"latest_receipt_info":[
{
"quantity":"1",
"product_id":"PRODUCT_ID",
"transaction_id":"140000855642848",
"original_transaction_id":"140000855642848",
"purchase_date":"2020-11-03 20:47:53 Etc/GMT",
"purchase_date_ms":"1604436473000",
"purchase_date_pst":"2020-11-03 12:47:53 America/Los_Angeles",
"original_purchase_date":"2020-11-03 20:47:54 Etc/GMT",
"original_purchase_date_ms":"1604436474000",
"original_purchase_date_pst":"2020-11-03 12:47:54 America/Los_Angeles",
"expires_date":"2020-12-03 20:47:53 Etc/GMT",
"expires_date_ms":"1607028473000",
"expires_date_pst":"2020-12-03 12:47:53 America/Los_Angeles",
"web_order_line_item_id":"140000447829668",
"is_trial_period":"false",
"is_in_intro_offer_period":"false",
"subscription_group_identifier":"20675121"
}
],
"latest_receipt":"RECEIPT",
"pending_renewal_info":[
{
"auto_renew_product_id":"PRODUCT_ID",
"original_transaction_id":"140000855642848",
"product_id":"PRODUCT_ID",
"auto_renew_status":"1"
}
],
"status":0
}
id
, .
in_app
latest_receipt_info
, , :
latest_receipt_info
.
in_app
Non-consumable Non-Auto-Renewable .
latest_receipt_info
, product_id
, . , , Consumable Purchase. original_transaction_id
, , .
/**
* product - id
* resultFromApple - Apple,
* productType - (, non-consumable)
* sandBox -
*
**/
async parseResponse(product: string, resultFromApple: any, productType: ProductType, sandBox: boolean) {
let parsedResult: IPurchaseParsedResultFromProvider = {
validated: false,
trial: false,
checked: false,
sandBox,
productType: productType,
lastResponseFromProvider: JSON.stringify(resultFromApple)
};
switch (resultFromApple.status) {
/**
*
*/
case 0: {
/**
*
**/
let currentPurchaseFromApple = this.getCurrentPurchaseFromAppleResult(resultFromApple, product!, productType);
if (!currentPurchaseFromApple) break;
parsedResult.checked = true;
parsedResult.originalTransactionId = this.getTransactionIdFromAppleResponse(currentPurchaseFromApple);
if (productType === ProductType.Subscription) {
parsedResult.validated = (this.checkDateIsAfter(currentPurchaseFromApple.expires_date_ms)) ? true : false;
parsedResult.expiredAt = (this.checkDateIsValid(currentPurchaseFromApple.expires_date_ms)) ?
this.formatDate(currentPurchaseFromApple.expires_date_ms) : undefined;
} else {
parsedResult.validated = true;
}
parsedResult.trial = !!currentPurchaseFromApple.is_trial_period;
break;
}
default:
if (!resultFromApple) console.log('empty result from apple');
else console.log('incorrect result from apple, status:', resultFromApple.status);
}
return parsedResult;
}
, parsedResult
. , , , , parsedResult.validated
.
, , iTunnes Connect , . , , , – , .
Android
, OAuth
.
:
google: any = {
host: 'androidpublisher.googleapis.com',
path: '/androidpublisher/v3/applications',
email: process.env.GOOGLE_EMAIL,
key: process.env.GOOGLE_KEY,
storeName: process.env.GOOGLE_STORE_NAME
}
.
, , :
/**
* product -
* token -
* productType – ,
**/
async getPurchaseInfoFromGoogle(product: string, token: string, productType: ProductType) {
try {
let options = {
email: this._constants.google.email,
key: this._constants.google.key,
scopes: ['https://www.googleapis.com/auth/androidpublisher'],
};
const client = new JWT(options);
let productOrSubscriptionUrlPart = productType === ProductType.Subscription ? 'subscriptions' : 'products';
const url = `https://${this._constants.google.host}${this._constants.google.path}/${this._constants.google.storeName}/purchases/${productOrSubscriptionUrlPart}/${product}/tokens/${token}`;
const res = await client.request({ url });
return res.data as ResultFromGoogle;
} catch(e) {
return e as ErrorFromGoogle;
}
}
google-auth-library
JWT
.
:
{
startTimeMillis: "1603956759767",
expiryTimeMillis: "1603966728908",
autoRenewing: false,
priceCurrencyCode: "RUB",
priceAmountMicros: "499000000",
countryCode: "RU",
developerPayload: {
"developerPayload":"",
"is_free_trial":false,
"has_introductory_price_trial":false,
"is_updated":false,
"accountId":""
},
cancelReason: 1,
orderId: "GPA.3335-9310-7555-53285..5",
purchaseType: 0,
acknowledgementState: 1,
kind: "androidpublisher#subscriptionPurchase"
}
parseResponse(product: string, result: ResultFromGoogle | ErrorFromGoogle, type: ProductType) {
let parsedResult: IPurchaseParsedResultFromProvider = {
validated: false,
trial: false,
checked: true,
sandBox: false,
productType: type,
lastResponseFromProvider: JSON.stringify(result),
};
if (this.isResultFromGoogle(result)) {
if (this.isSubscriptionResult(result)) {
parsedResult.expiredAt = moment(result.expiryTimeMillis, 'x').toDate();
parsedResult.validated = this.checkDateIsAfter(parsedResult.expiredAt);
} else if (this.isProductResult(result)) {
parsedResult.validated = true;
}
}
return parsedResult;
}
. parsedResult
, validated
– .
2 . https://github.com/denjoygroup/inapppurchase ( )
, , .
, : https://ru.adapty.io/ https://apphud.com/. , -, 3 , -, , .
P.S.
, , , – . , , iTunnes Connect Google API, .