From 2a12701f22f8b9919a6363fa40f9c8c96f2ebf7f Mon Sep 17 00:00:00 2001 From: Justin Flannery Date: Thu, 18 Jul 2024 22:31:23 -0600 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20get=5Frecurring=5Fitems?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #125 --- docs/interacting.md | 2 +- lunchable/_config/api_config.py | 1 + lunchable/models/_descriptions.py | 144 +++++++++++++ lunchable/models/_lunchmoney.py | 2 + lunchable/models/recurring_expenses.py | 15 +- lunchable/models/recurring_items.py | 190 ++++++++++++++++++ .../cassettes/test_get_recurring_items.yaml | 54 +++++ tests/models/test_recurring_expenses.py | 10 +- tests/models/test_recurring_items.py | 28 +++ 9 files changed, 439 insertions(+), 7 deletions(-) create mode 100644 lunchable/models/recurring_items.py create mode 100644 tests/models/cassettes/test_get_recurring_items.yaml create mode 100644 tests/models/test_recurring_items.py diff --git a/docs/interacting.md b/docs/interacting.md index 2f6b7a46..2c2e8f35 100644 --- a/docs/interacting.md +++ b/docs/interacting.md @@ -20,7 +20,7 @@ lunch = LunchMoney(access_token="xxxxxxxxxxx") | GET | [get_category](#lunchable.LunchMoney.get_category) | Get single category | | GET | [get_crypto](#lunchable.LunchMoney.get_crypto) | Get Crypto Assets | | GET | [get_plaid_accounts](#lunchable.LunchMoney.get_plaid_accounts) | Get Plaid Synced Assets | -| GET | [get_recurring_expenses](#lunchable.LunchMoney.get_recurring_expenses) | Get Recurring Expenses | +| GET | [get_recurring_items](#lunchable.LunchMoney.get_recurring_items) | Get Recurring Items | | GET | [get_tags](#lunchable.LunchMoney.get_tags) | Get Spending Tags | | GET | [get_transaction](#lunchable.LunchMoney.get_transaction) | Get a Transaction by ID | | GET | [get_transactions](#lunchable.LunchMoney.get_transactions) | Get Transactions Using Criteria | diff --git a/lunchable/_config/api_config.py b/lunchable/_config/api_config.py index 1fe52ce0..d1007acf 100644 --- a/lunchable/_config/api_config.py +++ b/lunchable/_config/api_config.py @@ -26,6 +26,7 @@ class APIConfig: LUNCHMONEY_TRANSACTION_GROUPS: str = "group" LUNCHMONEY_PLAID_ACCOUNTS: str = "plaid_accounts" LUNCH_MONEY_RECURRING_EXPENSES: str = "recurring_expenses" + LUNCH_MONEY_RECURRING_ITEMS: str = "recurring_items" LUNCHMONEY_BUDGET: str = "budgets" LUNCHMONEY_ASSETS: str = "assets" LUNCHMONEY_CATEGORIES: str = "categories" diff --git a/lunchable/models/_descriptions.py b/lunchable/models/_descriptions.py index cad5258a..836da2e7 100644 --- a/lunchable/models/_descriptions.py +++ b/lunchable/models/_descriptions.py @@ -273,6 +273,150 @@ class _RecurringExpensesDescriptions: """ +class _SummarizedTransactionDescriptions: + """ + Descriptions for Summarized Transaction + """ + + id = """ + Unique identifier for the transaction that matched this recurring item + """ + date = """ + Date of transaction in ISO 8601 format + """ + amount = """ + Amount of the transaction in numeric format to 4 decimal places + """ + currency = """ + Three-letter lowercase currency code of the transaction in ISO 4217 format + """ + payee = """ + Payee or payer of the recurring item + """ + category_id = """ + Unique identifier of associated category + """ + recurring_id = """ + Unique identifier of associated recurring item + """ + to_base = """ + The amount converted to the user's primary currency. If the multicurrency + feature is not being used, to_base and amount will be the same. + """ + + +class _RecurringItemsDescriptions: + """ + Descriptions for Recurring Items + """ + + id = """ + Unique identifier for recurring item + """ + start_date = """ + Denotes when recurring item starts occurring in ISO 8601 format. + If null, then this recurring item will show up for all time before end_date + """ + end_date = """ + Denotes when recurring item stops occurring in ISO 8601 format. + If null, then this recurring item has no set end date and will + show up for all months after start_date + """ + payee = """ + Payee or payer of the recurring item + """ + currency = """ + Three-letter lowercase currency code for the recurring item in ISO 4217 format + """ + created_by = """ + The id of the user who created this recurring item. + """ + created_at = """ + The date and time of when the recurring item was created (in the ISO 8601 + extended format). + """ + updated_at = """ + The date and time of when the recurring item was updated (in the ISO 8601 extended format). + """ + billing_date = """ + Initial date that a transaction associated with this recurring item occured. + This date is used in conjunction with values of quantity and granularity to + determine the expected dates of recurring transactions in the period. + """ + original_name = """ + If any, represents the original name of the recurring item as denoted by + the transaction that triggered its creation + """ + description = """ + If any, represents the user-entered description of the recurring item + """ + plaid_account_id = """ + If any, denotes the plaid account associated with the creation of this + recurring item (see Plaid Accounts) + """ + asset_id = """ + If any, denotes the manually-managed account (i.e. asset) associated + with the creation of this recurring item (see Assets) + """ + source = """ + This can be one of four values: + - manual: User created this recurring item manually from the Recurring Items page + - transaction: User created this by converting a transaction from the Transactions page + - system: Recurring item was created by the system on transaction import + - null: Some older recurring items may not have a source. + """ + notes = """ + If any, the user-entered notes for the recurring item + """ + amount = """ + Amount of the recurring item in numeric format to 4 decimal places. + For recurring items with flexible amounts, this is the average of the + specified min and max amounts. + """ + category_id = """ + If any, denotes the unique identifier for the associated category to this recurring item + """ + category_group_id = """ + If any, denotes the unique identifier of associated category group + """ + is_income = """ + Based on the associated category's property, denotes if the recurring transaction + is treated as income + """ + exclude_from_totals = """ + Based on the associated category's property, denotes if the recurring transaction is excluded from totals + """ + granularity = """ + The unit of time used to define the cadence of the recurring item. + One of `weeks`, `months`, `years` + """ + quantity = """ + The number of granular units between each occurrence + """ + occurrences = """ + An object which contains dates as keys and lists as values. The dates will + include all the dates in the month that a recurring item is expected, as well + as the last date in the previous period and the first date in the next period. + The value for each key is a list of Summarized Transaction Objects that matched + the recurring item for that date (if any) + """ + transactions_within_range = """ + A list of all the Summarized Transaction Objects for transactions that that + have occurred in the query month for the recurring item (if any) + """ + missing_dates_within_range = """ + A list of date strings when a recurring transaction is expected but has not (yet) occurred. + """ + date = """ + Denotes the value of the start_date query parameter, or if none was provided, the date when + the request was made. This indicates the month used by the system when populating the response. + """ + to_base = """ + The amount converted to the user's primary currency. If the multicurrency feature is not being + used, to_base and amount will be the same. + """ + + class _TransactionInsertDescriptions: """ Descriptions for TransactionInsertObject diff --git a/lunchable/models/_lunchmoney.py b/lunchable/models/_lunchmoney.py index a8c1c356..5e24deb5 100644 --- a/lunchable/models/_lunchmoney.py +++ b/lunchable/models/_lunchmoney.py @@ -18,6 +18,7 @@ from .crypto import CryptoClient from .plaid_accounts import PlaidAccountsClient from .recurring_expenses import RecurringExpensesClient +from .recurring_items import RecurringItemsClient from .tags import TagsClient from .transactions import TransactionsClient from .user import UserClient @@ -33,6 +34,7 @@ class LunchMoney( TagsClient, TransactionsClient, UserClient, + RecurringItemsClient, ): """ Lunch Money Python Client. diff --git a/lunchable/models/recurring_expenses.py b/lunchable/models/recurring_expenses.py index 144ca9d3..457b5c9b 100644 --- a/lunchable/models/recurring_expenses.py +++ b/lunchable/models/recurring_expenses.py @@ -6,6 +6,7 @@ import datetime import logging +import warnings from typing import List, Optional from pydantic import Field @@ -55,9 +56,6 @@ class RecurringExpensesObject(LunchableModel): asset_id: Optional[int] = Field( None, description=_RecurringExpensesDescriptions.asset_id ) - transaction_id: Optional[int] = Field( - None, description=_RecurringExpensesDescriptions.transaction_id - ) category_id: Optional[int] = Field( None, description=_RecurringExpensesDescriptions.category_id ) @@ -85,6 +83,9 @@ def get_recurring_expenses( """ Get Recurring Expenses + **DEPRECATED** - Use [LunchMoney.get_recurring_items()][lunchable.LunchMoney.get_recurring_items] + instead. + Retrieve a list of recurring expenses to expect for a specified period. Every month, a different set of recurring expenses is expected. This is because recurring @@ -111,6 +112,14 @@ def get_recurring_expenses( ------- List[RecurringExpensesObject] """ + warnings.warn( + message=( + "`LunchMoney.get_recurring_expenses` is deprecated, " + "use `LunchMoney.get_recurring_items` instead" + ), + category=DeprecationWarning, + stacklevel=2, + ) if start_date is None: start_date = datetime.datetime.now().date().replace(day=1) params = RecurringExpenseParamsGet( diff --git a/lunchable/models/recurring_items.py b/lunchable/models/recurring_items.py new file mode 100644 index 00000000..d77c7679 --- /dev/null +++ b/lunchable/models/recurring_items.py @@ -0,0 +1,190 @@ +""" +Lunch Money - Recurring Expenses + +https://lunchmoney.dev/#recurring-expenses +""" + +from __future__ import annotations + +import datetime +import logging +from typing import Any, Dict, List, Optional + +from pydantic import Field + +from lunchable._config import APIConfig +from lunchable.models._base import LunchableModel +from lunchable.models._core import LunchMoneyAPIClient +from lunchable.models._descriptions import ( + _RecurringItemsDescriptions, + _SummarizedTransactionDescriptions, +) + +logger = logging.getLogger(__name__) + + +class SummarizedTransactionObject(LunchableModel): + """ + Summarized Transaction Object + """ + + id: int = Field(description=_SummarizedTransactionDescriptions.id) + date: datetime.date = Field(description=_SummarizedTransactionDescriptions.date) + amount: float = Field(description=_SummarizedTransactionDescriptions.amount) + currency: str = Field( + max_length=3, description=_SummarizedTransactionDescriptions.currency + ) + payee: str = Field(description=_SummarizedTransactionDescriptions.payee) + category_id: Optional[int] = Field( + None, description=_SummarizedTransactionDescriptions.category_id + ) + recurring_id: Optional[int] = Field( + None, description=_SummarizedTransactionDescriptions.recurring_id + ) + to_base: float = Field(description=_SummarizedTransactionDescriptions.to_base) + + +class RecurringItemsObject(LunchableModel): + """ + Recurring Expenses Object + """ + + id: int = Field(description=_RecurringItemsDescriptions.id) + start_date: Optional[datetime.date] = Field( + None, description=_RecurringItemsDescriptions.start_date + ) + end_date: Optional[datetime.date] = Field( + None, description=_RecurringItemsDescriptions.end_date + ) + payee: str = Field(description=_RecurringItemsDescriptions.payee) + currency: str = Field( + max_length=3, description=_RecurringItemsDescriptions.currency + ) + created_by: int = Field(description=_RecurringItemsDescriptions.created_by) + created_at: datetime.datetime = Field( + description=_RecurringItemsDescriptions.created_at + ) + updated_at: datetime.datetime = Field( + description=_RecurringItemsDescriptions.updated_at + ) + billing_date: datetime.date = Field( + description=_RecurringItemsDescriptions.billing_date + ) + original_name: Optional[str] = Field( + None, description=_RecurringItemsDescriptions.original_name + ) + description: Optional[str] = Field( + None, description=_RecurringItemsDescriptions.description + ) + plaid_account_id: Optional[int] = Field( + None, description=_RecurringItemsDescriptions.plaid_account_id + ) + asset_id: Optional[int] = Field( + None, description=_RecurringItemsDescriptions.asset_id + ) + source: str = Field(description=_RecurringItemsDescriptions.source) + notes: Optional[str] = Field(None, description=_RecurringItemsDescriptions.notes) + amount: float = Field(description=_RecurringItemsDescriptions.amount) + category_id: Optional[int] = Field( + None, description=_RecurringItemsDescriptions.category_id + ) + category_group_id: Optional[int] = Field( + None, description=_RecurringItemsDescriptions.category_group_id + ) + is_income: bool = Field(description=_RecurringItemsDescriptions.is_income) + exclude_from_totals: bool = Field( + description=_RecurringItemsDescriptions.exclude_from_totals + ) + granularity: str = Field(description=_RecurringItemsDescriptions.granularity) + cadence: Optional[str] = Field(None) + quantity: Optional[int] = Field( + None, description=_RecurringItemsDescriptions.quantity + ) + occurrences: Dict[datetime.date, List[SummarizedTransactionObject]] = Field( + description=_RecurringItemsDescriptions.occurrences + ) + transactions_within_range: Optional[List[SummarizedTransactionObject]] = Field( + None, description=_RecurringItemsDescriptions.transactions_within_range + ) + missing_dates_within_range: Optional[List[Any]] = Field( + None, description=_RecurringItemsDescriptions.missing_dates_within_range + ) + date: Optional[datetime.date] = Field( + None, description=_RecurringItemsDescriptions.date + ) + to_base: float = Field(description=_RecurringItemsDescriptions.to_base) + + +class RecurringItemsParamsGet(LunchableModel): + """ + https://lunchmoney.dev/#get-recurring-items + """ + + start_date: datetime.date + debit_as_negative: Optional[bool] = None + + +class RecurringItemsClient(LunchMoneyAPIClient): + """ + Lunch Money Recurring Items Interactions + """ + + def get_recurring_items( + self, + start_date: Optional[datetime.date] = None, + debit_as_negative: Optional[bool] = None, + ) -> List[RecurringItemsObject]: + """ + Get Recurring Items + + Use this to retrieve a list of recurring items to expect for a specified month. + + A different set of recurring items is expected every month. These can be once a year, + twice a year, every four months, etc. + + If a recurring item is listed as “twice a month,” then the recurring item object returned + will have an occurrences attribute populated by the different billing dates the system believes + recurring transactions should occur, including the two dates in the current month, the last + transaction date prior to the month, and the next transaction date after the month. + + If the recurring item is listed as “once a week,” then the recurring item object returned will + have an occurrences object populated with as many times as there are weeks for the specified + month, along with the last transaction from the previous month and the next transaction for + the next month. + + In the same vein, if a recurring item that began last month is set to “Every 3 months”, + then that recurring item object that occurred will not include any dates for this month. + + Parameters + ---------- + start_date : Optional[datetime.date] + Date to search. Whatever your start date, the system will automatically + return recurring items expected for that month. For instance, if you + input 2020-01-25, the system will return recurring items which are to + be expected between 2020-01-01 to 2020-01-31. By default will return + the first day of the current month + debit_as_negative: bool + Pass in true if you'd like items to be returned as negative amounts + and credits as positive amounts. Defaults to false. + + Returns + ------- + List[RecurringItemsObject] + """ + if start_date is None: + start_date = datetime.datetime.now().date().replace(day=1) + params = RecurringItemsParamsGet( + start_date=start_date, debit_as_negative=debit_as_negative + ).model_dump(exclude_none=True) + response_data = self.make_request( + method=self.Methods.GET, + url_path=[APIConfig.LUNCH_MONEY_RECURRING_ITEMS], + params=params, + ) + recurring_expenses_objects = [ + RecurringItemsObject.model_validate(item) for item in response_data + ] + logger.debug( + "%s RecurringExpensesObjects retrieved", len(recurring_expenses_objects) + ) + return recurring_expenses_objects diff --git a/tests/models/cassettes/test_get_recurring_items.yaml b/tests/models/cassettes/test_get_recurring_items.yaml new file mode 100644 index 00000000..a2a446f3 --- /dev/null +++ b/tests/models/cassettes/test_get_recurring_items.yaml @@ -0,0 +1,54 @@ +interactions: +- request: + body: '' + headers: + accept: + - '*/*' + accept-encoding: + - gzip, deflate + authorization: + - XXXXXXXXXX + connection: + - keep-alive + content-type: + - application/json + host: + - dev.lunchmoney.app + user-agent: + - lunchable/1.3.3 + method: GET + uri: https://dev.lunchmoney.app/v1/recurring_items?start_date=2022-11-01 + response: + content: '[{"id":585244,"start_date":"2021-12-01T00:00:00.000Z","end_date":null,"payee":"Test Item","currency":"usd","created_by":12126,"created_at":"2023-12-15T02:38:04.131Z","updated_at":"2023-12-15T02:38:04.131Z","billing_date":"2023-12-01","original_name":null,"description":"Video Streaming","plaid_account_id":null,"asset_id":null,"source":"manual","amount":"100.0000","notes":null,"category_id":443126,"category_group_id":680257,"is_income":false,"exclude_from_totals":false,"cadence":"monthly","granularity":"month","quantity":1,"occurrences":{"2022-11-01":[],"2022-12-01":[],"2023-01-01":[],"2023-02-01":[],"2023-03-01":[],"2023-04-01":[],"2023-05-01":[],"2023-06-01":[],"2023-07-01":[],"2023-08-01":[],"2023-09-01":[],"2023-10-01":[],"2023-11-01":[],"2023-12-01":[],"2024-01-01":[],"2024-02-01":[],"2024-03-01":[],"2024-04-01":[],"2024-05-01":[],"2024-06-01":[],"2024-07-01":[],"2024-08-01":[]},"transactions_within_range":[],"missing_dates_within_range":["2022-11-01","2022-12-01","2023-01-01","2023-02-01","2023-03-01","2023-04-01","2023-05-01","2023-06-01","2023-07-01","2023-08-01","2023-09-01","2023-10-01","2023-11-01","2023-12-01","2024-01-01","2024-02-01","2024-03-01","2024-04-01","2024-05-01","2024-06-01","2024-07-01"],"date":"2024-07-19","to_base":100}]' + headers: + Access-Control-Allow-Credentials: + - 'true' + Connection: + - keep-alive + Content-Encoding: + - gzip + Content-Type: + - application/json; charset=utf-8 + Date: + - Fri, 19 Jul 2024 03:53:07 GMT + Etag: + - W/"4f1-jaQOBXD6F728udtE/T3Pci04HP4" + Nel: + - '{"report_to":"heroku-nel","max_age":3600,"success_fraction":0.005,"failure_fraction":0.05,"response_headers":["Via"]}' + Report-To: + - '{"group":"heroku-nel","max_age":3600,"endpoints":[{"url":"https://nel.heroku.com/reports?ts=1721361187&sid=1b10b0ff-8a76-4548-befa-353fc6c6c045&s=YtEpFVLeE%2BZqjZemsRdPEXAIOqgIB6TUtRVb%2FNYrMoc%3D"}]}' + Reporting-Endpoints: + - heroku-nel=https://nel.heroku.com/reports?ts=1721361187&sid=1b10b0ff-8a76-4548-befa-353fc6c6c045&s=YtEpFVLeE%2BZqjZemsRdPEXAIOqgIB6TUtRVb%2FNYrMoc%3D + Server: + - Cowboy + Transfer-Encoding: + - chunked + Vary: + - Origin, Accept-Encoding + Via: + - 1.1 vegur + X-Powered-By: + - Express + http_version: HTTP/1.1 + status_code: 200 +version: 1 diff --git a/tests/models/test_recurring_expenses.py b/tests/models/test_recurring_expenses.py index b7015f21..24e2c016 100644 --- a/tests/models/test_recurring_expenses.py +++ b/tests/models/test_recurring_expenses.py @@ -1,9 +1,12 @@ """ Run Tests on the Recurring Expenses Endpoint """ + import datetime import logging +import pytest + from lunchable import LunchMoney from lunchable.models.recurring_expenses import RecurringExpensesObject from tests.conftest import lunchable_cassette @@ -18,9 +21,10 @@ def test_get_recurring_expenses( """ Get Recurring Expense and Assert it's a Recurring Expense """ - recurring_expenses = lunch_money_obj.get_recurring_expenses( - start_date=obscure_start_date - ) + with pytest.warns(DeprecationWarning): + recurring_expenses = lunch_money_obj.get_recurring_expenses( + start_date=obscure_start_date + ) assert len(recurring_expenses) >= 1 for recurring_expense in recurring_expenses: assert isinstance(recurring_expense, RecurringExpensesObject) diff --git a/tests/models/test_recurring_items.py b/tests/models/test_recurring_items.py new file mode 100644 index 00000000..08cb0d75 --- /dev/null +++ b/tests/models/test_recurring_items.py @@ -0,0 +1,28 @@ +""" +Run Tests on the Recurring Items Endpoint +""" + +import datetime +import logging + +from lunchable import LunchMoney +from lunchable.models.recurring_items import RecurringItemsObject +from tests.conftest import lunchable_cassette + +logger = logging.getLogger(__name__) + + +@lunchable_cassette +def test_get_recurring_items( + lunch_money_obj: LunchMoney, obscure_start_date: datetime.datetime +): + """ + Get Recurring Items, and ensure they are returned as RecurringItemsObject + """ + recurring_expenses = lunch_money_obj.get_recurring_items( + start_date=obscure_start_date + ) + assert len(recurring_expenses) >= 1 + for recurring_expense in recurring_expenses: + assert isinstance(recurring_expense, RecurringItemsObject) + logger.info("%s Recurring Expenses returned", len(recurring_expenses))