Skip to content

Commit 6792937

Browse files
author
AmanJagotra
authored
[fix] Added handling of incomplete transactions (#3)
Signed-off-by: Aman Jagotra <aman.jagotra@beyondirr.tech>
1 parent a9a7fba commit 6792937

9 files changed

Lines changed: 529 additions & 314 deletions

File tree

cas2json/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
from cas2json.cams.processor import parse_cams_pdf
1+
from cas2json.cams import parse_cams_pdf
22
from cas2json.nsdl.processor import parse_nsdl_pdf
33

44
__all__ = ["parse_cams_pdf", "parse_nsdl_pdf"]

cas2json/cams/__init__.py

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import io
2+
3+
from cas2json.cams.processor import CASProcessor
4+
from cas2json.enums import FileType, FileVersion
5+
from cas2json.exceptions import CASParseError
6+
from cas2json.parser import cas_pdf_to_text
7+
from cas2json.types import CASData
8+
9+
10+
def parse_cams_pdf(filename: str | io.IOBase, password: str, sort_transactions=True) -> CASData:
11+
"""
12+
Parse CAMS or KFintech CAS pdf and returns processed data.
13+
14+
Parameters
15+
----------
16+
filename : str | io.IOBase
17+
The path to the PDF file or a file-like object.
18+
password : str
19+
The password to unlock the PDF file.
20+
sort_transactions : bool
21+
Whether to sort transactions by date and re-compute balances.
22+
"""
23+
24+
partial_cas_data = cas_pdf_to_text(filename, password)
25+
if partial_cas_data.file_type not in [FileType.CAMS, FileType.KFINTECH]:
26+
raise CASParseError("Not a valid CAMS file")
27+
28+
if partial_cas_data.file_version == FileVersion.DETAILED:
29+
processed_data = CASProcessor().process_detailed_version(partial_cas_data.document_data)
30+
elif partial_cas_data.file_version == FileVersion.SUMMARY:
31+
processed_data = CASProcessor().process_summary_version(partial_cas_data.document_data)
32+
else:
33+
raise CASParseError("Unknown CAS file type")
34+
35+
if sort_transactions:
36+
for scheme in processed_data.schemes:
37+
transactions = scheme.transactions
38+
sorted_transactions = sorted(transactions, key=lambda x: x.date)
39+
if transactions != sorted_transactions:
40+
balance = scheme.open
41+
for transaction in sorted_transactions:
42+
balance += transaction.units or 0
43+
transaction.balance = balance
44+
scheme.transactions = sorted_transactions
45+
46+
return CASData(
47+
statement_period=processed_data.statement_period,
48+
schemes=processed_data.schemes,
49+
file_version=partial_cas_data.file_version,
50+
investor_info=partial_cas_data.investor_info,
51+
file_type=partial_cas_data.file_type,
52+
)

cas2json/cams/helpers.py

Lines changed: 14 additions & 79 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,18 @@
1+
import logging
12
import re
23
from decimal import Decimal
34

4-
from dateutil import parser as date_parser
5-
65
from cas2json import patterns
7-
from cas2json.enums import CASFileType, TransactionType
8-
from cas2json.flags import MULTI_TEXT_FLAGS, TEXT_FLAGS
9-
from cas2json.types import TransactionData
10-
from cas2json.utils import formatINR
6+
from cas2json.constants import MISCELLANEOUS_KEYWORDS
7+
from cas2json.enums import TransactionType
8+
from cas2json.flags import TEXT_FLAGS
9+
10+
logger = logging.getLogger(__name__)
11+
logger.setLevel(logging.DEBUG)
1112

1213

1314
def get_transaction_type(description: str, units: Decimal | None) -> tuple[TransactionType, Decimal | None]:
14-
"""Get transaction type from the description text."""
15+
"""Get transaction type from the description text and units."""
1516

1617
description = description.lower()
1718
# Dividend
@@ -54,83 +55,17 @@ def get_transaction_type(description: str, units: Decimal | None) -> tuple[Trans
5455
return (TransactionType.SWITCH_OUT_MERGER if "merger" in description else TransactionType.SWITCH_OUT, None)
5556
return (TransactionType.REDEMPTION, None)
5657

57-
print("Warning: Error identifying transaction. Please report the issue with the transaction description")
58-
print(f"Txn description: {description} :: Units: {units}")
59-
return (TransactionType.UNKNOWN, None)
58+
for keyword in MISCELLANEOUS_KEYWORDS:
59+
if keyword in description:
60+
return (TransactionType.MISC, None)
6061

61-
62-
def get_transaction_values(values: str) -> tuple[str | None, str | None, str | None, str | None]:
63-
"""
64-
Extract transaction values in the order of amount, units, nav, and balance from the given string.
65-
"""
66-
values = re.findall(patterns.AMT, values.strip())
67-
units = nav = balance = amount = None
68-
if len(values) >= 4:
69-
# Normal entry
70-
amount, units, nav, balance, *_ = values
71-
elif len(values) == 3:
72-
# Zero unit entry
73-
amount, nav, balance = values
74-
units = "0.000"
75-
elif len(values) == 2:
76-
# Segregated Portfolio Entries
77-
units, balance = values
78-
elif len(values) == 1:
79-
# Tax entries
80-
amount = values[0]
81-
return amount, units, nav, balance
62+
logger.warning(f"Error identifying transaction. Description: {description} :: Units: {units}")
63+
return (TransactionType.UNKNOWN, None)
8264

8365

8466
def get_parsed_scheme_name(scheme: str) -> str:
67+
"""Helper to clean scheme names."""
8568
scheme = re.sub(r"\((formerly|erstwhile).+?\)", "", scheme, flags=TEXT_FLAGS).strip()
8669
scheme = re.sub(r"\((Demat|Non-Demat).*", "", scheme, flags=TEXT_FLAGS).strip()
8770
scheme = re.sub(r"\s+", " ", scheme).strip()
8871
return re.sub(r"[^a-zA-Z0-9_)]+$", "", scheme).strip()
89-
90-
91-
def detect_cas_type(parsed_lines: list[str]) -> CASFileType:
92-
"""Detect the type of CAS statement (detailed or summary) from the parsed lines."""
93-
text = "\u2029".join(parsed_lines)
94-
if m := re.search(patterns.CAS_TYPE, text, MULTI_TEXT_FLAGS):
95-
match = m.group(1).lower().strip()
96-
if match == "statement":
97-
return CASFileType.DETAILED
98-
elif match == "summary":
99-
return CASFileType.SUMMARY
100-
return CASFileType.UNKNOWN
101-
102-
103-
def parse_transaction(line: str) -> list[TransactionData]:
104-
"""
105-
Parse a transaction line and return a list of TransactionData objects.
106-
"""
107-
transactions: list[TransactionData] = []
108-
parsed_transactions = re.findall(patterns.TRANSACTIONS, line, MULTI_TEXT_FLAGS)
109-
if not parsed_transactions:
110-
return transactions
111-
112-
for txn in parsed_transactions:
113-
date, details, *_ = txn
114-
if not details or not details.strip() or not date:
115-
continue
116-
description_match = re.match(patterns.DESCRIPTION, details.strip(), MULTI_TEXT_FLAGS)
117-
if not description_match:
118-
continue
119-
description, values, *_ = description_match.groups()
120-
amount, units, nav, balance = get_transaction_values(values)
121-
description = description.strip()
122-
units = formatINR(units)
123-
txn_type, dividend_rate = get_transaction_type(description, units)
124-
transactions.append(
125-
TransactionData(
126-
date=date_parser.parse(date).date(),
127-
description=description,
128-
type=txn_type.name,
129-
amount=formatINR(amount),
130-
units=units,
131-
nav=formatINR(nav),
132-
balance=formatINR(balance),
133-
dividend_rate=dividend_rate,
134-
)
135-
)
136-
return transactions

0 commit comments

Comments
 (0)