bysquare 2.13.2 → 3.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/src/decode.ts ADDED
@@ -0,0 +1,346 @@
1
+ import { decompress } from "lzma1";
2
+
3
+ import * as base32hex from "./base32hex.js";
4
+ import {
5
+ BankAccount,
6
+ Beneficiary,
7
+ CurrencyCode,
8
+ DataModel,
9
+ type DirectDebit,
10
+ Payment,
11
+ PaymentOptions,
12
+ type Periodicity,
13
+ type StandingOrder,
14
+ Version,
15
+ } from "./index.js";
16
+
17
+ export const DecodeErrorMessage = {
18
+ MissingIBAN: "IBAN is missing",
19
+ /**
20
+ * @description - find original LZMA error in extensions
21
+ */
22
+ LZMADecompressionFailed: "LZMA decompression failed",
23
+ /**
24
+ * @description - find found version in extensions
25
+ * @see {@link ./types#Version} for valid ranges
26
+ */
27
+ UnsupportedVersion: "Unsupported version",
28
+ } as const;
29
+
30
+ export class DecodeError extends Error {
31
+ public extensions?: { [name: string]: any; };
32
+
33
+ constructor(
34
+ message: string,
35
+ extensions?: { [name: string]: any; },
36
+ ) {
37
+ super(message);
38
+ this.name = this.constructor.name;
39
+
40
+ if (extensions) {
41
+ this.extensions = extensions;
42
+ }
43
+ }
44
+ }
45
+
46
+ function decodeNumber(value: string | undefined): number | undefined {
47
+ return value?.length ? Number(value) : undefined;
48
+ }
49
+
50
+ function decodeString(value: string | undefined): string | undefined {
51
+ return value?.length ? value : undefined;
52
+ }
53
+
54
+ /**
55
+ * Converts date from YYYYMMDD format to ISO 8601 format (YYYY-MM-DD)
56
+ * per Pay by Square specification section 3.7.
57
+ *
58
+ * Note: This conversion is only used for paymentDueDate per specification.
59
+ * lastDate remains in YYYYMMDD format.
60
+ *
61
+ * @param input - Date in YYYYMMDD format
62
+ * @returns Date in ISO 8601 format (YYYY-MM-DD) | undefined
63
+ */
64
+ function deserializeDate(input?: string): string | undefined {
65
+ if (!input || input.length !== 8) {
66
+ return undefined;
67
+ }
68
+
69
+ const year = input.slice(0, 4);
70
+ const month = input.slice(4, 6);
71
+ const day = input.slice(6, 8);
72
+
73
+ return year + "-" + month + "-" + day;
74
+ }
75
+
76
+ /**
77
+ * Parse a tab-separated intermediate format into DataModel.
78
+ *
79
+ * Base fields
80
+ * - Field 0: invoiceId
81
+ * - Field 1: paymentsCount
82
+ *
83
+ * Payment block (repeated `paymentsCount` times)
84
+ * - Field +0: type
85
+ * - Field +1: amount
86
+ * - Field +2: currencyCode
87
+ * - Field +3: paymentDueDate (YYYYMMDD)
88
+ * - Field +4: variableSymbol
89
+ * - Field +5: constantSymbol
90
+ * - Field +6: specificSymbol
91
+ * - Field +7: originatorsReferenceInformation
92
+ * - Field +8: paymentNote
93
+ * - Field +9: bankAccountsCount
94
+ *
95
+ * Bank account block (nested, repeated `bankAccountsCount` times)
96
+ * - Field +0: iban
97
+ * - Field +1: bic
98
+ *
99
+ * Standing order extension
100
+ * - Field +X: standingOrderExt ("0" | "1")
101
+ * - if "1":
102
+ * - Field +1: day
103
+ * - Field +2: month (classifier sum)
104
+ * - Field +3: periodicity
105
+ * - Field +4: lastDate (YYYYMMDD)
106
+ *
107
+ * Direct debit extension
108
+ * - Field +Y: directDebitExt ("0" | "1")
109
+ * - if "1":
110
+ * - Field +1: directDebitScheme
111
+ * - Field +2: directDebitType
112
+ * - Field +3: variableSymbol
113
+ * - Field +4: specificSymbol
114
+ * - Field +5: originatorsReferenceInformation
115
+ * - Field +6: mandateId
116
+ * - Field +7: creditorId
117
+ * - Field +8: contractId
118
+ * - Field +9: maxAmount
119
+ * - Field +10: validTillDate
120
+ *
121
+ * Beneficiary block (repeated per payment)
122
+ * - Field +0: beneficiaryName
123
+ * - Field +1: beneficiaryStreet
124
+ * - Field +2: beneficiaryCity
125
+ *
126
+ * @see 3.14
127
+ * @see Table 15
128
+ */
129
+ export function deserialize(qr: string): DataModel {
130
+ const data = qr.split("\t");
131
+ const invoiceId = data.shift();
132
+
133
+ const output = {
134
+ invoiceId: invoiceId?.length ? invoiceId : undefined,
135
+ payments: new Array<Payment>(),
136
+ } satisfies DataModel;
137
+
138
+ const paymentslen = Number(data.shift());
139
+ for (let i = 0; i < paymentslen; i++) {
140
+ const paymentOptions = data.shift();
141
+ const ammount = data.shift();
142
+ const currency = data.shift();
143
+ const dueDate = data.shift();
144
+ const variableSymbol = data.shift();
145
+ const constantSymbol = data.shift();
146
+ const specificSymbol = data.shift();
147
+ const originatorRefInfo = data.shift();
148
+ const paymentNote = data.shift();
149
+
150
+ let payment = {
151
+ type: Number(paymentOptions),
152
+ currencyCode: currency ?? CurrencyCode.EUR,
153
+ amount: Number(ammount),
154
+ paymentDueDate: deserializeDate(dueDate),
155
+ variableSymbol: variableSymbol || undefined,
156
+ constantSymbol: constantSymbol || undefined,
157
+ specificSymbol: specificSymbol || undefined,
158
+ originatorsReferenceInformation: originatorRefInfo || undefined,
159
+ paymentNote: paymentNote || undefined,
160
+ bankAccounts: [],
161
+ } as Payment;
162
+
163
+ const numberOfAccounts = Number(data.shift());
164
+
165
+ for (let j = 0; j < numberOfAccounts; j++) {
166
+ const iban = data.shift();
167
+ if (iban === undefined || iban.length === 0) {
168
+ throw new DecodeError(DecodeErrorMessage.MissingIBAN);
169
+ }
170
+
171
+ const bic = data.shift();
172
+ const account = {
173
+ iban: iban,
174
+ bic: bic || undefined,
175
+ } satisfies BankAccount;
176
+
177
+ payment.bankAccounts.push(account);
178
+ }
179
+
180
+ const standingOrderExt = data.shift();
181
+ if (standingOrderExt === "1" && payment.type === PaymentOptions.StandingOrder) {
182
+ payment = {
183
+ ...payment,
184
+ day: decodeNumber(data.shift()),
185
+ month: decodeNumber(data.shift()),
186
+ periodicity: decodeString(data.shift()) as Periodicity,
187
+ // lastDate stays in YYYYMMDD format (not converted per specification)
188
+ lastDate: decodeString(data.shift()),
189
+ } satisfies StandingOrder;
190
+ }
191
+
192
+ const directDebitExt = data.shift();
193
+ if (directDebitExt === "1" && payment.type === PaymentOptions.DirectDebit) {
194
+ payment = {
195
+ ...payment,
196
+ directDebitScheme: decodeNumber(data.shift()),
197
+ directDebitType: decodeNumber(data.shift()),
198
+ variableSymbol: decodeString(data.shift()),
199
+ specificSymbol: decodeString(data.shift()),
200
+ originatorsReferenceInformation: decodeString(data.shift()),
201
+ mandateId: decodeString(data.shift()),
202
+ creditorId: decodeString(data.shift()),
203
+ contractId: decodeString(data.shift()),
204
+ maxAmount: decodeNumber(data.shift()),
205
+ validTillDate: decodeString(data.shift()),
206
+ } satisfies DirectDebit;
207
+ }
208
+
209
+ output.payments.push(payment);
210
+ }
211
+
212
+ for (let i = 0; i < paymentslen; i++) {
213
+ const name = data.shift();
214
+ const addressLine1 = data.shift();
215
+ const addressLine2 = data.shift();
216
+
217
+ if (Boolean(name) || Boolean(addressLine1) || Boolean(addressLine2)) {
218
+ const beneficiary = {
219
+ name: name || undefined,
220
+ street: addressLine1 || undefined,
221
+ city: addressLine2 || undefined,
222
+ } satisfies Beneficiary;
223
+
224
+ output.payments[i].beneficiary = beneficiary;
225
+ }
226
+ }
227
+
228
+ return output;
229
+ }
230
+
231
+ interface Header {
232
+ bysquareType: number;
233
+ version: number;
234
+ documentType: number;
235
+ reserved: number;
236
+ }
237
+
238
+ /**
239
+ * Extracts the 4 nibbles from a 2-byte bysquare header using bit-shifting and
240
+ * masking.
241
+ *
242
+ * ```
243
+ * | Attribute | Number of bits | Possible values | Note
244
+ * --------------------------------------------------------------------------------------------
245
+ * | BySquareType | 4 | 0-15 | by square type
246
+ * | Version | 4 | 0-15 | version of the by square type
247
+ * | DocumentType | 4 | 0-15 | document type within given by square type
248
+ * | Reserved | 4 | 0-15 | bits reserved for future needs
249
+ * ```
250
+ *
251
+ * @param header 2-bytes size
252
+ * @see 3.5.
253
+ */
254
+ function bysquareHeaderDecoder(header: Uint8Array): Header {
255
+ const bytes = (header[0] << 8) | header[1];
256
+ const bysquareType = bytes >> 12;
257
+ const version = (bytes >> 8) & 0b0000_1111;
258
+ const documentType = (bytes >> 4) & 0b0000_1111;
259
+ const reserved = bytes & 0b0000_1111;
260
+
261
+ return {
262
+ bysquareType,
263
+ version,
264
+ documentType,
265
+ reserved,
266
+ };
267
+ }
268
+
269
+ /**
270
+ * Decoding client data from QR Code 2005 symbol
271
+ *
272
+ * @see 3.16.
273
+ * @param qr base32hex encoded bysqaure binary data
274
+ */
275
+ export function decode(qr: string): DataModel {
276
+ const bytes = base32hex.decode(qr);
277
+ const headerBytes = bytes.slice(0, 2);
278
+ const headerData = bysquareHeaderDecoder(headerBytes);
279
+
280
+ if ((headerData.version > Version["1.1.0"])) {
281
+ throw new DecodeError(DecodeErrorMessage.UnsupportedVersion, {
282
+ version: headerData.version,
283
+ });
284
+ }
285
+
286
+ /**
287
+ * The process of decompressing data requires the addition of an LZMA header
288
+ * to the compressed data. This header is necessary for the decompression
289
+ * algorithm to properly interpret and extract the original uncompressed
290
+ * data. Bysquare only store properties
291
+ *
292
+ * @see https://docs.fileformat.com/compression/lzma/
293
+ *
294
+ * +---------------+---------------------------+-------------------+
295
+ * | 1B | 4B | 8B |
296
+ * +---------------+---------------------------+-------------------+
297
+ * | Properties | Dictionary Size | Uncompressed Size |
298
+ * +---------------+---------------------------+-------------------+
299
+ */
300
+ const defaultProperties = [0x5D]; // lc=3, lp=0, pb=2
301
+ const defaultDictionarySize = [0x00, 0x00, 0x20, 0x00]; // 2^21 = 2097152
302
+
303
+ // Parse the payload length from bytes 2-3 and properly expand to 8-byte uncompressed size
304
+ const payloadLengthBytes = bytes.slice(2, 4);
305
+ const payloadLength = payloadLengthBytes[0] | (payloadLengthBytes[1] << 8);
306
+
307
+ const uncompressedSize = new Uint8Array(8);
308
+ // Set the full 32-bit value in little-endian format
309
+ uncompressedSize[0] = payloadLength & 0xFF;
310
+ uncompressedSize[1] = (payloadLength >> 8) & 0xFF;
311
+ uncompressedSize[2] = (payloadLength >> 16) & 0xFF;
312
+ uncompressedSize[3] = (payloadLength >> 24) & 0xFF;
313
+ // Bytes 4-7 remain 0 for sizes < 2^32
314
+
315
+ const header = [
316
+ ...defaultProperties,
317
+ ...defaultDictionarySize,
318
+ ...uncompressedSize,
319
+ ];
320
+
321
+ const payload = bytes.slice(4);
322
+ const body = new Uint8Array([
323
+ ...header,
324
+ ...payload,
325
+ ]);
326
+
327
+ let decompressed: Uint8Array | undefined;
328
+ try {
329
+ decompressed = decompress(body);
330
+ } catch (error) {
331
+ throw new DecodeError(DecodeErrorMessage.LZMADecompressionFailed, { error });
332
+ }
333
+
334
+ if (!decompressed) {
335
+ throw new DecodeError(DecodeErrorMessage.LZMADecompressionFailed, {
336
+ error: "Decompression returned undefined",
337
+ });
338
+ }
339
+
340
+ // Extract checksum and body
341
+ const _checksum = decompressed.slice(0, 4);
342
+ const decompressedBody = decompressed.slice(4);
343
+ const decoded = new TextDecoder("utf-8").decode(decompressedBody.buffer);
344
+
345
+ return deserialize(decoded);
346
+ }