@tamtamchik/app-store-receipt-parser 1.0.1 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,6 +1,5 @@
1
1
  # Apple Receipt Parser
2
2
 
3
- [![Buy Me A Coffee][ico-coffee]][link-coffee]
4
3
  [![Latest Version on NPM][ico-version]][link-npm]
5
4
  [![Scrutinizer build][ico-scrutinizer-build]][link-scrutinizer]
6
5
  [![Scrutinizer quality][ico-scrutinizer-quality]][link-scrutinizer]
@@ -11,8 +10,10 @@
11
10
  A lightweight TypeScript library for extracting transaction IDs from Apple's ASN.1 encoded Unified Receipts.
12
11
 
13
12
  > **Warning!** This library is not a full-fledged receipt parser.
14
- > It only extracts transaction IDs from the Apple's ASN.1 encoded Unified Receipts.
15
- > It does not work with the old style transactions receipts.
13
+ > It only extracts some information from Apple's ASN.1 encoded Unified Receipts.
14
+ > It does not work with the old-style transactions receipts.
15
+
16
+ > **Documentation for the version 1.x of the library can be found [here](https://github.com/tamtamchik/app-store-receipt-parser/tree/1.x/README.md).**
16
17
 
17
18
  ## Installation
18
19
 
@@ -31,29 +32,51 @@ yarn add @tamtamchik/app-store-receipt-parser
31
32
  ## Usage
32
33
 
33
34
  ```typescript
34
- import { ReceiptParser } from '@tamtamchik/app-store-receipt-parser';
35
+ import { parseReceipt } from '@tamtamchik/app-store-receipt-parser';
35
36
 
36
37
  // Unified Receipt string
37
38
  const receiptString = "MII...";
38
-
39
- // As an instance ...
40
- const parser = new ReceiptParser(receiptString);
41
- const ids = parser.getTransactionIds();
42
- console.log(ids);
43
- // {
44
- // transactionIds: ['1000000000000000'],
45
- // originalTransactionIds: ['1000000000000000'],
46
- // }
47
-
48
- // ... or as a static method
49
- const ids = ReceiptParser.getTransactionIds(receiptString);
50
- console.log(ids);
51
- // {
52
- // transactionIds: ['1000000000000000'],
53
- // originalTransactionIds: ['1000000000000000'],
39
+ const data = parseReceipt(receiptString);
40
+
41
+ console.log(data);
42
+ // {
43
+ // ENVIRONMENT: 'ProductionSandbox',
44
+ // APP_VERSION: '1',
45
+ // ORIGINAL_APP_VERSION: '1.0',
46
+ // OPAQUE_VALUE: 'c4dd4054b0b61a07beb585f6a842e048',
47
+ // SHA1_HASH: '2e0a115beac1c57023a5bd37349955a9ad99db4d',
48
+ // BUNDLE_ID: 'com.mbaasy.ios.demo',
49
+ // RECEIPT_CREATION_DATE: '2015-08-13T07:50:46Z',
50
+ // ORIGINAL_PURCHASE_DATE: '2013-08-01T07:00:00Z',
51
+ // IN_APP_EXPIRES_DATE: '2015-08-10T07:19:32Z',
52
+ // IN_APP_CANCELLATION_DATE: '',
53
+ // IN_APP_QUANTITY: '020101',
54
+ // IN_APP_WEB_ORDER_LINE_ITEM_ID: '0207038d7ea69472c9',
55
+ // IN_APP_PRODUCT_ID: 'monthly',
56
+ // IN_APP_TRANSACTION_ID: '1000000166967782',
57
+ // IN_APP_TRANSACTION_IDS: [
58
+ // '1000000166865231',
59
+ // '1000000166965150',
60
+ // '1000000166965327',
61
+ // '1000000166965895',
62
+ // '1000000166967152',
63
+ // '1000000166967484',
64
+ // '1000000166967782'
65
+ // ],
66
+ // IN_APP_ORIGINAL_TRANSACTION_ID: '1000000166965150',
67
+ // IN_APP_ORIGINAL_TRANSACTION_IDS: [
68
+ // '1000000166865231',
69
+ // '1000000166965150'
70
+ // ],
71
+ // IN_APP_PURCHASE_DATE: '2015-08-10T07:14:32Z',
72
+ // IN_APP_ORIGINAL_PURCHASE_DATE: '2015-08-10T07:12:34Z'
54
73
  // }
55
74
  ```
56
75
 
76
+ ## Special Thanks
77
+
78
+ - [@Jurajzovinec](https://github.com/Jurajzovinec) for his superb contribution to the project.
79
+
57
80
  ## Contributing
58
81
 
59
82
  Pull requests are always welcome. If you have bigger changes in mind, please open an issue first to discuss your ideas.
@@ -66,6 +89,9 @@ Apple Receipt Parser is [MIT licensed](./LICENSE).
66
89
 
67
90
  This project uses `ASN1.js`, which is licensed under the BSD-3-Clause License. The license text can be found in [LICENSE](./LICENSE).
68
91
 
92
+ ---
93
+ [![Buy Me A Coffee][ico-coffee]][link-coffee]
94
+
69
95
  [ico-coffee]: https://img.shields.io/badge/Buy%20Me%20A-Coffee-%236F4E37.svg?style=flat-square
70
96
  [ico-version]: https://img.shields.io/npm/v/@tamtamchik/app-store-receipt-parser.svg?style=flat-square
71
97
  [ico-license]: https://img.shields.io/npm/l/@tamtamchik/app-store-receipt-parser.svg?style=flat-square
package/dist/index.js CHANGED
@@ -20,87 +20,175 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
20
20
  // src/index.ts
21
21
  var src_exports = {};
22
22
  __export(src_exports, {
23
- ReceiptParser: () => ReceiptParser
23
+ parseReceipt: () => parseReceipt
24
24
  });
25
25
  module.exports = __toCommonJS(src_exports);
26
26
 
27
- // src/ReceiptParser.ts
27
+ // src/parser.ts
28
+ var import_asn1js2 = require("asn1js");
29
+
30
+ // src/mappings.ts
31
+ var RECEIPT_FIELDS_MAP = /* @__PURE__ */ new Map([
32
+ [0, "ENVIRONMENT"],
33
+ [2, "BUNDLE_ID"],
34
+ [3, "APP_VERSION"],
35
+ [4, "OPAQUE_VALUE"],
36
+ [5, "SHA1_HASH"],
37
+ [12, "RECEIPT_CREATION_DATE"],
38
+ [18, "ORIGINAL_PURCHASE_DATE"],
39
+ [19, "ORIGINAL_APP_VERSION"],
40
+ [1701, "IN_APP_QUANTITY"],
41
+ [1702, "IN_APP_PRODUCT_ID"],
42
+ [1703, "IN_APP_TRANSACTION_ID"],
43
+ [1704, "IN_APP_PURCHASE_DATE"],
44
+ [1705, "IN_APP_ORIGINAL_TRANSACTION_ID"],
45
+ [1706, "IN_APP_ORIGINAL_PURCHASE_DATE"],
46
+ [1708, "IN_APP_EXPIRES_DATE"],
47
+ [1711, "IN_APP_WEB_ORDER_LINE_ITEM_ID"],
48
+ [1712, "IN_APP_CANCELLATION_DATE"]
49
+ ]);
50
+
51
+ // src/constants.ts
52
+ var IN_APP = 17;
53
+ var CONTENT_ID = "pkcs7_content";
54
+ var FIELD_TYPE_ID = "FieldType";
55
+ var FIELD_VALUE_ID = "FieldTypeOctetString";
56
+
57
+ // src/verifications.ts
28
58
  var import_asn1js = require("asn1js");
29
- var ReceiptParser = class _ReceiptParser {
30
- constructor(receiptString) {
31
- this.receiptString = receiptString;
59
+ var receiptSchema = new import_asn1js.Sequence({
60
+ value: [
61
+ new import_asn1js.ObjectIdentifier(),
62
+ new import_asn1js.Constructed({
63
+ idBlock: { tagClass: 3, tagNumber: 0 },
64
+ value: [
65
+ new import_asn1js.Sequence({
66
+ value: [
67
+ new import_asn1js.Integer(),
68
+ new import_asn1js.Set({
69
+ value: [
70
+ new import_asn1js.Sequence({
71
+ value: [new import_asn1js.ObjectIdentifier(), new import_asn1js.Any()]
72
+ })
73
+ ]
74
+ }),
75
+ new import_asn1js.Sequence({
76
+ value: [
77
+ new import_asn1js.ObjectIdentifier(),
78
+ new import_asn1js.Constructed({
79
+ idBlock: { tagClass: 3, tagNumber: 0 },
80
+ value: [new import_asn1js.OctetString({ name: CONTENT_ID })]
81
+ })
82
+ ]
83
+ })
84
+ ]
85
+ })
86
+ ]
87
+ })
88
+ ]
89
+ });
90
+ var fieldSchema = new import_asn1js.Sequence({
91
+ value: [
92
+ new import_asn1js.Integer({ name: FIELD_TYPE_ID }),
93
+ new import_asn1js.Integer(),
94
+ new import_asn1js.OctetString({ name: FIELD_VALUE_ID })
95
+ ]
96
+ });
97
+ function verifyReceiptSchema(receipt) {
98
+ const receiptVerification = (0, import_asn1js.verifySchema)(Buffer.from(receipt, "base64"), receiptSchema);
99
+ if (!receiptVerification.verified) {
100
+ throw new Error("Receipt verification failed.");
32
101
  }
33
- static getTransactionIdsFromBlock(block) {
34
- const { valueBlock } = block;
35
- if (!valueBlock || !Array.isArray(valueBlock.value)) {
36
- return null;
37
- }
38
- if (valueBlock.value.length === 3) {
39
- const result2 = this.extractTransactionIds(valueBlock.value);
40
- if (result2)
41
- return result2;
42
- }
43
- const result = { transactionIds: [], originalTransactionIds: [] };
44
- for (const innerBlock of valueBlock.value) {
45
- const innerIds = this.getTransactionIdsFromBlock(innerBlock);
46
- if (innerIds) {
47
- result.transactionIds.push(...innerIds.transactionIds);
48
- result.originalTransactionIds.push(...innerIds.originalTransactionIds);
49
- }
50
- }
51
- result.transactionIds = [...new Set(result.transactionIds)];
52
- result.originalTransactionIds = [...new Set(result.originalTransactionIds)];
53
- return result;
102
+ return receiptVerification;
103
+ }
104
+ function verifyFieldSchema(sequence) {
105
+ const fieldVerification = (0, import_asn1js.verifySchema)(sequence.toBER(), fieldSchema);
106
+ if (!fieldVerification.verified) {
107
+ return null;
54
108
  }
55
- static getTransactionIdsFromReceiptString(receiptString) {
56
- const { result } = (0, import_asn1js.fromBER)(Buffer.from(receiptString, "base64"));
57
- if (result.error) {
58
- throw new Error(`Error parsing receipt: ${result.error}`);
109
+ return fieldVerification;
110
+ }
111
+
112
+ // src/utils.ts
113
+ var uniqueArrayValues = (array) => Array.from(new Set(array));
114
+
115
+ // src/parser.ts
116
+ function isReceiptFieldKey(value) {
117
+ return Boolean(typeof value === "number" && RECEIPT_FIELDS_MAP.has(value));
118
+ }
119
+ function isParsedReceiptContentComplete(data) {
120
+ for (const fieldKey of RECEIPT_FIELDS_MAP.values()) {
121
+ if (!(fieldKey in data)) {
122
+ return false;
59
123
  }
60
- return this.getTransactionIdsFromBlock(result.toJSON());
61
124
  }
62
- /**
63
- * The transaction ID is encoded as an INTEGER with the value 0x06a7
64
- */
65
- static isTransactionIdBlock(block) {
66
- return block.blockName === "INTEGER" && block.valueBlock.valueHex === "06a7";
125
+ return true;
126
+ }
127
+ function extractFieldValue(field) {
128
+ const [fieldValue] = field.valueBlock.value;
129
+ if (fieldValue instanceof import_asn1js2.IA5String || fieldValue instanceof import_asn1js2.Utf8String) {
130
+ return fieldValue.valueBlock.value;
67
131
  }
68
- /**
69
- * The original transaction ID is encoded as an INTEGER with the value 0x06a9
70
- */
71
- static isOriginalTransactionIdBlock(block) {
72
- return block.blockName === "INTEGER" && block.valueBlock.valueHex === "06a9";
132
+ return field.toJSON().valueBlock.valueHex;
133
+ }
134
+ function appendField(parsed, name, value) {
135
+ if (name === "IN_APP_ORIGINAL_TRANSACTION_ID") {
136
+ parsed.IN_APP_ORIGINAL_TRANSACTION_IDS.push(value);
73
137
  }
74
- static extractIdFromBlock(block) {
75
- if (!Array.isArray(block.valueBlock.value)) {
76
- return null;
77
- }
78
- if (block.blockName === "OCTET STRING" && block.valueBlock.value[0].blockName === "UTF8String") {
79
- return block.valueBlock.value[0].valueBlock.value;
80
- }
81
- return null;
138
+ if (name === "IN_APP_TRANSACTION_ID") {
139
+ parsed.IN_APP_TRANSACTION_IDS.push(value);
82
140
  }
83
- static extractTransactionIds(blocks) {
84
- const [firstBlock, , lastBlock] = blocks;
85
- const result = { transactionIds: [], originalTransactionIds: [] };
86
- if (this.isTransactionIdBlock(firstBlock)) {
87
- const id = this.extractIdFromBlock(lastBlock);
88
- if (id)
89
- result.transactionIds.push(id);
90
- } else if (this.isOriginalTransactionIdBlock(firstBlock)) {
91
- const id = this.extractIdFromBlock(lastBlock);
92
- if (id)
93
- result.originalTransactionIds.push(id);
94
- } else {
95
- return null;
141
+ parsed[name] = value;
142
+ }
143
+ function processField(parsed, fieldKey, fieldValue) {
144
+ if (fieldKey === IN_APP) {
145
+ parseOctetStringContent(parsed, fieldValue);
146
+ return;
147
+ }
148
+ if (!isReceiptFieldKey(fieldKey)) {
149
+ return;
150
+ }
151
+ const name = RECEIPT_FIELDS_MAP.get(fieldKey);
152
+ appendField(parsed, name, extractFieldValue(fieldValue));
153
+ }
154
+ function parseOctetStringContent(parsed, content) {
155
+ const [contentSet] = content.valueBlock.value;
156
+ const contentSetSequences = contentSet.valueBlock.value.filter((v) => v instanceof import_asn1js2.Sequence);
157
+ for (const sequence of contentSetSequences) {
158
+ const verifiedSequence = verifyFieldSchema(sequence);
159
+ if (verifiedSequence) {
160
+ const fieldKey = verifiedSequence.result[FIELD_TYPE_ID].valueBlock.valueDec;
161
+ const fieldValueOctetString = verifiedSequence.result[FIELD_VALUE_ID];
162
+ processField(parsed, fieldKey, fieldValueOctetString);
96
163
  }
97
- return result;
98
164
  }
99
- getTransactionIds() {
100
- return _ReceiptParser.getTransactionIdsFromReceiptString(this.receiptString);
165
+ }
166
+ function postprocessParsedReceipt(parsed) {
167
+ parsed.IN_APP_ORIGINAL_TRANSACTION_IDS = uniqueArrayValues(parsed.IN_APP_ORIGINAL_TRANSACTION_IDS);
168
+ parsed.IN_APP_TRANSACTION_IDS = uniqueArrayValues(parsed.IN_APP_TRANSACTION_IDS);
169
+ }
170
+ function parseReceipt(receipt) {
171
+ const rootSchemaVerification = verifyReceiptSchema(receipt);
172
+ const content = rootSchemaVerification.result[CONTENT_ID];
173
+ const parsed = {
174
+ ENVIRONMENT: "Production",
175
+ IN_APP_ORIGINAL_TRANSACTION_IDS: [],
176
+ IN_APP_TRANSACTION_IDS: []
177
+ };
178
+ parseOctetStringContent(parsed, content);
179
+ if (!isParsedReceiptContentComplete(parsed)) {
180
+ const missingProps = [];
181
+ for (const fieldKey of RECEIPT_FIELDS_MAP.values()) {
182
+ if (!(fieldKey in parsed)) {
183
+ missingProps.push(fieldKey);
184
+ }
185
+ }
186
+ throw new Error(`Missing required fields: ${missingProps.join(", ")}`);
101
187
  }
102
- };
188
+ postprocessParsedReceipt(parsed);
189
+ return parsed;
190
+ }
103
191
  // Annotate the CommonJS export names for ESM import in node:
104
192
  0 && (module.exports = {
105
- ReceiptParser
193
+ parseReceipt
106
194
  });
package/dist/index.mjs CHANGED
@@ -1,79 +1,167 @@
1
- // src/ReceiptParser.ts
2
- import { fromBER } from "asn1js";
3
- var ReceiptParser = class _ReceiptParser {
4
- constructor(receiptString) {
5
- this.receiptString = receiptString;
1
+ // src/parser.ts
2
+ import { IA5String, Sequence as Sequence2, Utf8String } from "asn1js";
3
+
4
+ // src/mappings.ts
5
+ var RECEIPT_FIELDS_MAP = /* @__PURE__ */ new Map([
6
+ [0, "ENVIRONMENT"],
7
+ [2, "BUNDLE_ID"],
8
+ [3, "APP_VERSION"],
9
+ [4, "OPAQUE_VALUE"],
10
+ [5, "SHA1_HASH"],
11
+ [12, "RECEIPT_CREATION_DATE"],
12
+ [18, "ORIGINAL_PURCHASE_DATE"],
13
+ [19, "ORIGINAL_APP_VERSION"],
14
+ [1701, "IN_APP_QUANTITY"],
15
+ [1702, "IN_APP_PRODUCT_ID"],
16
+ [1703, "IN_APP_TRANSACTION_ID"],
17
+ [1704, "IN_APP_PURCHASE_DATE"],
18
+ [1705, "IN_APP_ORIGINAL_TRANSACTION_ID"],
19
+ [1706, "IN_APP_ORIGINAL_PURCHASE_DATE"],
20
+ [1708, "IN_APP_EXPIRES_DATE"],
21
+ [1711, "IN_APP_WEB_ORDER_LINE_ITEM_ID"],
22
+ [1712, "IN_APP_CANCELLATION_DATE"]
23
+ ]);
24
+
25
+ // src/constants.ts
26
+ var IN_APP = 17;
27
+ var CONTENT_ID = "pkcs7_content";
28
+ var FIELD_TYPE_ID = "FieldType";
29
+ var FIELD_VALUE_ID = "FieldTypeOctetString";
30
+
31
+ // src/verifications.ts
32
+ import { Any, Constructed, Integer, ObjectIdentifier, OctetString, Sequence, Set as Set2, verifySchema } from "asn1js";
33
+ var receiptSchema = new Sequence({
34
+ value: [
35
+ new ObjectIdentifier(),
36
+ new Constructed({
37
+ idBlock: { tagClass: 3, tagNumber: 0 },
38
+ value: [
39
+ new Sequence({
40
+ value: [
41
+ new Integer(),
42
+ new Set2({
43
+ value: [
44
+ new Sequence({
45
+ value: [new ObjectIdentifier(), new Any()]
46
+ })
47
+ ]
48
+ }),
49
+ new Sequence({
50
+ value: [
51
+ new ObjectIdentifier(),
52
+ new Constructed({
53
+ idBlock: { tagClass: 3, tagNumber: 0 },
54
+ value: [new OctetString({ name: CONTENT_ID })]
55
+ })
56
+ ]
57
+ })
58
+ ]
59
+ })
60
+ ]
61
+ })
62
+ ]
63
+ });
64
+ var fieldSchema = new Sequence({
65
+ value: [
66
+ new Integer({ name: FIELD_TYPE_ID }),
67
+ new Integer(),
68
+ new OctetString({ name: FIELD_VALUE_ID })
69
+ ]
70
+ });
71
+ function verifyReceiptSchema(receipt) {
72
+ const receiptVerification = verifySchema(Buffer.from(receipt, "base64"), receiptSchema);
73
+ if (!receiptVerification.verified) {
74
+ throw new Error("Receipt verification failed.");
6
75
  }
7
- static getTransactionIdsFromBlock(block) {
8
- const { valueBlock } = block;
9
- if (!valueBlock || !Array.isArray(valueBlock.value)) {
10
- return null;
11
- }
12
- if (valueBlock.value.length === 3) {
13
- const result2 = this.extractTransactionIds(valueBlock.value);
14
- if (result2)
15
- return result2;
16
- }
17
- const result = { transactionIds: [], originalTransactionIds: [] };
18
- for (const innerBlock of valueBlock.value) {
19
- const innerIds = this.getTransactionIdsFromBlock(innerBlock);
20
- if (innerIds) {
21
- result.transactionIds.push(...innerIds.transactionIds);
22
- result.originalTransactionIds.push(...innerIds.originalTransactionIds);
23
- }
24
- }
25
- result.transactionIds = [...new Set(result.transactionIds)];
26
- result.originalTransactionIds = [...new Set(result.originalTransactionIds)];
27
- return result;
76
+ return receiptVerification;
77
+ }
78
+ function verifyFieldSchema(sequence) {
79
+ const fieldVerification = verifySchema(sequence.toBER(), fieldSchema);
80
+ if (!fieldVerification.verified) {
81
+ return null;
28
82
  }
29
- static getTransactionIdsFromReceiptString(receiptString) {
30
- const { result } = fromBER(Buffer.from(receiptString, "base64"));
31
- if (result.error) {
32
- throw new Error(`Error parsing receipt: ${result.error}`);
83
+ return fieldVerification;
84
+ }
85
+
86
+ // src/utils.ts
87
+ var uniqueArrayValues = (array) => Array.from(new Set(array));
88
+
89
+ // src/parser.ts
90
+ function isReceiptFieldKey(value) {
91
+ return Boolean(typeof value === "number" && RECEIPT_FIELDS_MAP.has(value));
92
+ }
93
+ function isParsedReceiptContentComplete(data) {
94
+ for (const fieldKey of RECEIPT_FIELDS_MAP.values()) {
95
+ if (!(fieldKey in data)) {
96
+ return false;
33
97
  }
34
- return this.getTransactionIdsFromBlock(result.toJSON());
35
98
  }
36
- /**
37
- * The transaction ID is encoded as an INTEGER with the value 0x06a7
38
- */
39
- static isTransactionIdBlock(block) {
40
- return block.blockName === "INTEGER" && block.valueBlock.valueHex === "06a7";
99
+ return true;
100
+ }
101
+ function extractFieldValue(field) {
102
+ const [fieldValue] = field.valueBlock.value;
103
+ if (fieldValue instanceof IA5String || fieldValue instanceof Utf8String) {
104
+ return fieldValue.valueBlock.value;
41
105
  }
42
- /**
43
- * The original transaction ID is encoded as an INTEGER with the value 0x06a9
44
- */
45
- static isOriginalTransactionIdBlock(block) {
46
- return block.blockName === "INTEGER" && block.valueBlock.valueHex === "06a9";
106
+ return field.toJSON().valueBlock.valueHex;
107
+ }
108
+ function appendField(parsed, name, value) {
109
+ if (name === "IN_APP_ORIGINAL_TRANSACTION_ID") {
110
+ parsed.IN_APP_ORIGINAL_TRANSACTION_IDS.push(value);
47
111
  }
48
- static extractIdFromBlock(block) {
49
- if (!Array.isArray(block.valueBlock.value)) {
50
- return null;
51
- }
52
- if (block.blockName === "OCTET STRING" && block.valueBlock.value[0].blockName === "UTF8String") {
53
- return block.valueBlock.value[0].valueBlock.value;
54
- }
55
- return null;
112
+ if (name === "IN_APP_TRANSACTION_ID") {
113
+ parsed.IN_APP_TRANSACTION_IDS.push(value);
114
+ }
115
+ parsed[name] = value;
116
+ }
117
+ function processField(parsed, fieldKey, fieldValue) {
118
+ if (fieldKey === IN_APP) {
119
+ parseOctetStringContent(parsed, fieldValue);
120
+ return;
121
+ }
122
+ if (!isReceiptFieldKey(fieldKey)) {
123
+ return;
56
124
  }
57
- static extractTransactionIds(blocks) {
58
- const [firstBlock, , lastBlock] = blocks;
59
- const result = { transactionIds: [], originalTransactionIds: [] };
60
- if (this.isTransactionIdBlock(firstBlock)) {
61
- const id = this.extractIdFromBlock(lastBlock);
62
- if (id)
63
- result.transactionIds.push(id);
64
- } else if (this.isOriginalTransactionIdBlock(firstBlock)) {
65
- const id = this.extractIdFromBlock(lastBlock);
66
- if (id)
67
- result.originalTransactionIds.push(id);
68
- } else {
69
- return null;
125
+ const name = RECEIPT_FIELDS_MAP.get(fieldKey);
126
+ appendField(parsed, name, extractFieldValue(fieldValue));
127
+ }
128
+ function parseOctetStringContent(parsed, content) {
129
+ const [contentSet] = content.valueBlock.value;
130
+ const contentSetSequences = contentSet.valueBlock.value.filter((v) => v instanceof Sequence2);
131
+ for (const sequence of contentSetSequences) {
132
+ const verifiedSequence = verifyFieldSchema(sequence);
133
+ if (verifiedSequence) {
134
+ const fieldKey = verifiedSequence.result[FIELD_TYPE_ID].valueBlock.valueDec;
135
+ const fieldValueOctetString = verifiedSequence.result[FIELD_VALUE_ID];
136
+ processField(parsed, fieldKey, fieldValueOctetString);
70
137
  }
71
- return result;
72
138
  }
73
- getTransactionIds() {
74
- return _ReceiptParser.getTransactionIdsFromReceiptString(this.receiptString);
139
+ }
140
+ function postprocessParsedReceipt(parsed) {
141
+ parsed.IN_APP_ORIGINAL_TRANSACTION_IDS = uniqueArrayValues(parsed.IN_APP_ORIGINAL_TRANSACTION_IDS);
142
+ parsed.IN_APP_TRANSACTION_IDS = uniqueArrayValues(parsed.IN_APP_TRANSACTION_IDS);
143
+ }
144
+ function parseReceipt(receipt) {
145
+ const rootSchemaVerification = verifyReceiptSchema(receipt);
146
+ const content = rootSchemaVerification.result[CONTENT_ID];
147
+ const parsed = {
148
+ ENVIRONMENT: "Production",
149
+ IN_APP_ORIGINAL_TRANSACTION_IDS: [],
150
+ IN_APP_TRANSACTION_IDS: []
151
+ };
152
+ parseOctetStringContent(parsed, content);
153
+ if (!isParsedReceiptContentComplete(parsed)) {
154
+ const missingProps = [];
155
+ for (const fieldKey of RECEIPT_FIELDS_MAP.values()) {
156
+ if (!(fieldKey in parsed)) {
157
+ missingProps.push(fieldKey);
158
+ }
159
+ }
160
+ throw new Error(`Missing required fields: ${missingProps.join(", ")}`);
75
161
  }
76
- };
162
+ postprocessParsedReceipt(parsed);
163
+ return parsed;
164
+ }
77
165
  export {
78
- ReceiptParser
166
+ parseReceipt
79
167
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tamtamchik/app-store-receipt-parser",
3
- "version": "1.0.1",
3
+ "version": "2.1.0",
4
4
  "description": "A lightweight TypeScript library for extracting transaction IDs from Apple's ASN.1 encoded receipts.",
5
5
  "main": "./dist/index.js",
6
6
  "module": "./dist/index.mjs",
@@ -34,15 +34,15 @@
34
34
  "asn1js": "3.0.5"
35
35
  },
36
36
  "devDependencies": {
37
- "@types/jest": "29.5.8",
38
- "@types/node": "20.9.0",
39
- "@typescript-eslint/eslint-plugin": "6.11.0",
40
- "@typescript-eslint/parser": "6.11.0",
41
- "eslint": "8.53.0",
37
+ "@types/jest": "29.5.12",
38
+ "@types/node": "20.12.7",
39
+ "@typescript-eslint/eslint-plugin": "7.6.0",
40
+ "@typescript-eslint/parser": "7.6.0",
41
+ "eslint": "8.57.0",
42
42
  "jest": "29.7.0",
43
- "ts-jest": "29.1.1",
44
- "tsup": "7.2.0",
45
- "typescript": "5.2.2"
43
+ "ts-jest": "29.1.2",
44
+ "tsup": "8.0.2",
45
+ "typescript": "5.4.5"
46
46
  },
47
47
  "scripts": {
48
48
  "build": "tsup src/index.ts --format cjs,esm --clean",
@@ -0,0 +1,11 @@
1
+ /** Identifier for in-app receipt fields (starting with 17*) */
2
+ export const IN_APP: number = 17
3
+
4
+ /** Identifies pkcs7 content information encoded as Octet string */
5
+ export const CONTENT_ID = 'pkcs7_content'
6
+
7
+ /** Identifies field type id information */
8
+ export const FIELD_TYPE_ID: string = 'FieldType'
9
+
10
+ /** Identifies field value information encoded as Octet string */
11
+ export const FIELD_VALUE_ID: string = 'FieldTypeOctetString'
package/src/index.ts CHANGED
@@ -1 +1,2 @@
1
- export { ReceiptParser, TransactionIds } from './ReceiptParser'
1
+ export type { ParsedReceipt } from './parser'
2
+ export { parseReceipt } from './parser'
@@ -0,0 +1,62 @@
1
+ export type ReceiptFieldsKeyValues =
2
+ | 0
3
+ | 2
4
+ | 3
5
+ | 4
6
+ | 5
7
+ | 12
8
+ | 18
9
+ | 19
10
+ | 1701
11
+ | 1702
12
+ | 1703
13
+ | 1704
14
+ | 1705
15
+ | 1706
16
+ | 1708
17
+ | 1711
18
+ | 1712
19
+
20
+ export type ReceiptFieldsKeyNames =
21
+ | 'ENVIRONMENT'
22
+ | 'BUNDLE_ID'
23
+ | 'APP_VERSION'
24
+ | 'OPAQUE_VALUE'
25
+ | 'SHA1_HASH'
26
+ | 'RECEIPT_CREATION_DATE'
27
+ | 'ORIGINAL_PURCHASE_DATE'
28
+ | 'ORIGINAL_APP_VERSION'
29
+ | 'IN_APP_QUANTITY'
30
+ | 'IN_APP_PRODUCT_ID'
31
+ | 'IN_APP_TRANSACTION_ID'
32
+ | 'IN_APP_PURCHASE_DATE'
33
+ | 'IN_APP_ORIGINAL_TRANSACTION_ID'
34
+ | 'IN_APP_ORIGINAL_PURCHASE_DATE'
35
+ | 'IN_APP_EXPIRES_DATE'
36
+ | 'IN_APP_WEB_ORDER_LINE_ITEM_ID'
37
+ | 'IN_APP_CANCELLATION_DATE'
38
+
39
+ /**
40
+ * Receipt fields
41
+ * @see https://developer.apple.com/library/archive/releasenotes/General/ValidateAppStoreReceipt/Chapters/ReceiptFields.html
42
+ */
43
+ export const RECEIPT_FIELDS_MAP: ReadonlyMap<ReceiptFieldsKeyValues, ReceiptFieldsKeyNames> = new Map([
44
+ [0, 'ENVIRONMENT'],
45
+ [2, 'BUNDLE_ID'],
46
+ [3, 'APP_VERSION'],
47
+ [4, 'OPAQUE_VALUE'],
48
+ [5, 'SHA1_HASH'],
49
+ [12, 'RECEIPT_CREATION_DATE'],
50
+ [18, 'ORIGINAL_PURCHASE_DATE'],
51
+ [19, 'ORIGINAL_APP_VERSION'],
52
+ [1701, 'IN_APP_QUANTITY'],
53
+ [1702, 'IN_APP_PRODUCT_ID'],
54
+ [1703, 'IN_APP_TRANSACTION_ID'],
55
+ [1704, 'IN_APP_PURCHASE_DATE'],
56
+ [1705, 'IN_APP_ORIGINAL_TRANSACTION_ID'],
57
+ [1706, 'IN_APP_ORIGINAL_PURCHASE_DATE'],
58
+ [1708, 'IN_APP_EXPIRES_DATE'],
59
+ [1711, 'IN_APP_WEB_ORDER_LINE_ITEM_ID'],
60
+ [1712, 'IN_APP_CANCELLATION_DATE'],
61
+ ])
62
+
package/src/parser.ts ADDED
@@ -0,0 +1,114 @@
1
+ import { IA5String, Integer, OctetString, Sequence, Set, Utf8String } from 'asn1js'
2
+
3
+ import { RECEIPT_FIELDS_MAP, ReceiptFieldsKeyNames, ReceiptFieldsKeyValues } from './mappings'
4
+ import { CONTENT_ID, FIELD_TYPE_ID, FIELD_VALUE_ID, IN_APP } from './constants'
5
+ import { verifyFieldSchema, verifyReceiptSchema } from './verifications'
6
+ import { uniqueArrayValues } from './utils'
7
+
8
+ export type Environment = 'Production' | 'ProductionSandbox' | string
9
+
10
+ export type ParsedReceipt = Partial<Record<ReceiptFieldsKeyNames, string>> & {
11
+ ENVIRONMENT: Environment
12
+ IN_APP_ORIGINAL_TRANSACTION_IDS: string[]
13
+ IN_APP_TRANSACTION_IDS: string[]
14
+ }
15
+
16
+ function isReceiptFieldKey (value: unknown): value is ReceiptFieldsKeyValues {
17
+ return Boolean(typeof value === 'number' && RECEIPT_FIELDS_MAP.has(value as ReceiptFieldsKeyValues))
18
+ }
19
+
20
+ function isParsedReceiptContentComplete (data: ParsedReceipt): data is ParsedReceipt {
21
+ for (const fieldKey of RECEIPT_FIELDS_MAP.values()) {
22
+ if (!(fieldKey in data)) {
23
+ return false
24
+ }
25
+ }
26
+
27
+ return true
28
+ }
29
+
30
+ function extractFieldValue (field: OctetString): string {
31
+ const [fieldValue] = field.valueBlock.value
32
+
33
+ if (fieldValue instanceof IA5String || fieldValue instanceof Utf8String) {
34
+ return fieldValue.valueBlock.value
35
+ }
36
+
37
+ return field.toJSON().valueBlock.valueHex
38
+ }
39
+
40
+ function appendField (parsed: ParsedReceipt, name: ReceiptFieldsKeyNames, value: string) {
41
+ if (name === 'IN_APP_ORIGINAL_TRANSACTION_ID') {
42
+ parsed.IN_APP_ORIGINAL_TRANSACTION_IDS.push(value)
43
+ }
44
+
45
+ if (name === 'IN_APP_TRANSACTION_ID') {
46
+ parsed.IN_APP_TRANSACTION_IDS.push(value)
47
+ }
48
+
49
+ parsed[name] = value
50
+ }
51
+
52
+ function processField (parsed: ParsedReceipt, fieldKey: number, fieldValue: OctetString) {
53
+ if (fieldKey === IN_APP) {
54
+ parseOctetStringContent(parsed, fieldValue)
55
+ return
56
+ }
57
+
58
+ if (!isReceiptFieldKey(fieldKey)) {
59
+ return
60
+ }
61
+
62
+ const name = RECEIPT_FIELDS_MAP.get(fieldKey)!
63
+ appendField(parsed, name, extractFieldValue(fieldValue))
64
+ }
65
+
66
+ function parseOctetStringContent (parsed: ParsedReceipt, content: OctetString) {
67
+ const [contentSet] = content.valueBlock.value as Set[]
68
+ const contentSetSequences = contentSet.valueBlock.value.filter(v => v instanceof Sequence) as Sequence[]
69
+
70
+ for (const sequence of contentSetSequences) {
71
+ const verifiedSequence = verifyFieldSchema(sequence)
72
+ if (verifiedSequence) {
73
+ // We are confident to use "as" assertion because Integer type is guaranteed by positive verification above
74
+ const fieldKey = (verifiedSequence.result[FIELD_TYPE_ID] as Integer).valueBlock.valueDec
75
+ const fieldValueOctetString = verifiedSequence.result[FIELD_VALUE_ID] as OctetString
76
+
77
+ processField(parsed, fieldKey, fieldValueOctetString)
78
+ }
79
+ }
80
+ }
81
+
82
+ function postprocessParsedReceipt (parsed: ParsedReceipt) {
83
+ parsed.IN_APP_ORIGINAL_TRANSACTION_IDS = uniqueArrayValues(parsed.IN_APP_ORIGINAL_TRANSACTION_IDS)
84
+ parsed.IN_APP_TRANSACTION_IDS = uniqueArrayValues(parsed.IN_APP_TRANSACTION_IDS)
85
+ }
86
+
87
+ export function parseReceipt (receipt: string): ParsedReceipt {
88
+ const rootSchemaVerification = verifyReceiptSchema(receipt)
89
+
90
+ const content = rootSchemaVerification.result[CONTENT_ID] as OctetString
91
+ const parsed: ParsedReceipt = {
92
+ ENVIRONMENT: 'Production',
93
+ IN_APP_ORIGINAL_TRANSACTION_IDS: [],
94
+ IN_APP_TRANSACTION_IDS: [],
95
+ }
96
+
97
+ parseOctetStringContent(parsed, content)
98
+
99
+ // Verify if the parsed content contains all the required fields
100
+ if (!isParsedReceiptContentComplete(parsed)) {
101
+ const missingProps = []
102
+ for (const fieldKey of RECEIPT_FIELDS_MAP.values()) {
103
+ if (!(fieldKey in parsed)) {
104
+ missingProps.push(fieldKey)
105
+ }
106
+ }
107
+
108
+ throw new Error(`Missing required fields: ${missingProps.join(', ')}`)
109
+ }
110
+
111
+ postprocessParsedReceipt(parsed)
112
+
113
+ return parsed as ParsedReceipt
114
+ }
package/src/utils.ts ADDED
@@ -0,0 +1 @@
1
+ export const uniqueArrayValues = (array: string[]) => Array.from(new Set(array))
@@ -0,0 +1,62 @@
1
+ import { Any, Constructed, Integer, ObjectIdentifier, OctetString, Sequence, Set, verifySchema } from 'asn1js'
2
+
3
+ import { CONTENT_ID, FIELD_TYPE_ID, FIELD_VALUE_ID } from './constants'
4
+
5
+ const receiptSchema = new Sequence({
6
+ value: [
7
+ new ObjectIdentifier(),
8
+ new Constructed({
9
+ idBlock: { tagClass: 3, tagNumber: 0 },
10
+ value: [
11
+ new Sequence({
12
+ value: [
13
+ new Integer(),
14
+ new Set({
15
+ value: [
16
+ new Sequence({
17
+ value: [new ObjectIdentifier(), new Any()],
18
+ }),
19
+ ],
20
+ }),
21
+ new Sequence({
22
+ value: [
23
+ new ObjectIdentifier(),
24
+ new Constructed({
25
+ idBlock: { tagClass: 3, tagNumber: 0 },
26
+ value: [new OctetString({ name: CONTENT_ID })],
27
+ }),
28
+ ],
29
+ }),
30
+ ],
31
+ }),
32
+ ],
33
+ }),
34
+ ],
35
+ })
36
+
37
+ const fieldSchema = new Sequence({
38
+ value: [
39
+ new Integer({ name: FIELD_TYPE_ID }),
40
+ new Integer(),
41
+ new OctetString({ name: FIELD_VALUE_ID }),
42
+ ],
43
+ })
44
+
45
+ export function verifyReceiptSchema (receipt: string) {
46
+ const receiptVerification = verifySchema(Buffer.from(receipt, 'base64'), receiptSchema)
47
+ if (!receiptVerification.verified) {
48
+ throw new Error('Receipt verification failed.')
49
+ }
50
+
51
+ return receiptVerification
52
+ }
53
+
54
+ export function verifyFieldSchema (sequence: Sequence) {
55
+ const fieldVerification = verifySchema(sequence.toBER(), fieldSchema)
56
+ if (!fieldVerification.verified) {
57
+ // Return null if the field schema verification fails, so we can skip the field
58
+ return null
59
+ }
60
+
61
+ return fieldVerification
62
+ }
@@ -1,109 +0,0 @@
1
- import { fromBER } from 'asn1js'
2
-
3
- interface Block {
4
- blockName: string
5
- valueBlock: {
6
- valueHex?: string
7
- value?: Block[] | string
8
- }
9
- }
10
-
11
- export interface TransactionIds {
12
- transactionIds: string[]
13
- originalTransactionIds: string[]
14
- }
15
-
16
- /**
17
- * Parses a receipt string and extracts the transaction IDs from it.
18
- * Warning! This class does not validate the receipt string, only extracts them.
19
- */
20
- export class ReceiptParser {
21
- constructor (private readonly receiptString: string) {}
22
-
23
- static getTransactionIdsFromBlock (block: Block): TransactionIds | null {
24
- const { valueBlock } = block
25
-
26
- if (!valueBlock || !Array.isArray(valueBlock.value)) {
27
- return null
28
- }
29
-
30
- if (valueBlock.value.length === 3) {
31
- const result = this.extractTransactionIds(valueBlock.value)
32
- if (result) return result
33
- }
34
-
35
- const result: TransactionIds = { transactionIds: [], originalTransactionIds: [] }
36
-
37
- for (const innerBlock of valueBlock.value) {
38
- const innerIds = this.getTransactionIdsFromBlock(innerBlock)
39
- if (innerIds) {
40
- result.transactionIds.push(...innerIds.transactionIds)
41
- result.originalTransactionIds.push(...innerIds.originalTransactionIds)
42
- }
43
- }
44
-
45
- // Deduplicate the IDs
46
- result.transactionIds = [...new Set(result.transactionIds)]
47
- result.originalTransactionIds = [...new Set(result.originalTransactionIds)]
48
-
49
- return result
50
- }
51
-
52
- static getTransactionIdsFromReceiptString (receiptString: string): TransactionIds | null {
53
- const { result } = fromBER(Buffer.from(receiptString, 'base64'))
54
-
55
- if (result.error) {
56
- throw new Error(`Error parsing receipt: ${result.error}`)
57
- }
58
-
59
- return this.getTransactionIdsFromBlock(result.toJSON() as Block)
60
- }
61
-
62
- /**
63
- * The transaction ID is encoded as an INTEGER with the value 0x06a7
64
- */
65
- private static isTransactionIdBlock (block: Block): boolean {
66
- return block.blockName === 'INTEGER' && block.valueBlock.valueHex === '06a7'
67
- }
68
-
69
- /**
70
- * The original transaction ID is encoded as an INTEGER with the value 0x06a9
71
- */
72
- private static isOriginalTransactionIdBlock (block: Block): boolean {
73
- return block.blockName === 'INTEGER' && block.valueBlock.valueHex === '06a9'
74
- }
75
-
76
- private static extractIdFromBlock (block: Block): string | null {
77
- if (!Array.isArray(block.valueBlock.value)) {
78
- return null
79
- }
80
-
81
- // The transaction ID is encoded as an OCTET STRING containing a UTF8String
82
- if (block.blockName === 'OCTET STRING' && block.valueBlock.value[0].blockName === 'UTF8String') {
83
- return block.valueBlock.value[0].valueBlock.value as string
84
- }
85
-
86
- return null
87
- }
88
-
89
- private static extractTransactionIds (blocks: Block[]): TransactionIds | null {
90
- const [firstBlock, , lastBlock] = blocks
91
- const result: TransactionIds = { transactionIds: [], originalTransactionIds: [] }
92
-
93
- if (this.isTransactionIdBlock(firstBlock)) {
94
- const id = this.extractIdFromBlock(lastBlock)
95
- if (id) result.transactionIds.push(id)
96
- } else if (this.isOriginalTransactionIdBlock(firstBlock)) {
97
- const id = this.extractIdFromBlock(lastBlock)
98
- if (id) result.originalTransactionIds.push(id)
99
- } else {
100
- return null
101
- }
102
-
103
- return result
104
- }
105
-
106
- getTransactionIds (): TransactionIds | null {
107
- return ReceiptParser.getTransactionIdsFromReceiptString(this.receiptString)
108
- }
109
- }