feat(fava)!: Improve ledger configurability

This contains the following fixes and improvements for fava config:
- Changing the config now takes effect on old files, as well as new ones
- Options have been introduced to allow setting arbitrary beancount and
  fava config, rather than just the title

Change-Id: Ia827ee9646df6b18491a1257ec1a2582e4d8e971
Reviewed-on: https://git.clicks.codes/c/Infra/NixFiles/+/793
Tested-by: Skyler Grey <minion@clicks.codes>
Reviewed-by: Samuel Shuert <coded@clicks.codes>
diff --git a/modules/nixos/clicks/services/fava/default.nix b/modules/nixos/clicks/services/fava/default.nix
index 30014c3..fb707fd 100644
--- a/modules/nixos/clicks/services/fava/default.nix
+++ b/modules/nixos/clicks/services/fava/default.nix
@@ -23,13 +23,98 @@
       description = "Disable warnings about disrecommended or possibly unsafe authentication configurations";
     };
     accounts = lib.mkOption {
-      type = lib.home-manager.hm.types.dagOf lib.types.str;
+      type = lib.home-manager.hm.types.dagOf (lib.types.submodule (submodule: let
+          filename = let
+            optionPath = submodule.options._module.args.loc;
+            len = lib.lists.length optionPath;
+            index = len - 4; # Ends ["data" "_module" "args"] in a DAG
+          in lib.lists.elemAt optionPath index;
+        in {
+        options = {
+          name = lib.mkOption {
+            type = lib.types.str;
+            description = "The friendly name of the ledger";
+            example = "Clicks Codes";
+          };
+          favaExtraOptions = lib.mkOption {
+            type = lib.types.attrsOf (lib.types.nullOr lib.types.str);
+            description = "Extra options to set on this ledger for fava. See https://fava.pythonanywhere.com/example-beancount-file/help/options/ for a list of valid options. These mostly control fava UI, etc. Setting an option to null means that it will be given without a value";
+            example = {
+              invert-income-liabilities-equity = true;
+            };
+            default = {};
+          };
+          beancountExtraOptions = lib.mkOption {
+            type = lib.types.attrsOf lib.types.str;
+            description = "Extra options to set on this ledger for beancount. See https://beancount.github.io/docs/beancount_options_reference.html for a list of valid options. These mostly control the way beancount interprets numbers, account names, etc.";
+            example = {
+              long_string_maxlines = "128";
+            };
+          };
+          extraConfig = lib.mkOption {
+            type = lib.types.str;
+            description = "Extra lines to be added to the end of the beancount config file verbatim. Probably you want these to contain configuration, but it's not required - for example it would be valid to open accounts here to force those accounts to be opened";
+            example = ''
+              ; lines to be added to the beancount config file directly...
+            '';
+            default = "";
+          };
+          mainFile = lib.mkOption {
+            type = lib.types.str;
+            readOnly = true;
+            internal = true;
+            description = "The file that you will write your transactions, etc. in";
+            default = "/var/lib/private/fava/${filename}.beancount";
+          };
+          configFile = lib.mkOption {
+            type = lib.types.str;
+            readOnly = true;
+            internal = true;
+            description = "The file with all of the beancount/fava/etc. options compiled";
+          };
+        };
+        config = {
+          beancountExtraOptions.title = lib.mkDefault submodule.config.name;
+
+          configFile = builtins.toString (let
+            generateFavaOption = name: value:
+              if value == null
+              then ''1970-01-01 custom "fava-option" "${name}"''
+              else ''1970-01-01 custom "fava-option" "${name}" "${value}"'';
+            favaOptions = lib.pipe submodule.config.favaExtraOptions [
+              (lib.attrsets.mapAttrsToList generateFavaOption)
+              (lib.strings.concatStringsSep "\n")
+            ];
+
+            generateBeancountOption = name: value: ''option "${name}" "${value}"'';
+            beancountOptions = lib.pipe submodule.config.beancountExtraOptions [
+              (lib.attrsets.mapAttrsToList generateBeancountOption)
+              (lib.strings.concatStringsSep "\n")
+            ];
+          in pkgs.writeText "${filename}-config" ''
+            ${favaOptions}
+            ${beancountOptions}
+            ${submodule.config.extraConfig}
+
+            include "${submodule.config.mainFile}"
+          '');
+        };
+      }));
       example = {
-        "minion" = "Skyler Grey";
-        "clicks" = "Clicks Codes";
-        "coded" = "Samuel Shuert";
+        "clicks" = lib.home-manager.hm.dag.entryAnywhere {
+          name = "Clicks Codes";
+          favaExtraOptions = {
+            invert-income-liabilities-equity = true;
+          };
+          beancountExtraOptions = {
+            long_string_maxlines = "128";
+          };
+          extraConfig = ''
+            ; lines to be added to the beancount config file directly...
+          '';
+        };
       };
-      description = "A mapping of file names to ledger titles. Titles will not override any existing ledgers, they will only be added to new ones";
+      description = "A mapping of file names to ledger settings. Ledger settings will not be written to ledgers that are already created, although accompanying files will still be written";
     };
     addr = lib.mkOption {
       type = lib.types.str;
@@ -75,29 +160,18 @@
     };
 
     systemd.services.fava = let
