1
"""The lower-level records API."""
2

3 4
import datetime
4 4
import re
5 4
from typing import Iterable, List, Optional, Sequence, Tuple, Union
6

7 4
import attr
8 4
from attr.validators import optional
9

10 4
import netsgiro
11 4
from netsgiro.converters import (
12
    fixed_len_str,
13
    stripped_newlines,
14
    stripped_spaces_around,
15
    truthy_or_none,
16
    value_or_none,
17
)
18 4
from netsgiro.validators import str_of_length, str_of_max_length
19

20

21 4
__all__ = [
22
    'TransmissionStart',
23
    'TransmissionEnd',
24
    'AssignmentStart',
25
    'AssignmentEnd',
26
    'TransactionAmountItem1',
27
    'TransactionAmountItem2',
28
    'TransactionAmountItem3',
29
    'TransactionSpecification',
30
    'AvtaleGiroAgreement',
31
    'parse',
32
]
33

34

35 4
def to_service_code(
36
    value: Union[netsgiro.ServiceCode, int, str]
37
) -> netsgiro.ServiceCode:
38 4
    return netsgiro.ServiceCode(int(value))
39

40

41 4
def to_assignment_type(
42
    value: Union[netsgiro.AssignmentType, int, str]
43
) -> netsgiro.AssignmentType:
44 4
    return netsgiro.AssignmentType(int(value))
45

46

47 4
def to_transaction_type(
48
    value: Union[netsgiro.TransactionType, int, str]
49
) -> netsgiro.TransactionType:
50 4
    return netsgiro.TransactionType(int(value))
51

52

53 4
def to_avtalegiro_registration_type(
54
    value: Union[netsgiro.AvtaleGiroRegistrationType, int, str]
55
) -> netsgiro.AvtaleGiroRegistrationType:
56 4
    return netsgiro.AvtaleGiroRegistrationType(int(value))
57

58

59 4
def to_date(value: Union[datetime.date, str, None]) -> Optional[datetime.date]:
60 4
    if isinstance(value, datetime.date):
61 4
        return value
62 4
    if value is None or value == '000000':
63 4
        return None
64 4
    return datetime.datetime.strptime(value, '%d%m%y').date()
65

66

67 4
def to_bool(value: Union[bool, str]) -> bool:
68 4
    if isinstance(value, bool):
69 4
        return value
70 4
    if value == 'J':
71 4
        return True
72 4
    elif value == 'N':
73 4
        return False
74
    else:
75 0
        raise ValueError("Expected 'J' or 'N', got {!r}".format(value))
76

77

78 4
to_safe_str_or_none = value_or_none(
79
    stripped_newlines(stripped_spaces_around(truthy_or_none(str)))
80
)
81

82

83 4
@attr.s
84 1
class Record:
85 4
    service_code = attr.ib(converter=to_service_code)
86

87 4
    _PATTERNS = []
88

89 4
    @classmethod
90 4
    def from_string(cls, line: str) -> 'Record':
91
        """Parse OCR string into a record object."""
92

93 4
        for pattern in cls._PATTERNS:
94 4
            matches = pattern.match(line)
95 4
            if matches is not None:
96 4
                return cls(**matches.groupdict())
97

98 4
        raise ValueError(
99
            '{!r} did not match {} record formats'.format(line, cls.__name__)
100
        )
101

102 4
    def to_ocr(self) -> str:
103
        """Get record as OCR string."""
104 0
        raise NotImplementedError
105

106

107 4
@attr.s
108 4
class TransmissionStart(Record):
109
    """TransmissionStart is the first record in every OCR file.
110

111
    A file can only contain a single transmission.
112

113
    Each transmission can contain any number of assignments.
114
    """
115

116 4
    transmission_number = attr.ib(validator=str_of_length(7))
117 4
    data_transmitter = attr.ib(validator=str_of_length(8))
118 4
    data_recipient = attr.ib(validator=str_of_length(8))
119

120 4
    RECORD_TYPE = netsgiro.RecordType.TRANSMISSION_START
