UNPKG

@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
// 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 };