.
675 |
--------------------------------------------------------------------------------
/MANIFEST.in:
--------------------------------------------------------------------------------
1 | include VERSION
2 |
--------------------------------------------------------------------------------
/Makefile:
--------------------------------------------------------------------------------
1 | .PHONY: minimal
2 | minimal: venv
3 |
4 | venv: requirements.txt setup.py tox.ini
5 | tox -e venv
6 |
7 | .PHONY: test
8 | test:
9 | tox -e tests
10 |
11 | .PHONY: pre-commit
12 | pre-commit:
13 | tox -e pre-commit
14 |
15 | .PHONY: clean
16 | clean:
17 | find -name '*.pyc' -delete
18 | find -name '__pycache__' -delete
19 | rm -rf .tox
20 | rm -rf venv
21 |
22 | .PHONY: install-hooks
23 | install-hooks:
24 | tox -e install-hooks
25 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | ActualTap-Py
2 |
3 |
4 |
5 |
6 | Heavily inspired by Actual Tap but written in python using Actualpy and a added a few enhancements.
7 |
8 | Special thanks to @MattFaz for the initial Actual Tap work!
9 |
10 | Automatically create transactions in Actual Budget when you use Tap-to-Pay on a mobile device
11 |
12 |
13 | ## Contents
14 |
15 | - [Overview](#overview)
16 | - [Run the App](#run-the-app)
17 | - [iOS Setup](#ios-setup)
18 | - [Android Setup](#android-setup)
19 |
20 | # Overview
21 |
22 | Actual Tap uses FastAPI that utilises the Actualpy to create transactions.
23 |
24 | The primary purpose of Actual Tap is receive a POST request from mobile devices _(.e.g iOS Shortcuts)_ when a Tap to Pay transaction is made. Once the POST request is received Actual Tap will POST the Name and Amount to Actual Budget.
25 |
26 | In addition, there is a configuration file that allows you to map between the Tap to Pay account and your Actual Account ID.
27 |
28 | Ideal flow:
29 |
30 | 1. Mobile device is tapped to make a purchase
31 | 2. Automation on mobile device is triggered
32 | - Recommended apps are [Shortcuts](https://apps.apple.com/us/app/shortcuts/id915249334) (iOS) or [Tasker](https://play.google.com/store/apps/details?id=net.dinglisch.android.taskerm&pcampaignid=web_share) (Android)
33 | 3. POST request containing transaction information _(merchant, amount, and card)_ is sent to Actual Tap
34 | 4. Actual Tap creates the transaction in Actual Budget
35 |
36 |
37 |
38 |
39 |
40 | **Notes:** This is in active / heavy development, issues, pull requests, feature requests etc. are welcome.
41 |
42 | ---
43 |
44 | ## Running the App
45 |
46 | To run Actual Tap locally _(i.e. for development or not containerised)_:
47 |
48 | - `$ cp config/config.yml.sample config/config.yml`
49 | - Edit the `config.yml` file accordingly
50 | - Run locally using `uvicorn main:app --host 0.0.0.0 --port 8000`
51 |
52 | To run Actual Tap in docker ensure you edit variables in the `docker-compose.yml` file.
53 |
54 | - **Note:** You will also need to update the volumes path
55 |
56 | The app will be running on port `8000`
57 |
58 | ## iOS Setup
59 |
60 | 1. Import these 2 shortcuts:
61 | - Wallet Transactions to JSON - Shared
62 | - Wallet to ActualTap - Shared
63 | 2. Open Shortcuts app
64 | 3. Edit the Wallet to ActualTap - Shared shorcut
65 | - Under the Dictionary block, modify the following values
66 | | **Key** | **Text** | **Example** |
67 | | -----------| ------------------------------------ | --------------------------------------- |
68 | | `ActualTap URL` | The URL of your ActualTap instance followed by `/transactions/` | https://actualtap-api.com/transactions/ |
69 | | `API Key` | The `api_key` defined in your configuration file | 527D6AAA-B22A-4D48-9DC8-C203139E5531 |
70 | 4. Import these two automations:
71 | 5. Select Automations
72 | 6. Create new Automation to Run Immediately
73 | 7. When:
74 | - I tap any of x Wallet passes or payment cards
75 | 8. Do:
76 | - Receive transaction as input
77 | - Dictionary
78 |
79 | | **Key** | **Text** |
80 | | -----------| ------------------------------------ |
81 | | `amount` | (_shortcut Input_ -> _Amount_) |
82 | | `card` | (_shortcut Input_ -> _Card or Pass_) |
83 | | `merchant` | (_shortcut Input_ -> _Merchant_) |
84 | | `name` | (_shortcut Input_ -> _Name_) |
85 |
86 | - Run shortcut -> Wallet Transactions to JSON - Shared
87 | - Run shortcut -> Wallet to ActualTap - Shared
88 |
89 | ## Android Setup
90 |
91 | TBC
92 |
--------------------------------------------------------------------------------
/VERSION:
--------------------------------------------------------------------------------
1 | 0.1.6
2 |
--------------------------------------------------------------------------------
/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/bobokun/actualtap-py/d45e205fe6154a73c42c928adaa65a4a508bb070/__init__.py
--------------------------------------------------------------------------------
/api/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/bobokun/actualtap-py/d45e205fe6154a73c42c928adaa65a4a508bb070/api/__init__.py
--------------------------------------------------------------------------------
/api/transactions.py:
--------------------------------------------------------------------------------
1 | from decimal import Decimal
2 | from typing import List
3 |
4 | from fastapi import APIRouter
5 | from fastapi import HTTPException
6 |
7 | from schemas.transactions import Transaction
8 | from services.actual_service import actual_service
9 |
10 | router = APIRouter()
11 |
12 |
13 | @router.post("/transactions")
14 | @router.post("/transactions/")
15 | def add_transactions(transactions: List[Transaction]):
16 | # check if there is a body
17 | if not transactions:
18 | raise HTTPException(status_code=400, detail="No transactions provided")
19 | try:
20 | for transaction in transactions:
21 | transaction.amount *= Decimal(-1) # Invert the amount
22 |
23 | actual_service.add_transactions(transactions)
24 |
25 | return {"message": "Transactions added successfully"}
26 | except ValueError as ve:
27 | raise HTTPException(status_code=400, detail=str(ve))
28 | except Exception as e:
29 | raise HTTPException(status_code=500, detail=str(e))
30 |
--------------------------------------------------------------------------------
/config/config.yml.sample:
--------------------------------------------------------------------------------
1 | # Unique ID
2 | api_key: "527D6AAA-B22A-4D48-9DC8-C203139E5531"
3 | # URL to Actual Budget Server
4 | actual_url: "https://actual.yourdomain.com"
5 | # Password for your Actual Budget Server
6 | actual_password: "superSecretPassword"
7 | # The name of your budget or Sync ID of the budget
8 | actual_budget: "My budget"
9 | # The Unique Id of an account you want transactions to default too
10 | actual_default_account_id: "8AF657D4-4811-42C7-8272-E299A8DAC43A"
11 | # If the Merchant name is missing, but the Amount still has a value, a transaction can be created with this as the payee name
12 | actual_backup_payee: "Unknown"
13 | # Mapping of Tap to Pay account to Actual Account ID
14 | account_mappings:
15 | savings: "8AF657D4-4811-42C7-8272-E299A8DAC43A"
16 | checking: "B2F7DF53-6A9C-42CF-B091-9129A3A9B528"
17 | credit_card: "C3D8F25A-9C7D-4C9B-8589-8A3790C03B2D"
18 | log_level: INFO
19 |
--------------------------------------------------------------------------------
/copy-config.sh:
--------------------------------------------------------------------------------
1 | #!/bin/sh
2 |
3 | # Download latest config files if they don't exist or are different
4 | file="config.yml.sample"
5 | local_file="$CONFIG_DIR/$file"
6 | latest_file="/app/config/$file"
7 |
8 | if [ ! -f "$local_file" ] || ! diff -q "$latest_file" "$local_file" > /dev/null 2>&1; then
9 | echo "Copying latest $file"
10 | cp "$latest_file" "$local_file"
11 | chmod -R 777 /${local_file} > /dev/null 2>&1
12 | else
13 | echo "File $file is up to date"
14 | fi
15 |
16 | # Execute the main command
17 | exec "$@"
18 |
--------------------------------------------------------------------------------
/core/__init__.py:
--------------------------------------------------------------------------------
1 | import os
2 |
3 | # Define an empty version_info tuple
4 | __version_info__ = ()
5 |
6 | # Get the path to the project directory
7 | project_dir = os.path.dirname(os.path.abspath(__file__))
8 |
9 | # Get the path to the VERSION file
10 | version_file_path = os.path.join(project_dir, "..", "VERSION")
11 |
12 | # Read the version from the file
13 | with open(version_file_path) as f:
14 | version_str = f.read().strip()
15 |
16 | # Get only the first 3 digits
17 | version_str_split = version_str.rsplit("-", 1)[0]
18 | # Convert the version string to a tuple of integers
19 | __version_info__ = tuple(map(int, version_str_split.split(".")))
20 |
21 | # Define the version string using the version_info tuple
22 | __version__ = ".".join(str(i) for i in __version_info__)
23 |
--------------------------------------------------------------------------------
/core/config.py:
--------------------------------------------------------------------------------
1 | from pathlib import Path
2 | from typing import Dict
3 |
4 | import yaml
5 | from pydantic_settings import BaseSettings
6 |
7 |
8 | class Settings(BaseSettings):
9 | api_key: str
10 | actual_url: str
11 | actual_password: str
12 | actual_budget: str
13 | actual_default_account_id: str
14 | actual_backup_payee: str
15 | account_mappings: Dict[str, str]
16 | log_level: str = "INFO"
17 |
18 | class Config:
19 | env_file = ".env"
20 |
21 |
22 | # Define config_path globally
23 | config_path = Path("/config/config.yml") # Set default config path
24 | base_dir = Path(__file__).resolve().parent
25 | fallback_config_path = base_dir / ".." / "config" / "config.yml"
26 | if not config_path.exists():
27 | config_path = fallback_config_path
28 | if not config_path.exists():
29 | raise FileNotFoundError("Configuration file not found at '/config/config.yml'")
30 |
31 |
32 | def load_config():
33 | with open(config_path) as file:
34 | config = yaml.safe_load(file)
35 | if not config:
36 | raise ValueError("Empty configuration file")
37 | return Settings(**config)
38 |
39 |
40 | settings = load_config()
41 |
42 |
43 | def redact_sensitive_settings(keys_to_redact: list, redaction_placeholder: str = "REDACTED"):
44 | settings_dict = settings.dict()
45 | for key in keys_to_redact:
46 | if key in settings_dict:
47 | settings_dict[key] = redaction_placeholder # Redact the value
48 | return settings_dict
49 |
--------------------------------------------------------------------------------
/core/logs.py:
--------------------------------------------------------------------------------
1 | import logging
2 | from logging.handlers import RotatingFileHandler
3 |
4 | from core.config import config_path
5 | from core.config import settings
6 |
7 | CRITICAL = 50
8 | FATAL = CRITICAL
9 | ERROR = 40
10 | WARNING = 30
11 | WARN = WARNING
12 | INFO = 20
13 | DEBUG = 10
14 |
15 |
16 | class MyLogger:
17 | _instance = None
18 |
19 | def __new__(cls):
20 | if cls._instance is None:
21 | cls._instance = super().__new__(cls)
22 | cls._instance._setup_logging()
23 | return cls._instance
24 |
25 | def _setup_logging(self):
26 | self.logger = logging.getLogger("ActualTap")
27 | self._log_level = getattr(logging, settings.log_level.upper())
28 | self.logger.setLevel(self._log_level)
29 |
30 | # File handler
31 | log_file_path = config_path.parent.joinpath("ActualTap.log")
32 | file_handler = RotatingFileHandler(log_file_path, maxBytes=1048576, backupCount=5) # 1MB per file, with 5 backups
33 | file_formatter = logging.Formatter("%(asctime)s - %(name)s - %(levelname)s - %(message)s")
34 | file_handler.setFormatter(file_formatter)
35 | file_handler.setLevel(self._log_level)
36 | self.logger.addHandler(file_handler)
37 |
38 | # Console handler
39 | console_handler = logging.StreamHandler()
40 | console_formatter = logging.Formatter("%(asctime)s - %(name)s - %(levelname)s - %(message)s")
41 | console_handler.setFormatter(console_formatter)
42 | console_handler.setLevel(self._log_level)
43 | self.logger.addHandler(console_handler)
44 |
45 | def info(self, msg):
46 | self.logger.info(msg)
47 |
48 | def debug(self, msg):
49 | self.logger.debug(msg)
50 |
51 | def warning(self, msg):
52 | self.logger.warning(msg)
53 |
54 | def error(self, msg):
55 | self.logger.error(msg)
56 |
57 | def critical(self, msg):
58 | self.logger.critical(msg)
59 |
--------------------------------------------------------------------------------
/core/security.py:
--------------------------------------------------------------------------------
1 | from fastapi import HTTPException
2 | from fastapi import Security
3 | from fastapi.security.api_key import APIKeyHeader
4 |
5 | from core.config import settings
6 |
7 | API_KEY_NAME = "X-API-KEY"
8 | api_key_header = APIKeyHeader(name=API_KEY_NAME, auto_error=False)
9 |
10 |
11 | async def get_api_key(api_key_header: str = Security(api_key_header)):
12 | if api_key_header == settings.api_key:
13 | return api_key_header
14 | else:
15 | raise HTTPException(
16 | status_code=403,
17 | detail="Could not validate credentials",
18 | )
19 |
--------------------------------------------------------------------------------
/core/util.py:
--------------------------------------------------------------------------------
1 | from datetime import date
2 | from datetime import datetime
3 | from typing import Union
4 |
5 |
6 | def convert_to_date(date_input: Union[str, datetime]) -> date:
7 | if isinstance(date_input, datetime):
8 | return date_input.date()
9 |
10 | # Try different date formats
11 | date_formats = [
12 | "%Y-%m-%d", # 2024-11-25 (ISO format)
13 | "%b %d, %Y", # Nov 25, 2024
14 | "%b %d %Y", # Nov 25 2024
15 | "%d %b %Y", # 25 Nov 2024
16 | ]
17 |
18 | for date_format in date_formats:
19 | try:
20 | datetime_obj = datetime.strptime(date_input, date_format)
21 | return datetime_obj.date()
22 | except ValueError:
23 | continue
24 |
25 | # If none of the formats worked, raise an error with examples
26 | raise ValueError(
27 | "Invalid date format. Accepted formats:\n" "- YYYY-MM-DD (e.g. 2024-11-25)\n" "- MMM DD, YYYY (e.g. Nov 25, 2024)\n"
28 | )
29 |
--------------------------------------------------------------------------------
/docker-compose.yml:
--------------------------------------------------------------------------------
1 | services:
2 | actualtap-py:
3 | container_name: actualtap-py
4 | image: ghcr.io/bobokun/actualtap-py:latest
5 | ports:
6 | - 8000:8000
7 | volumes:
8 | - /your/path/here:/config
9 |
--------------------------------------------------------------------------------
/images/flow.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/bobokun/actualtap-py/d45e205fe6154a73c42c928adaa65a4a508bb070/images/flow.png
--------------------------------------------------------------------------------
/images/logo.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/bobokun/actualtap-py/d45e205fe6154a73c42c928adaa65a4a508bb070/images/logo.webp
--------------------------------------------------------------------------------
/main.py:
--------------------------------------------------------------------------------
1 | import json
2 |
3 | from fastapi import Depends
4 | from fastapi import FastAPI
5 | from fastapi import HTTPException
6 | from fastapi import Request
7 | from fastapi.exception_handlers import (
8 | http_exception_handler as default_http_exception_handler,
9 | )
10 | from fastapi.exceptions import RequestValidationError
11 | from fastapi.openapi.docs import get_redoc_html
12 | from fastapi.openapi.docs import get_swagger_ui_html
13 | from fastapi.openapi.utils import get_openapi
14 | from fastapi.responses import JSONResponse
15 |
16 | from api import transactions
17 | from core.config import redact_sensitive_settings
18 | from core.logs import MyLogger
19 | from core.security import get_api_key
20 |
21 | app = FastAPI(docs_url=None, redoc_url=None, openapi_url=None)
22 |
23 | app.include_router(transactions.router, prefix="", tags=["transactions"], dependencies=[Depends(get_api_key)])
24 | logger = MyLogger()
25 |
26 |
27 | @app.get("/", dependencies=[Depends(get_api_key)])
28 | def read_root():
29 | return {"message": "Welcome to the ActualTap API"}
30 |
31 |
32 | @app.get("/settings", dependencies=[Depends(get_api_key)])
33 | def get_settings():
34 | keys_to_remove = ["api_key", "actual_password"] # List the keys to remove
35 | filtered_settings = redact_sensitive_settings(keys_to_remove)
36 | return filtered_settings
37 |
38 |
39 | # Override the /docs endpoint
40 | @app.get("/docs", include_in_schema=False, dependencies=[Depends(get_api_key)])
41 | async def get_documentation():
42 | return get_swagger_ui_html(openapi_url="/openapi.json", title="docs")
43 |
44 |
45 | # Override the /redoc endpoint
46 | @app.get("/redoc", include_in_schema=False, dependencies=[Depends(get_api_key)])
47 | async def get_redoc():
48 | return get_redoc_html(openapi_url="/openapi.json", title="redoc")
49 |
50 |
51 | # Override the /openapi.json endpoint to secure access to the OpenAPI schema itself
52 | @app.get("/openapi.json", include_in_schema=False, dependencies=[Depends(get_api_key)])
53 | async def openapi():
54 | return get_openapi(title="FastAPI", version="1.0.0", routes=app.routes)
55 |
56 |
57 | @app.exception_handler(RequestValidationError)
58 | async def validation_exception_handler(request: Request, exc: RequestValidationError):
59 | errors = []
60 | for error in exc.errors():
61 | error_msg = error.get("msg", "")
62 | if isinstance(error_msg, str) and "Invalid date format" in error_msg:
63 | errors.append(
64 | {
65 | "loc": error.get("loc", []),
66 | "msg": "Invalid date format. Accepted formats:\n"
67 | "- YYYY-MM-DD (e.g. 2024-11-25)\n"
68 | "- MMM DD, YYYY (e.g. Nov 25, 2024)\n"
69 | "- MMM DD YYYY (e.g. Nov 25 2024)",
70 | "type": "value_error",
71 | }
72 | )
73 | else:
74 | errors.append(error)
75 |
76 | logger.error(f"Validation error for request {request.method} {request.url}:\n{json.dumps(errors, indent=2)}")
77 | try:
78 | body = await request.json()
79 | logger.error(f"Request body: {json.dumps(body, indent=2)}")
80 | except Exception as e:
81 | logger.error(f"Error reading request body: {str(e)}")
82 | body = None
83 |
84 | return JSONResponse(
85 | status_code=422,
86 | content={"detail": errors, "body": body},
87 | )
88 |
89 |
90 | @app.exception_handler(HTTPException)
91 | async def http_exception_handler(request: Request, exc: HTTPException):
92 | # Log the details of the bad request
93 | if exc.status_code == 400 or exc.status_code == 500:
94 | logger.error(f"Validation error for request {request.method} {request.url}:\n{json.dumps(exc.detail, indent=2)}")
95 | try:
96 | body = await request.json()
97 | logger.error(f"Request body: {json.dumps(body, indent=2)}")
98 | except Exception as e:
99 | logger.error(f"Error reading request body: {str(e)}")
100 | body = None
101 |
102 | # Return the default HTTP exception response
103 | return await default_http_exception_handler(request, exc)
104 |
--------------------------------------------------------------------------------
/requirements-dev.txt:
--------------------------------------------------------------------------------
1 | flake8==7.2.0
2 | pre-commit==4.2.0
3 |
--------------------------------------------------------------------------------
/requirements.txt:
--------------------------------------------------------------------------------
1 | actualpy==0.12.1
2 | fastapi[standard]==0.115.12
3 | pydantic
4 | pydantic-settings
5 | pyyaml
6 | uvicorn==0.34.3
7 |
--------------------------------------------------------------------------------
/schemas/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/bobokun/actualtap-py/d45e205fe6154a73c42c928adaa65a4a508bb070/schemas/__init__.py
--------------------------------------------------------------------------------
/schemas/transactions.py:
--------------------------------------------------------------------------------
1 | from datetime import date
2 | from datetime import datetime
3 | from decimal import Decimal
4 | from typing import Optional
5 |
6 | from pydantic import BaseModel
7 | from pydantic import Field
8 | from pydantic import field_validator
9 |
10 | from core.util import convert_to_date
11 |
12 |
13 | class Transaction(BaseModel):
14 | account: str = Field(..., description="Account name or ID is required")
15 | amount: Decimal = Field(default=Decimal(0), description="Transaction amount")
16 | date: datetime = Field(
17 | default_factory=datetime.now,
18 | description=("Transaction date in formats: YYYY-MM-DD, MMM DD, YYYY, or MMM DD YYYY"),
19 | )
20 | payee: Optional[str] = None
21 | notes: Optional[str] = None
22 | cleared: bool = False
23 |
24 | @field_validator("amount", mode="before")
25 | def validate_amount(cls, v):
26 | try:
27 | return Decimal(str(v)) if v else Decimal(0)
28 | except Exception:
29 | raise ValueError("Invalid amount format. Must be a valid decimal number.")
30 |
31 | @field_validator("date", mode="before")
32 | def parse_date(cls, value):
33 | try:
34 | parsed_date = convert_to_date(value)
35 | # If convert_to_date returns a date object, convert it to datetime
36 | if isinstance(parsed_date, date) and not isinstance(parsed_date, datetime):
37 | return datetime.combine(parsed_date, datetime.min.time())
38 | return parsed_date
39 | except ValueError as e:
40 | raise ValueError(str(e))
41 |
--------------------------------------------------------------------------------
/services/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/bobokun/actualtap-py/d45e205fe6154a73c42c928adaa65a4a508bb070/services/__init__.py
--------------------------------------------------------------------------------
/services/actual_service.py:
--------------------------------------------------------------------------------
1 | import json
2 | import uuid
3 | from decimal import Decimal
4 | from typing import List
5 |
6 | from actual import Actual
7 | from actual.queries import create_transaction
8 | from actual.queries import get_ruleset
9 |
10 | from core.config import settings
11 | from core.logs import MyLogger
12 | from core.util import convert_to_date
13 | from schemas.transactions import Transaction
14 |
15 | logger = MyLogger()
16 |
17 |
18 | class ActualService:
19 | def __init__(self):
20 | self.client = None
21 |
22 | def add_transactions(self, transactions: List[Transaction]):
23 | transaction_info_list = []
24 | submitted_transactions = []
25 |
26 | with Actual(
27 | settings.actual_url,
28 | password=settings.actual_password,
29 | file=settings.actual_budget,
30 | ) as actual:
31 | for tx in transactions:
32 | # Map account name to Actual account ID
33 | account_id = settings.account_mappings.get(tx.account, settings.actual_default_account_id)
34 | if not account_id:
35 | raise ValueError(f"Account name '{tx.account}' is not mapped to an Actual Account ID.")
36 |
37 | # Convert date and generate import ID
38 | date = convert_to_date(tx.date)
39 | import_id = f"ID-{uuid.uuid4()}"
40 |
41 | # Determine payee
42 | payee = tx.payee or settings.actual_backup_payee
43 |
44 | # Prepare transaction info for logging
45 | transaction_info = {
46 | "Account": tx.account,
47 | "Account_ID": account_id,
48 | "Amount": str(Decimal(tx.amount)),
49 | "Date": str(date),
50 | "Imported ID": import_id,
51 | "Payee": payee,
52 | "Notes": tx.notes,
53 | "Cleared": tx.cleared,
54 | }
55 | transaction_info_list.append(transaction_info)
56 |
57 | # Create transaction in Actual
58 | actual_transaction = create_transaction(
59 | s=actual.session,
60 | account=account_id,
61 | amount=Decimal(tx.amount),
62 | date=date,
63 | imported_id=import_id,
64 | payee=payee,
65 | notes=tx.notes,
66 | cleared=tx.cleared,
67 | imported_payee=payee,
68 | )
69 | submitted_transactions.append(actual_transaction)
70 |
71 | # Run ruleset on submitted transactions
72 | rs = get_ruleset(actual.session)
73 | rs.run(submitted_transactions)
74 |
75 | # Log transaction info
76 | logger.info("\n" + json.dumps(transaction_info_list, indent=2))
77 |
78 | # Commit changes
79 | actual.commit()
80 |
81 | return transaction_info_list
82 |
83 |
84 | # Initialize the service
85 | actual_service = ActualService()
86 |
--------------------------------------------------------------------------------
/setup.py:
--------------------------------------------------------------------------------
1 | import os
2 |
3 | from setuptools import find_packages
4 | from setuptools import setup
5 |
6 | from core import __version__
7 |
8 | # User-friendly description from README.md
9 | current_directory = os.path.dirname(os.path.abspath(__file__))
10 | try:
11 | with open(os.path.join(current_directory, "README.md"), encoding="utf-8") as f:
12 | long_description = f.read()
13 | except Exception:
14 | long_description = ""
15 |
16 | setup(
17 | # Name of the package
18 | name="actualtap-py",
19 | # Packages to include into the distribution
20 | packages=find_packages("."),
21 | # Start with a small number and increase it with
22 | # every change you make https://semver.org
23 | version=__version__,
24 | # Chose a license from here: https: //
25 | # help.github.com / articles / licensing - a -
26 | # repository. For example: MIT
27 | license="GNU v3.0",
28 | # Short description of your library
29 | description="Automatically create transactions in Actual Budget when you use Tap-to-Pay on a mobile device",
30 | # Long description of your library
31 | long_description=long_description,
32 | long_description_content_type="text/markdown",
33 | # Your name
34 | author="bobokun",
35 | # Your email
36 | author_email="",
37 | # Either the link to your github or to your website
38 | url="https://github.com/bobokun",
39 | # Link from which the project can be downloaded
40 | download_url="https://github.com/bobokun/actualtap-py",
41 | )
42 |
--------------------------------------------------------------------------------
/tox.ini:
--------------------------------------------------------------------------------
1 | [tox]
2 | envlist = py39,py310,py311,py312,pre-commit
3 | skip_missing_interpreters = true
4 | tox_pip_extensions_ext_pip_custom_platform = true
5 | tox_pip_extensions_ext_venv_update = true
6 |
7 | [testenv]
8 | deps =
9 | -r{toxinidir}/requirements.txt
10 | -r{toxinidir}/requirements-dev.txt
11 | passenv = HOME,SSH_AUTH_SOCK,USER
12 |
13 | [testenv:venv]
14 | envdir = venv
15 | commands =
16 |
17 | [testenv:install-hooks]
18 | deps = pre-commit
19 | commands = pre-commit install -f --install-hooks
20 |
21 | [testenv:pre-commit]
22 | deps = pre-commit
23 | commands = pre-commit run --all-files
24 |
25 | [testenv:tests]
26 | commands =
27 | pre-commit install -f --install-hooks
28 | pre-commit run --all-files
29 |
30 | [flake8]
31 | max-line-length = 130
32 |
33 | [pep8]
34 | extend-ignore = E722,E402
35 |
36 | [tool.isort]
37 | add_imports = ["from __future__ import annotations"]
38 | force_single_line = true
39 | profile = "black"
40 |
--------------------------------------------------------------------------------