Skip to content

Commit 57df4d7

Browse files
committed
fix: Installment transactions import
1 parent ee77b39 commit 57df4d7

File tree

8 files changed

+119
-43
lines changed

8 files changed

+119
-43
lines changed

.github/workflows/python-package.yml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,11 @@ jobs:
3434
run: |
3535
# stop the build if there are Python syntax errors or undefined names
3636
flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics
37+
- name: Setup locale # Required for pytest to run with pt_BR.UTF-8
38+
run: |
39+
sudo apt-get install -y locales
40+
sudo locale-gen pt_BR.UTF-8
41+
sudo update-locale LANG=pt_BR.UTF-8
3742
- name: Test with pytest
3843
run: |
3944
pytest --cov=brbanks2ynab

brbanks2ynab/cli.py

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -17,40 +17,43 @@ def _default_config_path():
1717
def main():
1818
logging.basicConfig()
1919
logger = logging.getLogger('brbanks2ynab')
20-
20+
2121
parser = ArgumentParser(description='Importador de transações de bancos brasileiros para o YNAB')
2222
parser.add_argument('--debug', action='store_true')
23-
23+
2424
subparsers = parser.add_subparsers(dest='cmd')
25-
25+
2626
sync_parser = subparsers.add_parser('sync')
2727
sync_parser.add_argument('--config-file')
2828
sync_parser.add_argument('--config')
2929
sync_parser.add_argument('--dry', action='store_true', default=False)
3030
sync_parser.add_argument('--ntfy-topic', default=None, help='Tópico do ntfy para notificação')
3131
configure_parser = subparsers.add_parser('configure')
32-
32+
3333
result = parser.parse_args()
34-
34+
3535
if result.debug:
3636
logger.setLevel(logging.DEBUG)
37-
37+
logger.debug('Debug mode enabled')
38+
3839
if result.cmd == 'configure':
3940
init_config()
4041
elif result.cmd == 'sync':
4142
if result.config_file and result.config or not result.config_file and not result.config:
4243
raise Exception('É necessário informar um arquivo de configuração ou uma string de configuração')
43-
44+
4445
if result.config_file:
4546
path = Path(result.config_file)
4647
if not path.exists():
4748
raise Exception(f'Arquivo de configuração "{path}" não encontrado')
48-
49+
4950
config = ImporterConfig.from_dict(json.loads(path.read_text()))
5051
else:
5152
config = ImporterConfig.from_dict(json.loads(base64.b64decode(result.config)))
5253
ntfy_topic = result.ntfy_topic
5354
sync(config, result.dry, ntfy_topic)
55+
else:
56+
parser.print_help()
5457

5558

5659
if __name__ == '__main__':

brbanks2ynab/importers/nubank/nubank_credit_card.py

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -12,11 +12,11 @@
1212

1313

1414
def _is_active_transactions(transaction: dict):
15-
return transaction['details']['status'] == 'settled'
15+
return transaction['details'].get('status', 'settled') == 'settled'
1616

1717

1818
def _is_installment_transaction(transaction: dict):
19-
return 'charges' in transaction['details']
19+
return 'charges' in transaction['details'] and transaction['details']['charges']['count'] > 1
2020

2121

2222
class NubankCreditCardData(DataImporter):
@@ -55,17 +55,20 @@ def _expand_installment_transaction(self, transaction: dict) -> List[Transaction
5555
parsed_date = datetime.strptime(transaction['time'][:10], '%Y-%m-%d')
5656

5757
def _to_transaction(index) -> Transaction:
58-
formatted_value = locale.currency(transaction['amount'] / 100, grouping=True)
58+
formatted_value = locale.currency(transaction['amount'] / 100, grouping=True, symbol=False)
5959
# Adds the index to the date so the transactions spans multiple months
60-
date = (parsed_date + relativedelta(months=index)).strftime('%Y-%m-%d')
60+
date = (parsed_date + relativedelta(months=index - 1))
61+
if index != 1:
62+
# If it's not the first transaction, sets the day to the first
63+
date = date.replace(day=1)
6164
return {
62-
'transaction_id': f'{transaction["id"]}-{index}',
65+
'transaction_id': f'{index}-{transaction["id"]}'[:35],
6366
'account_id': self.account_id,
6467
'payee': transaction['description'],
6568
'amount': installment_amount,
66-
'date': date,
67-
'memo': f'Parcela {index} de {count}. Valor total: {formatted_value}',
68-
'flag': 'Parcelado'
69+
'date': date.strftime('%Y-%m-%d'),
70+
'memo': f'Parcela {index} de {count}. Valor total: R$ {formatted_value}',
71+
'flag': 'red'
6972
}
7073

7174
return [_to_transaction(i + 1) for i in range(count)]

brbanks2ynab/sync/sync.py

Lines changed: 13 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,8 @@
99
from brbanks2ynab.config.config import ImporterConfig
1010
from brbanks2ynab.importers import get_importers_for_bank
1111
from brbanks2ynab.util import find_budget_by_name
12-
from brbanks2ynab.ynab.ynab_transaction_importer import YNABTransactionImporter
1312
from brbanks2ynab.utils.notification import send_notification
13+
from brbanks2ynab.ynab.ynab_transaction_importer import YNABTransactionImporter
1414

1515
logger = logging.getLogger('brbanks2ynab')
1616
logger.setLevel(logging.DEBUG)
@@ -24,37 +24,39 @@ def _build_summary(response: CreateTransactionResponse) -> dict:
2424
}
2525