121 4
    _PATTERNS = [
122
        re.compile(
123
            r'''
124
            ^
125
            NY      # Format code
126
            (?P<service_code>00)
127
            00      # Transmission type, always 00
128
            10      # Record type
129

130
            (?P<data_transmitter>\d{8})
131
            (?P<transmission_number>\d{7})
132
            (?P<data_recipient>\d{8})
133

134
            0{49}   # Padding
135
            $
136
            ''',
137
            re.VERBOSE,
138
        )
139
    ]
140

141 4
    def to_ocr(self) -> str:
142
        """Get record as OCR string."""
143 4
        return (
144
            'NY000010'
145
            '{self.data_transmitter:8}'
146
            '{self.transmission_number:7}'
147
            '{self.data_recipient:8}' + ('0' * 49)
148
        ).format(self=self)
149

150

151 4
@attr.s
152 4
class TransmissionEnd(Record):
153
    """TransmissionEnd is the first record in every OCR file."""
154

155 4
    num_transactions = attr.ib(converter=int)
156 4
    num_records = attr.ib(converter=int)
157 4
    total_amount = attr.ib(converter=int)
158 4
    nets_date = attr.ib(converter=to_date)
159

160 4
    RECORD_TYPE = netsgiro.RecordType.TRANSMISSION_END
161 4
    _PATTERNS = [
162
        re.compile(
163
            r'''
164
            ^
165
            NY      # Format code
166
            (?P<service_code>00)
167
            00      # Transmission type, always 00
168
            89      # Record type
169

170
            (?P<num_transactions>\d{8})
171
            (?P<num_records>\d{8})
172
            (?P<total_amount>\d{17})
173
            (?P<nets_date>\d{6})
174

175
            0{33}   # Filler
176
            $
177
            ''',
178
            re.VERBOSE,
179
        )
180
    ]
181

182 4
    def to_ocr(self) -> str:
183
        """Get record as OCR string."""
184 4
        return (
185
            'NY000089'
186
            '{self.num_transactions:08d}'
187
            '{self.num_records:08d}'
188
            '{self.total_amount:017d}'
189
            '{self.nets_date:%d%m%y}' + ('0' * 33)
190
        ).format(self=self)
191

192

193 4
@attr.s
194 4
class AssignmentStart(Record):
195
    """AssignmentStart is the first record of an assignment.
196

197
    Each assignment can contain any number of transactions.
198
    """
199

200 4
    assignment_type = attr.ib(converter=to_assignment_type)
201 4
    assignment_number = attr.ib(validator=str_of_length(7))
202 4
    assignment_account = attr.ib(validator=str_of_length(11))
203

204
    # Only for assignment_type == AssignmentType.TRANSACTIONS
205 4
    agreement_id = attr.ib(default=None, validator=optional(str_of_length(9)))
206

207 4
    RECORD_TYPE = netsgiro.RecordType.ASSIGNMENT_START
208 4
    _PATTERNS = [
209
        re.compile(
210
            r'''
211
            ^
212
            NY      # Format code
213
            (?P<service_code>(09|21))
214
            (?P<assignment_type>00)
215
            20      # Record type
216

217
            (?P<agreement_id>\d{9})
218
            (?P<assignment_number>\d{7})
219
            (?P<assignment_account>\d{11})
220

221
            0{45}   # Filler
222
            $
223
            ''',
224
            re.VERBOSE,
225
        ),
226
        re.compile(
227
            r'''
228
            ^
229
            NY      # Format code
230
            (?P<service_code>21)
231
            (?P<assignment_type>24)
232
            20      # Record type
233

234
            0{9}    # Filler
235

236
            (?P<assignment_number>\d{7})
237
            (?P<assignment_account>\d{11})
238

239
            0{45}   # Filler
240
            $
241
            ''',
242
            re.VERBOSE,
243
        ),
244
        re.compile(
245
            r'''
246
            ^
247
            NY      # Format code
248
            (?P<service_code>21)
249
            (?P<assignment_type>36)
250
            20      # Record type
251

252
            0{9}    # Filler
253

254
            (?P<assignment_number>\d{7})
255
            (?P<assignment_account>\d{11})
256

257
            0{45}   # Filler
258
            $
259
            ''',
260
            re.VERBOSE,
261
        ),
262
    ]
263

264 4
    def to_ocr(self) -> str:
