mirror of
https://github.com/eitchtee/WYGIWYH.git
synced 2026-11-13 12:23:44 +01:00
Merge pull request #650 from eitchtee/feat/bank-sync-2
feat: bank sync with external providers (Lunch Flow, SimpleFIN, Pluggy)
This commit is contained in:
28 files changed
+2317
-1
No files matched your search
@@ -63,6 +63,7 @@ INSTALLED_APPS = [
|
||||
"procrastinate.contrib.django",
|
||||
"apps.transactions.apps.TransactionsConfig",
|
||||
"apps.currencies.apps.CurrenciesConfig",
|
||||
"apps.bank_sync.apps.BankSyncConfig",
|
||||
"apps.accounts.apps.AccountsConfig",
|
||||
"apps.net_worth.apps.NetWorthConfig",
|
||||
"apps.import_app.apps.ImportConfig",
|
||||
|
||||
@@ -90,6 +90,7 @@ urlpatterns = [
|
||||
path("", include("apps.monthly_overview.urls")),
|
||||
path("", include("apps.yearly_overview.urls")),
|
||||
path("", include("apps.currencies.urls")),
|
||||
path("", include("apps.bank_sync.urls")),
|
||||
path("", include("apps.rules.urls")),
|
||||
path("", include("apps.calendar_view.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,145 @@
|
||||
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"
|
||||
)
|
||||
self.provider = PROVIDER_MAPPING.get(service_type)
|
||||
self.setting_names = []
|
||||
for key, field in (
|
||||
self.provider.settings_fields if self.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 clean_sync_interval(self):
|
||||
interval = self.cleaned_data["sync_interval"]
|
||||
if self.provider and interval < self.provider.min_sync_interval:
|
||||
raise forms.ValidationError(
|
||||
_("This service can't sync more often than every %(hours)s hours.")
|
||||
% {"hours": self.provider.min_sync_interval}
|
||||
)
|
||||
return interval
|
||||
|
||||
def clean(self):
|
||||
cleaned_data = super().clean()
|
||||
# Only once the rest is valid: a provider may spend a one-time token here
|
||||
if self.provider and not self.errors:
|
||||
try:
|
||||
self.instance.settings = self.provider.clean_settings(
|
||||
{
|
||||
name.removeprefix(SETTING_PREFIX): cleaned_data[name]
|
||||
for name in self.setting_names
|
||||
}
|
||||
)
|
||||
except forms.ValidationError as e:
|
||||
self.add_error(None, e)
|
||||
return cleaned_data
|
||||
|
||||
|
||||
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'), ('simplefin', 'SimpleFIN'), ('pluggy', 'Pluggy')], 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,104 @@
|
||||
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"
|
||||
SIMPLEFIN = "simplefin", "SimpleFIN"
|
||||
PLUGGY = "pluggy", "Pluggy"
|
||||
|
||||
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,10 @@
|
||||
from apps.bank_sync.providers.lunchflow import LunchFlowProvider
|
||||
from apps.bank_sync.providers.pluggy import PluggyProvider
|
||||
from apps.bank_sync.providers.simplefin import SimpleFINProvider
|
||||
|
||||
# Keys must match BankSyncService.ServiceType
|
||||
PROVIDER_MAPPING = {
|
||||
"lunchflow": LunchFlowProvider,
|
||||
"simplefin": SimpleFINProvider,
|
||||
"pluggy": PluggyProvider,
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
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 = ""
|
||||
# Not settled yet. Imported unpaid and updated when it posts, so only return
|
||||
# these when the provider keeps the id once they post
|
||||
pending: bool = False
|
||||
|
||||
|
||||
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 = {}
|
||||
# In hours, for services that limit how often they can be called
|
||||
min_sync_interval: int = 1
|
||||
|
||||
def __init__(self, settings: dict):
|
||||
self.settings = settings or {}
|
||||
|
||||
@classmethod
|
||||
def clean_settings(cls, settings: dict) -> dict:
|
||||
"""Validate or transform the settings before they're saved.
|
||||
|
||||
Runs once every other form field is valid. Raise ValidationError to reject.
|
||||
"""
|
||||
return settings
|
||||
|
||||
@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,147 @@
|
||||
import re
|
||||
from datetime import date
|
||||
from decimal import Decimal
|
||||
from urllib.parse import parse_qs, urlsplit
|
||||
|
||||
import requests
|
||||
from django import forms
|
||||
from django.utils import timezone
|
||||
from django.utils.translation import gettext as _
|
||||
from django.utils.translation import gettext_lazy
|
||||
|
||||
from apps.bank_sync.providers.base import (
|
||||
BankSyncProvider,
|
||||
RemoteAccount,
|
||||
RemoteTransaction,
|
||||
)
|
||||
|
||||
BASE_URL = "https://api.pluggy.ai"
|
||||
# Item statuses that need the user to reconnect on Pluggy
|
||||
NEEDS_ACTION = {"LOGIN_ERROR", "WAITING_USER_INPUT"}
|
||||
|
||||
|
||||
class PluggyProvider(BankSyncProvider):
|
||||
"""https://docs.pluggy.ai"""
|
||||
|
||||
settings_fields = {
|
||||
"client_id": forms.CharField(
|
||||
label=gettext_lazy("Client ID"),
|
||||
widget=forms.TextInput(attrs={"autocomplete": "off"}),
|
||||
),
|
||||
"client_secret": forms.CharField(
|
||||
label=gettext_lazy("Client Secret"),
|
||||
# Not a PasswordInput: browsers autofill those with saved logins
|
||||
widget=forms.TextInput(attrs={"autocomplete": "off"}),
|
||||
),
|
||||
"item_ids": forms.CharField(
|
||||
label=gettext_lazy("Item IDs"),
|
||||
widget=forms.TextInput(attrs={"autocomplete": "off"}),
|
||||
help_text=gettext_lazy(
|
||||
"The IDs of your Pluggy connections (items), separated by commas. "
|
||||
"Meu Pluggy shows them for each connected institution."
|
||||
),
|
||||
),
|
||||
}
|
||||
|
||||
def __init__(self, settings: dict):
|
||||
super().__init__(settings)
|
||||
self.session = requests.Session()
|
||||
self.item_ids = [
|
||||
item_id
|
||||
for item_id in re.split(r"[\s,]+", self.settings.get("item_ids", ""))
|
||||
if item_id
|
||||
]
|
||||
|
||||
def _get(self, path: str, **params) -> dict:
|
||||
if "X-API-KEY" not in self.session.headers:
|
||||
# Lasts 2 hours, longer than any sync
|
||||
response = self.session.post(
|
||||
f"{BASE_URL}/auth",
|
||||
json={
|
||||
"clientId": self.settings.get("client_id", ""),
|
||||
"clientSecret": self.settings.get("client_secret", ""),
|
||||
},
|
||||
timeout=30,
|
||||
)
|
||||
response.raise_for_status()
|
||||
self.session.headers["X-API-KEY"] = response.json()["apiKey"]
|
||||
response = self.session.get(f"{BASE_URL}{path}", params=params, timeout=30)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
def list_accounts(self) -> list[RemoteAccount]:
|
||||
accounts = []
|
||||
for item_id in self.item_ids:
|
||||
item = self._get(f"/items/{item_id}")
|
||||
institution = (item.get("connector") or {}).get("name")
|
||||
status = item.get("status") or ""
|
||||
error = (
|
||||
_(
|
||||
"Pluggy reports this connection as %(status)s, reconnect it "
|
||||
"on Pluggy."
|
||||
)
|
||||
% {"status": status}
|
||||
if status in NEEDS_ACTION
|
||||
else ""
|
||||
)
|
||||
for account in self._get("/accounts", itemId=item_id)["results"]:
|
||||
currency = account.get("currencyCode") or ""
|
||||
accounts.append(
|
||||
RemoteAccount(
|
||||
id=str(account["id"]),
|
||||
name=" - ".join(
|
||||
filter(
|
||||
None,
|
||||
[
|
||||
institution,
|
||||
account.get("marketingName") or account["name"],
|
||||
account.get("number"),
|
||||
],
|
||||
)
|
||||
),
|
||||
currencies=(currency.upper(),) if currency else (),
|
||||
error=error,
|
||||
)
|
||||
)
|
||||
return accounts
|
||||
|
||||
def get_transactions(
|
||||
self, account_id: str, start_date: date
|
||||
) -> list[RemoteTransaction]:
|
||||
params = {"accountId": account_id, "dateFrom": start_date.isoformat()}
|
||||
transactions = []
|
||||
while True:
|
||||
data = self._get("/v2/transactions", **params)
|
||||
for tx in data["results"]:
|
||||
day = date.fromisoformat(tx["date"][:10])
|
||||
# Pending keeps its id once it posts (a card's open bill). Future
|
||||
# ones (installments, scheduled payments) wait for their date
|
||||
pending = tx.get("status") == "PENDING"
|
||||
if pending and day > timezone.localdate():
|
||||
continue
|
||||
converted = tx.get("amountInAccountCurrency")
|
||||
amount = abs(Decimal(str(tx["amount"] if converted is None else converted)))
|
||||
notes = ""
|
||||
if converted is not None:
|
||||
# Foreign purchase: keep the account's amount, note the original
|
||||
notes = f"{tx.get('currencyCode', '')} {abs(Decimal(str(tx['amount'])))}"
|
||||
transactions.append(
|
||||
RemoteTransaction(
|
||||
id=str(tx["id"]),
|
||||
# Installments already come dated in their own month
|
||||
date=day,
|
||||
# Not the amount's sign: cards keep theirs, purchases are
|
||||
# positive. `type` is normalized for every account
|
||||
amount=amount if tx.get("type") == "CREDIT" else -amount,
|
||||
description=(tx.get("description") or "").strip(),
|
||||
notes=notes,
|
||||
currency=""
|
||||
if converted is not None
|
||||
else (tx.get("currencyCode") or "").upper(),
|
||||
pending=pending,
|
||||
)
|
||||
)
|
||||
if not data.get("next"):
|
||||
return transactions
|
||||
# Send our own filters again with the cursor, `next` may drop them
|
||||
params["after"] = parse_qs(urlsplit(data["next"]).query)["after"][0]
|
||||
@@ -0,0 +1,182 @@
|
||||
import base64
|
||||
import binascii
|
||||
import os
|
||||
from datetime import date, datetime, time, timedelta
|
||||
from decimal import Decimal
|
||||
from urllib.parse import unquote, urlsplit, urlunsplit
|
||||
|
||||
import requests
|
||||
from django import forms
|
||||
from django.utils import timezone
|
||||
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_ALLOWED_HOSTS = "bridge.simplefin.org,beta-bridge.simplefin.org"
|
||||
# SimpleFIN Bridge refuses longer ranges in a single request
|
||||
MAX_RANGE_DAYS = 90
|
||||
|
||||
|
||||
def _check_host(url: str) -> None:
|
||||
# Tokens are user input: without this they could point the server at any host
|
||||
allowed = {
|
||||
host.strip().lower()
|
||||
for host in os.getenv("SIMPLEFIN_ALLOWED_HOSTS", DEFAULT_ALLOWED_HOSTS).split(",")
|
||||
if host.strip()
|
||||
}
|
||||
parts = urlsplit(url)
|
||||
if parts.scheme not in ("http", "https") or (parts.hostname or "") not in allowed:
|
||||
raise ValueError(
|
||||
_("SimpleFIN server %(host)s isn't allowed on this instance.")
|
||||
% {"host": parts.hostname or url}
|
||||
)
|
||||
|
||||
|
||||
def _timestamp(day: date) -> int:
|
||||
# Local midnight, so windows and transaction dates agree on what a day is
|
||||
return int(
|
||||
datetime.combine(day, time(), tzinfo=timezone.get_current_timezone()).timestamp()
|
||||
)
|
||||
|
||||
|
||||
class SimpleFINProvider(BankSyncProvider):
|
||||
"""https://www.simplefin.org/protocol.html"""
|
||||
|
||||
# SimpleFIN Bridge disables tokens that go much past ~24 requests a day, and a
|
||||
# sync costs one plus one per account
|
||||
min_sync_interval = 6
|
||||
|
||||
settings_fields = {
|
||||
"access_url": forms.CharField(
|
||||
label=gettext_lazy("Setup Token"),
|
||||
widget=forms.TextInput(attrs={"autocomplete": "off"}),
|
||||
help_text=gettext_lazy(
|
||||
"Paste a setup token from your SimpleFIN server. It can only be "
|
||||
"used once: on save it's exchanged for an access URL, which is "
|
||||
"what you'll see here afterwards."
|
||||
),
|
||||
),
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def clean_settings(cls, settings: dict) -> dict:
|
||||
try:
|
||||
return cls._claim(settings)
|
||||
except ValueError as e:
|
||||
raise forms.ValidationError(str(e))
|
||||
|
||||
@staticmethod
|
||||
def _claim(settings: dict) -> dict:
|
||||
value = settings["access_url"].strip()
|
||||
# Already claimed: the form is being saved again
|
||||
if not value.startswith(("http://", "https://")):
|
||||
try:
|
||||
claim_url = base64.b64decode(
|
||||
value + "=" * (-len(value) % 4), validate=True
|
||||
).decode()
|
||||
except (binascii.Error, UnicodeDecodeError):
|
||||
raise ValueError(_("This isn't a valid SimpleFIN setup token."))
|
||||
_check_host(claim_url)
|
||||
try:
|
||||
response = requests.post(claim_url, timeout=30)
|
||||
response.raise_for_status()
|
||||
except requests.RequestException as e:
|
||||
raise ValueError(
|
||||
_(
|
||||
"Couldn't claim the setup token, it may have been used "
|
||||
"already: %(error)s"
|
||||
)
|
||||
% {"error": e}
|
||||
)
|
||||
value = response.text.strip()
|
||||
_check_host(value)
|
||||
return {**settings, "access_url": value}
|
||||
|
||||
def __init__(self, settings: dict):
|
||||
super().__init__(settings)
|
||||
parts = urlsplit(self.settings.get("access_url", ""))
|
||||
self.session = requests.Session()
|
||||
# Credentials go in a header, not the URL, so errors never show them
|
||||
self.session.auth = (unquote(parts.username or ""), unquote(parts.password or ""))
|
||||
netloc = (parts.hostname or "") + (f":{parts.port}" if parts.port else "")
|
||||
self.base_url = urlunsplit((parts.scheme, netloc, parts.path.rstrip("/"), "", ""))
|
||||
|
||||
def _get_accounts(self, **params) -> dict:
|
||||
# Settings can also arrive through a backup restore, skipping the form
|
||||
_check_host(self.base_url)
|
||||
response = self.session.get(
|
||||
f"{self.base_url}/accounts",
|
||||
params={"version": "2", **params},
|
||||
# Slow: the server can fetch from every bank on each request
|
||||
timeout=60,
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
def list_accounts(self) -> list[RemoteAccount]:
|
||||
data = self._get_accounts(**{"balances-only": "1"})
|
||||
connections = {c["conn_id"]: c.get("name", "") for c in data.get("connections", [])}
|
||||
account_errors, connection_errors = {}, {}
|
||||
for error in data.get("errlist", []):
|
||||
if error.get("account_id"):
|
||||
account_errors.setdefault(error["account_id"], error.get("msg", ""))
|
||||
elif error.get("conn_id"):
|
||||
connection_errors.setdefault(error["conn_id"], error.get("msg", ""))
|
||||
|
||||
accounts = []
|
||||
for account in data["accounts"]:
|
||||
institution = connections.get(account.get("conn_id")) or (
|
||||
account.get("org") or {}
|
||||
).get("name")
|
||||
currency = account.get("currency") or ""
|
||||
accounts.append(
|
||||
RemoteAccount(
|
||||
id=str(account["id"]),
|
||||
name=" - ".join(filter(None, [institution, account["name"]])),
|
||||
# Custom currencies are URLs, nothing to match them against
|
||||
currencies=(currency.upper(),) if currency and "/" not in currency else (),
|
||||
error=account_errors.get(account["id"])
|
||||
or connection_errors.get(account.get("conn_id"), ""),
|
||||
)
|
||||
)
|
||||
return accounts
|
||||
|
||||
def get_transactions(
|
||||
self, account_id: str, start_date: date
|
||||
) -> list[RemoteTransaction]:
|
||||
transactions = []
|
||||
start, stop = start_date, timezone.localdate() + timedelta(days=1)
|
||||
while start < stop:
|
||||
end = min(start + timedelta(days=MAX_RANGE_DAYS), stop)
|
||||
data = self._get_accounts(
|
||||
account=account_id,
|
||||
**{"start-date": _timestamp(start), "end-date": _timestamp(end)},
|
||||
)
|
||||
for account in data["accounts"]:
|
||||
if str(account["id"]) != account_id:
|
||||
continue
|
||||
currency = account.get("currency") or ""
|
||||
for tx in account.get("transactions", []):
|
||||
if tx.get("pending"):
|
||||
continue
|
||||
payee = (tx.get("payee") or "").strip()
|
||||
description = (tx.get("description") or "").strip()
|
||||
transactions.append(
|
||||
RemoteTransaction(
|
||||
id=str(tx["id"]),
|
||||
date=datetime.fromtimestamp(
|
||||
tx["posted"], tz=timezone.get_current_timezone()
|
||||
).date(),
|
||||
amount=Decimal(str(tx["amount"])),
|
||||
description=payee or description,
|
||||
notes=description if payee and description != payee else "",
|
||||
currency=currency.upper() if "/" not in currency else "",
|
||||
)
|
||||
)
|
||||
start = end
|
||||
return transactions
|
||||
@@ -0,0 +1,211 @@
|
||||
import logging
|
||||
from datetime import timedelta
|
||||
|
||||
from django.db.models import Min
|
||||
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
|
||||
# Pending ones older than this are taken as cancelled and no longer followed. A
|
||||
# card's bill cycle is at most ~40 days
|
||||
PENDING_MAX_DAYS = 60
|
||||
|
||||
|
||||
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 _settle(transaction: Transaction, amount, date) -> None:
|
||||
"""The bank posted a transaction that was still unpaid: take its final values.
|
||||
|
||||
Happens once, so later edits stick. One marked paid by hand is left alone.
|
||||
"""
|
||||
if transaction.is_paid or transaction.deleted:
|
||||
return
|
||||
# Only when it still follows the date, so one picked by the user is kept
|
||||
if transaction.reference_date == transaction.date.replace(day=1):
|
||||
transaction.reference_date = date.replace(day=1)
|
||||
transaction.is_paid = True
|
||||
transaction.amount = amount
|
||||
transaction.date = date
|
||||
# save() keeps a transfer's other side in step. It sends no signal, and as a
|
||||
# bank update rather than a user edit, rules don't run again
|
||||
transaction.save()
|
||||
|
||||
|
||||
def _sync_account(service: BankSyncService, link: BankSyncAccount, provider) -> int:
|
||||
prefix = f"{service.service_type}:{link.external_id}:"
|
||||
start = link.start_date
|
||||
if link.last_synced_date:
|
||||
start = max(start, link.last_synced_date - timedelta(days=OVERLAP_DAYS))
|
||||
# Pending ones can post weeks later (a card's bill closing): look back far
|
||||
# enough to see it happen
|
||||
oldest_unpaid = Transaction.userless_all_objects.filter(
|
||||
internal_id__startswith=prefix,
|
||||
is_paid=False,
|
||||
deleted=False,
|
||||
date__gte=timezone.localdate() - timedelta(days=PENDING_MAX_DAYS),
|
||||
).aggregate(Min("date"))["date__min"]
|
||||
if oldest_unpaid:
|
||||
start = min(start, oldest_unpaid)
|
||||
|
||||
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 = prefix + remote.id
|
||||
type_ = (
|
||||
Transaction.Type.INCOME if remote.amount > 0 else Transaction.Type.EXPENSE
|
||||
)
|
||||
amount = abs(remote.amount)
|
||||
|
||||
# userless_all_objects: also sees soft deleted ones, so deleting a synced
|
||||
# transaction doesn't bring it back on the next run
|
||||
existing = Transaction.userless_all_objects.filter(
|
||||
internal_id=internal_id
|
||||
).first()
|
||||
if existing:
|
||||
if not remote.pending:
|
||||
_settle(existing, amount, remote.date)
|
||||
continue
|
||||
|
||||
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=not remote.pending,
|
||||
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,897 @@
|
||||
import base64
|
||||
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.exceptions import ValidationError
|
||||
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.providers.pluggy import PluggyProvider
|
||||
from apps.bank_sync.providers.simplefin import SimpleFINProvider
|
||||
from apps.bank_sync.providers.simplefin import _timestamp as _ts
|
||||
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 SimpleFINProviderTests(TestCase):
|
||||
def setUp(self):
|
||||
self.enterContext(patch.dict("os.environ"))
|
||||
os.environ.pop("SIMPLEFIN_ALLOWED_HOSTS", None)
|
||||
self.provider = SimpleFINProvider(
|
||||
{"access_url": "https://us%40r:p%3Ass@bridge.simplefin.org/simplefin/"}
|
||||
)
|
||||
|
||||
def test_credentials_are_kept_out_of_the_url(self):
|
||||
self.assertEqual(self.provider.session.auth, ("us@r", "p:ss"))
|
||||
self.assertEqual(self.provider.base_url, "https://bridge.simplefin.org/simplefin")
|
||||
|
||||
def test_claims_setup_token(self):
|
||||
token = base64.b64encode(b"https://bridge.simplefin.org/simplefin/claim/abc").decode()
|
||||
response = MagicMock(text="https://u:p@bridge.simplefin.org/simplefin\n")
|
||||
with patch("requests.post", return_value=response) as post:
|
||||
settings = SimpleFINProvider.clean_settings({"access_url": token})
|
||||
post.assert_called_once_with(
|
||||
"https://bridge.simplefin.org/simplefin/claim/abc", timeout=30
|
||||
)
|
||||
self.assertEqual(settings, {"access_url": "https://u:p@bridge.simplefin.org/simplefin"})
|
||||
|
||||
def test_claimed_url_is_not_claimed_again(self):
|
||||
with patch("requests.post") as post:
|
||||
SimpleFINProvider.clean_settings({"access_url": "https://u:p@bridge.simplefin.org/x"})
|
||||
post.assert_not_called()
|
||||
|
||||
def test_rejects_hosts_not_allowed(self):
|
||||
token = base64.b64encode(b"http://db:5432/claim").decode()
|
||||
with patch("requests.post") as post:
|
||||
for value in (token, "https://u:p@evil.test/x", "not base64!"):
|
||||
with self.assertRaises(ValidationError):
|
||||
SimpleFINProvider.clean_settings({"access_url": value})
|
||||
post.assert_not_called()
|
||||
with patch.dict("os.environ", {"SIMPLEFIN_ALLOWED_HOSTS": "sfin.local"}):
|
||||
SimpleFINProvider.clean_settings({"access_url": "http://u:p@sfin.local/x"})
|
||||
# Restored settings skip the form, the request itself is checked too
|
||||
provider = SimpleFINProvider({"access_url": "https://u:p@evil.test/x"})
|
||||
with self.assertRaises(ValueError):
|
||||
provider.list_accounts()
|
||||
|
||||
def test_list_accounts(self):
|
||||
payload = {
|
||||
"errlist": [
|
||||
{"code": "con.auth", "msg": "Reconnect", "conn_id": "c2"},
|
||||
{"code": "gen.api", "msg": "Slow down"},
|
||||
],
|
||||
"connections": [{"conn_id": "c1", "name": "Bank"}, {"conn_id": "c2", "name": "Other"}],
|
||||
"accounts": [
|
||||
{"id": "a1", "name": "Checking", "conn_id": "c1", "currency": "usd"},
|
||||
{"id": "a2", "name": "Card", "conn_id": "c2", "currency": "https://x.test/pts"},
|
||||
],
|
||||
}
|
||||
with patch.object(self.provider.session, "get", return_value=_response(payload)) as get:
|
||||
accounts = self.provider.list_accounts()
|
||||
get.assert_called_once_with(
|
||||
"https://bridge.simplefin.org/simplefin/accounts",
|
||||
params={"version": "2", "balances-only": "1"},
|
||||
timeout=60,
|
||||
)
|
||||
self.assertEqual(
|
||||
accounts,
|
||||
[
|
||||
RemoteAccount("a1", "Bank - Checking", ("USD",), ""),
|
||||
RemoteAccount("a2", "Other - Card", (), "Reconnect"),
|
||||
],
|
||||
)
|
||||
|
||||
def test_get_transactions_in_90_day_windows(self):
|
||||
today = timezone.localdate()
|
||||
start = today - timedelta(days=100)
|
||||
|
||||
def get(url, params, timeout):
|
||||
if params["start-date"] != _ts(start):
|
||||
return _response({"accounts": []})
|
||||
return _response(
|
||||
{
|
||||
"accounts": [
|
||||
{
|
||||
"id": "a1",
|
||||
"currency": "USD",
|
||||
"transactions": [
|
||||
{"id": "t1", "posted": _ts(start) + 3600, "amount": "-5.10", "description": "CARD COFFEE", "payee": "Coffee"},
|
||||
{"id": "t2", "posted": 0, "amount": "-1", "description": "Pending", "pending": True},
|
||||
],
|
||||
},
|
||||
{"id": "other", "transactions": [{"id": "x", "posted": 0, "amount": "1"}]},
|
||||
]
|
||||
}
|
||||
)
|
||||
|
||||
with patch.object(self.provider.session, "get", side_effect=get) as mock_get:
|
||||
transactions = self.provider.get_transactions("a1", start)
|
||||
|
||||
windows = [(c.kwargs["params"]["start-date"], c.kwargs["params"]["end-date"]) for c in mock_get.call_args_list]
|
||||
self.assertEqual(
|
||||
windows,
|
||||
[
|
||||
(_ts(start), _ts(start + timedelta(days=90))),
|
||||
(_ts(start + timedelta(days=90)), _ts(today + timedelta(days=1))),
|
||||
],
|
||||
)
|
||||
self.assertEqual(mock_get.call_args.kwargs["params"]["account"], "a1")
|
||||
self.assertEqual(
|
||||
transactions,
|
||||
[RemoteTransaction("t1", start, Decimal("-5.10"), "Coffee", "CARD COFFEE", "USD")],
|
||||
)
|
||||
|
||||
|
||||
class PluggyProviderTests(TestCase):
|
||||
def setUp(self):
|
||||
self.provider = PluggyProvider(
|
||||
{"client_id": "id", "client_secret": "secret", "item_ids": "i1, i2\n"}
|
||||
)
|
||||
auth = _response({"apiKey": "key"})
|
||||
self.post = self.enterContext(
|
||||
patch.object(self.provider.session, "post", return_value=auth)
|
||||
)
|
||||
|
||||
def test_list_accounts(self):
|
||||
def get(url, params, timeout):
|
||||
self.assertEqual(self.provider.session.headers["X-API-KEY"], "key")
|
||||
if url.endswith("/items/i1"):
|
||||
return _response({"status": "UPDATED", "connector": {"name": "Nubank"}})
|
||||
if url.endswith("/items/i2"):
|
||||
return _response({"status": "LOGIN_ERROR", "connector": {"name": "Itaú"}})
|
||||
return _response(
|
||||
{
|
||||
"results": [
|
||||
{"id": f"{params['itemId']}-a", "name": "Conta", "marketingName": None, "number": "0001", "currencyCode": "BRL"}
|
||||
]
|
||||
}
|
||||
)
|
||||
|
||||
with patch.object(self.provider.session, "get", side_effect=get):
|
||||
accounts = self.provider.list_accounts()
|
||||
|
||||
self.post.assert_called_once_with(
|
||||
"https://api.pluggy.ai/auth",
|
||||
json={"clientId": "id", "clientSecret": "secret"},
|
||||
timeout=30,
|
||||
)
|
||||
self.assertEqual(accounts[0], RemoteAccount("i1-a", "Nubank - Conta - 0001", ("BRL",), ""))
|
||||
self.assertEqual(accounts[1].id, "i2-a")
|
||||
self.assertIn("LOGIN_ERROR", accounts[1].error)
|
||||
|
||||
def _get_transactions(self, pages):
|
||||
responses = [_response(p) for p in pages]
|
||||
with patch.object(self.provider.session, "get", side_effect=responses) as get:
|
||||
transactions = self.provider.get_transactions("a", date(2026, 10, 1))
|
||||
return transactions, get.call_args_list
|
||||
|
||||
def test_bank_account_follows_cursor_with_own_filters(self):
|
||||
pages = [
|
||||
{
|
||||
"results": [
|
||||
{"id": "t1", "date": "2026-10-01T03:00:00.000Z", "amount": -12.5, "type": "DEBIT", "status": "POSTED", "description": "Padaria", "currencyCode": "BRL"},
|
||||
{"id": "t2", "date": "2026-10-02T03:00:00.000Z", "amount": -1, "type": "DEBIT", "status": "PENDING", "description": "Pending"},
|
||||
],
|
||||
"next": "?accountId=a&after=MjAy%3D",
|
||||
},
|
||||
{
|
||||
"results": [
|
||||
{"id": "t3", "date": "2026-10-04T03:00:00.000Z", "amount": 100, "type": "CREDIT", "status": "POSTED", "description": "Pix", "currencyCode": "BRL"},
|
||||
],
|
||||
"next": None,
|
||||
},
|
||||
]
|
||||
transactions, calls = self._get_transactions(pages)
|
||||
|
||||
self.assertEqual(
|
||||
[c.kwargs["params"] for c in calls],
|
||||
[
|
||||
{"accountId": "a", "dateFrom": "2026-10-01"},
|
||||
{"accountId": "a", "dateFrom": "2026-10-01", "after": "MjAy="},
|
||||
],
|
||||
)
|
||||
self.assertEqual(
|
||||
transactions,
|
||||
[
|
||||
RemoteTransaction("t1", date(2026, 10, 1), Decimal("-12.5"), "Padaria", "", "BRL"),
|
||||
RemoteTransaction("t2", date(2026, 10, 2), Decimal("-1"), "Pending", "", "", pending=True),
|
||||
RemoteTransaction("t3", date(2026, 10, 4), Decimal("100"), "Pix", "", "BRL"),
|
||||
],
|
||||
)
|
||||
|
||||
def test_credit_card_follows_type_not_sign(self):
|
||||
# Shapes from a real Meu Pluggy card: purchases are positive DEBITs,
|
||||
# the bill payment a negative CREDIT
|
||||
pages = [
|
||||
{
|
||||
"results": [
|
||||
{"id": "t1", "date": "2026-10-03T03:00:00.000Z", "amount": 10, "amountInAccountCurrency": 55.3, "type": "DEBIT", "status": "POSTED", "description": "Steam", "currencyCode": "USD"},
|
||||
{"id": "t2", "date": "2026-10-05T03:00:00.000Z", "amount": -500, "type": "CREDIT", "status": "POSTED", "description": "Pagamento recebido", "currencyCode": "BRL"},
|
||||
{"id": "t3", "date": "2026-10-13T03:00:00.000Z", "amount": 18.72, "type": "DEBIT", "status": "POSTED", "description": "Loja 2/10", "currencyCode": "BRL",
|
||||
"creditCardMetadata": {"installmentNumber": 2, "totalInstallments": 10, "purchaseDate": "2026-09-12T03:00:00.000Z"}},
|
||||
# Future installment, waits for its date
|
||||
{"id": "t4", "date": "2027-07-13T03:00:00.000Z", "amount": 18.72, "type": "DEBIT", "status": "PENDING", "description": "Loja 10/10", "currencyCode": "BRL"},
|
||||
],
|
||||
"next": None,
|
||||
},
|
||||
]
|
||||
transactions, _ = self._get_transactions(pages)
|
||||
|
||||
self.assertEqual(
|
||||
transactions,
|
||||
[
|
||||
RemoteTransaction("t1", date(2026, 10, 3), Decimal("-55.3"), "Steam", "USD 10", ""),
|
||||
RemoteTransaction("t2", date(2026, 10, 5), Decimal("500"), "Pagamento recebido", "", "BRL"),
|
||||
RemoteTransaction("t3", date(2026, 10, 13), Decimal("-18.72"), "Loja 2/10", "", "BRL"),
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
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 _pending(self, days_ago, amount="-20", pending=True):
|
||||
day = timezone.localdate() - timedelta(days=days_ago)
|
||||
return RemoteTransaction("p", day, Decimal(amount), "Hotel", pending=pending)
|
||||
|
||||
def _hotel(self):
|
||||
return Transaction.userless_all_objects.get(internal_id="lunchflow:7:p")
|
||||
|
||||
def test_pending_is_imported_unpaid_and_settled_once_when_it_posts(self):
|
||||
self.link.start_date = timezone.localdate() - timedelta(days=90)
|
||||
self.link.save()
|
||||
self.provider.transactions = [self._pending(30)]
|
||||
self.assertEqual(self._sync(), 1)
|
||||
self.assertFalse(self._hotel().is_paid)
|
||||
|
||||
# Still pending with another amount: left alone, edits stick
|
||||
self.provider.transactions = [self._pending(30, "-22")]
|
||||
self._sync()
|
||||
self.assertEqual(self._hotel().amount, Decimal("20"))
|
||||
# And it's still looked for, way past the overlap
|
||||
self.assertEqual(self.provider.calls[-1][1], timezone.localdate() - timedelta(days=30))
|
||||
|
||||
# Posts with its final amount and date
|
||||
self.provider.transactions = [self._pending(29, "-25.509", pending=False)]
|
||||
self.assertEqual(self._sync(), 0)
|
||||
hotel = self._hotel()
|
||||
self.assertTrue(hotel.is_paid)
|
||||
self.assertEqual(hotel.amount, Decimal("25.50"))
|
||||
self.assertEqual(hotel.date, timezone.localdate() - timedelta(days=29))
|
||||
self.assertEqual(hotel.reference_date, hotel.date.replace(day=1))
|
||||
|
||||
# Settled: later changes on the bank don't touch it, look back is normal
|
||||
self.provider.transactions = [self._pending(29, "-99", pending=False)]
|
||||
self._sync()
|
||||
self.assertEqual(self._hotel().amount, Decimal("25.50"))
|
||||
self.assertEqual(self.provider.calls[-1][1], timezone.localdate() - timedelta(days=7))
|
||||
|
||||
def test_settling_keeps_a_reference_date_picked_by_the_user(self):
|
||||
self.link.start_date = timezone.localdate() - timedelta(days=90)
|
||||
self.link.save()
|
||||
self.provider.transactions = [self._pending(10)]
|
||||
self._sync()
|
||||
Transaction.userless_all_objects.filter(internal_id="lunchflow:7:p").update(
|
||||
emit_signal=False, reference_date=date(2030, 1, 1)
|
||||
)
|
||||
self.provider.transactions = [self._pending(10, pending=False)]
|
||||
self._sync()
|
||||
self.assertEqual(self._hotel().reference_date, date(2030, 1, 1))
|
||||
self.assertTrue(self._hotel().is_paid)
|
||||
|
||||
def test_unpaid_marked_paid_by_hand_is_left_alone(self):
|
||||
self.link.start_date = timezone.localdate() - timedelta(days=90)
|
||||
self.link.save()
|
||||
self.provider.transactions = [self._pending(10)]
|
||||
self._sync()
|
||||
Transaction.userless_all_objects.filter(internal_id="lunchflow:7:p").update(
|
||||
emit_signal=False, is_paid=True
|
||||
)
|
||||
self.provider.transactions = [self._pending(10, "-99", pending=False)]
|
||||
self._sync()
|
||||
self.assertEqual(self._hotel().amount, Decimal("20"))
|
||||
|
||||
def test_old_pending_is_no_longer_followed(self):
|
||||
self.link.start_date = timezone.localdate() - timedelta(days=200)
|
||||
self.link.save()
|
||||
self.provider.transactions = [self._pending(61)]
|
||||
self._sync()
|
||||
self._sync()
|
||||
# Taken as cancelled: back to the normal overlap
|
||||
self.assertEqual(self.provider.calls[-1][1], timezone.localdate() - timedelta(days=7))
|
||||
|
||||
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_provider_minimum_sync_interval(self):
|
||||
data = {"name": "SF", "service_type": "simplefin", "setting_access_url": "x"}
|
||||
with patch.object(SimpleFINProvider, "clean_settings", side_effect=lambda s: s):
|
||||
form = BankSyncServiceForm({**data, "sync_interval": "5"})
|
||||
self.assertIn("sync_interval", form.errors)
|
||||
self.assertTrue(BankSyncServiceForm({**data, "sync_interval": "6"}).is_valid())
|
||||
|
||||
@patch.object(SimpleFINProvider, "clean_settings")
|
||||
def test_clean_settings_runs_last_and_its_errors_show(self, clean_settings):
|
||||
data = {"service_type": "simplefin", "sync_interval": "24", "setting_access_url": "tok"}
|
||||
# Another field is invalid: a one-time token must not be spent
|
||||
self.assertFalse(BankSyncServiceForm(data).is_valid())
|
||||
clean_settings.assert_not_called()
|
||||
|
||||
clean_settings.side_effect = ValidationError("Token used")
|
||||
form = BankSyncServiceForm({**data, "name": "SF"})
|
||||
self.assertFalse(form.is_valid())
|
||||
self.assertEqual(form.non_field_errors(), ["Token used"])
|
||||
|
||||
clean_settings.side_effect = None
|
||||
clean_settings.return_value = {"access_url": "https://u:p@bridge.simplefin.org/x"}
|
||||
form = BankSyncServiceForm({**data, "name": "SF"})
|
||||
self.assertTrue(form.is_valid())
|
||||
self.assertEqual(form.save().settings, clean_settings.return_value)
|
||||
|
||||
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"})
|
||||
@@ -73,6 +73,13 @@ class ExportForm(forms.Form):
|
||||
label=_("Automatic Exchange Rates"),
|
||||
initial=False,
|
||||
)
|
||||
bank_sync = forms.BooleanField(
|
||||
required=False,
|
||||
widget=forms.CheckboxInput(),
|
||||
label=_("Bank Sync"),
|
||||
help_text=_("Includes the providers' API keys"),
|
||||
initial=False,
|
||||
)
|
||||
rules = forms.BooleanField(
|
||||
required=False,
|
||||
widget=forms.CheckboxInput(),
|
||||
@@ -111,6 +118,7 @@ class ExportForm(forms.Form):
|
||||
"recurring_transactions",
|
||||
"exchange_rates_services",
|
||||
"exchange_rates",
|
||||
"bank_sync",
|
||||
"rules",
|
||||
"dca",
|
||||
"import_profiles",
|
||||
@@ -142,6 +150,12 @@ class RestoreForm(forms.Form):
|
||||
required=False, label=_("Automatic Exchange Rates")
|
||||
)
|
||||
exchange_rates = forms.FileField(required=False, label=_("Exchange Rates"))
|
||||
bank_sync_services = forms.FileField(
|
||||
required=False, label=_("Bank Sync Services")
|
||||
)
|
||||
bank_sync_accounts = forms.FileField(
|
||||
required=False, label=_("Bank Sync Accounts")
|
||||
)
|
||||
transaction_rules = forms.FileField(required=False, label=_("Transaction rules"))
|
||||
transaction_rules_actions = forms.FileField(
|
||||
required=False, label=_("Edit transaction action")
|
||||
@@ -173,6 +187,8 @@ class RestoreForm(forms.Form):
|
||||
"recurring_transactions",
|
||||
"automatic_exchange_rates",
|
||||
"exchange_rates",
|
||||
"bank_sync_services",
|
||||
"bank_sync_accounts",
|
||||
"transaction_rules",
|
||||
"transaction_rules_actions",
|
||||
"transaction_rules_update_or_create",
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
from import_export import fields, resources, widgets
|
||||
|
||||
from apps.accounts.models import Account
|
||||
from apps.bank_sync.models import BankSyncAccount, BankSyncService
|
||||
|
||||
|
||||
class _AnyOwnerWidget(widgets.ForeignKeyWidget):
|
||||
"""By id, across every user.
|
||||
|
||||
The default managers only see the restoring user's rows, and names can clash
|
||||
between users. Ids survive a restore.
|
||||
"""
|
||||
|
||||
def get_queryset(self, value, row, *args, **kwargs):
|
||||
return self.model._base_manager.all()
|
||||
|
||||
|
||||
class BankSyncServiceResource(resources.ModelResource):
|
||||
class Meta:
|
||||
model = BankSyncService
|
||||
|
||||
def get_queryset(self):
|
||||
return BankSyncService._base_manager.all()
|
||||
|
||||
|
||||
class BankSyncAccountResource(resources.ModelResource):
|
||||
service = fields.Field(
|
||||
attribute="service",
|
||||
column_name="service",
|
||||
widget=_AnyOwnerWidget(BankSyncService),
|
||||
)
|
||||
account = fields.Field(
|
||||
attribute="account",
|
||||
column_name="account",
|
||||
widget=_AnyOwnerWidget(Account),
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = BankSyncAccount
|
||||
@@ -18,6 +18,10 @@ from apps.export_app.resources.accounts import (
|
||||
AccountResource,
|
||||
InvestmentClassResource,
|
||||
)
|
||||
from apps.export_app.resources.bank_sync import (
|
||||
BankSyncAccountResource,
|
||||
BankSyncServiceResource,
|
||||
)
|
||||
from apps.export_app.resources.currencies import (
|
||||
CurrencyResource,
|
||||
ExchangeRateResource,
|
||||
@@ -125,6 +129,9 @@ def export_form(request):
|
||||
)
|
||||
if export_exchange_rates:
|
||||
exports.append((ExchangeRateResource().export(), "exchange_rates"))
|
||||
if form.cleaned_data.get("bank_sync"):
|
||||
exports.append((BankSyncServiceResource().export(), "bank_sync_services"))
|
||||
exports.append((BankSyncAccountResource().export(), "bank_sync_accounts"))
|
||||
if export_rules:
|
||||
exports.append(
|
||||
(TransactionRuleResource().export(), "transaction_rules")
|
||||
@@ -244,6 +251,8 @@ def process_imports(request, cleaned_data):
|
||||
("installment_plans", InstallmentPlanResource),
|
||||
("recurring_transactions", RecurringTransactionResource),
|
||||
("transactions", TransactionResource),
|
||||
("bank_sync_services", BankSyncServiceResource),
|
||||
("bank_sync_accounts", BankSyncAccountResource),
|
||||
("dca_strategies", DCAStrategyResource),
|
||||
("dca_entries", DCAEntryResource),
|
||||
("import_profiles", ImportProfileResource),
|
||||
|
||||
@@ -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 %}
|
||||
@@ -150,7 +150,7 @@
|
||||
<c-components.sidebar-collapsible-panel
|
||||
title="{% translate 'Management' %}"
|
||||
icon="fa-solid fa-toolbox"
|
||||
active="tags_index||entities_index||categories_index||accounts_index||account_groups_index||investment_classes_index||currencies_index||exchange_rates_index||rules_index||import_profiles_index||automatic_exchange_rates_index||export_index||users_index">
|
||||
active="tags_index||entities_index||categories_index||accounts_index||account_groups_index||investment_classes_index||currencies_index||exchange_rates_index||rules_index||import_profiles_index||automatic_exchange_rates_index||bank_sync_index||export_index||users_index">
|
||||
<c-components.sidebar-menu-header title="{% translate 'Transactions' %}"></c-components.sidebar-menu-header>
|
||||
<c-components.sidebar-menu-item
|
||||
title="{% translate 'Categories' %}"
|
||||
@@ -234,6 +234,12 @@
|
||||
active="automatic_exchange_rates_index"
|
||||
icon="fa-solid fa-right-left">
|
||||
</c-components.sidebar-menu-item>
|
||||
<c-components.sidebar-menu-item
|
||||
title="{% translate 'Bank Sync' %}"
|
||||
url='bank_sync_index'
|
||||
active="bank_sync_index"
|
||||
icon="fa-solid fa-building-columns">
|
||||
</c-components.sidebar-menu-item>
|
||||
|
||||
{% if user.is_superuser %}
|
||||
<c-components.sidebar-menu-header title="{% translate 'Admin' %}"></c-components.sidebar-menu-header>
|
||||
|
||||
Reference in new issue
Block a user