2626

27-
def sync(config: ImporterConfig, dry: bool, notify: Optional[str] = None):
27+
def sync(config: ImporterConfig, dry: bool, ntfy_topic: Optional[str] = None):
2828
ynab = YNAB(config.ynab_token)
29-
29+
3030
budget = find_budget_by_name(ynab.budgets.get_budgets().data.budgets, config.ynab_budget)
3131
ynab_accounts = ynab.accounts.get_accounts(budget.id).data.accounts
32-
32+
3333
ynab_importer = YNABTransactionImporter(ynab, budget.id, config.start_import_date)
34-
34+
3535
for bank in config.banks:
3636
importers = get_importers_for_bank(bank, config, ynab_accounts)
37-
37+
3838
for importer in importers:
3939
ynab_importer.get_transactions_from(importer)
40-
40+
4141
if dry:
4242
logger.info('Dry running! No transactions will be imported into YNAB.')
4343
logger.info(f'{len(ynab_importer.transactions)} would be imported into YNAB')
44-
44+
4545
with open('import_result.json', 'w') as f:
4646
data = [dataclasses.asdict(t) for t in ynab_importer.transactions]
4747
json.dump(data, f)
4848
else:
4949
response = ynab_importer.save()
50+
logger.debug(f'YNAB response: \n {json.dumps(dataclasses.asdict(response), indent=2)}')
51+
5052
summary = _build_summary(response)
51-
53+
5254
logger.info(f"""
5355
{summary['transaction_count']} new transactions imported into YNAB
5456
{summary['duplicated_count']} transactions were already imported.
5557
""")
56-
if notify:
57-
send_notification(summary, notify)
58+
if ntfy_topic:
59+
send_notification(summary, ntfy_topic)
5860

5961

6062
if __name__ == '__main__':

brbanks2ynab/ynab/ynab_transaction_importer.py

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,26 +14,28 @@ def __init__(self, ynab: YNAB, budget_id: str, starting_date: str):
1414
self.budget_id = budget_id
1515
self.starting_date = datetime.strptime(starting_date, '%Y-%m-%d')
1616
self.transactions: List[TransactionRequest] = []
17-
17+
1818
def get_transactions_from(self, transaction_importer: DataImporter):
1919
transactions = transaction_importer.get_data()
2020
transactions = filter(self._filter_transaction, transactions)
2121
transformed = map(self._create_transaction_request, transactions)
2222
self.transactions.extend(transformed)
2323
return self
24-
24+
2525
def save(self):
2626
return self.ynab.transactions.create_transactions(self.budget_id, self.transactions)
27-
27+
2828
def _create_transaction_request(self, transaction: Transaction) -> TransactionRequest:
2929
return TransactionRequest(
3030
transaction['account_id'],
3131
transaction['date'],
3232
transaction['amount'],
3333
payee_name=transaction['payee'],
3434
import_id=transaction['transaction_id'],
35+
memo=transaction['memo'],
36+
flag_color=transaction['flag'],
3537
)
36-
38+
3739
def _filter_transaction(self, transaction: Transaction) -> bool:
3840
now = datetime.now()
3941
transaction_date = datetime.strptime(transaction['date'], '%Y-%m-%d')

requirements.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ PyJWT==2.2.0
44

55
ynab_sdk==0.5.0
66
inquirer==2.8.0
7+
python-dateutil==2.8.2
78

89
pytest==6.2.5
910
pytest-cov==2.11.1

tests/importers/nubank/test_nubank_credit_card.py