265
        """Get record as OCR string."""
266 4
        return (
267
            'NY'
268
            '{self.service_code:02d}'
269
            '{self.assignment_type:02d}'
270
            '20'
271
            + (self.agreement_id and '{self.agreement_id:9}' or ('0' * 9))
272
            + '{self.assignment_number:7}'
273
            '{self.assignment_account:11}' + ('0' * 45)
274
        ).format(self=self)
275

276

277 4
@attr.s
278 4
class AssignmentEnd(Record):
279
    """AssignmentEnd is the last record of an assignment."""
280

281 4
    assignment_type = attr.ib(converter=to_assignment_type)
282 4
    num_transactions = attr.ib(converter=int)
283 4
    num_records = attr.ib(converter=int)
284

285
    # Only for transactions and cancellations
286 4
    total_amount = attr.ib(default=None, converter=value_or_none(int))
287 4
    nets_date_1 = attr.ib(default=None, converter=to_date)
288 4
    nets_date_2 = attr.ib(default=None, converter=to_date)
289 4
    nets_date_3 = attr.ib(default=None, converter=to_date)
290

291 4
    RECORD_TYPE = netsgiro.RecordType.ASSIGNMENT_END
292 4
    _PATTERNS = [
293
        re.compile(
294
            r'''
295
            ^
296
            NY      # Format code
297
            (?P<service_code>(09|21))
298
            (?P<assignment_type>00)     # Transactions / payment requests
299
            88      # Record type
300

301
            (?P<num_transactions>\d{8})
302
            (?P<num_records>\d{8})
303
            (?P<total_amount>\d{17})
304
            (?P<nets_date_1>\d{6})
305
            (?P<nets_date_2>\d{6})
306
            (?P<nets_date_3>\d{6})
307

308
            0{21}   # Filler
309
            $
310
            ''',
311
            re.VERBOSE,
312
        ),
313
        re.compile(
314
            r'''
315
            ^
316
            NY      # Format code
317
            (?P<service_code>21)
318
            (?P<assignment_type>24)     # AvtaleGiro agreements
319
            88      # Record type
320

321
            (?P<num_transactions>\d{8})
322
            (?P<num_records>\d{8})
323

324
            0{56}   # Filler
325
            $
326
            ''',
327
            re.VERBOSE,
328
        ),
329
        re.compile(
330
            r'''
331
            ^
332
            NY      # Format code
333
            (?P<service_code>21)
334
            (?P<assignment_type>36)     # AvtaleGiro cancellations
335
            88      # Record type
336

337
            (?P<num_transactions>\d{8})
338
            (?P<num_records>\d{8})
339
            (?P<total_amount>\d{17})
340
            (?P<nets_date_1>\d{6})
341
            (?P<nets_date_2>\d{6})
342

343
            0{27}   # Filler
344
            $
345
            ''',
346
            re.VERBOSE,
347
        ),
348
    ]
349

350 4
    @property
351 1
    def nets_date(self):
352
        """Nets' processing date.
353

354
        Only used for OCR Giro.
355
        """
356 4
        if self.service_code == netsgiro.ServiceCode.OCR_GIRO:
357 4
            return self.nets_date_1
358
        else:
359 4
            return None
360

361 4
    @property
362 1
    def nets_date_earliest(self):
363
        """Earliest date from the contained transactions."""
364

365 4
        if self.service_code == netsgiro.ServiceCode.OCR_GIRO:
366 4
            return self.nets_date_2
367 4
        elif self.service_code == netsgiro.ServiceCode.AVTALEGIRO:
368 4
            return self.nets_date_1
369
        else:
370 0
            raise ValueError(
371
                'Unhandled service code: {}'.format(self.service_code)
372
            )
373

374 4
    @property
375 1
    def nets_date_latest(self):
376
        """Latest date from the contained transactions."""
377

378 4
        if self.service_code == netsgiro.ServiceCode.OCR_GIRO:
379 4
            return self.nets_date_3
380 4
        elif self.service_code == netsgiro.ServiceCode.AVTALEGIRO:
381 4
            return self.nets_date_2
382
        else:
