@tamtamchik/app-store-receipt-parser 1.0.0 → 2.0.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/LICENSE CHANGED
@@ -19,3 +19,36 @@ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
19
  LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
20
  OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
21
  SOFTWARE.
22
+
23
+ --- Third Party Licenses
24
+
25
+ Copyright (c) 2014, GMO GlobalSign
26
+ Copyright (c) 2015-2022, Peculiar Ventures
27
+ All rights reserved.
28
+
29
+ Author 2014-2019, Yury Strozhevsky
30
+
31
+ Redistribution and use in source and binary forms, with or without modification,
32
+ are permitted provided that the following conditions are met:
33
+
34
+ * Redistributions of source code must retain the above copyright notice, this
35
+ list of conditions and the following disclaimer.
36
+
37
+ * Redistributions in binary form must reproduce the above copyright notice, this
38
+ list of conditions and the following disclaimer in the documentation and/or
39
+ other materials provided with the distribution.
40
+
41
+ * Neither the name of the copyright holder nor the names of its
42
+ contributors may be used to endorse or promote products derived from
43
+ this software without specific prior written permission.
44
+
45
+ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
46
+ ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
47
+ WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
48
+ DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR
49
+ ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
50
+ (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
51
+ LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
52
+ ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
53
+ (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
54
+ SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
package/README.md CHANGED
@@ -2,16 +2,20 @@
2
2
 
3
3
  [![Buy Me A Coffee][ico-coffee]][link-coffee]
4
4
  [![Latest Version on NPM][ico-version]][link-npm]
5
- [![CircleCI][ico-circleci]][link-circleci]
5
+ [![Scrutinizer build][ico-scrutinizer-build]][link-scrutinizer]
6
+ [![Scrutinizer quality][ico-scrutinizer-quality]][link-scrutinizer]
7
+ [![Scrutinizer coverage][ico-scrutinizer-coverage]][link-scrutinizer]
6
8
  [![Software License][ico-license]](./LICENSE)
7
9
  [![Total Downloads][ico-downloads]][link-downloads]
8
10
 
9
11
  A lightweight TypeScript library for extracting transaction IDs from Apple's ASN.1 encoded Unified Receipts.
10
12
 
11
13
  > **Warning!** This library is not a full-fledged receipt parser.
12
- > It only extracts transaction IDs from the Apple's ASN.1 encoded Unified Receipts.
14
+ > It only extracts some information from the Apple's ASN.1 encoded Unified Receipts.
13
15
  > It does not work with the old style transactions receipts.
14
16
 
17
+ > **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).**
18
+
15
19
  ## Installation
16
20
 
17
21
  Using npm:
@@ -29,29 +33,50 @@ yarn add @tamtamchik/app-store-receipt-parser
29
33
  ## Usage
30
34
 
31
35
  ```typescript
32
- import { ReceiptParser } from '@tamtamchik/app-store-receipt-parser';
36
+ import { parseReceipt } from '@tamtamchik/app-store-receipt-parser';
33
37
 
34
38
  // Unified Receipt string
35
39
  const receiptString = "MII...";
36
-
37
- // As an instance ...
38
- const parser = new ReceiptParser(receiptString);
39
- const ids = parser.getTransactionIds();
40
- console.log(ids);
41
- // {
42
- // transactionIds: ['1000000000000000'],
43
- // originalTransactionIds: ['1000000000000000'],
44
- // }
45
-
46
- // ... or as a static method
47
- const ids = ReceiptParser.getTransactionIds(receiptString);
48
- console.log(ids);
49
- // {
50
- // transactionIds: ['1000000000000000'],
51
- // originalTransactionIds: ['1000000000000000'],
40
+ const data = parseReceipt(receiptString);
41
+
42
+ console.log(data);
43
+ // {
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'
52
73
  // }
53
74
  ```
54
75
 
76
+ ## Special Thanks
77
+
78
+ - [@Jurajzovinec](https://github.com/Jurajzovinec) for his superb contribution to the project.
79
+
55
80
  ## Contributing
56
81
 
57
82
  Pull requests are always welcome. If you have bigger changes in mind, please open an issue first to discuss your ideas.
@@ -60,13 +85,19 @@ Pull requests are always welcome. If you have bigger changes in mind, please ope
60
85
 
61
86
  Apple Receipt Parser is [MIT licensed](./LICENSE).
62
87
 
88
+ ## Third-Party Licenses
89
+
90
+ This project uses `ASN1.js`, which is licensed under the BSD-3-Clause License. The license text can be found in [LICENSE](./LICENSE).
91
+
63
92
  [ico-coffee]: https://img.shields.io/badge/Buy%20Me%20A-Coffee-%236F4E37.svg?style=flat-square
64
93
  [ico-version]: https://img.shields.io/npm/v/@tamtamchik/app-store-receipt-parser.svg?style=flat-square
65
94
  [ico-license]: https://img.shields.io/npm/l/@tamtamchik/app-store-receipt-parser.svg?style=flat-square
66
95
  [ico-downloads]: https://img.shields.io/npm/dt/@tamtamchik/app-store-receipt-parser.svg?style=flat-square
67
- [ico-circleci]: https://img.shields.io/circleci/build/github/tamtamchik/app-store-receipt-parser.svg?style=flat-square
96
+ [ico-scrutinizer-build]: https://img.shields.io/scrutinizer/build/g/tamtamchik/app-store-receipt-parser/main.svg?style=flat-square
97
+ [ico-scrutinizer-quality]: https://img.shields.io/scrutinizer/quality/g/tamtamchik/app-store-receipt-parser/main.svg?style=flat-square
98
+ [ico-scrutinizer-coverage]: https://img.shields.io/scrutinizer/coverage/g/tamtamchik/app-store-receipt-parser/main.svg?style=flat-square
68
99
 
69
100
  [link-coffee]: https://www.buymeacoffee.com/tamtamchik
70
101
  [link-npm]: https://www.npmjs.com/package/@tamtamchik/app-store-receipt-parser
71
102
  [link-downloads]: https://www.npmjs.com/package/@tamtamchik/app-store-receipt-parser
72
- [link-circleci]: https://app.circleci.com/pipelines/github/tamtamchik/app-store-receipt-parser?branch=main
103
+ [link-scrutinizer]: https://scrutinizer-ci.com/g/tamtamchik/app-store-receipt-parser/
package/dist/index.js CHANGED
@@ -20,87 +20,173 @@ 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
+ [2, "BUNDLE_ID"],
33
+ [3, "APP_VERSION"],
34
+ [4, "OPAQUE_VALUE"],
35
+ [5, "SHA1_HASH"],
36
+ [12, "RECEIPT_CREATION_DATE"],
37
+ [18, "ORIGINAL_PURCHASE_DATE"],
38
+ [19, "ORIGINAL_APP_VERSION"],
39
+ [1701, "IN_APP_QUANTITY"],
40
+ [1702, "IN_APP_PRODUCT_ID"],
41
+ [1703, "IN_APP_TRANSACTION_ID"],
42
+ [1704, "IN_APP_PURCHASE_DATE"],
43
+ [1705, "IN_APP_ORIGINAL_TRANSACTION_ID"],
44
+ [1706, "IN_APP_ORIGINAL_PURCHASE_DATE"],
45
+ [1708, "IN_APP_EXPIRES_DATE"],
46
+ [1711, "IN_APP_WEB_ORDER_LINE_ITEM_ID"],
47
+ [1712, "IN_APP_CANCELLATION_DATE"]
48
+ ]);
49
+
50
+ // src/constants.ts
51
+ var IN_APP = 17;
52
+ var CONTENT_ID = "pkcs7_content";
53
+ var FIELD_TYPE_ID = "FieldType";
54
+ var FIELD_VALUE_ID = "FieldTypeOctetString";
55
+
56
+ // src/verifications.ts
28
57
  var import_asn1js = require("asn1js");