-      getFilePath = name: "/var/lib/private/fava/${name}.beancount";
-        # Cannot use $STATE_DIRECTORY here as:
-        # (1) it's not supported via fava environment variables
-        # (2) it is a symlink and fava does not like that
-      attrNamesToPaths = accounts: lib.attrsets.mapAttrs' (name: value: {
-        name = getFilePath name;
-        inherit value;
-      }) accounts;
-
       sortedAccounts = (lib.home-manager.hm.dag.topoSort cfg.accounts).result;
 
       fileNames = lib.trivial.pipe sortedAccounts [
-        (map (account: account.name))
-        (map getFilePath)
+        (map (account: account.data.configFile))
         (lib.strings.concatStringsSep " ")
       ];
 
-      accountToCreationScript = { name, data }: let
-        path = getFilePath name;
+      accountToCreationScript = { data, ... }: let
       in ''
-        ${pkgs.coreutils}/bin/mkdir -p "$(${pkgs.coreutils}/bin/dirname "${path}")"
-        if [ ! -f "${path}" ]; then
-          ${pkgs.coreutils}/bin/echo 'option "title" "${data}"' >> ${path}
+        ${pkgs.coreutils}/bin/mkdir -p "$(${pkgs.coreutils}/bin/dirname "${data.mainFile}")"
+        if [ ! -f "${data.mainFile}" ]; then
+          ${pkgs.coreutils}/bin/echo '1970-01-01 custom "fava-option" "default-file"' >> ${data.mainFile}
         fi
       '';
 
diff --git a/systems/x86_64-linux/teal/default.nix b/systems/x86_64-linux/teal/default.nix
index 2a9b99b..d09bb7f 100644
--- a/systems/x86_64-linux/teal/default.nix
+++ b/systems/x86_64-linux/teal/default.nix
@@ -164,10 +164,21 @@
         enable = true;
         tailscaleAuth = true;
         accounts = {
-          "clicks" = lib.home-manager.hm.dag.entryAnywhere "Clicks Codes";
-          "coded" = lib.home-manager.hm.dag.entryBetween [ "testing" ] [ "clicks" ] "Samuel Shuert";
-          "minion" = lib.home-manager.hm.dag.entryBetween [ "testing" ] [ "clicks" ] "Skyler Grey";
-          "testing" = lib.home-manager.hm.dag.entryAfter [ "clicks" ] "Test Data - May Be Wiped At Any Time";
+          "clicks" = lib.home-manager.hm.dag.entryAnywhere {
+            name = "Clicks Codes";
+            beancountExtraOptions.operating_currency = "GBP";
+          };
+          "coded" = lib.home-manager.hm.dag.entryBetween [ "testing" ] [ "clicks" ] {
+            name = "Samuel Shuert";
+            beancountExtraOptions.operating_currency = "USD";
+          };
+          "minion" = lib.home-manager.hm.dag.entryBetween [ "testing" ] [ "clicks" ] {
+            name = "Skyler Grey";
+            beancountExtraOptions.operating_currency = "GBP";
+          };
+          "testing" = lib.home-manager.hm.dag.entryAfter [ "clicks" ] {
+            name = "Test Data - May Be Wiped At Any Time";
+          };
         };
         domain = "fava.clicks.codes";
       };