383 0
            raise ValueError(
384
                'Unhandled service code: {}'.format(self.service_code)
385
            )
386

387 4
    def to_ocr(self) -> str:
388
        """Get record as OCR string."""
389 4
        return (
390
            'NY'
391
            '{self.service_code:02d}'
392
            '{self.assignment_type:02d}'
393
            '88'
394
            '{self.num_transactions:08d}'
395
            '{self.num_records:08d}'
396
            + (self.total_amount and '{self.total_amount:017d}' or ('0' * 17))
397
            + (self.nets_date_1 and '{self.nets_date_1:%d%m%y}' or ('0' * 6))
398
            + (self.nets_date_2 and '{self.nets_date_2:%d%m%y}' or ('0' * 6))
399
            + (self.nets_date_3 and '{self.nets_date_3:%d%m%y}' or ('0' * 6))
400
            + ('0' * 21)
401
        ).format(self=self)
402

403

404 4
@attr.s
405 4
class TransactionRecord(Record):
406 4
    transaction_type = attr.ib(converter=to_transaction_type)
407 4
    transaction_number = attr.ib(converter=int)
408

409

410 4
@attr.s
411 4
class TransactionAmountItem1(TransactionRecord):
412
    """TransactionAmountItem1 is the first record of a transaction.
413

414
    The record is used both for AvtaleGiro and for OCR Giro.
415
    """
416

417 4
    nets_date = attr.ib(converter=to_date)
418 4
    amount = attr.ib(converter=int)
419 4
    kid = attr.ib(
420
        converter=to_safe_str_or_none, validator=optional(str_of_max_length(25))
421
    )
422

423
    # Only OCR Giro
424 4
    centre_id = attr.ib(default=None, validator=optional(str_of_length(2)))
425 4
    day_code = attr.ib(default=None, converter=value_or_none(int))
426 4
    partial_settlement_number = attr.ib(
427
        default=None, converter=value_or_none(int)
428
    )
429 4
    partial_settlement_serial_number = attr.ib(
430
        default=None, validator=optional(str_of_length(5))
431
    )
432 4
    sign = attr.ib(default=None, validator=optional(str_of_length(1)))
433

434 4
    RECORD_TYPE = netsgiro.RecordType.TRANSACTION_AMOUNT_ITEM_1
435 4
    _PATTERNS = [
436
        re.compile(
437
            r'''
438
            ^
439
            NY      # Format code
440
            (?P<service_code>09)
441
            (?P<transaction_type>\d{2})  # 10-21
442
            30      # Record type
443

444
            (?P<transaction_number>\d{7})
445
            (?P<nets_date>\d{6})
446

447
            (?P<centre_id>\d{2})
448
            (?P<day_code>\d{2})
449
            (?P<partial_settlement_number>\d{1})
450
            (?P<partial_settlement_serial_number>\d{5})
451
            (?P<sign>[-0]{1})
452

453
            (?P<amount>\d{17})
454
            (?P<kid>[\d ]{25})
455

456
            0{6}    # Filler
457
            $
458
            ''',
459
            re.VERBOSE,
460
        ),
461
        re.compile(
462
            r'''
463
            ^
464
            NY      # Format code
465
            (?P<service_code>21)
466
            (?P<transaction_type>\d{2})  # 02, 21, or 93
467
            30      # Record type
468

469
            (?P<transaction_number>\d{7})
470
            (?P<nets_date>\d{6})
471

472
            [ ]{11} # Filler
473

474
            (?P<amount>\d{17})
475
            (?P<kid>[\d ]{25})
476

477
            0{6}    # Filler
478
            $
479
            ''',
480
            re.VERBOSE,
481
        ),
482
    ]
483

484 4
    def to_ocr(self) -> str:
485
        """Get record as OCR string."""
486 4
        if self.service_code == netsgiro.ServiceCode.OCR_GIRO:
487 4
            ocr_giro_fields = (
488
                '{self.centre_id:2}'
489
                '{self.day_code:02d}'
490
                '{self.partial_settlement_number:01d}'
491
                '{self.partial_settlement_serial_number:5}'
492
                '{self.sign:1}'
493
            ).format(self=self)
494
        else:
495 4
            ocr_giro_fields = ' ' * 11
