mirror of
https://github.com/eitchtee/WYGIWYH.git
synced 2026-11-13 12:23:44 +01:00
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:
1 parent
a42265ba49
commit
620e7df015
22 files changed
+1526
No files matched your search
@@ -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",
|
||||||
|
|||||||
@@ -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")),
|
||||||
|
|||||||
Whitespace-only changes.
@@ -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)
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class BankSyncConfig(AppConfig):
|
||||||
|
default_auto_field = "django.db.models.BigAutoField"
|
||||||
|
name = "apps.bank_sync"
|
||||||
@@ -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.
@@ -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
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
from apps.bank_sync.providers.lunchflow import LunchFlowProvider
|
||||||
|
|
||||||
|
# Keys must match BankSyncService.ServiceType
|
||||||
|
PROVIDER_MAPPING = {
|
||||||
|
"lunchflow": LunchFlowProvider,
|
||||||
|
}
|
||||||
@@ -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."""
|
||||||
@@ -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
|
||||||
@@ -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)
|
||||||
@@ -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.
@@ -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)
|
||||||
@@ -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"
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -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>
|
||||||
@@ -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 %}
|
||||||
Reference in new issue
Block a user