@tamtamchik/app-store-receipt-parser
Version:
A lightweight TypeScript library for extracting transaction IDs from Apple's ASN.1 encoded receipts.
146 lines (116 loc) • 4.7 kB
text/typescript
import * as ASN1 from 'asn1js'
import {
CONTENT_ID,
FIELD_TYPE_ID,
FIELD_VALUE_ID,
IN_APP,
RECEIPT_FIELDS_MAP,
ReceiptFieldsKeyNames, ReceiptFieldsKeyValues,
} from './constants'
import { ReceiptVerifier } from './ReceiptVerifier'
export type Environment = 'Production' | 'ProductionSandbox' | string
export type ParsedReceipt = Partial<Record<ReceiptFieldsKeyNames, string>> & {
ENVIRONMENT: Environment
IN_APP_ORIGINAL_TRANSACTION_IDS: string[]
IN_APP_TRANSACTION_IDS: string[]
}
class ReceiptParser {
private readonly parsed: ParsedReceipt
private readonly receiptVerifier: ReceiptVerifier
constructor() {
this.receiptVerifier = new ReceiptVerifier()
this.parsed = this.createInitialParsedReceipt()
}
public parseReceipt(receipt: string): ParsedReceipt {
if (receipt.trim() === '') {
throw new Error('Receipt must be a non-empty string.')
}
const rootSchemaVerification = this.receiptVerifier.verifyReceiptSchema(receipt)
const content = rootSchemaVerification.result[CONTENT_ID] as ASN1.OctetString
this.parseReceiptContent(content)
this.validateParsedFields()
this.deduplicateArrayFields()
return this.parsed
}
private createInitialParsedReceipt(): ParsedReceipt {
return {
ENVIRONMENT: 'Production',
IN_APP_ORIGINAL_TRANSACTION_IDS: [],
IN_APP_TRANSACTION_IDS: [],
}
}
private parseReceiptContent(content: ASN1.OctetString): void {
const sequences = this.extractSequencesFromContent(content)
sequences.forEach(this.processSequence.bind(this))
}
private extractSequencesFromContent(content: ASN1.OctetString): ASN1.Sequence[] {
const [contentSet] = content.valueBlock.value as ASN1.Set[]
return contentSet.valueBlock.value
.filter(v => v instanceof ASN1.Sequence) as ASN1.Sequence[]
}
private processSequence(sequence: ASN1.Sequence): void {
const verifiedSequence = this.receiptVerifier.verifyFieldSchema(sequence)
if (verifiedSequence) {
this.handleVerifiedSequence(verifiedSequence)
}
}
private handleVerifiedSequence(verifiedSequence: ASN1.CompareSchemaSuccess): void {
const fieldKey = (verifiedSequence.result[FIELD_TYPE_ID] as ASN1.Integer).valueBlock.valueDec
const fieldValue = verifiedSequence.result[FIELD_VALUE_ID] as ASN1.OctetString
const handler = this.getFieldHandler(fieldKey)
handler(fieldValue)
}
private getFieldHandler(fieldKey: number): (fieldValue: ASN1.OctetString) => void {
if (fieldKey === IN_APP) {
return this.parseReceiptContent.bind(this)
}
if (this.isValidReceiptFieldKey(fieldKey)) {
const name = RECEIPT_FIELDS_MAP.get(fieldKey)!
return (fieldValue: ASN1.OctetString) => {
this.addFieldToReceipt(name, this.extractStringValue(fieldValue))
}
}
return () => {}
}
private isValidReceiptFieldKey(value: unknown): value is ReceiptFieldsKeyValues {
return typeof value === 'number' && RECEIPT_FIELDS_MAP.has(value as ReceiptFieldsKeyValues)
}
private extractStringValue(field: ASN1.OctetString): string {
const [fieldValue] = field.valueBlock.value
if (fieldValue instanceof ASN1.IA5String || fieldValue instanceof ASN1.Utf8String) {
return fieldValue.valueBlock.value
}
return field.toJSON().valueBlock.valueHex
}
private addFieldToReceipt(name: ReceiptFieldsKeyNames, value: string): void {
this.addToArrayFieldIfApplicable(name, value)
this.parsed[name] = value
}
private addToArrayFieldIfApplicable(name: ReceiptFieldsKeyNames, value: string): void {
const arrayFields: Record<string, keyof ParsedReceipt> = {
'IN_APP_ORIGINAL_TRANSACTION_ID': 'IN_APP_ORIGINAL_TRANSACTION_IDS',
'IN_APP_TRANSACTION_ID': 'IN_APP_TRANSACTION_IDS',
}
const arrayFieldName = arrayFields[name]
if (arrayFieldName) {
(this.parsed[arrayFieldName] as string[]).push(value)
}
}
private validateParsedFields(): void {
const missingFields = Array.from(RECEIPT_FIELDS_MAP.values())
.filter(fieldKey => !(fieldKey in this.parsed))
if (missingFields.length > 0) {
throw new Error(`Missing required fields: ${missingFields.join(', ')}`)
}
}
private deduplicateArrayFields(): void {
this.parsed.IN_APP_ORIGINAL_TRANSACTION_IDS = this.removeDuplicates(this.parsed.IN_APP_ORIGINAL_TRANSACTION_IDS)
this.parsed.IN_APP_TRANSACTION_IDS = this.removeDuplicates(this.parsed.IN_APP_TRANSACTION_IDS)
}
private removeDuplicates(array: string[]): string[] {
return [...new Set(array)]
}
}
export function parseReceipt(receipt: string): ParsedReceipt {
return new ReceiptParser().parseReceipt(receipt)
}