496

497 4
        return (
498
            'NY'
499
            '{self.service_code:02d}'
500
            '{self.transaction_type:02d}'
501
            '30'
502
            '{self.transaction_number:07d}'
503
            '{self.nets_date:%d%m%y}' + ocr_giro_fields + '{self.amount:017d}'
504
            '{self.kid:>25}' + ('0' * 6)
505
        ).format(self=self)
506

507

508 4
@attr.s
509 4
class TransactionAmountItem2(TransactionRecord):
510
    """TransactionAmountItem2 is the second record of a transaction.
511

512
    The record is used both for AvtaleGiro and for OCR Giro.
513
    """
514

515
    # TODO Validate `reference` length, which depends on service code
516 4
    reference = attr.ib(converter=to_safe_str_or_none)
517

518
    # Only OCR Giro
519 4
    form_number = attr.ib(default=None, validator=optional(str_of_length(10)))
520 4
    bank_date = attr.ib(default=None, converter=to_date)
521 4
    debit_account = attr.ib(default=None, validator=optional(str_of_length(11)))
522
    # XXX In use in OCR Giro "from giro debited account" transactions in test
523
    # data, but documented as a filler field.
524 4
    _filler = attr.ib(default=None)
525

526
    # Only AvtaleGiro
527 4
    payer_name = attr.ib(default=None, converter=to_safe_str_or_none)
528

529 4
    RECORD_TYPE = netsgiro.RecordType.TRANSACTION_AMOUNT_ITEM_2
530 4
    _PATTERNS = [
531
        re.compile(
532
            r'''
533
            ^
534
            NY      # Format code
535
            (?P<service_code>09)
536
            (?P<transaction_type>\d{2})  # 10-21
537
            31      # Record type
538

539
            (?P<transaction_number>\d{7})
540
            (?P<form_number>\d{10})
541
            (?P<reference>\d{9})
542

543
            (?P<filler>.{7})  # XXX Documented as filler, in use in test data
544

545
            (?P<bank_date>\d{6})
546
            (?P<debit_account>\d{11})
547

548
            0{22}    # Filler
549
            $
550
            ''',
551
            re.VERBOSE,
552
        ),
553
        re.compile(
554
            r'''
555
            ^
556
            NY      # Format code
557
            (?P<service_code>21)
558
            (?P<transaction_type>\d{2})  # 02, 21, or 93
559
            31      # Record type
560

561
            (?P<transaction_number>\d{7})
562
            (?P<payer_name>.{10})
563

564
            [ ]{25} # Filler
565

566
            (?P<reference>.{25})
567

568
            0{5}    # Filler
569
            $
570
            ''',
571
            re.VERBOSE,
572
        ),
573
    ]
574

575 4
    def to_ocr(self) -> str:
576
        """Get record as OCR string."""
577 4
        common_fields = (
578
            'NY'
579
            '{self.service_code:02d}'
580
            '{self.transaction_type:02d}'
581
            '31'
582
            '{self.transaction_number:07d}'
583
        ).format(self=self)
584

585 4
        if self.service_code == netsgiro.ServiceCode.OCR_GIRO:
586 4
            service_fields = (
587
                '{self.form_number:10}'
588
                + (self.reference and '{self.reference:9}' or (' ' * 9))
589
                + (self._filler and '{self._filler:7}' or ('0' * 7))
590
                + (self.bank_date and '{self.bank_date:%d%m%y}' or '0' * 6)
591
                + '{self.debit_account:11}'
592
                + ('0' * 22)
593
            ).format(self=self)
594 4
        elif self.service_code == netsgiro.ServiceCode.AVTALEGIRO:
595 4
            service_fields = (
596
                (
597
                    self.payer_name
598
                    and '{:10}'.format(self.payer_name[:10])
599
                    or (' ' * 10)
600
                )
601
                + (' ' * 25)
602
                + (self.reference and '{self.reference:25}' or (' ' * 25))
603
                + ('0' * 5)
604
            ).format(self=self)
605
        else:
606 0
            service_fields = ' ' * 35
607

608 4
        return common_fields + service_fields
609

610

