@tamtamchik/app-store-receipt-parser
Version:
A lightweight TypeScript library for extracting transaction IDs from Apple's ASN.1 encoded receipts.
193 lines (189 loc) • 6.08 kB
JavaScript
// src/ReceiptParser.ts
import * as ASN1 from "asn1js";
// src/constants.ts
var IN_APP = 17;
var CONTENT_ID = "pkcs7_content";
var FIELD_TYPE_ID = "FieldType";
var FIELD_VALUE_ID = "FieldTypeOctetString";
var RECEIPT_FIELDS_MAP = /* @__PURE__ */ new Map([
[0, "ENVIRONMENT"],
[2, "BUNDLE_ID"],
[3, "APP_VERSION"],
[4, "OPAQUE_VALUE"],
[5, "SHA1_HASH"],
[12, "RECEIPT_CREATION_DATE"],
[18, "ORIGINAL_PURCHASE_DATE"],
[19, "ORIGINAL_APP_VERSION"],
[1701, "IN_APP_QUANTITY"],
[1702, "IN_APP_PRODUCT_ID"],
[1703, "IN_APP_TRANSACTION_ID"],
[1704, "IN_APP_PURCHASE_DATE"],
[1705, "IN_APP_ORIGINAL_TRANSACTION_ID"],
[1706, "IN_APP_ORIGINAL_PURCHASE_DATE"],
[1708, "IN_APP_EXPIRES_DATE"],
[1711, "IN_APP_WEB_ORDER_LINE_ITEM_ID"],
[1712, "IN_APP_CANCELLATION_DATE"]
]);
// src/ReceiptVerifier.ts
import { Any, Constructed, Integer, ObjectIdentifier, OctetString, Sequence, Set as Set2, verifySchema } from "asn1js";
var ReceiptVerifier = class {
receiptSchema;
fieldSchema;
constructor() {
this.receiptSchema = new Sequence({
value: [
new ObjectIdentifier(),
new Constructed({
idBlock: { tagClass: 3, tagNumber: 0 },
value: [
new Sequence({
value: [
new Integer(),
new Set2({
value: [
new Sequence({
value: [new ObjectIdentifier(), new Any()]
})
]
}),
new Sequence({
value: [
new ObjectIdentifier(),
new Constructed({
idBlock: { tagClass: 3, tagNumber: 0 },
value: [new OctetString({ name: CONTENT_ID })]
})
]
})
]
})
]
})
]
});
this.fieldSchema = new Sequence({
value: [
new Integer({ name: FIELD_TYPE_ID }),
new Integer(),
new OctetString({ name: FIELD_VALUE_ID })
]
});
}
verifyReceiptSchema(receipt) {
const receiptVerification = verifySchema(Buffer.from(receipt, "base64"), this.receiptSchema);
if (!receiptVerification.verified) {
throw new Error("Receipt verification failed.");
}
return receiptVerification;
}
verifyFieldSchema(sequence) {
const fieldVerification = verifySchema(sequence.toBER(), this.fieldSchema);
if (!fieldVerification.verified) {
return null;
}
return fieldVerification;
}
};
// src/ReceiptParser.ts
var ReceiptParser = class {
parsed;
receiptVerifier;
constructor() {
this.receiptVerifier = new ReceiptVerifier();
this.parsed = this.createInitialParsedReceipt();
}
parseReceipt(receipt) {
if (receipt.trim() === "") {
throw new Error("Receipt must be a non-empty string.");
}
const rootSchemaVerification = this.receiptVerifier.verifyReceiptSchema(receipt);
const content = rootSchemaVerification.result[CONTENT_ID];
this.parseReceiptContent(content);
this.validateParsedFields();
this.deduplicateArrayFields();
return this.parsed;
}
createInitialParsedReceipt() {
return {
ENVIRONMENT: "Production",
IN_APP_ORIGINAL_TRANSACTION_IDS: [],
IN_APP_TRANSACTION_IDS: []
};
}
parseReceiptContent(content) {
const sequences = this.extractSequencesFromContent(content);
sequences.forEach(this.processSequence.bind(this));
}
extractSequencesFromContent(content) {
const [contentSet] = content.valueBlock.value;
return contentSet.valueBlock.value.filter((v) => v instanceof ASN1.Sequence);
}
processSequence(sequence) {
const verifiedSequence = this.receiptVerifier.verifyFieldSchema(sequence);
if (verifiedSequence) {
this.handleVerifiedSequence(verifiedSequence);
}
}
handleVerifiedSequence(verifiedSequence) {
const fieldKey = verifiedSequence.result[FIELD_TYPE_ID].valueBlock.valueDec;
const fieldValue = verifiedSequence.result[FIELD_VALUE_ID];
const handler = this.getFieldHandler(fieldKey);
handler(fieldValue);
}
getFieldHandler(fieldKey) {
if (fieldKey === IN_APP) {
return this.parseReceiptContent.bind(this);
}
if (this.isValidReceiptFieldKey(fieldKey)) {
const name = RECEIPT_FIELDS_MAP.get(fieldKey);
return (fieldValue) => {
this.addFieldToReceipt(name, this.extractStringValue(fieldValue));
};
}
return () => {
};
}
isValidReceiptFieldKey(value) {
return typeof value === "number" && RECEIPT_FIELDS_MAP.has(value);
}
extractStringValue(field) {
const [fieldValue] = field.valueBlock.value;
if (fieldValue instanceof ASN1.IA5String || fieldValue instanceof ASN1.Utf8String) {
return fieldValue.valueBlock.value;
}
return field.toJSON().valueBlock.valueHex;
}
addFieldToReceipt(name, value) {
this.addToArrayFieldIfApplicable(name, value);
this.parsed[name] = value;
}
addToArrayFieldIfApplicable(name, value) {
const arrayFields = {
"IN_APP_ORIGINAL_TRANSACTION_ID": "IN_APP_ORIGINAL_TRANSACTION_IDS",
"IN_APP_TRANSACTION_ID": "IN_APP_TRANSACTION_IDS"
};
const arrayFieldName = arrayFields[name];
if (arrayFieldName) {
this.parsed[arrayFieldName].push(value);
}
}
validateParsedFields() {
const missingFields = Array.from(RECEIPT_FIELDS_MAP.values()).filter((fieldKey) => !(fieldKey in this.parsed));
if (missingFields.length > 0) {
throw new Error(`Missing required fields: ${missingFields.join(", ")}`);
}
}
deduplicateArrayFields() {
this.parsed.IN_APP_ORIGINAL_TRANSACTION_IDS = this.removeDuplicates(this.parsed.IN_APP_ORIGINAL_TRANSACTION_IDS);
this.parsed.IN_APP_TRANSACTION_IDS = this.removeDuplicates(this.parsed.IN_APP_TRANSACTION_IDS);
}
removeDuplicates(array) {
return [...new Set(array)];
}
};
function parseReceipt(receipt) {
return new ReceiptParser().parseReceipt(receipt);
}
export {
parseReceipt
};