29
- var ReceiptParser = class _ReceiptParser {
30
- constructor(receiptString) {
31
- this.receiptString = receiptString;
58
+ var receiptSchema = new import_asn1js.Sequence({
59
+ value: [
60
+ new import_asn1js.ObjectIdentifier(),
61
+ new import_asn1js.Constructed({
62
+ idBlock: { tagClass: 3, tagNumber: 0 },
63
+ value: [
64
+ new import_asn1js.Sequence({
65
+ value: [
66
+ new import_asn1js.Integer(),
67
+ new import_asn1js.Set({
68
+ value: [
69
+ new import_asn1js.Sequence({
70
+ value: [new import_asn1js.ObjectIdentifier(), new import_asn1js.Any()]
71
+ })
72
+ ]
73
+ }),
74
+ new import_asn1js.Sequence({
75
+ value: [
76
+ new import_asn1js.ObjectIdentifier(),
77
+ new import_asn1js.Constructed({
78
+ idBlock: { tagClass: 3, tagNumber: 0 },
79
+ value: [new import_asn1js.OctetString({ name: CONTENT_ID })]
80
+ })
81
+ ]
82
+ })
83
+ ]
84
+ })
85
+ ]
86
+ })
87
+ ]
88
+ });
89
+ var fieldSchema = new import_asn1js.Sequence({
90
+ value: [
91
+ new import_asn1js.Integer({ name: FIELD_TYPE_ID }),
92
+ new import_asn1js.Integer(),
93
+ new import_asn1js.OctetString({ name: FIELD_VALUE_ID })
94
+ ]
95
+ });
96
+ function verifyReceiptSchema(receipt) {
97
+ const receiptVerification = (0, import_asn1js.verifySchema)(Buffer.from(receipt, "base64"), receiptSchema);
98
+ if (!receiptVerification.verified) {
99
+ throw new Error("Receipt verification failed.");
32
100
  }
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;
101
+ return receiptVerification;
102
+ }
103
+ function verifyFieldSchema(sequence) {
104
+ const fieldVerification = (0, import_asn1js.verifySchema)(sequence.toBER(), fieldSchema);
105
+ if (!fieldVerification.verified) {
106
+ return null;
54
107
  }
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}`);
108
+ return fieldVerification;
109
+ }
110
+
111
+ // src/utils.ts
112
+ var uniqueArrayValues = (array) => Array.from(new Set(array));
113
+
114
+ // src/parser.ts
115
+ function isReceiptFieldKey(value) {
116
+ return Boolean(value && typeof value === "number" && RECEIPT_FIELDS_MAP.has(value));
117
+ }
118
+ function isParsedReceiptContentComplete(data) {
119
+ for (const fieldKey of RECEIPT_FIELDS_MAP.values()) {
120
+ if (!(fieldKey in data)) {
121
+ return false;
59
122
  }
60
- return this.getTransactionIdsFromBlock(result.toJSON());
61
123
  }
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";
124
+ return true;
125
+ }
126
+ function extractFieldValue(field) {
127
+ const [fieldValue] = field.valueBlock.value;
128
+ if (fieldValue instanceof import_asn1js2.IA5String || fieldValue instanceof import_asn1js2.Utf8String) {
129
+ return fieldValue.valueBlock.value;
67
130
  }
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";
131
+ return field.toJSON().valueBlock.valueHex;
132
+ }
133
+ function appendField(parsed, name, value) {
134
+ if (name === "IN_APP_ORIGINAL_TRANSACTION_ID") {
135
+ parsed.IN_APP_ORIGINAL_TRANSACTION_IDS.push(value);
73
136
  }
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;
137
+ if (name === "IN_APP_TRANSACTION_ID") {
138
+ parsed.IN_APP_TRANSACTION_IDS.push(value);
82
139
  }
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;
140
+ parsed[name] = value;
141
+ }
142
+ function processField(parsed, fieldKey, fieldValue) {
143
+ if (fieldKey === IN_APP) {
144
+ parseOctetStringContent(parsed, fieldValue);
145
+ return;
146
+ }
147
+ if (!isReceiptFieldKey(fieldKey)) {
148
+ return;
149
+ }
150
+ const name = RECEIPT_FIELDS_MAP.get(fieldKey);
151
+ appendField(parsed, name, extractFieldValue(fieldValue));
152
+ }
153
+ function parseOctetStringContent(parsed, content) {
154
+ const [contentSet] = content.valueBlock.value;
155
+ const contentSetSequences = contentSet.valueBlock.value.filter((v) => v instanceof import_asn1js2.Sequence);
156
+ for (const sequence of contentSetSequences) {
157
+ const verifiedSequence = verifyFieldSchema(sequence);
158
+ if (verifiedSequence) {
159
+ const fieldKey = verifiedSequence.result[FIELD_TYPE_ID].valueBlock.valueDec;
160
+ const fieldValueOctetString = verifiedSequence.result[FIELD_VALUE_ID];
161
+ processField(parsed, fieldKey, fieldValueOctetString);
96
162
  }
97
- return result;
98
163
  }
99
- getTransactionIds() {
100
- return _ReceiptParser.getTransactionIdsFromReceiptString(this.receiptString);
164
+ }
165
+ function postprocessParsedReceipt(parsed) {
166
+ parsed.IN_APP_ORIGINAL_TRANSACTION_IDS = uniqueArrayValues(parsed.IN_APP_ORIGINAL_TRANSACTION_IDS);
167
+ parsed.IN_APP_TRANSACTION_IDS = uniqueArrayValues(parsed.IN_APP_TRANSACTION_IDS);
168
+ }
169
+ function parseReceipt(receipt) {
170
+ const rootSchemaVerification = verifyReceiptSchema(receipt);
171
+ const content = rootSchemaVerification.result[CONTENT_ID];
172
+ const parsed = {
173
+ IN_APP_ORIGINAL_TRANSACTION_IDS: [],
174
+ IN_APP_TRANSACTION_IDS: []
175
+ };
176
+ parseOctetStringContent(parsed, content);
177
+ if (!isParsedReceiptContentComplete(parsed)) {
178
+ const missingProps = [];
179
+ for (const fieldKey of RECEIPT_FIELDS_MAP.values()) {
180
+ if (!(fieldKey in parsed)) {
181
+ missingProps.push(fieldKey);
182
+ }
183
+ }
184
+ throw new Error(`Missing required fields: ${missingProps.join(", ")}`);
101
185
  }
102
- };
186
+ postprocessParsedReceipt(parsed);
187
+ return parsed;
188
+ }
103
189
  // Annotate the CommonJS export names for ESM import in node:
104
190
  0 && (module.exports = {
105
- ReceiptParser
191
+ parseReceipt
106
192
  });
package/dist/index.mjs CHANGED
@@ -1,79 +1,165 @@
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
+ [2, "BUNDLE_ID"],
7
+ [3, "APP_VERSION"],
8
+ [4, "OPAQUE_VALUE"],
9
+ [5, "SHA1_HASH"],
10
+ [12, "RECEIPT_CREATION_DATE"],
11
+ [18, "ORIGINAL_PURCHASE_DATE"],
12
+ [19, "ORIGINAL_APP_VERSION"],
13
+ [1701, "IN_APP_QUANTITY"],
14
+ [1702, "IN_APP_PRODUCT_ID"],
15
+ [1703, "IN_APP_TRANSACTION_ID"],
16
+ [1704, "IN_APP_PURCHASE_DATE"],
17
+ [1705, "IN_APP_ORIGINAL_TRANSACTION_ID"],
18
+ [1706, "IN_APP_ORIGINAL_PURCHASE_DATE"],
19
+ [1708, "IN_APP_EXPIRES_DATE"],
20
+ [1711, "IN_APP_WEB_ORDER_LINE_ITEM_ID"],
21
+ [1712, "IN_APP_CANCELLATION_DATE"]
22
+ ]);
23
+
24
+ // src/constants.ts
25
+ var IN_APP = 17;
26
+ var CONTENT_ID = "pkcs7_content";
27
+ var FIELD_TYPE_ID = "FieldType";
28
+ var FIELD_VALUE_ID = "FieldTypeOctetString";
29
+
30
+ // src/verifications.ts
31
+ import { Any, Constructed, Integer, ObjectIdentifier, OctetString, Sequence, Set as Set2, verifySchema } from "asn1js";
32
+ var receiptSchema = new Sequence({
33
+ value: [
34
+ new ObjectIdentifier(),
35
+ new Constructed({
36
+ idBlock: { tagClass: 3, tagNumber: 0 },
37
+ value: [
38
+ new Sequence({
39
+ value: [
40
+ new Integer(),
41
+ new Set2({
42
+ value: [
43
+ new Sequence({
44
+ value: [new ObjectIdentifier(), new Any()]
45
+ })
46
+ ]
47
+ }),
48
+ new Sequence({
49
+ value: [
50
+ new ObjectIdentifier(),
51
+ new Constructed({
52
+ idBlock: { tagClass: 3, tagNumber: 0 },
53
+ value: [new OctetString({ name: CONTENT_ID })]
54
+ })
55
+ ]
56
+ })
57
+ ]
58
+ })
59
+ ]
60
+ })
61
+ ]
62
+ });
63
+ var fieldSchema = new Sequence({
64
+ value: [
65
+ new Integer({ name: FIELD_TYPE_ID }),
66
+ new Integer(),
67
+ new OctetString({ name: FIELD_VALUE_ID })
68
+ ]
69
+ });
70
+ function verifyReceiptSchema(receipt) {
71
+ const receiptVerification = verifySchema(Buffer.from(receipt, "base64"), receiptSchema);
72
+ if (!receiptVerification.verified) {
73
+ throw new Error("Receipt verification failed.");
6
74
  }
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;
75
+ return receiptVerification;
76
+ }
77
+ function verifyFieldSchema(sequence) {
78
+ const fieldVerification = verifySchema(sequence.toBER(), fieldSchema);
79
+ if (!fieldVerification.verified) {
80
+ return null;
28
81
  }
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}`);
82
+ return fieldVerification;
83
+ }
84
+
85
+ // src/utils.ts
86
+ var uniqueArrayValues = (array) => Array.from(new Set(array));
87
+
88
+ // src/parser.ts
89
+ function isReceiptFieldKey(value) {
90
+ return Boolean(value && typeof value === "number" && RECEIPT_FIELDS_MAP.has(value));
91
+ }
92
+ function isParsedReceiptContentComplete(data) {
93
+ for (const fieldKey of RECEIPT_FIELDS_MAP.values()) {
94
+ if (!(fieldKey in data)) {
95
+ return false;
33
96
  }
34
- return this.getTransactionIdsFromBlock(result.toJSON());
35
97
  }
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";
98
+ return true;
99
+ }
100
+ function extractFieldValue(field) {
101
+ const [fieldValue] = field.valueBlock.value;
102
+ if (fieldValue instanceof IA5String || fieldValue instanceof Utf8String) {
103
+ return fieldValue.valueBlock.value;
41
104
  }
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";
105
+ return field.toJSON().valueBlock.valueHex;
106
+ }
107
+ function appendField(parsed, name, value) {
108
+ if (name === "IN_APP_ORIGINAL_TRANSACTION_ID") {
109
+ parsed.IN_APP_ORIGINAL_TRANSACTION_IDS.push(value);
47
110
  }
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;
111
+ if (name === "IN_APP_TRANSACTION_ID") {
112
+ parsed.IN_APP_TRANSACTION_IDS.push(value);
113
+ }
114
+ parsed[name] = value;
115
+ }
116
+ function processField(parsed, fieldKey, fieldValue) {
117
+ if (fieldKey === IN_APP) {
118
+ parseOctetStringContent(parsed, fieldValue);
119
+ return;
120
+ }
121
+ if (!isReceiptFieldKey(fieldKey)) {
122
+ return;
56
123
  }
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;
124
+ const name = RECEIPT_FIELDS_MAP.get(fieldKey);
125
+ appendField(parsed, name, extractFieldValue(fieldValue));
126
+ }
127
+ function parseOctetStringContent(parsed, content) {
128
+ const [contentSet] = content.valueBlock.value;
129
+ const contentSetSequences = contentSet.valueBlock.value.filter((v) => v instanceof Sequence2);
130
+ for (const sequence of contentSetSequences) {
131
+ const verifiedSequence = verifyFieldSchema(sequence);
132
+ if (verifiedSequence) {
133
+ const fieldKey = verifiedSequence.result[FIELD_TYPE_ID].valueBlock.valueDec;
134
+ const fieldValueOctetString = verifiedSequence.result[FIELD_VALUE_ID];
135
+ processField(parsed, fieldKey, fieldValueOctetString);
70
136
  }
71
- return result;
72
137
  }
73
- getTransactionIds() {
74
- return _ReceiptParser.getTransactionIdsFromReceiptString(this.receiptString);
138
+ }
139
+ function postprocessParsedReceipt(parsed) {
140
+ parsed.IN_APP_ORIGINAL_TRANSACTION_IDS = uniqueArrayValues(parsed.IN_APP_ORIGINAL_TRANSACTION_IDS);
141
+ parsed.IN_APP_TRANSACTION_IDS = uniqueArrayValues(parsed.IN_APP_TRANSACTION_IDS);
142
+ }
143
+ function parseReceipt(receipt) {
144
+ const rootSchemaVerification = verifyReceiptSchema(receipt);
145
+ const content = rootSchemaVerification.result[CONTENT_ID];
146
+ const parsed = {
147
+ IN_APP_ORIGINAL_TRANSACTION_IDS: [],
148
+ IN_APP_TRANSACTION_IDS: []
149
+ };
150
+ parseOctetStringContent(parsed, content);
151
+ if (!isParsedReceiptContentComplete(parsed)) {
152
+ const missingProps = [];
153
+ for (const fieldKey of RECEIPT_FIELDS_MAP.values()) {
154
+ if (!(fieldKey in parsed)) {
155
+ missingProps.push(fieldKey);
156
+ }
157
+ }
158
+ throw new Error(`Missing required fields: ${missingProps.join(", ")}`);
75
159
  }
76
- };
160
+ postprocessParsedReceipt(parsed);
161
+ return parsed;
162
+ }
77
163
  export {
78
- ReceiptParser
164
+ parseReceipt
79
165
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tamtamchik/app-store-receipt-parser",
3
- "version": "1.0.0",
3
+ "version": "2.0.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.3",
38
- "@types/node": "20.5.1",
39
- "@typescript-eslint/eslint-plugin": "6.4.0",
40
- "@typescript-eslint/parser": "6.4.0",
41
- "eslint": "8.47.0",
42
- "jest": "29.6.2",
37
+ "@types/jest": "29.5.11",
38
+ "@types/node": "20.10.6",
39
+ "@typescript-eslint/eslint-plugin": "6.17.0",
40
+ "@typescript-eslint/parser": "6.17.0",
41
+ "eslint": "8.56.0",
42
+ "jest": "29.7.0",
43
43
  "ts-jest": "29.1.1",
44
- "tsup": "7.2.0",
45
- "typescript": "5.1.6"
44
+ "tsup": "8.0.1",
45
+ "typescript": "5.3.3"
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,59 @@
1
+ export type ReceiptFieldsKeyValues =
2
+ | 2
3
+ | 3
4
+ | 4
5
+ | 5
6
+ | 12
7
+ | 18
8
+ | 19
9
+ | 1701
10
+ | 1702
11
+ | 1703
12
+ | 1704
13
+ | 1705
14
+ | 1706
15
+ | 1708
16
+ | 1711
17
+ | 1712
18
+
19
+ export type ReceiptFieldsKeyNames =
20
+ | 'BUNDLE_ID'
21
+ | 'APP_VERSION'
22
+ | 'OPAQUE_VALUE'
23
+ | 'SHA1_HASH'
24
+ | 'RECEIPT_CREATION_DATE'
25
+ | 'ORIGINAL_PURCHASE_DATE'
26
+ | 'ORIGINAL_APP_VERSION'
27
+ | 'IN_APP_QUANTITY'
28
+ | 'IN_APP_PRODUCT_ID'
29
+ | 'IN_APP_TRANSACTION_ID'
30
+ | 'IN_APP_PURCHASE_DATE'
31
+ | 'IN_APP_ORIGINAL_TRANSACTION_ID'
32
+ | 'IN_APP_ORIGINAL_PURCHASE_DATE'
33
+ | 'IN_APP_EXPIRES_DATE'
34
+ | 'IN_APP_WEB_ORDER_LINE_ITEM_ID'
35
+ | 'IN_APP_CANCELLATION_DATE'
36
+
37
+ /**
38
+ * Receipt fields
39
+ * @see https://developer.apple.com/library/archive/releasenotes/General/ValidateAppStoreReceipt/Chapters/ReceiptFields.html
40
+ */
41
+ export const RECEIPT_FIELDS_MAP: ReadonlyMap<ReceiptFieldsKeyValues, ReceiptFieldsKeyNames> = new Map([
42
+ [2, 'BUNDLE_ID'],
43
+ [3, 'APP_VERSION'],
44
+ [4, 'OPAQUE_VALUE'],
45
+ [5, 'SHA1_HASH'],
46
+ [12, 'RECEIPT_CREATION_DATE'],
47
+ [18, 'ORIGINAL_PURCHASE_DATE'],
48
+ [19, 'ORIGINAL_APP_VERSION'],
49
+ [1701, 'IN_APP_QUANTITY'],
50
+ [1702, 'IN_APP_PRODUCT_ID'],
51
+ [1703, 'IN_APP_TRANSACTION_ID'],
52
+ [1704, 'IN_APP_PURCHASE_DATE'],
53
+ [1705, 'IN_APP_ORIGINAL_TRANSACTION_ID'],
54
+ [1706, 'IN_APP_ORIGINAL_PURCHASE_DATE'],
55
+ [1708, 'IN_APP_EXPIRES_DATE'],
56
+ [1711, 'IN_APP_WEB_ORDER_LINE_ITEM_ID'],
57
+ [1712, 'IN_APP_CANCELLATION_DATE'],
58
+ ])
59
+
package/src/parser.ts ADDED
@@ -0,0 +1,110 @@
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 ParsedReceipt = Partial<Record<ReceiptFieldsKeyNames, string>> & {
9
+ IN_APP_ORIGINAL_TRANSACTION_IDS: string[]
10
+ IN_APP_TRANSACTION_IDS: string[]
11
+ }
12
+
13
+ function isReceiptFieldKey (value: unknown): value is ReceiptFieldsKeyValues {
14
+ return Boolean(value && typeof value === 'number' && RECEIPT_FIELDS_MAP.has(value as ReceiptFieldsKeyValues))
15
+ }
16
+
17
+ function isParsedReceiptContentComplete (data: ParsedReceipt): data is ParsedReceipt {
18
+ for (const fieldKey of RECEIPT_FIELDS_MAP.values()) {
19
+ if (!(fieldKey in data)) {
20
+ return false
21
+ }
22
+ }
23
+
24
+ return true
25
+ }
26
+
27
+ function extractFieldValue (field: OctetString): string {
28
+ const [fieldValue] = field.valueBlock.value
29
+
30
+ if (fieldValue instanceof IA5String || fieldValue instanceof Utf8String) {
31
+ return fieldValue.valueBlock.value
32
+ }
33
+
34
+ return field.toJSON().valueBlock.valueHex
35
+ }
36
+
37
+ function appendField (parsed: ParsedReceipt, name: ReceiptFieldsKeyNames, value: string) {
38
+ if (name === 'IN_APP_ORIGINAL_TRANSACTION_ID') {
39
+ parsed.IN_APP_ORIGINAL_TRANSACTION_IDS.push(value)
40
+ }
41
+
42
+ if (name === 'IN_APP_TRANSACTION_ID') {
43
+ parsed.IN_APP_TRANSACTION_IDS.push(value)
44
+ }
45
+
46
+ parsed[name] = value
47
+ }
48
+
49
+ function processField (parsed: ParsedReceipt, fieldKey: number, fieldValue: OctetString) {
50
+ if (fieldKey === IN_APP) {
51
+ parseOctetStringContent(parsed, fieldValue)
52
+ return
53
+ }
54
+
55
+ if (!isReceiptFieldKey(fieldKey)) {
56
+ return
57
+ }
58
+
59
+ const name = RECEIPT_FIELDS_MAP.get(fieldKey)!
60
+ appendField(parsed, name, extractFieldValue(fieldValue))
61
+ }
62
+
63
+ function parseOctetStringContent (parsed: ParsedReceipt, content: OctetString) {
64
+ const [contentSet] = content.valueBlock.value as Set[]
65
+ const contentSetSequences = contentSet.valueBlock.value.filter(v => v instanceof Sequence) as Sequence[]
66
+
67
+ for (const sequence of contentSetSequences) {
68
+ const verifiedSequence = verifyFieldSchema(sequence)
69
+ if (verifiedSequence) {
70
+ // We are confident to use "as" assertion because Integer type is guaranteed by positive verification above
71
+ const fieldKey = (verifiedSequence.result[FIELD_TYPE_ID] as Integer).valueBlock.valueDec
72
+ const fieldValueOctetString = verifiedSequence.result[FIELD_VALUE_ID] as OctetString
73
+
74
+ processField(parsed, fieldKey, fieldValueOctetString)
75
+ }
76
+ }
77
+ }
78
+
79
+ function postprocessParsedReceipt (parsed: ParsedReceipt) {
80
+ parsed.IN_APP_ORIGINAL_TRANSACTION_IDS = uniqueArrayValues(parsed.IN_APP_ORIGINAL_TRANSACTION_IDS)
81
+ parsed.IN_APP_TRANSACTION_IDS = uniqueArrayValues(parsed.IN_APP_TRANSACTION_IDS)
82
+ }
83
+
84
+ export function parseReceipt (receipt: string): ParsedReceipt {
85
+ const rootSchemaVerification = verifyReceiptSchema(receipt)
86
+
87
+ const content = rootSchemaVerification.result[CONTENT_ID] as OctetString
88
+ const parsed: ParsedReceipt = {
89
+ IN_APP_ORIGINAL_TRANSACTION_IDS: [],
90
+ IN_APP_TRANSACTION_IDS: [],
91
+ }
92
+
93
+ parseOctetStringContent(parsed, content)
94
+
95
+ // Verify if the parsed content contains all the required fields
96
+ if (!isParsedReceiptContentComplete(parsed)) {
97
+ const missingProps = []
98
+ for (const fieldKey of RECEIPT_FIELDS_MAP.values()) {
99
+ if (!(fieldKey in parsed)) {
100
+ missingProps.push(fieldKey)
101
+ }
102
+ }
103
+
104
+ throw new Error(`Missing required fields: ${missingProps.join(', ')}`)
105
+ }
106
+
107
+ postprocessParsedReceipt(parsed)
108
+
109
+ return parsed as ParsedReceipt
110
+ }
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
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,20 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "esnext",
4
+ "module": "esnext",
5
+ "strict": true,
6
+ "esModuleInterop": true,
7
+ "declaration": true,
8
+ "outDir": "./dist",
9
+ "rootDir": "./src",
10
+ "moduleResolution": "node"
11
+ },
12
+ "include": [
13
+ "src/**/*.ts",
14
+ "test/**/*.ts"
15
+ ],
16
+ "exclude": [
17
+ "node_modules",
18
+ "dist"
19
+ ]
20
+ }
@@ -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
- }