611 4
@attr.s
612 4
class TransactionAmountItem3(TransactionRecord):
613
    """TransactionAmountItem3 is the third record of a transaction.
614

615
    The record is only used for some OCR Giro transaction types.
616
    """
617

618 4
    text = attr.ib(
619
        converter=to_safe_str_or_none, validator=optional(str_of_max_length(40))
620
    )
621

622 4
    RECORD_TYPE = netsgiro.RecordType.TRANSACTION_AMOUNT_ITEM_3
623 4
    _PATTERNS = [
624
        re.compile(
625
            r'''
626
            ^
627
            NY      # Format code
628
            (?P<service_code>09)
629
            (?P<transaction_type>\d{2})  # 20-21
630
            32      # Record type
631

632
            (?P<transaction_number>\d{7})
633
            (?P<text>.{40})
634

635
            0{25}    # Filler
636
            $
637
            ''',
638
            re.VERBOSE,
639
        )
640
    ]
641

642 4
    def to_ocr(self) -> str:
643
        """Get record as OCR string."""
644 4
        return (
645
            'NY09'
646
            '{self.transaction_type:02d}'
647
            '32'
648
            '{self.transaction_number:07d}'
649
            + (self.text and '{:40}'.format(self.text) or (' ' * 40))
650
            + ('0' * 25)
651
        ).format(self=self)
652

653

654 4
@attr.s
655 4
class TransactionSpecification(TransactionRecord):
656
    """TransactionSpecification is used for AvtaleGiro transactions.
657

658
    The record is only used when bank notification is used to notify the payer.
659

660
    Each record contains half of an 80 char long line of text and can be
661
    repeated up to 84 times for a single transaction for a total of 42 lines of
662
    specification text.
663
    """
664

665 4
    line_number = attr.ib(converter=int)
666 4
    column_number = attr.ib(converter=int)
667 4
    text = attr.ib(
668
        converter=stripped_newlines(fixed_len_str(40, str)),
669
        validator=optional(str_of_max_length(40)),
670
    )
671

672 4
    RECORD_TYPE = netsgiro.RecordType.TRANSACTION_SPECIFICATION
673 4
    _PATTERNS = [
674
        re.compile(
675
            r'''
676
            ^
677
            NY      # Format code
678
            (?P<service_code>21)
679
            (?P<transaction_type>21)
680
            49      # Record type
681

682
            (?P<transaction_number>\d{7})
683
            4       # Payment notification
684
            (?P<line_number>\d{3})
685
            (?P<column_number>\d{1})
686
            (?P<text>.{40})
687

688
            0{20}    # Filler
689
            $
690
            ''',
691
            re.VERBOSE,
692
        )
693
    ]
694

695 4
    _MAX_LINES = 42
696 4
    _MAX_LINE_LENGTH = 80
697 4
    _MAX_COLUMNS = 2
698 4
    _MAX_RECORDS = _MAX_LINES * _MAX_COLUMNS
699

700 4
    @classmethod
701 4
    def from_text(
702
        cls, *, service_code, transaction_type, transaction_number, text
703
    ) -> Iterable['TransactionSpecification']:
704
        """Create a sequence of specification records from a text string."""
705

706 4
        for line, column, text in cls._split_text_to_lines_and_columns(text):
707 4
            yield cls(
708
                service_code=service_code,
709
                transaction_type=transaction_type,
710
                transaction_number=transaction_number,
711
                line_number=line,
712
                column_number=column,
713
                text=text,
714
            )
715

716 4
    @classmethod
717 4
    def _split_text_to_lines_and_columns(
718
        cls, text
719
    ) -> Iterable[Tuple[int, int, str]]:
720 4
        lines = text.splitlines()
721

722 4
        if len(lines) > cls._MAX_LINES:
723 0
            raise ValueError(
724
                'Max {} specification lines allowed, got {}'.format(
725
                    cls._MAX_LINES, len(lines)
726
                )
727
            )
728

729 4
        for line_number, line_text in enumerate(lines, 1):
730 4
            if len(line_text) > cls._MAX_LINE_LENGTH:
731 0
                raise ValueError(
732
                    'Specification lines must be max {} chars long, '
733
                    'got {}: {!r}'.format(
734
                        cls._MAX_LINE_LENGTH, len(line_text), line_text
735
                    )
736
                )
