Hallo! Heute werde ich Ihnen erklären, wie Sie einen Server zur Überprüfung des In-App-Kaufs und des In-App-Abonnements für iOS und Android bereitstellen (Server-Server-Validierung).
Auf Habré gibt es einen Artikel aus dem Jahr 2013 über die Serverüberprüfung von Einkäufen. Der Artikel besagt, dass eine Validierung in erster Linie erforderlich ist, um den Zugriff auf kostenpflichtige Inhalte mithilfe von Jailbreak und anderer Software zu verhindern. Meiner Meinung nach ist dieses Problem im Jahr 2020 nicht so dringend, und zunächst wird ein Server mit Kaufüberprüfung benötigt, um Einkäufe innerhalb eines Kontos auf mehreren Geräten zu synchronisieren.
Es gibt keine technischen Schwierigkeiten bei der Überprüfung von Kaufbelegen. Tatsächlich "proxys" der Server die Anfrage einfach und speichert die Kaufdaten.
Das heiĂźt, die Aufgabe eines solchen Servers kann in vier Stufen unterteilt werden:
- Erhalten Sie eine Anfrage mit einer Quittung, die von der App nach dem Kauf gesendet wird
- Anfrage an Apple / Google zur ĂśberprĂĽfung
- Transaktionsdaten speichern
- Antwort der Anwendung
Im Rahmen des Artikels werden wir Punkt 3 weglassen, da er rein individuell ist.
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, .