Lines changed: 61 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -60,9 +60,9 @@ def test_should_only_import_settled_transactions(setup_nubank, monkeypatch):
6060
def test_should_expand_installment_transactions(setup_nubank, monkeypatch):
6161
nu = setup_nubank
6262
installment_transaction = build_card_transaction({
63+
'amount': 3000,
6364
'details': {
6465
'status': 'settled',
65-
'amount': 3000,
6666
'charges': {
6767
'count': 3,
6868
'amount': 1000
@@ -83,11 +83,68 @@ def test_should_expand_installment_transactions(setup_nubank, monkeypatch):
8383

8484
assert len(imported_transactions) == 4
8585
assert imported_transactions[0]['amount'] == -30000
86+
assert imported_transactions[1]['memo'] == 'Parcela 1 de 3. Valor total: R$ 30,00'
87+
assert imported_transactions[1]['flag'] == 'red'
8688
assert imported_transactions[1]['amount'] == -10000
89+
assert imported_transactions[2]['memo'] == 'Parcela 2 de 3. Valor total: R$ 30,00'
90+
assert imported_transactions[2]['flag'] == 'red'
8791
assert imported_transactions[2]['amount'] == -10000
92+
assert imported_transactions[3]['memo'] == 'Parcela 3 de 3. Valor total: R$ 30,00'
93+
assert imported_transactions[3]['flag'] == 'red'
8894
assert imported_transactions[3]['amount'] == -10000
8995
# All ids should be unique
9096
assert len(ids) == len(set(ids))
91-
# Installments should have the memo and flag set
92-
assert imported_transactions[1]['memo'] == 'Parcela 1 de 3. Valor total R$ 30,00'
93-
assert imported_transactions[1]['flag'] == 'Red'
97+
98+
99+
100+
def test_should_not_expand_transactions_with_one_installment(setup_nubank, monkeypatch):
101+
nu = setup_nubank
102+
installment_transaction = build_card_transaction({
103+
'amount': 3000,
104+
'details': {
105+
'status': 'settled',
106+
'charges': {
107+
'count': 1,
108+
'amount': 3000
109+
}
110+
}
111+
})
112+
transactions = [
113+
installment_transaction
114+
]
115+
monkeypatch.setattr(nu, 'get_card_statements', lambda: transactions)
116+
117+
importer = NubankCreditCardData(nu, 'some-id')
118+
imported_transactions = list(importer.get_data())
119+
120+
assert len(imported_transactions) == 1
121+
assert imported_transactions[0]['amount'] == -30000
122+
assert imported_transactions[0]['memo'] == ''
123+
assert imported_transactions[0]['flag'] is None
124+
125+
126+
def test_should_set_the_day_to_first_for_following_installments(setup_nubank, monkeypatch):
127+
nu = setup_nubank
128+
installment_transaction = build_card_transaction({
129+
'amount': 3000,
130+
'time': '2021-01-15',
131+
'details': {
132+
'status': 'settled',
133+
'charges': {
134+
'count': 3,
135+
'amount': 1000
136+
}
137+
}
138+
})
139+
transactions = [
140+
installment_transaction
141+
]
142+
monkeypatch.setattr(nu, 'get_card_statements', lambda: transactions)
143+
144+
importer = NubankCreditCardData(nu, 'some-id')
145+
imported_transactions = list(importer.get_data())
146+
147+
assert len(imported_transactions) == 3
148+
assert imported_transactions[0]['date'] == '2021-01-15'
149+
assert imported_transactions[1]['date'] == '2021-02-01'
150+
assert imported_transactions[2]['date'] == '2021-03-01'

tests/ynab/test_ynab_transaction_importer.py

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313

1414

1515
class FakeImporter(DataImporter):
16-
16+
1717
def get_data(self) -> Iterable[Transaction]:
1818
return [
1919
{
@@ -22,36 +22,39 @@ def get_data(self) -> Iterable[Transaction]:
2222
'payee': 'Some Payee',
2323
'amount': 15000,
2424
'date': today.strftime('%Y-%m-%d'),
25+
'memo': '',
26+
'flag': None,
2527
},
2628
{
2729
'transaction_id': '2',
2830
'account_id': 'some-id-two',
2931
'payee': 'Some Mall',
3032
'amount': 99000,
3133
'date': (today - timedelta(weeks=54)).strftime('%Y-%m-%d'),
34+
'memo': '',
35+
'flag': None,
3236
}
3337
]
3438

3539

3640
class TestYNABTransactionImporter(unittest.TestCase):
37-
41+
3842
def test_should_import_transactions(self):
3943
fake_ynab = Mock(YNAB)
4044
start_date = (today - timedelta(weeks=4)).strftime('%Y-%m-%d')
4145
importer = YNABTransactionImporter(fake_ynab, '1234', start_date)
42-
46+
4347
importer.get_transactions_from(FakeImporter())
4448
importer.save()
45-
49+
4650
fake_ynab.transactions.create_transactions.assert_called_once()
47-
51+
4852
def test_should_ignore_transactions_past_start_date(self):
4953
fake_ynab = Mock(YNAB)
5054
start_date = (today - timedelta(weeks=4)).strftime('%Y-%m-%d')
5155
importer = YNABTransactionImporter(fake_ynab, '1234', start_date)
52-
56+
5357
importer.get_transactions_from(FakeImporter())
54-
58+
5559
self.assertEqual(len(importer.transactions), 1)
5660
self.assertEqual(importer.transactions[0].import_id, '1')
57-

0 commit comments

Comments
 (0)