737

738 4
            yield (line_number, 1, '{:40}'.format(line_text[0:40]))
739 4
            yield (line_number, 2, '{:40}'.format(line_text[40:80]))
740

741 4
    @classmethod
742 4
    def to_text(cls, records: Sequence['TransactionSpecification']) -> str:
743
        """Get a text string from a sequence of specification records."""
744

745 4
        if len(records) > cls._MAX_RECORDS:
746 4
            raise ValueError(
747
                'Max {} specification records allowed, got {}'.format(
748
                    cls._MAX_RECORDS, len(records)
749
                )
750
            )
751

752 4
        tuples = sorted([(r.line_number, r.column_number, r) for r in records])
753

754 4
        text = ''
755 4
        for _, column, specification in tuples:
756 4
            text += specification.text
757 4
            if column == cls._MAX_COLUMNS:
758 4
                text += '\n'
759

760 4
        return text
761

762 4
    def to_ocr(self) -> str:
763
        """Get record as OCR string."""
764 4
        return (
765
            'NY212149'
766
            '{self.transaction_number:07d}'
767
            '4'
768
            '{self.line_number:03d}'
769
            '{self.column_number:01d}'
770
            '{self.text:40}' + ('0' * 20)
771
        ).format(self=self)
772

773

774 4
@attr.s
775 4
class AvtaleGiroAgreement(TransactionRecord):
776
    """AvtaleGiroAgreement is used by Nets to notify about agreement changes.
777

778
    This includes new or deleted agreements, as well as updates to the payer's
779
    notification preferences.
780
    """
781

782 4
    registration_type = attr.ib(converter=to_avtalegiro_registration_type)
783 4
    kid = attr.ib(
784
        converter=to_safe_str_or_none, validator=optional(str_of_max_length(25))
785
    )
786 4
    notify = attr.ib(converter=to_bool)
787

788 4
    RECORD_TYPE = netsgiro.RecordType.TRANSACTION_AGREEMENTS
789 4
    _PATTERNS = [
790
        re.compile(
791
            r'''
792
            ^
793
            NY      # Format code
794
            (?P<service_code>21)
795
            (?P<transaction_type>94)
796
            70      # Record type
797

798
            (?P<transaction_number>\d{7})
799
            (?P<registration_type>\d{1})
800
            (?P<kid>[\d ]{25})
801
            (?P<notify>[JN]{1})
802

803
            0{38}   # Filler
804
            $
805
            ''',
806
            re.VERBOSE,
807
        )
808
    ]
809

810 4
    def to_ocr(self) -> str:
811
        """Get record as OCR string."""
812 4
        return (
813
            'NY219470'
814
            '{self.transaction_number:07d}'
815
            '{self.registration_type:01d}'
816
            '{self.kid:>25}' + (self.notify and 'J' or 'N') + ('0' * 38)
817
        ).format(self=self)
818

819

820 4
def parse(data: str) -> List[Record]:
821
    """Parse an OCR file into a list of record objects."""
822

823 4
    def all_subclasses(cls):
824 4
        return cls.__subclasses__() + [
825
            subsubcls
826
            for subcls in cls.__subclasses__()
827
            for subsubcls in all_subclasses(subcls)
828
        ]
829

830 4
    record_classes = {
831
        cls.RECORD_TYPE: cls
832
        for cls in all_subclasses(Record)
833
        if hasattr(cls, 'RECORD_TYPE')
834
    }
835

836 4
    results = []
837

838 4
    for line in data.strip().splitlines():
839 4
        if len(line) != 80:
840 4
            raise ValueError('All lines must be exactly 80 chars long')
841

842 4
        record_type_str = line[6:8]
843 4
        if not record_type_str.isnumeric():
844 4
            raise ValueError(
845
                'Record type must be numeric, got {!r}'.format(record_type_str)
846
            )
847

848 4
        record_type = netsgiro.RecordType(int(record_type_str))
849 4
        record_cls = record_classes[record_type]
850

851 4
        results.append(record_cls.from_string(line))
852

853 4
    return results

Read our documentation on viewing source code .

Loading