feat(bank-sync): sync bank transactions from external providers

Provider registry modeled after automatic exchange rates: each provider
declares its own settings fields, rendered dynamically in the service form.
Lunch Flow is the first provider.

- Map provider accounts to WYGIWYH accounts with a start date and an
  optional currency filter
- Hourly task syncs due services; manual sync from the list
- Deduplicates through Transaction.internal_id, optionally matching
  transactions already entered by hand
- Per account errors and provider reported connection problems

Refs #498
This commit is contained in:
Herculino Trotta committed 2026-11-08 12:39:16 -03:00
1 parent a42265ba49
commit 620e7df015
22 files changed
+1526

No files matched your search

+1
View File
@@ -63,6 +63,7 @@ INSTALLED_APPS = [
"procrastinate.contrib.django", "procrastinate.contrib.django",
"apps.transactions.apps.TransactionsConfig", "apps.transactions.apps.TransactionsConfig",
"apps.currencies.apps.CurrenciesConfig", "apps.currencies.apps.CurrenciesConfig",
"apps.bank_sync.apps.BankSyncConfig",
"apps.accounts.apps.AccountsConfig", "apps.accounts.apps.AccountsConfig",
"apps.net_worth.apps.NetWorthConfig", "apps.net_worth.apps.NetWorthConfig",
"apps.import_app.apps.ImportConfig", "apps.import_app.apps.ImportConfig",
+1
View File
@@ -90,6 +90,7 @@ urlpatterns = [
path("", include("apps.monthly_overview.urls")), path("", include("apps.monthly_overview.urls")),
path("", include("apps.yearly_overview.urls")), path("", include("apps.yearly_overview.urls")),
path("", include("apps.currencies.urls")), path("", include("apps.currencies.urls")),
path("", include("apps.bank_sync.urls")),
path("", include("apps.rules.urls")), path("", include("apps.rules.urls")),
path("", include("apps.calendar_view.urls")), path("", include("apps.calendar_view.urls")),
path("", include("apps.dca.urls")), path("", include("apps.dca.urls")),
View File
Whitespace-only changes.
+6
View File
@@ -0,0 +1,6 @@
from django.contrib import admin
from apps.bank_sync.models import BankSyncAccount, BankSyncService
admin.site.register(BankSyncService)
admin.site.register(BankSyncAccount)
+6
View File
@@ -0,0 +1,6 @@
from django.apps import AppConfig
class BankSyncConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "apps.bank_sync"
+126
View File
@@ -0,0 +1,126 @@
import copy
from crispy_forms.bootstrap import FormActions
from crispy_forms.helper import FormHelper
from crispy_forms.layout import Layout
from django import forms
from django.db.models import Q
from django.utils.translation import gettext_lazy as _
from apps.accounts.models import Account
from apps.bank_sync.models import BankSyncAccount, BankSyncService
from apps.bank_sync.providers import PROVIDER_MAPPING
from apps.common.widgets.crispy.daisyui import Switch
from apps.common.widgets.crispy.submit import NoClassSubmit
from apps.common.widgets.datepicker import AirDatePickerInput
SETTING_PREFIX = "setting_"
class BankSyncServiceForm(forms.ModelForm):
class Meta:
model = BankSyncService
fields = [
"name",
"service_type",
"is_active",
"sync_interval",
"trigger_rules",
"match_existing",
]
def __init__(self, *args, refresh_url=None, **kwargs):
super().__init__(*args, **kwargs)
if self.instance.pk:
# Synced ids and links belong to the provider, it can't change
self.fields["service_type"].disabled = True
service_type = self.instance.service_type
else:
# The provider's own settings depend on the selected service type
service_type = self.data.get("service_type") or self.initial.get(
"service_type"
)
provider = PROVIDER_MAPPING.get(service_type)
self.setting_names = []
for key, field in (provider.settings_fields if provider else {}).items():
name = SETTING_PREFIX + key
self.fields[name] = copy.deepcopy(field)
if key in self.instance.settings:
self.initial.setdefault(name, self.instance.settings[key])
self.setting_names.append(name)
# Re-renders the form with the new provider's fields
self.fields["service_type"].widget.attrs.update(
{
"hx-get": refresh_url,
"hx-include": "closest form",
"hx-target": "#generic-offcanvas",
"hx-trigger": "change",
}
)
self.helper = FormHelper()
self.helper.form_tag = False
self.helper.layout = Layout(
"name",
"service_type",
*self.setting_names,
Switch("is_active"),
Switch("trigger_rules"),
Switch("match_existing"),
"sync_interval",
FormActions(
NoClassSubmit(
"submit",
_("Update") if self.instance.pk else _("Add"),
css_class="btn btn-primary",
),
),
)
def save(self, commit=True):
self.instance.settings = {
name.removeprefix(SETTING_PREFIX): self.cleaned_data[name]
for name in self.setting_names
}
return super().save(commit)
class BankSyncAccountForm(forms.ModelForm):
currencies = forms.MultipleChoiceField(
required=False,
widget=forms.CheckboxSelectMultiple,
label=_("Currencies"),
help_text=_("Only sync transactions in these currencies. Empty syncs all."),
)
class Meta:
model = BankSyncAccount
fields = ["account", "start_date", "currencies"]
widgets = {"start_date": AirDatePickerInput(clear_button=False)}
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# Keeps an account archived after being linked, or the whole formset fails
self.fields["account"].queryset = Account.objects.filter(
Q(is_archived=False) | Q(pk=self.instance.account_id)
).select_related("currency")
self.fields["account"].empty_label = _("Don't sync")
self.fields["currencies"].choices = [
(code, code)
for code in sorted(
set(self.instance.available_currencies) | set(self.instance.currencies)
)
]
def clean(self):
cleaned_data = super().clean()
if cleaned_data.get("account") and not cleaned_data.get("start_date"):
self.add_error("start_date", _("Required when an account is set."))
return cleaned_data
BankSyncAccountFormSet = forms.modelformset_factory(
BankSyncAccount, form=BankSyncAccountForm, extra=0
)
@@ -0,0 +1,61 @@
# Generated by Django 5.2.17 on 2026-11-08 15:27
import django.core.validators
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
('accounts', '0016_account_untracked_by'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='BankSyncService',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=255, verbose_name='Name')),
('service_type', models.CharField(choices=[('lunchflow', 'Lunch Flow')], max_length=255, verbose_name='Service Type')),
('is_active', models.BooleanField(default=True, verbose_name='Active')),
('settings', models.JSONField(blank=True, default=dict, verbose_name='Settings')),
('sync_interval', models.PositiveIntegerField(default=24, help_text='Most banks only update once a day.', validators=[django.core.validators.MinValueValidator(1), django.core.validators.MaxValueValidator(168)], verbose_name='Sync every X hours')),
('trigger_rules', models.BooleanField(default=True, help_text='Run your transaction rules on synced transactions.', verbose_name='Trigger transaction rules')),
('match_existing', models.BooleanField(default=False, help_text='Instead of creating a synced transaction, link it to one you already added on the same account, with the same type and amount, dated up to 3 days apart.', verbose_name='Match existing transactions')),
('last_sync', models.DateTimeField(blank=True, null=True, verbose_name='Last Successful Sync')),
('failure_count', models.PositiveIntegerField(default=0)),
('last_error', models.TextField(blank=True, default='')),
('owner', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='%(class)s_owned', to=settings.AUTH_USER_MODEL)),
],
options={
'verbose_name': 'Bank Sync Service',
'verbose_name_plural': 'Bank Sync Services',
'ordering': ['name'],
},
),
migrations.CreateModel(
name='BankSyncAccount',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('external_id', models.CharField(max_length=255)),
('external_name', models.CharField(blank=True, max_length=255)),
('available_currencies', models.JSONField(blank=True, default=list)),
('currencies', models.JSONField(blank=True, default=list, help_text='Only sync transactions in these currencies. Empty syncs all.', verbose_name='Currencies')),
('remote_error', models.TextField(blank=True, default='')),
('last_error', models.TextField(blank=True, default='')),
('start_date', models.DateField(blank=True, help_text='Transactions before this date are never imported.', null=True, verbose_name='Sync from')),
('last_synced_date', models.DateField(blank=True, null=True)),
('account', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='bank_sync_accounts', to='accounts.account', verbose_name='Account')),
('service', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='accounts', to='bank_sync.banksyncservice')),
],
options={
'ordering': ['external_name'],
'unique_together': {('service', 'external_id')},
},
),
]
Whitespace-only changes.
+102
View File
@@ -0,0 +1,102 @@
from django.core.validators import MaxValueValidator, MinValueValidator
from django.db import models
from django.utils.translation import gettext_lazy as _
from apps.common.models import OwnedObject, OwnedObjectManager
class BankSyncService(OwnedObject):
class ServiceType(models.TextChoices):
LUNCHFLOW = "lunchflow", "Lunch Flow"
name = models.CharField(max_length=255, verbose_name=_("Name"))
service_type = models.CharField(
max_length=255, choices=ServiceType.choices, verbose_name=_("Service Type")
)
is_active = models.BooleanField(default=True, verbose_name=_("Active"))
# Provider specific, keyed by the provider's settings_fields
settings = models.JSONField(default=dict, blank=True, verbose_name=_("Settings"))
sync_interval = models.PositiveIntegerField(
default=24,
validators=[MinValueValidator(1), MaxValueValidator(168)],
verbose_name=_("Sync every X hours"),
help_text=_("Most banks only update once a day."),
)
trigger_rules = models.BooleanField(
default=True,
verbose_name=_("Trigger transaction rules"),
help_text=_("Run your transaction rules on synced transactions."),
)
match_existing = models.BooleanField(
default=False,
verbose_name=_("Match existing transactions"),
help_text=_(
"Instead of creating a synced transaction, link it to one you already "
"added on the same account, with the same type and amount, dated up to "
"3 days apart."
),
)
last_sync = models.DateTimeField(
null=True, blank=True, verbose_name=_("Last Successful Sync")
)
failure_count = models.PositiveIntegerField(default=0)
last_error = models.TextField(blank=True, default="")
objects = OwnedObjectManager()
class Meta:
verbose_name = _("Bank Sync Service")
verbose_name_plural = _("Bank Sync Services")
ordering = ["name"]
def __str__(self):
return self.name
def get_provider(self):
from apps.bank_sync.providers import PROVIDER_MAPPING
return PROVIDER_MAPPING[self.service_type](self.settings)
class BankSyncAccount(models.Model):
"""An account on the provider, optionally linked to a WYGIWYH account."""
service = models.ForeignKey(
BankSyncService, on_delete=models.CASCADE, related_name="accounts"
)
external_id = models.CharField(max_length=255)
external_name = models.CharField(max_length=255, blank=True)
# Currencies seen on the provider, offered as choices for `currencies`
available_currencies = models.JSONField(default=list, blank=True)
currencies = models.JSONField(
default=list,
blank=True,
verbose_name=_("Currencies"),
help_text=_("Only sync transactions in these currencies. Empty syncs all."),
)
# Provider side problem, like an expired connection
remote_error = models.TextField(blank=True, default="")
last_error = models.TextField(blank=True, default="")
account = models.ForeignKey(
"accounts.Account",
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name="bank_sync_accounts",
verbose_name=_("Account"),
)
start_date = models.DateField(
null=True,
blank=True,
verbose_name=_("Sync from"),
help_text=_("Transactions before this date are never imported."),
)
last_synced_date = models.DateField(null=True, blank=True)
class Meta:
unique_together = ("service", "external_id")
ordering = ["external_name"]
def __str__(self):
return self.external_name or self.external_id
+6
View File
@@ -0,0 +1,6 @@
from apps.bank_sync.providers.lunchflow import LunchFlowProvider
# Keys must match BankSyncService.ServiceType
PROVIDER_MAPPING = {
"lunchflow": LunchFlowProvider,
}
+49
View File
@@ -0,0 +1,49 @@
from abc import ABC, abstractmethod
from dataclasses import dataclass
from datetime import date
from decimal import Decimal
@dataclass(frozen=True)
class RemoteAccount:
id: str
name: str
# Currency codes as the provider spells them
currencies: tuple = ()
# Why the account can't be synced right now (expired connection...), empty if fine
error: str = ""
@dataclass(frozen=True)
class RemoteTransaction:
id: str
date: date
# Signed: negative is money leaving the account
amount: Decimal
description: str = ""
notes: str = ""
currency: str = ""
class BankSyncProvider(ABC):
"""A bank data aggregator.
``settings_fields`` declares the provider's own configuration as Django form
fields. The service form renders them when the provider is selected and the
values end up in ``BankSyncService.settings`` under the same keys.
"""
settings_fields: dict = {}
def __init__(self, settings: dict):
self.settings = settings or {}
@abstractmethod
def list_accounts(self) -> list[RemoteAccount]:
"""Every account the credentials can see."""
@abstractmethod
def get_transactions(
self, account_id: str, start_date: date
) -> list[RemoteTransaction]:
"""Posted transactions of ``account_id`` dated ``start_date`` or later."""
+101
View File
@@ -0,0 +1,101 @@
import os
from datetime import date
from decimal import Decimal
import requests
from django import forms
from django.utils.translation import gettext as _
from django.utils.translation import gettext_lazy
from apps.bank_sync.providers.base import (
BankSyncProvider,
RemoteAccount,
RemoteTransaction,
)
DEFAULT_BASE_URL = "https://www.lunchflow.app/api/v1"
class LunchFlowProvider(BankSyncProvider):
"""https://www.lunchflow.app/docs/api/personal-api-overview"""
settings_fields = {
"api_key": forms.CharField(
label=gettext_lazy("API Key"),
# Not a PasswordInput: browsers autofill those with saved logins
widget=forms.TextInput(attrs={"autocomplete": "off"}),
help_text=gettext_lazy(
"Create a REST API destination on Lunch Flow and give it access to "
"the accounts you want to sync."
),
),
}
def __init__(self, settings: dict):
super().__init__(settings)
# Server side only, users must not point the server at arbitrary hosts
self.base_url = os.getenv("LUNCHFLOW_API_URL", DEFAULT_BASE_URL).rstrip("/")
self.session = requests.Session()
self.session.headers["x-api-key"] = self.settings.get("api_key", "")
def _get(self, path: str, **params) -> dict:
response = self.session.get(f"{self.base_url}{path}", params=params, timeout=30)
response.raise_for_status()
return response.json()
def _balance_currency(self, account_id) -> str:
# The account list has no currency, the balance does
try:
return self._get(f"/accounts/{account_id}/balance")["balance"]["currency"]
except (requests.RequestException, ValueError, KeyError, TypeError):
return ""
def list_accounts(self) -> list[RemoteAccount]:
accounts = []
for account in self._get("/accounts")["accounts"]:
currency = account.get("currency") or self._balance_currency(account["id"])
status = account.get("status") or "ACTIVE"
accounts.append(
RemoteAccount(
id=str(account["id"]),
name=" - ".join(
filter(None, [account.get("institution_name"), account["name"]])
),
currencies=(currency.upper(),) if currency else (),
error=(
""
if status == "ACTIVE"
else _(
"Lunch Flow reports this account as %(status)s, "
"check the connection on Lunch Flow."
)
% {"status": status}
),
)
)
return accounts
def get_transactions(
self, account_id: str, start_date: date
) -> list[RemoteTransaction]:
data = self._get(
f"/accounts/{account_id}/transactions", **{"from": start_date.isoformat()}
)
transactions = []
for tx in data["transactions"]:
# Pending ones are excluded by default, but they change ids when posted
if tx.get("isPending"):
continue
merchant = (tx.get("merchant") or "").strip()
description = (tx.get("description") or "").strip()
transactions.append(
RemoteTransaction(
id=str(tx["id"]),
date=date.fromisoformat(tx["date"][:10]),
amount=Decimal(str(tx["amount"])),
description=merchant or description,
notes=description if merchant and description != merchant else "",
currency=(tx.get("currency") or "").upper(),
)
)
return transactions
+172
View File
@@ -0,0 +1,172 @@
import logging
from datetime import timedelta
from django.utils import timezone
from apps.bank_sync.models import BankSyncAccount, BankSyncService
from apps.common.functions.decimals import truncate_decimal
from apps.common.middleware.thread_local import delete_current_user, write_current_user
from apps.rules.signals import transaction_created
from apps.transactions.models import Transaction
logger = logging.getLogger(__name__)
# Banks post late and back-date transactions, so every sync looks this far back
OVERLAP_DAYS = 7
# How far apart a synced transaction and the one it matches can be dated
MATCH_DAYS = 3
def refresh_remote_accounts(service: BankSyncService, provider=None) -> None:
"""Mirror the provider's account list, keeping existing links.
Accounts the provider no longer returns are dropped: their ids are gone for good.
"""
provider = provider or service.get_provider()
remote_accounts = {remote.id: remote for remote in provider.list_accounts()}
service.accounts.exclude(external_id__in=remote_accounts).delete()
for remote in remote_accounts.values():
link, _ = BankSyncAccount.objects.get_or_create(
service=service, external_id=remote.id
)
link.external_name = remote.name
link.remote_error = remote.error
link.available_currencies = sorted(
set(link.available_currencies) | set(remote.currencies)
)
link.save()
def _match_existing(link: BankSyncAccount, internal_id, type_, amount, date) -> bool:
"""Tag a transaction the user already added instead of creating a new one."""
candidates = Transaction.userless_all_objects.filter(
account=link.account,
type=type_,
amount=truncate_decimal(amount, link.account.currency.decimal_places),
date__range=(date - timedelta(days=MATCH_DAYS), date + timedelta(days=MATCH_DAYS)),
internal_id__isnull=True,
deleted=False,
)
best = min(candidates, key=lambda t: abs((t.date - date).days), default=None)
if best is None:
return False
# Not a user edit, so no transaction_updated (it would run "on update" rules)
Transaction.userless_all_objects.filter(pk=best.pk).update(
emit_signal=False, internal_id=internal_id
)
return True
def _sync_account(service: BankSyncService, link: BankSyncAccount, provider) -> int:
start = link.start_date
if link.last_synced_date:
start = max(start, link.last_synced_date - timedelta(days=OVERLAP_DAYS))
created = 0
seen_currencies = set()
for remote in provider.get_transactions(link.external_id, start):
if remote.date < link.start_date:
continue
if remote.currency:
seen_currencies.add(remote.currency)
if link.currencies and remote.currency not in link.currencies:
continue
internal_id = f"{service.service_type}:{link.external_id}:{remote.id}"
# userless_all_objects: also sees soft deleted ones, so deleting a synced
# transaction doesn't bring it back on the next run
if Transaction.userless_all_objects.filter(internal_id=internal_id).exists():
continue
type_ = (
Transaction.Type.INCOME if remote.amount > 0 else Transaction.Type.EXPENSE
)
amount = abs(remote.amount)
if service.match_existing and _match_existing(
link, internal_id, type_, amount, remote.date
):
continue
transaction = Transaction.objects.create(
account=link.account,
owner=service.owner,
type=type_,
amount=amount,
date=remote.date,
description=remote.description[:500],
notes=remote.notes,
is_paid=True,
internal_id=internal_id,
)
created += 1
if service.trigger_rules:
transaction_created.send(sender=transaction)
link.last_synced_date = timezone.localdate()
link.available_currencies = sorted(set(link.available_currencies) | seen_currencies)
link.save(update_fields=["last_synced_date", "available_currencies"])
return created
def sync_service(service: BankSyncService) -> int:
"""Import new transactions for every linked account. Returns how many.
A failing account doesn't stop the others. Any failure keeps last_sync as is,
so the service is retried on the next run.
"""
created = 0
errors = []
# Rules and the owned-object managers read the user from the thread
write_current_user(service.owner)
try:
provider = service.get_provider()
refresh_remote_accounts(service, provider)
links = service.accounts.filter(
account__isnull=False, start_date__isnull=False
).select_related("account__currency")
for link in links:
error = link.remote_error
if not error:
try:
created += _sync_account(service, link, provider)
except Exception as e:
logger.exception(f"Bank sync failed for {service.name} / {link}")
error = str(e)
link.last_error = error[:1000]
link.save(update_fields=["last_error"])
if error:
errors.append(f"{link}: {error}")
except Exception as e:
logger.exception(f"Bank sync failed for {service.name}")
errors.append(str(e))
finally:
delete_current_user()
if errors:
service.failure_count += 1
service.last_error = "\n".join(errors)[:1000]
service.save(update_fields=["failure_count", "last_error"])
else:
service.last_sync = timezone.now()
service.failure_count = 0
service.last_error = ""
service.save(update_fields=["last_sync", "failure_count", "last_error"])
logger.info(f"Bank sync for {service.name} created {created} transactions")
return created
def is_due(service: BankSyncService) -> bool:
if service.last_sync is None:
return True
# Hour granularity, the scheduler runs on the hour
elapsed = timezone.now().replace(minute=0, second=0, microsecond=0) - (
service.last_sync.replace(minute=0, second=0, microsecond=0)
)
return elapsed >= timedelta(hours=service.sync_interval)
def sync_due_services(force: bool = False) -> None:
for service in BankSyncService.objects.filter(is_active=True):
if force or is_due(service):
sync_service(service)
+17
View File
@@ -0,0 +1,17 @@
from procrastinate.contrib.django import app
from apps.bank_sync.models import BankSyncService
from apps.bank_sync.sync import sync_due_services, sync_service
@app.periodic(cron="0 * * * *")
@app.task(lock="bank_sync", name="automatic_bank_sync")
def automatic_bank_sync(timestamp=None):
sync_due_services()
@app.task(lock="bank_sync", name="manual_bank_sync")
def manual_bank_sync(service_id: int):
service = BankSyncService.objects.filter(id=service_id).first()
if service:
sync_service(service)
Whitespace-only changes.
+583
View File
@@ -0,0 +1,583 @@
import io
import os
import zipfile
from datetime import date, timedelta
from decimal import Decimal
from unittest.mock import MagicMock, patch
from django.contrib.auth import get_user_model
from django.core.files.uploadedfile import SimpleUploadedFile
from django.test import TestCase
from django.urls import reverse
from django.utils import timezone
from apps.accounts.models import Account
from apps.bank_sync.forms import BankSyncAccountForm, BankSyncServiceForm
from apps.bank_sync.models import BankSyncAccount, BankSyncService
from apps.bank_sync.providers.base import RemoteAccount, RemoteTransaction
from apps.bank_sync.providers.lunchflow import LunchFlowProvider
from apps.bank_sync.sync import is_due, refresh_remote_accounts, sync_service
from apps.common.middleware.thread_local import delete_current_user, write_current_user
from apps.currencies.models import Currency
from apps.transactions.models import Transaction
def _response(payload):
response = MagicMock()
response.json.return_value = payload
return response
class LunchFlowProviderTests(TestCase):
def setUp(self):
# Independent of whatever the test machine has set
self.enterContext(patch.dict("os.environ"))
os.environ.pop("LUNCHFLOW_API_URL", None)
self.provider = LunchFlowProvider({"api_key": "key"})
def test_sends_api_key_and_uses_default_url(self):
self.assertEqual(self.provider.session.headers["x-api-key"], "key")
self.assertEqual(self.provider.base_url, "https://www.lunchflow.app/api/v1")
def test_url_comes_from_the_environment_not_settings(self):
with patch.dict("os.environ", {"LUNCHFLOW_API_URL": "https://x.test/v1/"}):
provider = LunchFlowProvider({"api_key": "k", "base_url": "http://db:5432"})
self.assertEqual(provider.base_url, "https://x.test/v1")
def test_list_accounts(self):
def get(url, **kwargs):
if url.endswith("/accounts"):
return _response(
{
"accounts": [
{"id": 7, "name": "Checking", "institution_name": "Bank", "status": "ACTIVE"},
{"id": 8, "name": "Card", "institution_name": None, "status": "EXPIRED"},
]
}
)
if url.endswith("/7/balance"):
return _response({"balance": {"amount": 1, "currency": "eur"}})
raise __import__("requests").HTTPError("expired")
with patch.object(self.provider.session, "get", side_effect=get):
accounts = self.provider.list_accounts()
self.assertEqual(accounts[0], RemoteAccount("7", "Bank - Checking", ("EUR",), ""))
self.assertEqual(accounts[1].name, "Card")
self.assertEqual(accounts[1].currencies, ())
self.assertIn("EXPIRED", accounts[1].error)
def test_get_transactions(self):
payload = {
"transactions": [
{"id": "a", "amount": -12.5, "currency": "EUR", "date": "2026-10-01", "merchant": "Coffee", "description": "CARD 123 COFFEE", "isPending": False},
{"id": "b", "amount": 1000, "currency": "usd", "date": "2026-10-02", "merchant": None, "description": "Salary"},
{"id": "c", "amount": -1, "date": "2026-10-03", "merchant": "Pending", "isPending": True},
{"id": "d", "amount": -3, "date": "2026-10-04", "merchant": "Same", "description": "Same"},
]
}
with patch.object(
self.provider.session, "get", return_value=_response(payload)
) as mock_get:
transactions = self.provider.get_transactions("7", date(2026, 10, 1))
mock_get.assert_called_once_with(
"https://www.lunchflow.app/api/v1/accounts/7/transactions",
params={"from": "2026-10-01"},
timeout=30,
)
self.assertEqual(
transactions,
[
RemoteTransaction("a", date(2026, 10, 1), Decimal("-12.5"), "Coffee", "CARD 123 COFFEE", "EUR"),
RemoteTransaction("b", date(2026, 10, 2), Decimal("1000"), "Salary", "", "USD"),
RemoteTransaction("d", date(2026, 10, 4), Decimal("-3"), "Same", "", ""),
],
)
class FakeProvider:
def __init__(self, transactions=(), accounts=()):
self.transactions = list(transactions)
self.accounts = list(accounts)
self.calls = []
def list_accounts(self):
return self.accounts
def get_transactions(self, account_id, start_date):
self.calls.append((account_id, start_date))
return [t for t in self.transactions if t.date >= start_date]
class SyncTests(TestCase):
def setUp(self):
self.user = get_user_model().objects.create_user(
email="sync@example.com", password="x"
)
self.eur = Currency.objects.create(code="EUR", name="Euro", decimal_places=2)
self.account = Account.objects.create(
name="Checking", currency=self.eur, owner=self.user
)
self.service = BankSyncService.objects.create(
name="LF", service_type="lunchflow", owner=self.user
)
self.link = BankSyncAccount.objects.create(
service=self.service,
external_id="7",
account=self.account,
start_date=date(2026, 10, 1),
)
self.provider = FakeProvider(
[
RemoteTransaction("old", date(2026, 9, 30), Decimal("-1"), "Before start"),
RemoteTransaction("a", date(2026, 10, 1), Decimal("-12.555"), "Coffee", "note", "EUR"),
RemoteTransaction("b", date(2026, 10, 2), Decimal("1000"), "Salary", "", "EUR"),
],
accounts=[RemoteAccount("7", "Bank - Checking", ("EUR",))],
)
def _sync(self):
with patch.object(BankSyncService, "get_provider", return_value=self.provider):
created = sync_service(self.service)
self.service.refresh_from_db()
self.link.refresh_from_db()
return created
def test_creates_transactions(self):
self.assertEqual(self._sync(), 2)
coffee = Transaction.userless_all_objects.get(internal_id="lunchflow:7:a")
self.assertEqual(coffee.type, Transaction.Type.EXPENSE)
self.assertEqual(coffee.amount, Decimal("12.55")) # truncated to currency
self.assertEqual(coffee.description, "Coffee")
self.assertEqual(coffee.notes, "note")
self.assertEqual(coffee.owner, self.user)
self.assertEqual(coffee.account, self.account)
self.assertTrue(coffee.is_paid)
salary = Transaction.userless_all_objects.get(internal_id="lunchflow:7:b")
self.assertEqual(salary.type, Transaction.Type.INCOME)
self.assertEqual(salary.amount, Decimal("1000"))
self.assertIsNotNone(self.service.last_sync)
self.assertEqual(self.link.last_synced_date, timezone.localdate())
self.assertEqual(self.link.external_name, "Bank - Checking")
def test_second_run_does_not_duplicate(self):
self._sync()
self.assertEqual(self._sync(), 0)
self.assertEqual(Transaction.userless_all_objects.count(), 2)
def test_deleted_transactions_are_not_reimported(self):
self._sync()
Transaction.userless_all_objects.filter(internal_id="lunchflow:7:a").update(
deleted=True
)
self.assertEqual(self._sync(), 0)
def test_later_runs_only_look_back_the_overlap(self):
self.link.last_synced_date = date(2026, 11, 1)
self.link.save()
self._sync()
self.assertEqual(self.provider.calls, [("7", date(2026, 10, 25))])
def test_unlinked_accounts_are_skipped(self):
self.link.account = None
self.link.save()
self.assertEqual(self._sync(), 0)
self.assertEqual(self.provider.calls, [])
def test_currency_filter(self):
self.provider.transactions.append(
RemoteTransaction("usd", date(2026, 10, 3), Decimal("-5"), "Abroad", "", "USD")
)
self.link.currencies = ["USD"]
self.link.save()
self.assertEqual(self._sync(), 1)
self.assertTrue(
Transaction.userless_all_objects.filter(internal_id="lunchflow:7:usd").exists()
)
# Every currency seen becomes a choice, even filtered out ones
self.assertEqual(self.link.available_currencies, ["EUR", "USD"])
def test_no_currency_filter_syncs_all(self):
self.provider.transactions.append(
RemoteTransaction("usd", date(2026, 10, 3), Decimal("-5"), "Abroad", "", "USD")
)
self.assertEqual(self._sync(), 3)
def test_match_existing_is_off_by_default(self):
Transaction.objects.create(
account=self.account, owner=self.user, type=Transaction.Type.INCOME,
amount=Decimal("1000"), date=date(2026, 10, 2), description="Mine",
)
self.assertEqual(self._sync(), 2)
@patch("apps.rules.signals.check_for_transaction_rules.defer")
def test_matching_does_not_trigger_rules(self, defer):
self.service.match_existing = True
self.service.save()
self.provider.transactions = self.provider.transactions[2:] # salary only
Transaction.objects.create(
account=self.account, owner=self.user, type=Transaction.Type.INCOME,
amount=Decimal("1000"), date=date(2026, 10, 2), description="Mine",
)
defer.reset_mock()
self.assertEqual(self._sync(), 0)
defer.assert_not_called()
def test_match_existing(self):
self.service.match_existing = True
self.service.save()
far = Transaction.objects.create(
account=self.account, owner=self.user, type=Transaction.Type.INCOME,
amount=Decimal("1000"), date=date(2026, 9, 28), description="Too far",
)
close = Transaction.objects.create(
account=self.account, owner=self.user, type=Transaction.Type.INCOME,
amount=Decimal("1000"), date=date(2026, 10, 3), description="Mine",
)
expense = Transaction.objects.create(
account=self.account, owner=self.user, type=Transaction.Type.EXPENSE,
amount=Decimal("12.55"), date=date(2026, 10, 1), description="Coffee by hand",
)
self.assertEqual(self._sync(), 0)
close.refresh_from_db()
expense.refresh_from_db()
far.refresh_from_db()
self.assertEqual(close.internal_id, "lunchflow:7:b")
self.assertEqual(close.description, "Mine") # left as the user wrote it
self.assertEqual(expense.internal_id, "lunchflow:7:a")
self.assertIsNone(far.internal_id)
# Matched once, never again
self.assertEqual(self._sync(), 0)
def test_match_existing_takes_one_per_synced_transaction(self):
self.service.match_existing = True
self.service.save()
self.provider.transactions = [
RemoteTransaction("x1", date(2026, 10, 5), Decimal("-3"), "Coffee", "", "EUR"),
RemoteTransaction("x2", date(2026, 10, 5), Decimal("-3"), "Coffee", "", "EUR"),
]
Transaction.objects.create(
account=self.account, owner=self.user, type=Transaction.Type.EXPENSE,
amount=Decimal("3"), date=date(2026, 10, 5), description="One coffee",
)
self.assertEqual(self._sync(), 1)
self.assertEqual(Transaction.userless_all_objects.count(), 2)
def test_failing_account_does_not_stop_the_others(self):
other = Account.objects.create(name="Other", currency=self.eur, owner=self.user)
other_link = BankSyncAccount.objects.create(
service=self.service, external_id="8", account=other, start_date=date(2026, 10, 1)
)
self.provider.accounts.append(RemoteAccount("8", "Broken"))
get_transactions = self.provider.get_transactions
def flaky(account_id, start_date):
if account_id == "8":
raise Exception("boom")
return get_transactions(account_id, start_date)
self.provider.get_transactions = flaky
self.assertEqual(self._sync(), 2)
other_link.refresh_from_db()
self.assertEqual(other_link.last_error, "boom")
self.assertEqual(self.link.last_error, "")
self.assertEqual(self.service.failure_count, 1)
self.assertIn("boom", self.service.last_error)
self.assertIsNone(self.service.last_sync) # retried next run
self.provider.get_transactions = get_transactions
self._sync()
other_link.refresh_from_db()
self.assertEqual(other_link.last_error, "")
self.assertEqual(self.service.failure_count, 0)
self.assertEqual(self.service.last_error, "")
self.assertIsNotNone(self.service.last_sync)
def test_provider_reported_problem_skips_the_account(self):
self.provider.accounts = [RemoteAccount("7", "Bank", (), "Connection expired")]
self.assertEqual(self._sync(), 0)
self.assertEqual(self.provider.calls, [])
self.assertEqual(self.link.remote_error, "Connection expired")
self.assertEqual(self.link.last_error, "Connection expired")
self.assertEqual(self.service.failure_count, 1)
def test_listing_failure_is_recorded(self):
self.provider.list_accounts = MagicMock(side_effect=Exception("401"))
self.assertEqual(self._sync(), 0)
self.assertEqual(self.service.failure_count, 1)
self.assertEqual(self.service.last_error, "401")
self.assertTrue(BankSyncAccount.objects.filter(pk=self.link.pk).exists())
@patch("apps.rules.signals.check_for_transaction_rules.defer")
def test_rules_are_triggered(self, defer):
self._sync()
self.assertEqual(defer.call_count, 2)
self.assertEqual(defer.call_args.kwargs["user_id"], self.user.id)
@patch("apps.rules.signals.check_for_transaction_rules.defer")
def test_rules_can_be_disabled(self, defer):
self.service.trigger_rules = False
self.service.save()
self._sync()
defer.assert_not_called()
def test_refresh_remote_accounts(self):
self.link.available_currencies = ["GBP"]
self.link.save()
gone = BankSyncAccount.objects.create(service=self.service, external_id="gone")
self.provider.accounts = [
RemoteAccount("7", "Renamed", ("EUR",)),
RemoteAccount("9", "New", ("USD",)),
]
refresh_remote_accounts(self.service, self.provider)
self.link.refresh_from_db()
self.assertEqual(self.link.external_name, "Renamed")
self.assertEqual(self.link.account, self.account)
self.assertEqual(self.link.available_currencies, ["EUR", "GBP"])
self.assertTrue(self.service.accounts.filter(external_id="9", account=None).exists())
self.assertFalse(BankSyncAccount.objects.filter(pk=gone.pk).exists())
def test_is_due(self):
self.service.sync_interval = 24
self.assertTrue(is_due(self.service))
self.service.last_sync = timezone.now() - timedelta(hours=23)
self.assertFalse(is_due(self.service))
self.service.last_sync = timezone.now() - timedelta(hours=25)
self.assertTrue(is_due(self.service))
class FormTests(TestCase):
def setUp(self):
# Account choices are scoped to the logged in user
self.user = get_user_model().objects.create_user(email="f@example.com", password="x")
write_current_user(self.user)
self.addCleanup(delete_current_user)
def test_provider_settings_fields_are_added(self):
form = BankSyncServiceForm(initial={"service_type": "lunchflow"})
self.assertIn("setting_api_key", form.fields)
self.assertNotIn("setting_base_url", form.fields)
self.assertNotIn("setting_api_key", BankSyncServiceForm().fields)
def test_settings_are_saved_and_loaded(self):
form = BankSyncServiceForm(
{
"name": "LF",
"service_type": "lunchflow",
"is_active": "on",
"sync_interval": "24",
"setting_api_key": "secret",
}
)
self.assertTrue(form.is_valid(), form.errors)
service = form.save()
self.assertEqual(service.settings, {"api_key": "secret"})
edit = BankSyncServiceForm(instance=service)
self.assertEqual(edit.initial["setting_api_key"], "secret")
def test_service_type_is_locked_on_edit(self):
service = BankSyncService.objects.create(
name="LF", service_type="lunchflow", settings={"api_key": "k"}
)
form = BankSyncServiceForm(
{"name": "LF", "service_type": "other", "sync_interval": "24", "setting_api_key": "k"},
instance=service,
)
self.assertTrue(form.fields["service_type"].disabled)
self.assertTrue(form.is_valid(), form.errors)
self.assertEqual(form.save().service_type, "lunchflow")
def test_provider_settings_are_validated(self):
form = BankSyncServiceForm(
{"name": "LF", "service_type": "lunchflow", "sync_interval": "24"}
)
self.assertFalse(form.is_valid())
self.assertIn("setting_api_key", form.errors)
def test_account_link_form(self):
eur = Currency.objects.create(code="€", name="Euro")
service = BankSyncService.objects.create(name="LF", service_type="lunchflow")
link = BankSyncAccount.objects.create(
service=service, external_id="7", available_currencies=["EUR", "USD"]
)
account = Account.objects.create(name="EUR", currency=eur, owner=self.user)
form = BankSyncAccountForm({"account": account.id}, instance=link)
self.assertFalse(form.is_valid())
self.assertIn("start_date", form.errors)
# Currency codes don't need to match: WYGIWYH's are free form
form = BankSyncAccountForm(
{"account": account.id, "start_date": "2026-10-01", "currencies": ["USD"]},
instance=link,
)
self.assertTrue(form.is_valid(), form.errors)
self.assertEqual(form.save().currencies, ["USD"])
form = BankSyncAccountForm({"currencies": ["GBP"]}, instance=link)
self.assertIn("currencies", form.errors)
self.assertTrue(BankSyncAccountForm({}, instance=link).is_valid())
def test_linked_archived_account_stays_valid(self):
eur = Currency.objects.create(code="EUR", name="Euro")
service = BankSyncService.objects.create(name="LF", service_type="lunchflow")
archived = Account.objects.create(name="Old", currency=eur, owner=self.user, is_archived=True)
other_archived = Account.objects.create(name="Older", currency=eur, owner=self.user, is_archived=True)
link = BankSyncAccount.objects.create(
service=service, external_id="7", account=archived, start_date=date(2026, 10, 1)
)
form = BankSyncAccountForm(
{"account": archived.id, "start_date": "2026-10-01"}, instance=link
)
self.assertTrue(form.is_valid(), form.errors)
self.assertNotIn(other_archived, form.fields["account"].queryset)
class ViewTests(TestCase):
def setUp(self):
self.user = get_user_model().objects.create_superuser(
email="admin@example.com", password="x"
)
self.client.force_login(self.user)
self.htmx = {"HTTP_HX_REQUEST": "true"}
def test_switching_provider_renders_its_fields(self):
response = self.client.get(
reverse("bank_sync_add"), {"service_type": "lunchflow", "name": "Typed"}, **self.htmx
)
self.assertContains(response, 'name="setting_api_key"')
self.assertContains(response, 'value="Typed"')
def test_add_and_map_accounts(self):
response = self.client.post(
reverse("bank_sync_add"),
{"name": "LF", "service_type": "lunchflow", "sync_interval": "24", "setting_api_key": "k"},
**self.htmx,
)
self.assertEqual(response.status_code, 204)
service = BankSyncService.objects.get()
self.assertEqual(service.owner, self.user)
provider = FakeProvider(
accounts=[
RemoteAccount("7", "Bank - Checking", ("EUR",)),
RemoteAccount("8", "Card", (), "Connection expired"),
]
)
with patch.object(BankSyncService, "get_provider", return_value=provider):
response = self.client.get(reverse("bank_sync_accounts", args=[service.id]), **self.htmx)
self.assertContains(response, "Bank - Checking")
self.assertContains(response, "Connection expired")
self.assertContains(response, 'value="EUR"')
account = Account.objects.create(
name="Checking", currency=Currency.objects.create(code="EUR", name="Euro")
)
link = service.accounts.get(external_id="7")
card = service.accounts.get(external_id="8")
response = self.client.post(
reverse("bank_sync_accounts", args=[service.id]),
{
"form-TOTAL_FORMS": "2",
"form-INITIAL_FORMS": "2",
"form-0-id": card.id,
"form-1-id": link.id,
"form-1-account": account.id,
"form-1-start_date": "2026-10-01",
"form-1-currencies": "EUR",
},
**self.htmx,
)
self.assertEqual(response.status_code, 204)
link.refresh_from_db()
self.assertEqual(link.account, account)
self.assertEqual(link.currencies, ["EUR"])
def test_accounts_view_shows_provider_errors(self):
service = BankSyncService.objects.create(name="LF", service_type="lunchflow")
with patch.object(BankSyncService, "get_provider", side_effect=Exception("401")):
response = self.client.get(reverse("bank_sync_accounts", args=[service.id]), **self.htmx)
self.assertContains(response, "401")
def test_other_users_services_are_hidden(self):
other = get_user_model().objects.create_user(email="o@example.com", password="x")
service = BankSyncService.objects.create(name="LF", service_type="lunchflow", owner=other)
response = self.client.get(reverse("bank_sync_edit", args=[service.id]), **self.htmx)
self.assertEqual(response.status_code, 404)
class ExportRestoreTests(TestCase):
def setUp(self):
self.admin = get_user_model().objects.create_superuser(
email="admin@example.com", password="x"
)
self.other = get_user_model().objects.create_user(
email="other@example.com", password="x"
)
self.client.force_login(self.admin)
eur = Currency.objects.create(code="EUR", name="Euro")
# Same name for both users: links must not mix them up
self.account = Account.objects.create(name="Checking", currency=eur, owner=self.other)
Account.objects.create(name="Checking", currency=eur, owner=self.admin)
self.service = BankSyncService.objects.create(
name="LF",
service_type="lunchflow",
owner=self.other,
settings={"api_key": "secret"},
sync_interval=12,
trigger_rules=False,
match_existing=True,
)
BankSyncAccount.objects.create(
service=self.service,
external_id="7",
external_name="Bank - Checking",
available_currencies=["EUR", "USD"],
currencies=["EUR"],
account=self.account,
start_date=date(2026, 10, 1),
last_synced_date=date(2026, 11, 1),
)
BankSyncAccount.objects.create(service=self.service, external_id="8")
def test_round_trip(self):
response = self.client.post(
reverse("export_form"),
{"users": "on", "accounts": "on", "currencies": "on", "bank_sync": "on"},
HTTP_HX_REQUEST="true",
)
self.assertEqual(response["Content-Type"], "application/zip")
with zipfile.ZipFile(io.BytesIO(response.content)) as z:
self.assertIn("bank_sync_services.csv", z.namelist())
self.assertIn("bank_sync_accounts.csv", z.namelist())
BankSyncService._base_manager.all().delete()
upload = SimpleUploadedFile("export.zip", response.content)
response = self.client.post(
reverse("restore_form"), {"zip_file": upload}, HTTP_HX_REQUEST="true"
)
self.assertEqual(response.status_code, 204)
service = BankSyncService._base_manager.get()
self.assertEqual(service.owner, self.other)
self.assertEqual(service.settings, {"api_key": "secret"})
self.assertEqual(service.sync_interval, 12)
self.assertFalse(service.trigger_rules)
self.assertTrue(service.match_existing)
linked = service.accounts.get(external_id="7")
self.assertEqual(linked.account, self.account)
self.assertEqual(linked.start_date, date(2026, 10, 1))
self.assertEqual(linked.last_synced_date, date(2026, 11, 1))
self.assertEqual(linked.available_currencies, ["EUR", "USD"])
self.assertEqual(linked.currencies, ["EUR"])
self.assertIsNone(service.accounts.get(external_id="8").account)
+21
View File
@@ -0,0 +1,21 @@
from django.urls import path
from . import views
urlpatterns = [
path("bank-sync/", views.bank_sync_index, name="bank_sync_index"),
path("bank-sync/list/", views.bank_sync_list, name="bank_sync_list"),
path("bank-sync/add/", views.bank_sync_add, name="bank_sync_add"),
path("bank-sync/<int:pk>/edit/", views.bank_sync_edit, name="bank_sync_edit"),
path(
"bank-sync/<int:pk>/delete/", views.bank_sync_delete, name="bank_sync_delete"
),
path(
"bank-sync/<int:pk>/accounts/",
views.bank_sync_accounts,
name="bank_sync_accounts",
),
path(
"bank-sync/<int:pk>/sync/", views.bank_sync_sync_now, name="bank_sync_sync_now"
),
]
+135
View File
@@ -0,0 +1,135 @@
import logging
from django.contrib import messages
from django.contrib.auth.decorators import login_required
from django.http import HttpResponse
from django.shortcuts import get_object_or_404, render
from django.urls import reverse
from django.utils.translation import gettext_lazy as _
from django.views.decorators.http import require_http_methods
from apps.bank_sync.forms import BankSyncAccountFormSet, BankSyncServiceForm
from apps.bank_sync.models import BankSyncService
from apps.bank_sync.sync import refresh_remote_accounts
from apps.bank_sync.tasks import manual_bank_sync
from apps.common.decorators.demo import disabled_on_demo
from apps.common.decorators.htmx import only_htmx
logger = logging.getLogger(__name__)
_CLOSE = {"HX-Trigger": "updated, hide_offcanvas"}
@login_required
@disabled_on_demo
@require_http_methods(["GET"])
def bank_sync_index(request):
return render(request, "bank_sync/pages/index.html")
@only_htmx
@login_required
@disabled_on_demo
@require_http_methods(["GET"])
def bank_sync_list(request):
services = BankSyncService.objects.prefetch_related("accounts")
return render(request, "bank_sync/fragments/list.html", {"services": services})
def _service_form(request, instance=None):
url = request.path
if request.method == "POST":
return BankSyncServiceForm(request.POST, instance=instance, refresh_url=url)
# A GET with params comes from switching provider: keep what was typed
return BankSyncServiceForm(
instance=instance, initial=request.GET.dict() or None, refresh_url=url
)
@only_htmx
@login_required
@disabled_on_demo
@require_http_methods(["GET", "POST"])
def bank_sync_add(request):
form = _service_form(request)
if request.method == "POST" and form.is_valid():
form.save()
messages.success(request, _("Service added successfully"))
return HttpResponse(status=204, headers=_CLOSE)
return render(
request,
"bank_sync/fragments/form.html",
{"form": form, "title": _("Add bank sync service")},
)
@only_htmx
@login_required
@disabled_on_demo
@require_http_methods(["GET", "POST"])
def bank_sync_edit(request, pk):
service = get_object_or_404(BankSyncService, id=pk)
form = _service_form(request, instance=service)
if request.method == "POST" and form.is_valid():
form.save()
messages.success(request, _("Service updated successfully"))
return HttpResponse(status=204, headers=_CLOSE)
return render(
request,
"bank_sync/fragments/form.html",
{"form": form, "title": _("Edit bank sync service")},
)
@only_htmx
@login_required
@disabled_on_demo
@require_http_methods(["DELETE"])
def bank_sync_delete(request, pk):
get_object_or_404(BankSyncService, id=pk).delete()
messages.success(request, _("Service deleted successfully"))
return HttpResponse(status=204, headers=_CLOSE)
@only_htmx
@login_required
@disabled_on_demo
@require_http_methods(["GET", "POST"])
def bank_sync_accounts(request, pk):
service = get_object_or_404(BankSyncService, id=pk)
error = None
if request.method == "POST":
formset = BankSyncAccountFormSet(request.POST, queryset=service.accounts.all())
if formset.is_valid():
formset.save()
messages.success(request, _("Accounts updated successfully"))
return HttpResponse(status=204, headers=_CLOSE)
else:
try:
refresh_remote_accounts(service)
except Exception as e:
logger.warning(f"Could not list accounts for {service.name}: {e}")
error = _("Couldn't load the accounts from the provider: %(error)s") % {
"error": e
}
formset = BankSyncAccountFormSet(queryset=service.accounts.all())
return render(
request,
"bank_sync/fragments/accounts.html",
{"service": service, "formset": formset, "error": error},
)
@only_htmx
@login_required
@disabled_on_demo
@require_http_methods(["GET"])
def bank_sync_sync_now(request, pk):
service = get_object_or_404(BankSyncService, id=pk)
manual_bank_sync.defer(service_id=service.id)
messages.success(request, _("Sync queued successfully"))
return HttpResponse(status=204, headers={"HX-Trigger": "toasts"})
@@ -0,0 +1,37 @@
{% extends 'extends/offcanvas.html' %}
{% load i18n %}
{% load crispy_forms_tags %}
{% block title %}{% translate 'Accounts' %} - {{ service.name }}{% endblock %}
{% block body %}
{% if error %}
<div role="alert" class="alert alert-error mb-3">{{ error }}</div>
{% endif %}
<form hx-post="{% url 'bank_sync_accounts' pk=service.id %}" hx-target="#generic-offcanvas" novalidate>
{% csrf_token %}
{{ formset.management_form }}
{% for form in formset %}
<fieldset class="fieldset border border-base-300 rounded-box p-3 mb-3">
<legend class="fieldset-legend">
{{ form.instance.external_name|default:form.instance.external_id }}
<span class="text-base-content/60 font-mono text-xs">#{{ form.instance.external_id }}</span>
</legend>
{% if form.instance.remote_error %}
<div role="alert" class="alert alert-warning alert-soft mb-2">{{ form.instance.remote_error }}</div>
{% elif form.instance.last_error %}
<div role="alert" class="alert alert-error alert-soft mb-2">{{ form.instance.last_error }}</div>
{% endif %}
{{ form.id }}
{{ form.account|as_crispy_field }}
{{ form.start_date|as_crispy_field }}
{% if form.currencies.field.choices %}{{ form.currencies|as_crispy_field }}{% endif %}
</fieldset>
{% empty %}
<c-msg.empty title="{% translate 'No accounts found on this service' %}" remove-padding></c-msg.empty>
{% endfor %}
{% if formset.forms %}
<button type="submit" class="btn btn-primary">{% translate 'Save' %}</button>
{% endif %}
</form>
{% endblock %}
@@ -0,0 +1,10 @@
{% extends 'extends/offcanvas.html' %}
{% load crispy_forms_tags %}
{% block title %}{{ title }}{% endblock %}
{% block body %}
<form hx-post="{{ request.path }}" hx-target="#generic-offcanvas" novalidate>
{% crispy form %}
</form>
{% endblock %}
@@ -0,0 +1,84 @@
{% load i18n %}
<c-ui.fab-single-action
url="{% url 'bank_sync_add' %}"
hx_target="#generic-offcanvas">
</c-ui.fab-single-action>
<div class="container">
<div class="text-3xl font-bold font-mono w-full mb-3">
<div>{% translate 'Bank Sync' %}</div>
</div>
<div class="card bg-base-100 shadow-xl">
<div class="card-body">
{% if services %}
<div class="overflow-x-auto">
<table class="table table-hover">
<thead>
<tr>
<th scope="col" class="table-col-auto"></th>
<th scope="col" class="table-col-auto"></th>
<th scope="col" class="w-auto">{% translate 'Name' %}</th>
<th scope="col">{% translate 'Service' %}</th>
<th scope="col">{% translate 'Accounts' %}</th>
<th scope="col">{% translate 'Last sync' %}</th>
</tr>
</thead>
<tbody>
{% for service in services %}
<tr>
<td class="table-col-auto">
<div class="join" role="group" aria-label="{% translate 'Actions' %}">
<a class="btn btn-secondary btn-sm join-item"
role="button"
data-tippy-content="{% translate "Edit" %}"
hx-get="{% url 'bank_sync_edit' pk=service.id %}"
hx-target="#generic-offcanvas">
<i class="fa-solid fa-pencil fa-fw"></i></a>
<a class="btn btn-secondary btn-sm join-item"
role="button"
data-tippy-content="{% translate "Accounts" %}"
hx-get="{% url 'bank_sync_accounts' pk=service.id %}"
hx-target="#generic-offcanvas">
<i class="fa-solid fa-link fa-fw"></i></a>
<a class="btn btn-secondary btn-sm join-item"
role="button"
data-tippy-content="{% translate "Sync now" %}"
hx-get="{% url 'bank_sync_sync_now' pk=service.id %}">
<i class="fa-solid fa-rotate fa-fw"></i></a>
<a class="btn btn-error btn-sm join-item"
role="button"
data-tippy-content="{% translate "Delete" %}"
hx-delete="{% url 'bank_sync_delete' pk=service.id %}"
hx-trigger='confirmed'
data-bypass-on-ctrl="true"
data-title="{% translate "Are you sure?" %}"
data-text="{% translate "Synced transactions are kept." %}"
data-confirm-text="{% translate "Yes, delete it!" %}"
_="install prompt_swal"><i class="fa-solid fa-trash fa-fw"></i></a>
</div>
</td>
<td class="table-col-auto">{% if service.is_active %}<i class="fa-solid fa-circle text-success"></i>{% else %}
<i class="fa-solid fa-circle text-error"></i>{% endif %}</td>
<td>
{{ service.name }}
{% if service.failure_count > 0 %}
<span class="badge badge-error gap-1" data-tippy-content="{{ service.last_error }}">
<i class="fa-solid fa-triangle-exclamation fa-fw"></i>
{{ service.failure_count }}
</span>
{% endif %}
</td>
<td>{{ service.get_service_type_display }}</td>
<td>{% with linked=service.accounts.all %}{% for link in linked %}{% if link.account %}{% with error=link.remote_error|default:link.last_error %}<span class="badge {% if error %}badge-warning{% else %}badge-secondary{% endif %} me-1"{% if error %} data-tippy-content="{{ error }}"{% endif %}>{% if error %}<i class="fa-solid fa-triangle-exclamation fa-fw"></i>{% endif %}{{ link.account.name }}</span>{% endwith %}{% endif %}{% endfor %}{% endwith %}</td>
<td>{{ service.last_sync|date:"SHORT_DATETIME_FORMAT" }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<c-msg.empty title="{% translate "No services configured" %}" remove-padding></c-msg.empty>
{% endif %}
</div>
</div>
</div>
+8
View File
@@ -0,0 +1,8 @@
{% extends "layouts/base.html" %}
{% load i18n %}
{% block title %}{% translate 'Bank Sync' %}{% endblock %}
{% block content %}
<div hx-get="{% url 'bank_sync_list' %}" hx-trigger="load, updated from:window" class="show-loading"></div>
{% endblock %}