@tamtamchik/app-store-receipt-parser 2.1.2 → 2.2.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/dist/index.js +141 -114
- package/dist/index.mjs +142 -115
- package/eslint.config.mjs +58 -0
- package/package.json +9 -5
- package/src/ReceiptParser.ts +145 -0
- package/src/ReceiptVerifier.ts +69 -0
- package/src/constants.ts +62 -0
- package/src/index.ts +2 -2
- package/tsconfig.json +1 -1
- package/src/mappings.ts +0 -61
- package/src/parser.ts +0 -116
- package/src/verifications.ts +0 -62
package/dist/index.js
CHANGED
|
@@ -34,10 +34,14 @@ __export(src_exports, {
|
|
|
34
34
|
});
|
|
35
35
|
module.exports = __toCommonJS(src_exports);
|
|
36
36
|
|
|
37
|
-
// src/
|
|
37
|
+
// src/ReceiptParser.ts
|
|
38
38
|
var ASN1 = __toESM(require("asn1js"));
|
|
39
39
|
|
|
40
|
-
// src/
|
|
40
|
+
// src/constants.ts
|
|
41
|
+
var IN_APP = 17;
|
|
42
|
+
var CONTENT_ID = "pkcs7_content";
|
|
43
|
+
var FIELD_TYPE_ID = "FieldType";
|
|
44
|
+
var FIELD_VALUE_ID = "FieldTypeOctetString";
|
|
41
45
|
var RECEIPT_FIELDS_MAP = /* @__PURE__ */ new Map([
|
|
42
46
|
[0, "ENVIRONMENT"],
|
|
43
47
|
[2, "BUNDLE_ID"],
|
|
@@ -58,143 +62,166 @@ var RECEIPT_FIELDS_MAP = /* @__PURE__ */ new Map([
|
|
|
58
62
|
[1712, "IN_APP_CANCELLATION_DATE"]
|
|
59
63
|
]);
|
|
60
64
|
|
|
61
|
-
// src/
|
|
62
|
-
var IN_APP = 17;
|
|
63
|
-
var CONTENT_ID = "pkcs7_content";
|
|
64
|
-
var FIELD_TYPE_ID = "FieldType";
|
|
65
|
-
var FIELD_VALUE_ID = "FieldTypeOctetString";
|
|
66
|
-
|
|
67
|
-
// src/verifications.ts
|
|
65
|
+
// src/ReceiptVerifier.ts
|
|
68
66
|
var import_asn1js = require("asn1js");
|
|
69
|
-
var
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
67
|
+
var ReceiptVerifier = class {
|
|
68
|
+
receiptSchema;
|
|
69
|
+
fieldSchema;
|
|
70
|
+
constructor() {
|
|
71
|
+
this.receiptSchema = new import_asn1js.Sequence({
|
|
74
72
|
value: [
|
|
75
|
-
new import_asn1js.
|
|
73
|
+
new import_asn1js.ObjectIdentifier(),
|
|
74
|
+
new import_asn1js.Constructed({
|
|
75
|
+
idBlock: { tagClass: 3, tagNumber: 0 },
|
|
76
76
|
value: [
|
|
77
|
-
new import_asn1js.Integer(),
|
|
78
|
-
new import_asn1js.Set({
|
|
79
|
-
value: [
|
|
80
|
-
new import_asn1js.Sequence({
|
|
81
|
-
value: [new import_asn1js.ObjectIdentifier(), new import_asn1js.Any()]
|
|
82
|
-
})
|
|
83
|
-
]
|
|
84
|
-
}),
|
|
85
77
|
new import_asn1js.Sequence({
|
|
86
78
|
value: [
|
|
87
|
-
new import_asn1js.
|
|
88
|
-
new import_asn1js.
|
|
89
|
-
|
|
90
|
-
|
|
79
|
+
new import_asn1js.Integer(),
|
|
80
|
+
new import_asn1js.Set({
|
|
81
|
+
value: [
|
|
82
|
+
new import_asn1js.Sequence({
|
|
83
|
+
value: [new import_asn1js.ObjectIdentifier(), new import_asn1js.Any()]
|
|
84
|
+
})
|
|
85
|
+
]
|
|
86
|
+
}),
|
|
87
|
+
new import_asn1js.Sequence({
|
|
88
|
+
value: [
|
|
89
|
+
new import_asn1js.ObjectIdentifier(),
|
|
90
|
+
new import_asn1js.Constructed({
|
|
91
|
+
idBlock: { tagClass: 3, tagNumber: 0 },
|
|
92
|
+
value: [new import_asn1js.OctetString({ name: CONTENT_ID })]
|
|
93
|
+
})
|
|
94
|
+
]
|
|
91
95
|
})
|
|
92
96
|
]
|
|
93
97
|
})
|
|
94
98
|
]
|
|
95
99
|
})
|
|
96
100
|
]
|
|
97
|
-
})
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
]
|
|
106
|
-
});
|
|
107
|
-
function verifyReceiptSchema(receipt) {
|
|
108
|
-
const receiptVerification = (0, import_asn1js.verifySchema)(Buffer.from(receipt, "base64"), receiptSchema);
|
|
109
|
-
if (!receiptVerification.verified) {
|
|
110
|
-
throw new Error("Receipt verification failed.");
|
|
101
|
+
});
|
|
102
|
+
this.fieldSchema = new import_asn1js.Sequence({
|
|
103
|
+
value: [
|
|
104
|
+
new import_asn1js.Integer({ name: FIELD_TYPE_ID }),
|
|
105
|
+
new import_asn1js.Integer(),
|
|
106
|
+
new import_asn1js.OctetString({ name: FIELD_VALUE_ID })
|
|
107
|
+
]
|
|
108
|
+
});
|
|
111
109
|
}
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
return
|
|
110
|
+
verifyReceiptSchema(receipt) {
|
|
111
|
+
const receiptVerification = (0, import_asn1js.verifySchema)(Buffer.from(receipt, "base64"), this.receiptSchema);
|
|
112
|
+
if (!receiptVerification.verified) {
|
|
113
|
+
throw new Error("Receipt verification failed.");
|
|
114
|
+
}
|
|
115
|
+
return receiptVerification;
|
|
118
116
|
}
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
var uniqueArrayValues = (array) => Array.from(new Set(array));
|
|
124
|
-
function isReceiptFieldKey(value) {
|
|
125
|
-
return Boolean(typeof value === "number" && RECEIPT_FIELDS_MAP.has(value));
|
|
126
|
-
}
|
|
127
|
-
function isParsedReceiptContentComplete(data) {
|
|
128
|
-
for (const fieldKey of RECEIPT_FIELDS_MAP.values()) {
|
|
129
|
-
if (!(fieldKey in data)) {
|
|
130
|
-
return false;
|
|
117
|
+
verifyFieldSchema(sequence) {
|
|
118
|
+
const fieldVerification = (0, import_asn1js.verifySchema)(sequence.toBER(), this.fieldSchema);
|
|
119
|
+
if (!fieldVerification.verified) {
|
|
120
|
+
return null;
|
|
131
121
|
}
|
|
122
|
+
return fieldVerification;
|
|
132
123
|
}
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
// src/ReceiptParser.ts
|
|
127
|
+
var ReceiptParser = class {
|
|
128
|
+
parsed;
|
|
129
|
+
receiptVerifier;
|
|
130
|
+
constructor() {
|
|
131
|
+
this.receiptVerifier = new ReceiptVerifier();
|
|
132
|
+
this.parsed = this.createInitialParsedReceipt();
|
|
139
133
|
}
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
134
|
+
parseReceipt(receipt) {
|
|
135
|
+
if (receipt.trim() === "") {
|
|
136
|
+
throw new Error("Receipt must be a non-empty string.");
|
|
137
|
+
}
|
|
138
|
+
const rootSchemaVerification = this.receiptVerifier.verifyReceiptSchema(receipt);
|
|
139
|
+
const content = rootSchemaVerification.result[CONTENT_ID];
|
|
140
|
+
this.parseReceiptContent(content);
|
|
141
|
+
this.validateParsedFields();
|
|
142
|
+
this.deduplicateArrayFields();
|
|
143
|
+
return this.parsed;
|
|
145
144
|
}
|
|
146
|
-
|
|
147
|
-
|
|
145
|
+
createInitialParsedReceipt() {
|
|
146
|
+
return {
|
|
147
|
+
ENVIRONMENT: "Production",
|
|
148
|
+
IN_APP_ORIGINAL_TRANSACTION_IDS: [],
|
|
149
|
+
IN_APP_TRANSACTION_IDS: []
|
|
150
|
+
};
|
|
148
151
|
}
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
if (fieldKey === IN_APP) {
|
|
153
|
-
parseOctetStringContent(parsed, fieldValue);
|
|
154
|
-
return;
|
|
152
|
+
parseReceiptContent(content) {
|
|
153
|
+
const sequences = this.extractSequencesFromContent(content);
|
|
154
|
+
sequences.forEach(this.processSequence.bind(this));
|
|
155
155
|
}
|
|
156
|
-
|
|
157
|
-
|
|
156
|
+
extractSequencesFromContent(content) {
|
|
157
|
+
const [contentSet] = content.valueBlock.value;
|
|
158
|
+
return contentSet.valueBlock.value.filter((v) => v instanceof ASN1.Sequence);
|
|
158
159
|
}
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
}
|
|
162
|
-
function parseOctetStringContent(parsed, content) {
|
|
163
|
-
const [contentSet] = content.valueBlock.value;
|
|
164
|
-
const contentSetSequences = contentSet.valueBlock.value.filter((v) => v instanceof ASN1.Sequence);
|
|
165
|
-
for (const sequence of contentSetSequences) {
|
|
166
|
-
const verifiedSequence = verifyFieldSchema(sequence);
|
|
160
|
+
processSequence(sequence) {
|
|
161
|
+
const verifiedSequence = this.receiptVerifier.verifyFieldSchema(sequence);
|
|
167
162
|
if (verifiedSequence) {
|
|
168
|
-
|
|
169
|
-
const fieldValueOctetString = verifiedSequence.result[FIELD_VALUE_ID];
|
|
170
|
-
processField(parsed, fieldKey, fieldValueOctetString);
|
|
163
|
+
this.handleVerifiedSequence(verifiedSequence);
|
|
171
164
|
}
|
|
172
165
|
}
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
const missingProps = [];
|
|
189
|
-
for (const fieldKey of RECEIPT_FIELDS_MAP.values()) {
|
|
190
|
-
if (!(fieldKey in parsed)) {
|
|
191
|
-
missingProps.push(fieldKey);
|
|
192
|
-
}
|
|
166
|
+
handleVerifiedSequence(verifiedSequence) {
|
|
167
|
+
const fieldKey = verifiedSequence.result[FIELD_TYPE_ID].valueBlock.valueDec;
|
|
168
|
+
const fieldValue = verifiedSequence.result[FIELD_VALUE_ID];
|
|
169
|
+
const handler = this.getFieldHandler(fieldKey);
|
|
170
|
+
handler(fieldValue);
|
|
171
|
+
}
|
|
172
|
+
getFieldHandler(fieldKey) {
|
|
173
|
+
if (fieldKey === IN_APP) {
|
|
174
|
+
return this.parseReceiptContent.bind(this);
|
|
175
|
+
}
|
|
176
|
+
if (this.isValidReceiptFieldKey(fieldKey)) {
|
|
177
|
+
const name = RECEIPT_FIELDS_MAP.get(fieldKey);
|
|
178
|
+
return (fieldValue) => {
|
|
179
|
+
this.addFieldToReceipt(name, this.extractStringValue(fieldValue));
|
|
180
|
+
};
|
|
193
181
|
}
|
|
194
|
-
|
|
182
|
+
return () => {
|
|
183
|
+
};
|
|
195
184
|
}
|
|
196
|
-
|
|
197
|
-
|
|
185
|
+
isValidReceiptFieldKey(value) {
|
|
186
|
+
return typeof value === "number" && RECEIPT_FIELDS_MAP.has(value);
|
|
187
|
+
}
|
|
188
|
+
extractStringValue(field) {
|
|
189
|
+
const [fieldValue] = field.valueBlock.value;
|
|
190
|
+
if (fieldValue instanceof ASN1.IA5String || fieldValue instanceof ASN1.Utf8String) {
|
|
191
|
+
return fieldValue.valueBlock.value;
|
|
192
|
+
}
|
|
193
|
+
return field.toJSON().valueBlock.valueHex;
|
|
194
|
+
}
|
|
195
|
+
addFieldToReceipt(name, value) {
|
|
196
|
+
this.addToArrayFieldIfApplicable(name, value);
|
|
197
|
+
this.parsed[name] = value;
|
|
198
|
+
}
|
|
199
|
+
addToArrayFieldIfApplicable(name, value) {
|
|
200
|
+
const arrayFields = {
|
|
201
|
+
"IN_APP_ORIGINAL_TRANSACTION_ID": "IN_APP_ORIGINAL_TRANSACTION_IDS",
|
|
202
|
+
"IN_APP_TRANSACTION_ID": "IN_APP_TRANSACTION_IDS"
|
|
203
|
+
};
|
|
204
|
+
const arrayFieldName = arrayFields[name];
|
|
205
|
+
if (arrayFieldName) {
|
|
206
|
+
this.parsed[arrayFieldName].push(value);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
validateParsedFields() {
|
|
210
|
+
const missingFields = Array.from(RECEIPT_FIELDS_MAP.values()).filter((fieldKey) => !(fieldKey in this.parsed));
|
|
211
|
+
if (missingFields.length > 0) {
|
|
212
|
+
throw new Error(`Missing required fields: ${missingFields.join(", ")}`);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
deduplicateArrayFields() {
|
|
216
|
+
this.parsed.IN_APP_ORIGINAL_TRANSACTION_IDS = this.removeDuplicates(this.parsed.IN_APP_ORIGINAL_TRANSACTION_IDS);
|
|
217
|
+
this.parsed.IN_APP_TRANSACTION_IDS = this.removeDuplicates(this.parsed.IN_APP_TRANSACTION_IDS);
|
|
218
|
+
}
|
|
219
|
+
removeDuplicates(array) {
|
|
220
|
+
return [...new Set(array)];
|
|
221
|
+
}
|
|
222
|
+
};
|
|
223
|
+
function parseReceipt(receipt) {
|
|
224
|
+
return new ReceiptParser().parseReceipt(receipt);
|
|
198
225
|
}
|
|
199
226
|
// Annotate the CommonJS export names for ESM import in node:
|
|
200
227
|
0 && (module.exports = {
|
package/dist/index.mjs
CHANGED
|
@@ -1,7 +1,11 @@
|
|
|
1
|
-
// src/
|
|
1
|
+
// src/ReceiptParser.ts
|
|
2
2
|
import * as ASN1 from "asn1js";
|
|
3
3
|
|
|
4
|
-
// src/
|
|
4
|
+
// src/constants.ts
|
|
5
|
+
var IN_APP = 17;
|
|
6
|
+
var CONTENT_ID = "pkcs7_content";
|
|
7
|
+
var FIELD_TYPE_ID = "FieldType";
|
|
8
|
+
var FIELD_VALUE_ID = "FieldTypeOctetString";
|
|
5
9
|
var RECEIPT_FIELDS_MAP = /* @__PURE__ */ new Map([
|
|
6
10
|
[0, "ENVIRONMENT"],
|
|
7
11
|
[2, "BUNDLE_ID"],
|
|
@@ -22,143 +26,166 @@ var RECEIPT_FIELDS_MAP = /* @__PURE__ */ new Map([
|
|
|
22
26
|
[1712, "IN_APP_CANCELLATION_DATE"]
|
|
23
27
|
]);
|
|
24
28
|
|
|
25
|
-
// src/
|
|
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
|
|
29
|
+
// src/ReceiptVerifier.ts
|
|
32
30
|
import { Any, Constructed, Integer, ObjectIdentifier, OctetString, Sequence, Set as Set2, verifySchema } from "asn1js";
|
|
33
|
-
var
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
31
|
+
var ReceiptVerifier = class {
|
|
32
|
+
receiptSchema;
|
|
33
|
+
fieldSchema;
|
|
34
|
+
constructor() {
|
|
35
|
+
this.receiptSchema = new Sequence({
|
|
38
36
|
value: [
|
|
39
|
-
new
|
|
37
|
+
new ObjectIdentifier(),
|
|
38
|
+
new Constructed({
|
|
39
|
+
idBlock: { tagClass: 3, tagNumber: 0 },
|
|
40
40
|
value: [
|
|
41
|
-
new Integer(),
|
|
42
|
-
new Set2({
|
|
43
|
-
value: [
|
|
44
|
-
new Sequence({
|
|
45
|
-
value: [new ObjectIdentifier(), new Any()]
|
|
46
|
-
})
|
|
47
|
-
]
|
|
48
|
-
}),
|
|
49
41
|
new Sequence({
|
|
50
42
|
value: [
|
|
51
|
-
new
|
|
52
|
-
new
|
|
53
|
-
|
|
54
|
-
|
|
43
|
+
new Integer(),
|
|
44
|
+
new Set2({
|
|
45
|
+
value: [
|
|
46
|
+
new Sequence({
|
|
47
|
+
value: [new ObjectIdentifier(), new Any()]
|
|
48
|
+
})
|
|
49
|
+
]
|
|
50
|
+
}),
|
|
51
|
+
new Sequence({
|
|
52
|
+
value: [
|
|
53
|
+
new ObjectIdentifier(),
|
|
54
|
+
new Constructed({
|
|
55
|
+
idBlock: { tagClass: 3, tagNumber: 0 },
|
|
56
|
+
value: [new OctetString({ name: CONTENT_ID })]
|
|
57
|
+
})
|
|
58
|
+
]
|
|
55
59
|
})
|
|
56
60
|
]
|
|
57
61
|
})
|
|
58
62
|
]
|
|
59
63
|
})
|
|
60
64
|
]
|
|
61
|
-
})
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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.");
|
|
75
|
-
}
|
|
76
|
-
return receiptVerification;
|
|
77
|
-
}
|
|
78
|
-
function verifyFieldSchema(sequence) {
|
|
79
|
-
const fieldVerification = verifySchema(sequence.toBER(), fieldSchema);
|
|
80
|
-
if (!fieldVerification.verified) {
|
|
81
|
-
return null;
|
|
65
|
+
});
|
|
66
|
+
this.fieldSchema = new Sequence({
|
|
67
|
+
value: [
|
|
68
|
+
new Integer({ name: FIELD_TYPE_ID }),
|
|
69
|
+
new Integer(),
|
|
70
|
+
new OctetString({ name: FIELD_VALUE_ID })
|
|
71
|
+
]
|
|
72
|
+
});
|
|
82
73
|
}
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
var uniqueArrayValues = (array) => Array.from(new Set(array));
|
|
88
|
-
function isReceiptFieldKey(value) {
|
|
89
|
-
return Boolean(typeof value === "number" && RECEIPT_FIELDS_MAP.has(value));
|
|
90
|
-
}
|
|
91
|
-
function isParsedReceiptContentComplete(data) {
|
|
92
|
-
for (const fieldKey of RECEIPT_FIELDS_MAP.values()) {
|
|
93
|
-
if (!(fieldKey in data)) {
|
|
94
|
-
return false;
|
|
74
|
+
verifyReceiptSchema(receipt) {
|
|
75
|
+
const receiptVerification = verifySchema(Buffer.from(receipt, "base64"), this.receiptSchema);
|
|
76
|
+
if (!receiptVerification.verified) {
|
|
77
|
+
throw new Error("Receipt verification failed.");
|
|
95
78
|
}
|
|
79
|
+
return receiptVerification;
|
|
96
80
|
}
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
return
|
|
81
|
+
verifyFieldSchema(sequence) {
|
|
82
|
+
const fieldVerification = verifySchema(sequence.toBER(), this.fieldSchema);
|
|
83
|
+
if (!fieldVerification.verified) {
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
return fieldVerification;
|
|
103
87
|
}
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
// src/ReceiptParser.ts
|
|
91
|
+
var ReceiptParser = class {
|
|
92
|
+
parsed;
|
|
93
|
+
receiptVerifier;
|
|
94
|
+
constructor() {
|
|
95
|
+
this.receiptVerifier = new ReceiptVerifier();
|
|
96
|
+
this.parsed = this.createInitialParsedReceipt();
|
|
109
97
|
}
|
|
110
|
-
|
|
111
|
-
|
|
98
|
+
parseReceipt(receipt) {
|
|
99
|
+
if (receipt.trim() === "") {
|
|
100
|
+
throw new Error("Receipt must be a non-empty string.");
|
|
101
|
+
}
|
|
102
|
+
const rootSchemaVerification = this.receiptVerifier.verifyReceiptSchema(receipt);
|
|
103
|
+
const content = rootSchemaVerification.result[CONTENT_ID];
|
|
104
|
+
this.parseReceiptContent(content);
|
|
105
|
+
this.validateParsedFields();
|
|
106
|
+
this.deduplicateArrayFields();
|
|
107
|
+
return this.parsed;
|
|
112
108
|
}
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
109
|
+
createInitialParsedReceipt() {
|
|
110
|
+
return {
|
|
111
|
+
ENVIRONMENT: "Production",
|
|
112
|
+
IN_APP_ORIGINAL_TRANSACTION_IDS: [],
|
|
113
|
+
IN_APP_TRANSACTION_IDS: []
|
|
114
|
+
};
|
|
119
115
|
}
|
|
120
|
-
|
|
121
|
-
|
|
116
|
+
parseReceiptContent(content) {
|
|
117
|
+
const sequences = this.extractSequencesFromContent(content);
|
|
118
|
+
sequences.forEach(this.processSequence.bind(this));
|
|
122
119
|
}
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
for (const sequence of contentSetSequences) {
|
|
130
|
-
const verifiedSequence = verifyFieldSchema(sequence);
|
|
120
|
+
extractSequencesFromContent(content) {
|
|
121
|
+
const [contentSet] = content.valueBlock.value;
|
|
122
|
+
return contentSet.valueBlock.value.filter((v) => v instanceof ASN1.Sequence);
|
|
123
|
+
}
|
|
124
|
+
processSequence(sequence) {
|
|
125
|
+
const verifiedSequence = this.receiptVerifier.verifyFieldSchema(sequence);
|
|
131
126
|
if (verifiedSequence) {
|
|
132
|
-
|
|
133
|
-
const fieldValueOctetString = verifiedSequence.result[FIELD_VALUE_ID];
|
|
134
|
-
processField(parsed, fieldKey, fieldValueOctetString);
|
|
127
|
+
this.handleVerifiedSequence(verifiedSequence);
|
|
135
128
|
}
|
|
136
129
|
}
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
const missingProps = [];
|
|
153
|
-
for (const fieldKey of RECEIPT_FIELDS_MAP.values()) {
|
|
154
|
-
if (!(fieldKey in parsed)) {
|
|
155
|
-
missingProps.push(fieldKey);
|
|
156
|
-
}
|
|
130
|
+
handleVerifiedSequence(verifiedSequence) {
|
|
131
|
+
const fieldKey = verifiedSequence.result[FIELD_TYPE_ID].valueBlock.valueDec;
|
|
132
|
+
const fieldValue = verifiedSequence.result[FIELD_VALUE_ID];
|
|
133
|
+
const handler = this.getFieldHandler(fieldKey);
|
|
134
|
+
handler(fieldValue);
|
|
135
|
+
}
|
|
136
|
+
getFieldHandler(fieldKey) {
|
|
137
|
+
if (fieldKey === IN_APP) {
|
|
138
|
+
return this.parseReceiptContent.bind(this);
|
|
139
|
+
}
|
|
140
|
+
if (this.isValidReceiptFieldKey(fieldKey)) {
|
|
141
|
+
const name = RECEIPT_FIELDS_MAP.get(fieldKey);
|
|
142
|
+
return (fieldValue) => {
|
|
143
|
+
this.addFieldToReceipt(name, this.extractStringValue(fieldValue));
|
|
144
|
+
};
|
|
157
145
|
}
|
|
158
|
-
|
|
146
|
+
return () => {
|
|
147
|
+
};
|
|
159
148
|
}
|
|
160
|
-
|
|
161
|
-
|
|
149
|
+
isValidReceiptFieldKey(value) {
|
|
150
|
+
return typeof value === "number" && RECEIPT_FIELDS_MAP.has(value);
|
|
151
|
+
}
|
|
152
|
+
extractStringValue(field) {
|
|
153
|
+
const [fieldValue] = field.valueBlock.value;
|
|
154
|
+
if (fieldValue instanceof ASN1.IA5String || fieldValue instanceof ASN1.Utf8String) {
|
|
155
|
+
return fieldValue.valueBlock.value;
|
|
156
|
+
}
|
|
157
|
+
return field.toJSON().valueBlock.valueHex;
|
|
158
|
+
}
|
|
159
|
+
addFieldToReceipt(name, value) {
|
|
160
|
+
this.addToArrayFieldIfApplicable(name, value);
|
|
161
|
+
this.parsed[name] = value;
|
|
162
|
+
}
|
|
163
|
+
addToArrayFieldIfApplicable(name, value) {
|
|
164
|
+
const arrayFields = {
|
|
165
|
+
"IN_APP_ORIGINAL_TRANSACTION_ID": "IN_APP_ORIGINAL_TRANSACTION_IDS",
|
|
166
|
+
"IN_APP_TRANSACTION_ID": "IN_APP_TRANSACTION_IDS"
|
|
167
|
+
};
|
|
168
|
+
const arrayFieldName = arrayFields[name];
|
|
169
|
+
if (arrayFieldName) {
|
|
170
|
+
this.parsed[arrayFieldName].push(value);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
validateParsedFields() {
|
|
174
|
+
const missingFields = Array.from(RECEIPT_FIELDS_MAP.values()).filter((fieldKey) => !(fieldKey in this.parsed));
|
|
175
|
+
if (missingFields.length > 0) {
|
|
176
|
+
throw new Error(`Missing required fields: ${missingFields.join(", ")}`);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
deduplicateArrayFields() {
|
|
180
|
+
this.parsed.IN_APP_ORIGINAL_TRANSACTION_IDS = this.removeDuplicates(this.parsed.IN_APP_ORIGINAL_TRANSACTION_IDS);
|
|
181
|
+
this.parsed.IN_APP_TRANSACTION_IDS = this.removeDuplicates(this.parsed.IN_APP_TRANSACTION_IDS);
|
|
182
|
+
}
|
|
183
|
+
removeDuplicates(array) {
|
|
184
|
+
return [...new Set(array)];
|
|
185
|
+
}
|
|
186
|
+
};
|
|
187
|
+
function parseReceipt(receipt) {
|
|
188
|
+
return new ReceiptParser().parseReceipt(receipt);
|
|
162
189
|
}
|
|
163
190
|
export {
|
|
164
191
|
parseReceipt
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { fileURLToPath } from "node:url";
|
|
3
|
+
|
|
4
|
+
import globals from "globals";
|
|
5
|
+
import stylisticTs from '@stylistic/eslint-plugin-ts'
|
|
6
|
+
import tsParser from "@typescript-eslint/parser";
|
|
7
|
+
import js from "@eslint/js";
|
|
8
|
+
import { FlatCompat } from "@eslint/eslintrc";
|
|
9
|
+
|
|
10
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
11
|
+
const __dirname = path.dirname(__filename);
|
|
12
|
+
const compat = new FlatCompat({
|
|
13
|
+
baseDirectory: __dirname,
|
|
14
|
+
recommendedConfig: js.configs.recommended,
|
|
15
|
+
allConfig: js.configs.all
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
export default [
|
|
19
|
+
...compat.extends("plugin:@typescript-eslint/recommended"),
|
|
20
|
+
|
|
21
|
+
{
|
|
22
|
+
plugins: {
|
|
23
|
+
"@stylistic/ts": stylisticTs,
|
|
24
|
+
},
|
|
25
|
+
},
|
|
26
|
+
|
|
27
|
+
{
|
|
28
|
+
languageOptions: {
|
|
29
|
+
globals: {
|
|
30
|
+
...globals.node,
|
|
31
|
+
...globals.jest,
|
|
32
|
+
},
|
|
33
|
+
|
|
34
|
+
parser: tsParser,
|
|
35
|
+
ecmaVersion: 2021,
|
|
36
|
+
sourceType: "module",
|
|
37
|
+
},
|
|
38
|
+
|
|
39
|
+
rules: {
|
|
40
|
+
indent: ["error", 2, {
|
|
41
|
+
SwitchCase: 1,
|
|
42
|
+
}],
|
|
43
|
+
|
|
44
|
+
semi: "off",
|
|
45
|
+
quotes: "off",
|
|
46
|
+
"object-curly-spacing": "off",
|
|
47
|
+
|
|
48
|
+
"@typescript-eslint/array-type": ["error", {
|
|
49
|
+
default: "array",
|
|
50
|
+
}],
|
|
51
|
+
|
|
52
|
+
"@stylistic/ts/ban-ts-comment": 0,
|
|
53
|
+
"@stylistic/ts/comma-dangle": ["error", "always-multiline"],
|
|
54
|
+
"@stylistic/ts/quotes": ["error", "single"],
|
|
55
|
+
"@stylistic/ts/semi": ["error", "never"],
|
|
56
|
+
"@stylistic/ts/object-curly-spacing": ["error", "always"],
|
|
57
|
+
},
|
|
58
|
+
}];
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tamtamchik/app-store-receipt-parser",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.2.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,11 +34,15 @@
|
|
|
34
34
|
"asn1js": "^3.0.5"
|
|
35
35
|
},
|
|
36
36
|
"devDependencies": {
|
|
37
|
+
"@eslint/eslintrc": "^3.1.0",
|
|
38
|
+
"@eslint/js": "^9.11.1",
|
|
39
|
+
"@stylistic/eslint-plugin-ts": "^2.8.0",
|
|
37
40
|
"@types/jest": "^29.5.12",
|
|
38
|
-
"@types/node": "^
|
|
39
|
-
"@typescript-eslint/eslint-plugin": "^7.
|
|
40
|
-
"@typescript-eslint/parser": "^7.
|
|
41
|
-
"eslint": "^
|
|
41
|
+
"@types/node": "^22.7.1",
|
|
42
|
+
"@typescript-eslint/eslint-plugin": "^8.7.0",
|
|
43
|
+
"@typescript-eslint/parser": "^8.7.0",
|
|
44
|
+
"eslint": "^9.11.1",
|
|
45
|
+
"globals": "^15.9.0",
|
|
42
46
|
"jest": "^29.7.0",
|
|
43
47
|
"ts-jest": "^29.1.2",
|
|
44
48
|
"tsup": "^8.0.2",
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import * as ASN1 from 'asn1js'
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
CONTENT_ID,
|
|
5
|
+
FIELD_TYPE_ID,
|
|
6
|
+
FIELD_VALUE_ID,
|
|
7
|
+
IN_APP,
|
|
8
|
+
RECEIPT_FIELDS_MAP,
|
|
9
|
+
ReceiptFieldsKeyNames, ReceiptFieldsKeyValues,
|
|
10
|
+
} from './constants'
|
|
11
|
+
|
|
12
|
+
import { ReceiptVerifier } from './ReceiptVerifier'
|
|
13
|
+
|
|
14
|
+
export type Environment = 'Production' | 'ProductionSandbox' | string
|
|
15
|
+
|
|
16
|
+
export type ParsedReceipt = Partial<Record<ReceiptFieldsKeyNames, string>> & {
|
|
17
|
+
ENVIRONMENT: Environment
|
|
18
|
+
IN_APP_ORIGINAL_TRANSACTION_IDS: string[]
|
|
19
|
+
IN_APP_TRANSACTION_IDS: string[]
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
class ReceiptParser {
|
|
23
|
+
private readonly parsed: ParsedReceipt
|
|
24
|
+
private readonly receiptVerifier: ReceiptVerifier
|
|
25
|
+
|
|
26
|
+
constructor() {
|
|
27
|
+
this.receiptVerifier = new ReceiptVerifier()
|
|
28
|
+
this.parsed = this.createInitialParsedReceipt()
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
public parseReceipt(receipt: string): ParsedReceipt {
|
|
32
|
+
if (receipt.trim() === '') {
|
|
33
|
+
throw new Error('Receipt must be a non-empty string.')
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const rootSchemaVerification = this.receiptVerifier.verifyReceiptSchema(receipt)
|
|
37
|
+
const content = rootSchemaVerification.result[CONTENT_ID] as ASN1.OctetString
|
|
38
|
+
|
|
39
|
+
this.parseReceiptContent(content)
|
|
40
|
+
this.validateParsedFields()
|
|
41
|
+
this.deduplicateArrayFields()
|
|
42
|
+
|
|
43
|
+
return this.parsed
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
private createInitialParsedReceipt(): ParsedReceipt {
|
|
47
|
+
return {
|
|
48
|
+
ENVIRONMENT: 'Production',
|
|
49
|
+
IN_APP_ORIGINAL_TRANSACTION_IDS: [],
|
|
50
|
+
IN_APP_TRANSACTION_IDS: [],
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
private parseReceiptContent(content: ASN1.OctetString): void {
|
|
55
|
+
const sequences = this.extractSequencesFromContent(content)
|
|
56
|
+
sequences.forEach(this.processSequence.bind(this))
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
private extractSequencesFromContent(content: ASN1.OctetString): ASN1.Sequence[] {
|
|
60
|
+
const [contentSet] = content.valueBlock.value as ASN1.Set[]
|
|
61
|
+
return contentSet.valueBlock.value
|
|
62
|
+
.filter(v => v instanceof ASN1.Sequence) as ASN1.Sequence[]
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
private processSequence(sequence: ASN1.Sequence): void {
|
|
66
|
+
const verifiedSequence = this.receiptVerifier.verifyFieldSchema(sequence)
|
|
67
|
+
if (verifiedSequence) {
|
|
68
|
+
this.handleVerifiedSequence(verifiedSequence)
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
private handleVerifiedSequence(verifiedSequence: ASN1.CompareSchemaSuccess): void {
|
|
73
|
+
const fieldKey = (verifiedSequence.result[FIELD_TYPE_ID] as ASN1.Integer).valueBlock.valueDec
|
|
74
|
+
const fieldValue = verifiedSequence.result[FIELD_VALUE_ID] as ASN1.OctetString
|
|
75
|
+
|
|
76
|
+
const handler = this.getFieldHandler(fieldKey)
|
|
77
|
+
handler(fieldValue)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
private getFieldHandler(fieldKey: number): (fieldValue: ASN1.OctetString) => void {
|
|
81
|
+
if (fieldKey === IN_APP) {
|
|
82
|
+
return this.parseReceiptContent.bind(this)
|
|
83
|
+
}
|
|
84
|
+
if (this.isValidReceiptFieldKey(fieldKey)) {
|
|
85
|
+
const name = RECEIPT_FIELDS_MAP.get(fieldKey)!
|
|
86
|
+
return (fieldValue: ASN1.OctetString) => {
|
|
87
|
+
this.addFieldToReceipt(name, this.extractStringValue(fieldValue))
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return () => {}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
private isValidReceiptFieldKey(value: unknown): value is ReceiptFieldsKeyValues {
|
|
94
|
+
return typeof value === 'number' && RECEIPT_FIELDS_MAP.has(value as ReceiptFieldsKeyValues)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
private extractStringValue(field: ASN1.OctetString): string {
|
|
98
|
+
const [fieldValue] = field.valueBlock.value
|
|
99
|
+
|
|
100
|
+
if (fieldValue instanceof ASN1.IA5String || fieldValue instanceof ASN1.Utf8String) {
|
|
101
|
+
return fieldValue.valueBlock.value
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return field.toJSON().valueBlock.valueHex
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
private addFieldToReceipt(name: ReceiptFieldsKeyNames, value: string): void {
|
|
108
|
+
this.addToArrayFieldIfApplicable(name, value)
|
|
109
|
+
this.parsed[name] = value
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
private addToArrayFieldIfApplicable(name: ReceiptFieldsKeyNames, value: string): void {
|
|
113
|
+
const arrayFields: Record<string, keyof ParsedReceipt> = {
|
|
114
|
+
'IN_APP_ORIGINAL_TRANSACTION_ID': 'IN_APP_ORIGINAL_TRANSACTION_IDS',
|
|
115
|
+
'IN_APP_TRANSACTION_ID': 'IN_APP_TRANSACTION_IDS',
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const arrayFieldName = arrayFields[name]
|
|
119
|
+
if (arrayFieldName) {
|
|
120
|
+
(this.parsed[arrayFieldName] as string[]).push(value)
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
private validateParsedFields(): void {
|
|
125
|
+
const missingFields = Array.from(RECEIPT_FIELDS_MAP.values())
|
|
126
|
+
.filter(fieldKey => !(fieldKey in this.parsed))
|
|
127
|
+
|
|
128
|
+
if (missingFields.length > 0) {
|
|
129
|
+
throw new Error(`Missing required fields: ${missingFields.join(', ')}`)
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
private deduplicateArrayFields(): void {
|
|
134
|
+
this.parsed.IN_APP_ORIGINAL_TRANSACTION_IDS = this.removeDuplicates(this.parsed.IN_APP_ORIGINAL_TRANSACTION_IDS)
|
|
135
|
+
this.parsed.IN_APP_TRANSACTION_IDS = this.removeDuplicates(this.parsed.IN_APP_TRANSACTION_IDS)
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
private removeDuplicates(array: string[]): string[] {
|
|
139
|
+
return [...new Set(array)]
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export function parseReceipt(receipt: string): ParsedReceipt {
|
|
144
|
+
return new ReceiptParser().parseReceipt(receipt)
|
|
145
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
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
|
+
export class ReceiptVerifier {
|
|
6
|
+
private readonly receiptSchema: Sequence
|
|
7
|
+
private readonly fieldSchema: Sequence
|
|
8
|
+
|
|
9
|
+
constructor() {
|
|
10
|
+
this.receiptSchema = new Sequence({
|
|
11
|
+
value: [
|
|
12
|
+
new ObjectIdentifier(),
|
|
13
|
+
new Constructed({
|
|
14
|
+
idBlock: { tagClass: 3, tagNumber: 0 },
|
|
15
|
+
value: [
|
|
16
|
+
new Sequence({
|
|
17
|
+
value: [
|
|
18
|
+
new Integer(),
|
|
19
|
+
new Set({
|
|
20
|
+
value: [
|
|
21
|
+
new Sequence({
|
|
22
|
+
value: [new ObjectIdentifier(), new Any()],
|
|
23
|
+
}),
|
|
24
|
+
],
|
|
25
|
+
}),
|
|
26
|
+
new Sequence({
|
|
27
|
+
value: [
|
|
28
|
+
new ObjectIdentifier(),
|
|
29
|
+
new Constructed({
|
|
30
|
+
idBlock: { tagClass: 3, tagNumber: 0 },
|
|
31
|
+
value: [new OctetString({ name: CONTENT_ID })],
|
|
32
|
+
}),
|
|
33
|
+
],
|
|
34
|
+
}),
|
|
35
|
+
],
|
|
36
|
+
}),
|
|
37
|
+
],
|
|
38
|
+
}),
|
|
39
|
+
],
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
this.fieldSchema = new Sequence({
|
|
43
|
+
value: [
|
|
44
|
+
new Integer({ name: FIELD_TYPE_ID }),
|
|
45
|
+
new Integer(),
|
|
46
|
+
new OctetString({ name: FIELD_VALUE_ID }),
|
|
47
|
+
],
|
|
48
|
+
})
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
public verifyReceiptSchema(receipt: string) {
|
|
52
|
+
const receiptVerification = verifySchema(Buffer.from(receipt, 'base64'), this.receiptSchema)
|
|
53
|
+
if (!receiptVerification.verified) {
|
|
54
|
+
throw new Error('Receipt verification failed.')
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return receiptVerification
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
public verifyFieldSchema(sequence: Sequence) {
|
|
61
|
+
const fieldVerification = verifySchema(sequence.toBER(), this.fieldSchema)
|
|
62
|
+
if (!fieldVerification.verified) {
|
|
63
|
+
// Return null if the field schema verification fails, so we can skip the field
|
|
64
|
+
return null
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return fieldVerification
|
|
68
|
+
}
|
|
69
|
+
}
|
package/src/constants.ts
CHANGED
|
@@ -9,3 +9,65 @@ export const FIELD_TYPE_ID: string = 'FieldType'
|
|
|
9
9
|
|
|
10
10
|
/** Identifies field value information encoded as Octet string */
|
|
11
11
|
export const FIELD_VALUE_ID: string = 'FieldTypeOctetString'
|
|
12
|
+
|
|
13
|
+
export type ReceiptFieldsKeyValues =
|
|
14
|
+
| 0 // Environment
|
|
15
|
+
| 2 // Bundle ID
|
|
16
|
+
| 3 // App version
|
|
17
|
+
| 4 // Opaque value
|
|
18
|
+
| 5 // SHA-1 hash
|
|
19
|
+
| 12 // Receipt creation date
|
|
20
|
+
| 18 // Original purchase date
|
|
21
|
+
| 19 // Original app version
|
|
22
|
+
| 1701 // In-app quantity
|
|
23
|
+
| 1702 // In-app product ID
|
|
24
|
+
| 1703 // In-app transaction ID
|
|
25
|
+
| 1704 // In-app purchase date
|
|
26
|
+
| 1705 // In-app original transaction ID
|
|
27
|
+
| 1706 // In-app original purchase date
|
|
28
|
+
| 1708 // In-app expires date
|
|
29
|
+
| 1711 // In-app web order line item ID
|
|
30
|
+
| 1712 // In-app cancellation date
|
|
31
|
+
|
|
32
|
+
export type ReceiptFieldsKeyNames =
|
|
33
|
+
| 'ENVIRONMENT'
|
|
34
|
+
| 'BUNDLE_ID'
|
|
35
|
+
| 'APP_VERSION'
|
|
36
|
+
| 'OPAQUE_VALUE'
|
|
37
|
+
| 'SHA1_HASH'
|
|
38
|
+
| 'RECEIPT_CREATION_DATE'
|
|
39
|
+
| 'ORIGINAL_PURCHASE_DATE'
|
|
40
|
+
| 'ORIGINAL_APP_VERSION'
|
|
41
|
+
| 'IN_APP_QUANTITY'
|
|
42
|
+
| 'IN_APP_PRODUCT_ID'
|
|
43
|
+
| 'IN_APP_TRANSACTION_ID'
|
|
44
|
+
| 'IN_APP_PURCHASE_DATE'
|
|
45
|
+
| 'IN_APP_ORIGINAL_TRANSACTION_ID'
|
|
46
|
+
| 'IN_APP_ORIGINAL_PURCHASE_DATE'
|
|
47
|
+
| 'IN_APP_EXPIRES_DATE'
|
|
48
|
+
| 'IN_APP_WEB_ORDER_LINE_ITEM_ID'
|
|
49
|
+
| 'IN_APP_CANCELLATION_DATE'
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Receipt fields
|
|
53
|
+
* @see https://developer.apple.com/library/archive/releasenotes/General/ValidateAppStoreReceipt/Chapters/ReceiptFields.html
|
|
54
|
+
*/
|
|
55
|
+
export const RECEIPT_FIELDS_MAP: ReadonlyMap<ReceiptFieldsKeyValues, ReceiptFieldsKeyNames> = new Map([
|
|
56
|
+
[0, 'ENVIRONMENT'],
|
|
57
|
+
[2, 'BUNDLE_ID'],
|
|
58
|
+
[3, 'APP_VERSION'],
|
|
59
|
+
[4, 'OPAQUE_VALUE'],
|
|
60
|
+
[5, 'SHA1_HASH'],
|
|
61
|
+
[12, 'RECEIPT_CREATION_DATE'],
|
|
62
|
+
[18, 'ORIGINAL_PURCHASE_DATE'],
|
|
63
|
+
[19, 'ORIGINAL_APP_VERSION'],
|
|
64
|
+
[1701, 'IN_APP_QUANTITY'],
|
|
65
|
+
[1702, 'IN_APP_PRODUCT_ID'],
|
|
66
|
+
[1703, 'IN_APP_TRANSACTION_ID'],
|
|
67
|
+
[1704, 'IN_APP_PURCHASE_DATE'],
|
|
68
|
+
[1705, 'IN_APP_ORIGINAL_TRANSACTION_ID'],
|
|
69
|
+
[1706, 'IN_APP_ORIGINAL_PURCHASE_DATE'],
|
|
70
|
+
[1708, 'IN_APP_EXPIRES_DATE'],
|
|
71
|
+
[1711, 'IN_APP_WEB_ORDER_LINE_ITEM_ID'],
|
|
72
|
+
[1712, 'IN_APP_CANCELLATION_DATE'],
|
|
73
|
+
])
|
package/src/index.ts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
export type { ParsedReceipt } from './
|
|
2
|
-
export { parseReceipt } from './
|
|
1
|
+
export type { ParsedReceipt } from './ReceiptParser'
|
|
2
|
+
export { parseReceipt } from './ReceiptParser'
|
package/tsconfig.json
CHANGED
package/src/mappings.ts
DELETED
|
@@ -1,61 +0,0 @@
|
|
|
1
|
-
export type ReceiptFieldsKeyValues =
|
|
2
|
-
| 0 // Environment
|
|
3
|
-
| 2 // Bundle ID
|
|
4
|
-
| 3 // App version
|
|
5
|
-
| 4 // Opaque value
|
|
6
|
-
| 5 // SHA-1 hash
|
|
7
|
-
| 12 // Receipt creation date
|
|
8
|
-
| 18 // Original purchase date
|
|
9
|
-
| 19 // Original app version
|
|
10
|
-
| 1701 // In-app quantity
|
|
11
|
-
| 1702 // In-app product ID
|
|
12
|
-
| 1703 // In-app transaction ID
|
|
13
|
-
| 1704 // In-app purchase date
|
|
14
|
-
| 1705 // In-app original transaction ID
|
|
15
|
-
| 1706 // In-app original purchase date
|
|
16
|
-
| 1708 // In-app expires date
|
|
17
|
-
| 1711 // In-app web order line item ID
|
|
18
|
-
| 1712 // In-app cancellation date
|
|
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
|
-
])
|
package/src/parser.ts
DELETED
|
@@ -1,116 +0,0 @@
|
|
|
1
|
-
import * as ASN1 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
|
-
|
|
7
|
-
export type Environment = 'Production' | 'ProductionSandbox' | string
|
|
8
|
-
|
|
9
|
-
const uniqueArrayValues = (array: string[]) => Array.from(new Set(array))
|
|
10
|
-
|
|
11
|
-
export type ParsedReceipt = Partial<Record<ReceiptFieldsKeyNames, string>> & {
|
|
12
|
-
ENVIRONMENT: Environment
|
|
13
|
-
IN_APP_ORIGINAL_TRANSACTION_IDS: string[]
|
|
14
|
-
IN_APP_TRANSACTION_IDS: string[]
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
function isReceiptFieldKey (value: unknown): value is ReceiptFieldsKeyValues {
|
|
18
|
-
return Boolean(typeof value === 'number' && RECEIPT_FIELDS_MAP.has(value as ReceiptFieldsKeyValues))
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
function isParsedReceiptContentComplete (data: ParsedReceipt): data is ParsedReceipt {
|
|
22
|
-
for (const fieldKey of RECEIPT_FIELDS_MAP.values()) {
|
|
23
|
-
if (!(fieldKey in data)) {
|
|
24
|
-
return false
|
|
25
|
-
}
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
return true
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
function extractFieldValue (field: ASN1.OctetString): string {
|
|
32
|
-
const [fieldValue] = field.valueBlock.value
|
|
33
|
-
|
|
34
|
-
if (fieldValue instanceof ASN1.IA5String || fieldValue instanceof ASN1.Utf8String) {
|
|
35
|
-
return fieldValue.valueBlock.value
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
return field.toJSON().valueBlock.valueHex
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
function appendField (parsed: ParsedReceipt, name: ReceiptFieldsKeyNames, value: string) {
|
|
42
|
-
if (name === 'IN_APP_ORIGINAL_TRANSACTION_ID') {
|
|
43
|
-
parsed.IN_APP_ORIGINAL_TRANSACTION_IDS.push(value)
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
if (name === 'IN_APP_TRANSACTION_ID') {
|
|
47
|
-
parsed.IN_APP_TRANSACTION_IDS.push(value)
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
parsed[name] = value
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
function processField (parsed: ParsedReceipt, fieldKey: number, fieldValue: ASN1.OctetString) {
|
|
54
|
-
if (fieldKey === IN_APP) {
|
|
55
|
-
parseOctetStringContent(parsed, fieldValue)
|
|
56
|
-
return
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
if (!isReceiptFieldKey(fieldKey)) {
|
|
60
|
-
return
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
const name = RECEIPT_FIELDS_MAP.get(fieldKey)!
|
|
64
|
-
appendField(parsed, name, extractFieldValue(fieldValue))
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
function parseOctetStringContent (parsed: ParsedReceipt, content: ASN1.OctetString) {
|
|
68
|
-
const [contentSet] = content.valueBlock.value as ASN1.Set[]
|
|
69
|
-
const contentSetSequences = contentSet.valueBlock.value
|
|
70
|
-
.filter(v => v instanceof ASN1.Sequence) as ASN1.Sequence[]
|
|
71
|
-
|
|
72
|
-
for (const sequence of contentSetSequences) {
|
|
73
|
-
const verifiedSequence = verifyFieldSchema(sequence)
|
|
74
|
-
if (verifiedSequence) {
|
|
75
|
-
// We are confident to use "as" assertion because Integer type is guaranteed by positive verification above
|
|
76
|
-
const fieldKey = (verifiedSequence.result[FIELD_TYPE_ID] as ASN1.Integer).valueBlock.valueDec
|
|
77
|
-
const fieldValueOctetString = verifiedSequence.result[FIELD_VALUE_ID] as ASN1.OctetString
|
|
78
|
-
|
|
79
|
-
processField(parsed, fieldKey, fieldValueOctetString)
|
|
80
|
-
}
|
|
81
|
-
}
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
function postprocessParsedReceipt (parsed: ParsedReceipt) {
|
|
85
|
-
parsed.IN_APP_ORIGINAL_TRANSACTION_IDS = uniqueArrayValues(parsed.IN_APP_ORIGINAL_TRANSACTION_IDS)
|
|
86
|
-
parsed.IN_APP_TRANSACTION_IDS = uniqueArrayValues(parsed.IN_APP_TRANSACTION_IDS)
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
export function parseReceipt (receipt: string): ParsedReceipt {
|
|
90
|
-
const rootSchemaVerification = verifyReceiptSchema(receipt)
|
|
91
|
-
|
|
92
|
-
const content = rootSchemaVerification.result[CONTENT_ID] as ASN1.OctetString
|
|
93
|
-
const parsed: ParsedReceipt = {
|
|
94
|
-
ENVIRONMENT: 'Production',
|
|
95
|
-
IN_APP_ORIGINAL_TRANSACTION_IDS: [],
|
|
96
|
-
IN_APP_TRANSACTION_IDS: [],
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
parseOctetStringContent(parsed, content)
|
|
100
|
-
|
|
101
|
-
// Verify if the parsed content contains all the required fields
|
|
102
|
-
if (!isParsedReceiptContentComplete(parsed)) {
|
|
103
|
-
const missingProps = []
|
|
104
|
-
for (const fieldKey of RECEIPT_FIELDS_MAP.values()) {
|
|
105
|
-
if (!(fieldKey in parsed)) {
|
|
106
|
-
missingProps.push(fieldKey)
|
|
107
|
-
}
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
throw new Error(`Missing required fields: ${missingProps.join(', ')}`)
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
postprocessParsedReceipt(parsed)
|
|
114
|
-
|
|
115
|
-
return parsed as ParsedReceipt
|
|
116
|
-
}
|
package/src/verifications.ts
DELETED
|
@@ -1,62 +0,0 @@
|
|
|
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
|
-
}
|