feat(fava.accounts.minion): Add truelayer importer

Truelayer is a platform that connects to many European financial
institutions, including all the ones I care about! I plan to make this
generic over accounts in the future, but for now it's awesome to have a
proof-of-concept for importers working on a single account...

Change-Id: I1b51da2952a666316a68c4dae913b5cdfae2718a
Reviewed-on: https://git.clicks.codes/c/Infra/NixFiles/+/805
Tested-by: Skyler Grey <minion@clicks.codes>
Reviewed-by: Skyler Grey <minion@clicks.codes>
diff --git a/modules/nixos/clicks/services/fava/default.nix b/modules/nixos/clicks/services/fava/default.nix
index 73cab36..c489c1d 100644
--- a/modules/nixos/clicks/services/fava/default.nix
+++ b/modules/nixos/clicks/services/fava/default.nix
@@ -22,6 +22,19 @@
       internal = true;
       description = "Disable warnings about disrecommended or possibly unsafe authentication configurations";
     };
+    extraPythonPackages = lib.mkOption {
+      type = lib.types.listOf lib.types.package;
+      description = "Extra python packages to include in the fava environment. You need to put all your plugins and importers in here";
+      default = [];
+    };
+    credentials = lib.mkOption {
+      type = lib.types.attrsOf lib.types.str;
+      description = "A mapping of systemd credential names to the paths you will find them at, see https://systemd.io/CREDENTIALS/ for more";
+      example = {
+        truelayer_client_secret = "/run/agenix/truelayer_client_secret";
+      };
+      default = {};
+    };
     accounts = lib.mkOption {
       type = lib.home-manager.hm.types.dagOf (lib.types.submodule (submodule: let
           filename = let
@@ -188,12 +201,20 @@
         FAVA_PORT = builtins.toString cfg.port;
       };
 
-      serviceConfig = {
+      serviceConfig = let
+        credentialToStr = name: path: "${name}:${path}";
+        LoadCredential = lib.attrsets.mapAttrsToList credentialToStr cfg.credentials;
+      in {
         StateDirectory = "fava";
         DynamicUser = true;
+        LoadCredential = LoadCredential;
       };
 
-      script = "${pkgs.fava}/bin/fava ${if cfg.readOnly then "--read-only" else ""} ${fileNames}";
+      script = let
+        fava = pkgs.fava.overrideAttrs (prevAttrs: {
+          propagatedBuildInputs = prevAttrs.propagatedBuildInputs ++ cfg.extraPythonPackages;
+        });
+      in "${fava}/bin/fava ${if cfg.readOnly then "--read-only" else ""} ${fileNames}";
       preStart = fileCreateCommands;
 
       wantedBy = ["multi-user.target"];
diff --git a/packages/beancount-autobean/default.nix b/packages/beancount-autobean/default.nix
new file mode 100644
index 0000000..440375f
--- /dev/null
+++ b/packages/beancount-autobean/default.nix
@@ -0,0 +1,38 @@
+{ lib
+, python3
+}: let
+  pname = "autobean";
+  version = "0.2.2";
+in python3.pkgs.buildPythonApplication {
+  inherit pname version;
+
+  src = python3.pkgs.fetchPypi {
+    inherit pname version;
+    sha256 = "sha256-2gN9AyP66r2AwrTWJnRf+9W+TTfylRQkSAEea4tvKko=";
+  };
+
+  format = "pyproject";
+
+  propagatedBuildInputs = [
+    python3.pkgs.beancount
+    python3.pkgs.python-dateutil
+    python3.pkgs.pyyaml
+    python3.pkgs.requests
+  ];
+
+  buildInputs = [
+    python3.pkgs.setuptools
+    python3.pkgs.pdm-pep517
+  ];
+
+  meta = {
+    homepage = "https://github.com/SEIAROTg/autobean";
+    description = "A collection of plugins and scripts that help automating bookkeeping with beancount";
+    license = lib.licenses.gpl2;
+    maintainers = [ lib.maintainers.minion3665 ];
+  };
+
+  patches = [
+    ./skip-empty-account-types.patch
+  ];
+}
diff --git a/packages/beancount-autobean/skip-empty-account-types.patch b/packages/beancount-autobean/skip-empty-account-types.patch
new file mode 100644
index 0000000..eee3807
--- /dev/null
+++ b/packages/beancount-autobean/skip-empty-account-types.patch
@@ -0,0 +1,15 @@
+diff --git a/autobean/truelayer/importer.py b/autobean/truelayer/importer.py
+index 97a651ab91...da5a91d47e 100644
+--- a/autobean/truelayer/importer.py
++++ b/autobean/truelayer/importer.py
+@@ -183,6 +183,9 @@
+     def _fetch_all_transactions(self) -> list[Directive]:
+         entries: list[Directive] = []
+         for type_ in ACCOUNT_TYPES:
++            if not type_ in self._config.data:
++                continue
++
+             for account_id, account in self._config.data[type_].items():
+                 if not account['enabled']:
+                     continue
+
diff --git a/packages/beancount-smart_importer/default.nix b/packages/beancount-smart_importer/default.nix
new file mode 100644
index 0000000..9257137
--- /dev/null
+++ b/packages/beancount-smart_importer/default.nix
@@ -0,0 +1,33 @@
+{ lib
+, python3
+}: let
+  pname = "smart_importer";
+  version = "0.5";
+in python3.pkgs.buildPythonApplication {
+  inherit pname version;
+
+  src = python3.pkgs.fetchPypi {
+    inherit pname version;
+    sha256 = "sha256-n0mBayg3Ny/5eHByonDnqpDeErv3tDhp577cCoM6l1I=";
+  };
+
+  format = "pyproject";
+
+  propagatedBuildInputs = [
+    python3.pkgs.beancount
+    python3.pkgs.scikit-learn
+    python3.pkgs.numpy
+  ];
+
+  buildInputs = [
+    python3.pkgs.setuptools
+    python3.pkgs.setuptools_scm
+  ];
+
+  meta = {
+    homepage = "https://github.com/beancount/smart_importer";
+    description = "Augment Beancount importers with machine learning functionality";
+    license = lib.licenses.mit;
+    maintainers = [ lib.maintainers.minion3665 ];
+  };
+}
diff --git a/secrets/rekeyed/teal/11d9d957b13608f13fb57001f76bcf3c-clicks.services.fava.credentials.truelayer_client_secret.age b/secrets/rekeyed/teal/11d9d957b13608f13fb57001f76bcf3c-clicks.services.fava.credentials.truelayer_client_secret.age
new file mode 100644
index 0000000..413bca4
--- /dev/null
+++ b/secrets/rekeyed/teal/11d9d957b13608f13fb57001f76bcf3c-clicks.services.fava.credentials.truelayer_client_secret.age
@@ -0,0 +1,8 @@
+age-encryption.org/v1
+-> ssh-ed25519 BfRbTA COJ+xJVK7oXXPoZV5fldvF01MUo85Rpg7o0K3wEvHF0
+A5o0q3Z+zuVH0LV5zQ6xCmG3Yc9hdzyZYCZJb7kP1LQ
+-> C%mJ4-grease l9
+a5FW55b7DxTahKbUK4d3tjnPQQ1O6WpDdyYaEXbpUVosXAj6Ghnkny/sq/ObdCUv
+VuEP2HymUjTgJbqwV6gjMPbN+6U/Ew8x6fPCtQvKmHaRdZKVbEzu2gq+0DA
+--- fAy7k43PzFHKw0aRUwMQbY9lSh29jA9bfXkeJdxChDQ
+Þôdj×všá+¬±Ç֜6	+™ñ':š¬Ô%ž¤J&W¢<ààÊÓxÕï	?!À¦¥Ô>@ö¢Ó Ps)ç¯K
\ No newline at end of file
diff --git a/systems/x86_64-linux/teal/clicks.services.fava.credentials.truelayer_client_secret.age b/systems/x86_64-linux/teal/clicks.services.fava.credentials.truelayer_client_secret.age
new file mode 100644
index 0000000..aba1823
--- /dev/null
+++ b/systems/x86_64-linux/teal/clicks.services.fava.credentials.truelayer_client_secret.age
@@ -0,0 +1,12 @@
+age-encryption.org/v1
+-> piv-p256 xE4ypg AvXds3fQckoyJX7ngCsw6qEsS1eqyTrrZDfqCOtYPwyw
+9JrqfxKrdrbKU/GEJNXWQnvm7LaSstJI1+QrdxU8WeA
+-> piv-p256 Hpt/+Q ApIWBJsxqUVP20vGfP9Wk46+0dOQB7GBiDKgXIkuAszL
+OSyjcTm/68lT9YgcYwZInW91cTycFDbNFPqa1GOEkVs
+-> piv-p256 zfskmQ A29S1Bw/JdpP9q+lXiOUL5Jx++o24OAqqQAEh22kVIjk
++asGJnE/9hBzXRBxXsjV35Y2mPCMIMUKNZu+DGD/9+w
+-> `}-jU-grease R)E o I|QlCYwi
+AhsxhCITkpnW4sWLfZqaqXmdIsOxBsqpVqUcTlblRF0fAIlTSw1Wc62W/U48WcqU
+a3YP4hceJqSFPajJhzUQiCCI+B0
+--- yZAwEUb6evblHmLnvshoptetkfghChxmDZ33N/pVfA0
+÷ÑÝf=âKĪc%«@ÞTõ:±ÆX»w0²•61XÛ"YÜ@T9MÕy¿×‹Ò±•3‡äDõˆêZ.rŒ¹jêä
\ No newline at end of file
diff --git a/systems/x86_64-linux/teal/default.nix b/systems/x86_64-linux/teal/default.nix
index a97ccd8..0bd7bd4 100644
--- a/systems/x86_64-linux/teal/default.nix
+++ b/systems/x86_64-linux/teal/default.nix
@@ -176,7 +176,14 @@
       };
       fava = {
         enable = true;
+        extraPythonPackages = [
+          pkgs.clicks.beancount-autobean
+          pkgs.clicks.beancount-smart_importer
+        ];
         tailscaleAuth = true;
+        credentials = {
+          truelayer_client_secret = config.age.secrets."clicks.services.fava.credentials.truelayer_client_secret".path;
+        };
         accounts = {
           "clicks" = lib.home-manager.hm.dag.entryAnywhere {
             name = "Clicks Codes";
@@ -189,6 +196,34 @@
           "minion" = lib.home-manager.hm.dag.entryBetween [ "testing" ] [ "clicks" ] {
             name = "Skyler Grey";
             beancountExtraOptions.operating_currency = "GBP";
+            favaExtraOptions = {
+              invert-income-liabilities-equity = "true";
+              auto-reload = "true";
+              import-config = builtins.toString (pkgs.writeText "minion-imports.py" ''
+                import autobean.truelayer
+                from smart_importer import apply_hooks, PredictPayees, PredictPostings
+
+                import os
+                import pathlib
+
+                with open(pathlib.Path(os.environ["CREDENTIALS_DIRECTORY"]) / pathlib.Path("truelayer_client_secret")) as f:
+                  truelayer_client_secret = f.read().strip()
+
+                CONFIG = [
+                  apply_hooks(
+                    autobean.truelayer.Importer(
+                      "fava-228732",
+                      truelayer_client_secret
+                    ),
+                    [
+                      PredictPayees(),
+                      PredictPostings(),
+                    ]
+                  )
+                ]
+              '');
+              import-dirs = "/var/lib/private/fava/minion/";
+            };
           };
           "testing" = lib.home-manager.hm.dag.entryAfter [ "clicks" ] {
             name = "Test Data - May Be Wiped At Any Time";
@@ -250,8 +285,12 @@
 
   system.stateVersion = "24.05";
 
+  age.secrets."clicks.networking.tailscale.authKeyFile".rekeyFile = ./clicks.networking.tailscale.authKeyFile.age;
+
   age.secrets."clicks.security.acme.defaults.environmentFile".rekeyFile = ./clicks.security.acme.defaults.environmentFile.age;
 
+  age.secrets."clicks.services.fava.credentials.truelayer_client_secret".rekeyFile = ./clicks.services.fava.credentials.truelayer_client_secret.age;
+
   age.secrets."clicks.services.headscale.oidc.client_secret_path" = {
     rekeyFile = ./clicks.services.headscale.oidc.client_secret_path.age;
     group = "headscale";
@@ -272,6 +311,4 @@
     group = "headscale";
     mode = "440";
   };
-
-  age.secrets."clicks.networking.tailscale.authKeyFile".rekeyFile = ./clicks.networking.tailscale.authKeyFile.age;
 }